diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 47162ec3af..c6d0fafa25 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -101,7 +101,7 @@ jobs: run: | if grep -q "igz-controls/" build.log; then echo -e "\033[31mModule not found: Error: Can't resolve 'igz-controls....'.\033[0m" - echo -e "\033[33mPlease check for changes in the 'dashboard-react-controls' repository.\033[0m" + echo -e "\033[33mPlease check for missing files in 'ui/src/igz-controls/'.\033[0m" echo "::set-output name=igz-controls-import-error::failure" echo "::error::Module not found: Error: Can't resolve 'igz-controls" else diff --git a/eslint.config.mjs b/eslint.config.mjs index d476aa233d..6cadbacad0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,7 +6,7 @@ import reactHooks from 'eslint-plugin-react-hooks' import eslintPluginImport from 'eslint-plugin-import' export default [ - { ignores: ['dist'] }, + { ignores: ['dist', 'src/igz-controls/nextGenComponents/**'] }, js.configs.recommended, eslintConfigPrettier, { diff --git a/jsconfig.json b/jsconfig.json index 7e84976282..b271628265 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -5,8 +5,11 @@ "@/*": [ "src/nextGenComponents/*" ], + "@igz-controls/*": [ + "src/igz-controls/nextGenComponents/*" + ], "igz-controls/*": [ - "node_modules/iguazio.dashboard-react-controls/dist/*" + "src/igz-controls/*" ] } } diff --git a/package-lock.json b/package-lock.json index 561afa4bd5..682a6ff2bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,6 @@ "final-form-arrays": "^3.1.0", "fs-extra": "^10.0.0", "identity-obj-proxy": "^3.0.0", - "iguazio.dashboard-react-controls": "3.2.25", "is-wsl": "^1.1.0", "js-base64": "^2.6.4", "js-yaml": "^4.1.0", @@ -5246,12 +5245,6 @@ "node": ">17.0.0" } }, - "node_modules/@date-fns/tz": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", - "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", - "license": "MIT" - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -6586,12 +6579,6 @@ "url": "https://opencollective.com/pkgr" } }, - "node_modules/@radix-ui/number": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", - "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", - "license": "MIT" - }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -6621,36 +6608,6 @@ } } }, - "node_modules/@radix-ui/react-checkbox": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", - "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-collapsible": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", @@ -6996,61 +6953,6 @@ } } }, - "node_modules/@radix-ui/react-popover": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", - "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-popper": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", @@ -7203,98 +7105,6 @@ } } }, - "node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", - "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", - "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-separator": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", @@ -7359,36 +7169,6 @@ } } }, - "node_modules/@radix-ui/react-tabs": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", - "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-tooltip": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", @@ -7526,21 +7306,6 @@ } } }, - "node_modules/@radix-ui/react-use-previous": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", - "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-use-rect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", @@ -9086,75 +8851,6 @@ "@swc/counter": "^0.1.3" } }, - "node_modules/@tabby_ai/hijri-converter": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@tabby_ai/hijri-converter/-/hijri-converter-1.0.5.tgz", - "integrity": "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==", - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@tanstack/react-table": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", - "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", - "license": "MIT", - "dependencies": { - "@tanstack/table-core": "8.21.3" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" - } - }, - "node_modules/@tanstack/react-virtual": { - "version": "3.14.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.3.tgz", - "integrity": "sha512-k/cnHPVaOfn46hSbiY6n4Dzf4QjCGWSF40zR5QIIYUqPAjpA6TN7InfYmcMiDVQGP2iUn9xsRbAl8u1v3UmeVQ==", - "license": "MIT", - "dependencies": { - "@tanstack/virtual-core": "3.17.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "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/@tanstack/table-core": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", - "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/virtual-core": { - "version": "3.17.1", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.1.tgz", - "integrity": "sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, "node_modules/@teppeis/multimaps": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@teppeis/multimaps/-/multimaps-3.0.0.tgz", @@ -12984,6 +12680,7 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.21.0" }, @@ -12995,12 +12692,6 @@ "url": "https://opencollective.com/date-fns" } }, - "node_modules/date-fns-jalali": { - "version": "4.1.0-0", - "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", - "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", - "license": "MIT" - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -16047,88 +15738,6 @@ "dev": true, "license": "ISC" }, - "node_modules/iguazio.dashboard-react-controls": { - "version": "3.2.25", - "resolved": "https://registry.npmjs.org/iguazio.dashboard-react-controls/-/iguazio.dashboard-react-controls-3.2.25.tgz", - "integrity": "sha512-lPrwFOuFi3v0OrC4Mh2jHnvbvcCVSbYKTeroOz/nYbsViPsEUf7trGJ17vrIOZ8+i7AMIEFJCp8eMbqeea7xwA==", - "dependencies": { - "@radix-ui/react-checkbox": "^1.3.3", - "@radix-ui/react-collapsible": "^1.1.12", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-popover": "^1.1.15", - "@radix-ui/react-scroll-area": "^1.2.10", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-separator": "^1.1.8", - "@radix-ui/react-slot": "^1.2.3", - "@radix-ui/react-tabs": "^1.1.13", - "@radix-ui/react-tooltip": "^1.2.8", - "@tanstack/react-table": "^8.21.3", - "@tanstack/react-virtual": "^3.13.19", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "date-fns": "^4.1.0", - "lucide-react": "^0.552.0", - "react-day-picker": "^9.13.0", - "tailwind-merge": "^3.3.1", - "zustand": "^5.0.8" - }, - "peerDependencies": { - "@reduxjs/toolkit": "*", - "classnames": "*", - "final-form": "*", - "final-form-arrays": "*", - "lodash": "*", - "moment": "*", - "prop-types": "*", - "react": "*", - "react-dom": "*", - "react-final-form": "*", - "react-final-form-arrays": "*", - "react-modal-promise": "*", - "react-redux": "*", - "react-router-dom": "*", - "react-transition-group": "*" - } - }, - "node_modules/iguazio.dashboard-react-controls/node_modules/lucide-react": { - "version": "0.552.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.552.0.tgz", - "integrity": "sha512-g9WCjmfwqbexSnZE+2cl21PCfXOcqnGeWeMTNAOGEfpPbm/ZF4YIq77Z8qWrxbu660EKuLB4nSLggoKnCb+isw==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/iguazio.dashboard-react-controls/node_modules/zustand": { - "version": "5.0.13", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", - "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - }, - "use-sync-external-store": { - "optional": true - } - } - }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -21309,28 +20918,6 @@ "dev": true, "license": "MIT" }, - "node_modules/react-day-picker": { - "version": "9.14.0", - "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.14.0.tgz", - "integrity": "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==", - "license": "MIT", - "dependencies": { - "@date-fns/tz": "^1.4.1", - "@tabby_ai/hijri-converter": "1.0.5", - "date-fns": "^4.1.0", - "date-fns-jalali": "4.1.0-0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "individual", - "url": "https://github.com/sponsors/gpbl" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, "node_modules/react-dev-utils": { "version": "12.0.1", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz", diff --git a/package.json b/package.json index 17236fe4fb..dbdf3315ba 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,6 @@ "final-form-arrays": "^3.1.0", "fs-extra": "^10.0.0", "identity-obj-proxy": "^3.0.0", - "iguazio.dashboard-react-controls": "3.2.25", "is-wsl": "^1.1.0", "js-base64": "^2.6.4", "js-yaml": "^4.1.0", @@ -100,9 +99,7 @@ "report": "node tests/report.js", "test:regression": "npm run test:ui && npm run report", "start:regression": "concurrently \"npm:mock-server\" \"npm:start\" \"npm:test:regression\"", - "ui-steps": "cross-env BABEL_ENV=test; cross-env NODE_ENV=test; npx -p @babel/core -p @babel/node babel-node --presets @babel/preset-env scripts/collectUITestsSteps.js", - "nli": "npm link iguazio.dashboard-react-controls", - "nui": "npm unlink iguazio.dashboard-react-controls" + "ui-steps": "cross-env BABEL_ENV=test; cross-env NODE_ENV=test; npx -p @babel/core -p @babel/node babel-node --presets @babel/preset-env scripts/collectUITestsSteps.js" }, "devDependencies": { "@babel/core": "^7.16.0", diff --git a/src/igz-controls/components/ActionsMenu/ActionsMenu.jsx b/src/igz-controls/components/ActionsMenu/ActionsMenu.jsx new file mode 100644 index 0000000000..6a4d80c9d8 --- /dev/null +++ b/src/igz-controls/components/ActionsMenu/ActionsMenu.jsx @@ -0,0 +1,194 @@ +/* +Copyright 2019 Iguazio Systems Ltd. + +Licensed under the Apache License, Version 2.0 (the "License") with +an addition restriction as set forth herein. You may not use this +file except in compliance with the License. You may obtain a copy of +the License at http://www.apache.org/licenses/LICENSE-2.0. + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. + +In addition, you may not use the software for any purposes that are +illegal under applicable law, and the grant of the foregoing license +under the Apache 2.0 license is conditioned upon your compliance with +such restriction. +*/ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import PropTypes from 'prop-types' +import { isEmpty } from 'lodash' +import classnames from 'classnames' + +import ActionsMenuItem from '../../elements/ActionsMenuItem/ActionsMenuItem' +import PopUpDialog from '../PopUpDialog/PopUpDialog' +import RoundedIcon from '../RoundedIcon/RoundedIcon' + +import { ACTIONS_MENU } from '../../types' + +import ActionMenuIcon from '../../images/elipsis.svg?react' + +import './actionsMenu.scss' + +const ActionsMenu = ({ + dataItem = {}, + menu, + menuPosition = '', + time = 100, + withQuickActions = false +}) => { + const [[actionMenu, quickActions], setActionMenuContent] = useState(menu) + const [isIconDisplayed, setIsIconDisplayed] = useState(false) + const [isShowMenu, setIsShowMenu] = useState(false) + const actionMenuRef = useRef() + const actionMenuBtnRef = useRef() + const dropDownMenuRef = useRef() + const mainActionsWrapperRef = useRef() + + let idTimeout = null + + const actionMenuClassNames = classnames( + 'actions-menu__container', + withQuickActions && + (actionMenu.length > 0 || quickActions.length > 1) && + 'actions-menu__container_extended', + isShowMenu && 'actions-menu__container-active' + ) + + const clickHandler = useCallback( + event => { + if (!event.target.closest('.actions-menu-button')) { + setIsShowMenu(false) + } + }, + [setIsShowMenu] + ) + + const scrollHandler = useCallback( + event => { + if (!event.target.closest('.actions-menu__body')) { + setIsShowMenu(false) + } + }, + [setIsShowMenu] + ) + + const onMouseOut = () => { + if (isShowMenu) { + idTimeout = setTimeout(() => { + setIsShowMenu(false) + }, time) + } + } + + const handleMouseOver = event => { + if (mainActionsWrapperRef.current?.contains(event.target)) { + setIsShowMenu(false) + } + + if (idTimeout) clearTimeout(idTimeout) + } + + useEffect(() => { + if (!isEmpty(dataItem)) { + setActionMenuContent(typeof menu === 'function' ? menu(dataItem, menuPosition) : menu) + } + }, [dataItem, menu, menuPosition]) + + useEffect(() => { + setIsIconDisplayed(actionMenu?.some(menuItem => menuItem.icon)) + }, [actionMenu]) + + useEffect(() => { + window.addEventListener('click', clickHandler) + window.addEventListener('scroll', scrollHandler, true) + + return () => { + window.removeEventListener('click', clickHandler) + window.removeEventListener('scroll', scrollHandler, true) + } + }, [clickHandler, scrollHandler]) + + return ( +
+ {withQuickActions && ( +
+ {quickActions.map( + mainAction => + !mainAction.hidden && ( + mainAction.onClick(dataItem)} + tooltipText={mainAction.label} + key={mainAction.label} + > + {mainAction.icon} + + ) + )} +
+ )} + {actionMenu.length > 0 && ( +
+ { + setIsShowMenu(prevValue => !prevValue) + }} + ref={actionMenuBtnRef} + tooltipText="More actions" + > + + + {isShowMenu && ( + +
    + {actionMenu.map( + (menuItem, idx) => + !menuItem.hidden && ( + + ) + )} +
+
+ )} +
+ )} +
+ ) +} + +ActionsMenu.propTypes = { + dataItem: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), + menu: ACTIONS_MENU.isRequired, + menuPosition: PropTypes.string, + time: PropTypes.number, + withQuickActions: PropTypes.bool +} + +export default ActionsMenu diff --git a/src/igz-controls/components/ActionsMenu/actionsMenu.scss b/src/igz-controls/components/ActionsMenu/actionsMenu.scss new file mode 100644 index 0000000000..b5024b3361 --- /dev/null +++ b/src/igz-controls/components/ActionsMenu/actionsMenu.scss @@ -0,0 +1,60 @@ +@use '../../scss/colors'; + +.actions-menu { + position: relative; + + &__container { + position: relative; + display: none; + + &_extended { + position: absolute; + right: 0; + display: none; + align-items: center; + justify-content: center; + background-color: colors.$ghostWhite; + height: 100%; + + &:before { + content: ''; + width: 30px; + height: 100%; + position: absolute; + display: block; + left: -30px; + background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(245, 247, 255, 1) 100%); + } + + .actions-menu { + padding: 0 5px 0 0; + } + } + + &-active { + display: flex; + } + } + + &__main-actions-wrapper { + display: flex; + align-items: center; + justify-content: center; + } + + &__body { + min-width: 150px; + max-width: 250px; + + .pop-up-dialog { + width: 100%; + padding: 0; + } + } + + &__list { + list-style-type: none; + margin: 0; + padding: 0; + } +} diff --git a/src/igz-controls/components/Backdrop/Backdrop.jsx b/src/igz-controls/components/Backdrop/Backdrop.jsx new file mode 100644 index 0000000000..02fcbb8f77 --- /dev/null +++ b/src/igz-controls/components/Backdrop/Backdrop.jsx @@ -0,0 +1,46 @@ +/* +Copyright 2022 Iguazio Systems Ltd. +Licensed under the Apache License, Version 2.0 (the "License") with +an addition restriction as set forth herein. You may not use this +file except in compliance with the License. You may obtain a copy of +the License at http://www.apache.org/licenses/LICENSE-2.0. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. +In addition, you may not use the software for any purposes that are +illegal under applicable law, and the grant of the foregoing license +under the Apache 2.0 license is conditioned upon your compliance with +such restriction. +*/ +import React, { useRef } from 'react' +import PropTypes from 'prop-types' +import { CSSTransition } from 'react-transition-group' + +import './Backdrop.scss' + +const Backdrop = ({ duration = 300, show = false, onClose = null }) => { + const nodeRef = useRef(null) + + return ( + +
+
+ ) +} + +Backdrop.propTypes = { + duration: PropTypes.number, + onClose: PropTypes.func, + show: PropTypes.bool +} + +export default Backdrop diff --git a/src/igz-controls/components/Backdrop/Backdrop.scss b/src/igz-controls/components/Backdrop/Backdrop.scss new file mode 100644 index 0000000000..036a91b12e --- /dev/null +++ b/src/igz-controls/components/Backdrop/Backdrop.scss @@ -0,0 +1,32 @@ +@use '../../scss/colors'; + +.backdrop { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: colors.$black; + z-index: 9; + + &-transition { + &-enter { + opacity: 0; + } + + &-enter-active, + &-enter-done { + opacity: 0.5; + transition: opacity 0.3s ease-in-out; + } + + &-exit { + opacity: 0.5; + } + + &-exit-active { + opacity: 0; + transition: opacity 0.3s ease-in-out; + } + } +} diff --git a/src/igz-controls/components/BlockerSpy/BlockerSpy.jsx b/src/igz-controls/components/BlockerSpy/BlockerSpy.jsx new file mode 100644 index 0000000000..db1028d710 --- /dev/null +++ b/src/igz-controls/components/BlockerSpy/BlockerSpy.jsx @@ -0,0 +1,39 @@ +/* +Copyright 2019 Iguazio Systems Ltd. + +Licensed under the Apache License, Version 2.0 (the "License") with +an addition restriction as set forth herein. You may not use this +file except in compliance with the License. You may obtain a copy of +the License at http://www.apache.org/licenses/LICENSE-2.0. + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. + +In addition, you may not use the software for any purposes that are +illegal under applicable law, and the grant of the foregoing license +under the Apache 2.0 license is conditioned upon your compliance with +such restriction. +*/ +import React, { useEffect } from 'react' +import PropTypes from 'prop-types' +import { useBlocker } from 'react-router-dom' + +const BlockerSpy = ({ setBlocker, shouldBlock }) => { + const blocker = useBlocker(shouldBlock) + + useEffect(() => { + setBlocker(blocker) + }, [setBlocker, blocker]) + + return <> +} + +BlockerSpy.propTypes = { + setBlocker: PropTypes.func.isRequired, + shouldBlock: PropTypes.func.isRequired +} + +export default BlockerSpy diff --git a/src/igz-controls/components/Button/Button.jsx b/src/igz-controls/components/Button/Button.jsx new file mode 100644 index 0000000000..542eda8a51 --- /dev/null +++ b/src/igz-controls/components/Button/Button.jsx @@ -0,0 +1,73 @@ +/* +Copyright 2022 Iguazio Systems Ltd. +Licensed under the Apache License, Version 2.0 (the "License") with +an addition restriction as set forth herein. You may not use this +file except in compliance with the License. You may obtain a copy of +the License at http://www.apache.org/licenses/LICENSE-2.0. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. +In addition, you may not use the software for any purposes that are +illegal under applicable law, and the grant of the foregoing license +under the Apache 2.0 license is conditioned upon your compliance with +such restriction. +*/ +import React, { forwardRef } from 'react' +import PropTypes from 'prop-types' +import classNames from 'classnames' + +import Tooltip from '../Tooltip/Tooltip' +import TextTooltipTemplate from '../TooltipTemplate/TextTooltipTemplate' + +import { BUTTON_VARIANTS, DENSITY } from '../../types' +import { TERTIARY_BUTTON } from '../../constants' + +import './Button.scss' + +let Button = ( + { + className = '', + density = 'normal', + icon = null, + iconPosition = 'left', + id = 'btn', + label = 'Button', + tooltip = '', + variant = TERTIARY_BUTTON, + ...restProps + }, + ref +) => { + const buttonClassName = classNames('btn', `btn-${variant}`, `btn-${density}`, className) + + return ( + + ) +} + +Button = forwardRef(Button) + +Button.displayName = 'Button' + +Button.propTypes = { + className: PropTypes.string, + density: DENSITY, + icon: PropTypes.element, + iconPosition: PropTypes.oneOf(['left', 'right']), + id: PropTypes.string, + label: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), + tooltip: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), + variant: BUTTON_VARIANTS +} + +export default Button diff --git a/src/igz-controls/components/Button/Button.scss b/src/igz-controls/components/Button/Button.scss new file mode 100644 index 0000000000..0f199c6308 --- /dev/null +++ b/src/igz-controls/components/Button/Button.scss @@ -0,0 +1,171 @@ +@use '../../scss/variables'; +@use '../../scss/colors'; +@use '../../scss/borders'; + +.btn { + display: flex; + align-items: center; + justify-content: center; + min-width: 90px; + height: variables.$fieldNormal; + padding: 0 16px; + color: colors.$white; + font-weight: 500; + font-size: 0.875rem; + font-style: normal; + border: borders.$transparentBorder; + border-radius: variables.$mainBorderRadius; + + &-dense { + height: variables.$fieldDense; + } + + &-normal { + height: variables.$fieldNormal; + } + + &-medium { + height: variables.$fieldMedium; + } + + &-chunky { + height: variables.$fieldChunky; + } + + svg { + & > * { + fill: currentColor; + } + } + + &:focus { + border-color: rgba(colors.$black, 0.4); + } + + &:active { + border-color: rgba(colors.$black, 0.4); + } + + &:disabled { + color: colors.$spunPearl; + background: colors.$white; + cursor: not-allowed; + + svg { + & > * { + fill: colors.$alto; + } + } + } + + :not(:last-child) { + margin-right: 10px; + } + + :last-child { + margin: 0; + } + + &-secondary { + background: colors.$brightTurquoise; + + &:hover:not(:disabled) { + background: colors.$javaLight; + } + + &:active:not(:disabled) { + background: colors.$mountainMeadow; + } + + &:disabled { + border: 1px solid colors.$brightTurquoise; + } + } + + &-tertiary { + color: colors.$primary; + background: colors.$white; + border: borders.$primaryBorder; + + svg { + & > * { + fill: colors.$primary; + } + } + + &:hover:not(:disabled) { + background: colors.$alabaster; + } + + &:active:not(:disabled) { + background: colors.$mercury; + } + + &:disabled { + border: borders.$primaryBorder; + } + } + + &-primary { + color: colors.$white; + background: colors.$malibu; + + &:hover:not(:disabled) { + background: colors.$cornflowerBlue; + } + + &:active:not(:disabled) { + background: colors.$indigo; + } + + &:disabled { + border: 1px solid colors.$malibu; + } + } + + &-danger { + color: colors.$white; + background: colors.$amaranth; + + &:hover:not(:disabled) { + background: colors.$ceriseRed; + } + + &:active:not(:disabled) { + background: colors.$maroonFlash; + } + + &:disabled { + border: 1px solid colors.$amaranth; + } + } + + &-label { + color: colors.$primary; + background: transparent; + border: 0; + + svg { + & > * { + fill: colors.$primary; + } + } + + &:focus:not(:disabled) { + border-color: transparent; + } + + &:hover:not(:disabled) { + color: colors.$topaz; + } + + &:active:not(:disabled) { + color: colors.$black; + border-color: transparent; + } + + &:disabled { + border-color: transparent; + } + } +} diff --git a/src/igz-controls/components/ConfirmDialog/ConfirmDialog.jsx b/src/igz-controls/components/ConfirmDialog/ConfirmDialog.jsx new file mode 100644 index 0000000000..5f2a7dda9d --- /dev/null +++ b/src/igz-controls/components/ConfirmDialog/ConfirmDialog.jsx @@ -0,0 +1,115 @@ +/* +Copyright 2022 Iguazio Systems Ltd. +Licensed under the Apache License, Version 2.0 (the "License") with +an addition restriction as set forth herein. You may not use this +file except in compliance with the License. You may obtain a copy of +the License at http://www.apache.org/licenses/LICENSE-2.0. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. +In addition, you may not use the software for any purposes that are +illegal under applicable law, and the grant of the foregoing license +under the Apache 2.0 license is conditioned upon your compliance with +such restriction. +*/ +import React from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' + +import Button from '../Button/Button' +import PopUpDialog from '../PopUpDialog/PopUpDialog' + +import { + CONFIRM_DIALOG_CANCEL_BUTTON, + CONFIRM_DIALOG_MESSAGE, + CONFIRM_DIALOG_SUBMIT_BUTTON +} from '../../types' + +import './confirmDialog.scss' + +const ConfirmDialog = ({ + cancelButton = null, + children = null, + className = '', + closePopUp = null, + confirmButton = null, + customPosition = {}, + header = '', + isOpen = false, + message = '', + messageOnly = false, + onResolve = null +}) => { + const messageClassNames = classnames( + 'confirm-dialog__message', + messageOnly && 'confirm-dialog__message-only' + ) + + const handleCancelDialog = event => { + onResolve && onResolve() + cancelButton.handler && cancelButton.handler(event) + } + + const handleCloseDialog = event => { + onResolve && onResolve() + closePopUp && closePopUp(event) + } + + const handleConfirmDialog = event => { + onResolve && onResolve() + confirmButton.handler && confirmButton.handler(event) + } + + return ( + isOpen && ( + +
+ {message &&
{message}
} + {children &&
{children}
} +
+ {cancelButton && ( +
+
+
+ ) + ) +} + +ConfirmDialog.propTypes = { + cancelButton: CONFIRM_DIALOG_CANCEL_BUTTON, + children: PropTypes.node, + className: PropTypes.string, + closePopUp: PropTypes.func, + confirmButton: CONFIRM_DIALOG_SUBMIT_BUTTON, + customPosition: PropTypes.object, + header: PropTypes.string, + isOpen: PropTypes.bool, + message: CONFIRM_DIALOG_MESSAGE, + messageOnly: PropTypes.bool, + onResolve: PropTypes.func +} + +export default ConfirmDialog diff --git a/src/igz-controls/components/ConfirmDialog/confirmDialog.scss b/src/igz-controls/components/ConfirmDialog/confirmDialog.scss new file mode 100644 index 0000000000..f8c5683b93 --- /dev/null +++ b/src/igz-controls/components/ConfirmDialog/confirmDialog.scss @@ -0,0 +1,24 @@ +@use '../../scss/colors'; + +.confirm-dialog { + color: colors.$primary; + + &__message { + font-size: 15px; + line-height: 24px; + + &-only { + font-size: 22px; + } + } + + &__btn-container { + display: flex; + justify-content: flex-end; + margin-top: 20px; + } + + &__body { + margin: 20px 0; + } +} diff --git a/src/igz-controls/components/CopyToClipboard/CopyToClipboard.jsx b/src/igz-controls/components/CopyToClipboard/CopyToClipboard.jsx new file mode 100644 index 0000000000..32c8bfbe85 --- /dev/null +++ b/src/igz-controls/components/CopyToClipboard/CopyToClipboard.jsx @@ -0,0 +1,87 @@ +/* +Copyright 2019 Iguazio Systems Ltd. + +Licensed under the Apache License, Version 2.0 (the "License") with +an addition restriction as set forth herein. You may not use this +file except in compliance with the License. You may obtain a copy of +the License at http://www.apache.org/licenses/LICENSE-2.0. + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. + +In addition, you may not use the software for any purposes that are +illegal under applicable law, and the grant of the foregoing license +under the Apache 2.0 license is conditioned upon your compliance with +such restriction. +*/ +import PropTypes from 'prop-types' +import { useMemo } from 'react' +import { useDispatch } from 'react-redux' + +import RoundedIcon from '../RoundedIcon/RoundedIcon' +import TextTooltipTemplate from '../TooltipTemplate/TextTooltipTemplate' +import Tooltip from '../Tooltip/Tooltip' + +import { setNotification } from '../../reducers/notificationReducer' +import { showErrorNotification } from '../../utils/notification.util' + +import Copy from '../../images/copy-to-clipboard-icon.svg?react' + +const CopyToClipboard = ({ + children = null, + className = '', + disabled = false, + textToCopy = '', + tooltipText +}) => { + const dispatch = useDispatch() + const copyIsDisabled = useMemo(() => disabled || !textToCopy, [disabled, textToCopy]) + + const copyToClipboard = textToCopy => { + navigator.clipboard + .writeText(textToCopy) + .then(() => { + dispatch( + setNotification({ + status: 200, + id: Math.random(), + message: 'Copied to clipboard successfully' + }) + ) + }) + .catch(error => { + showErrorNotification(dispatch, error, '', 'Copy to clipboard failed') + }) + } + + return ( +
+ {children ? ( + } textShow> + copyToClipboard(textToCopy)}>{children} + + ) : ( + copyToClipboard(textToCopy)} + disabled={copyIsDisabled} + > + + + )} +
+ ) +} + +CopyToClipboard.propTypes = { + children: PropTypes.oneOfType([PropTypes.string, PropTypes.array, PropTypes.element]), + className: PropTypes.string, + disabled: PropTypes.bool, + textToCopy: PropTypes.string, + tooltipText: PropTypes.string.isRequired +} + +export default CopyToClipboard diff --git a/src/igz-controls/components/ErrorMessage/ErrorMessage.jsx b/src/igz-controls/components/ErrorMessage/ErrorMessage.jsx new file mode 100644 index 0000000000..a48ed3295e --- /dev/null +++ b/src/igz-controls/components/ErrorMessage/ErrorMessage.jsx @@ -0,0 +1,56 @@ +/* +Copyright 2019 Iguazio Systems Ltd. + +Licensed under the Apache License, Version 2.0 (the "License") with +an addition restriction as set forth herein. You may not use this +file except in compliance with the License. You may obtain a copy of +the License at http://www.apache.org/licenses/LICENSE-2.0. + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. + +In addition, you may not use the software for any purposes that are +illegal under applicable law, and the grant of the foregoing license +under the Apache 2.0 license is conditioned upon your compliance with +such restriction. +*/ +import React from 'react' +import PropTypes from 'prop-types' + +import Tooltip from '../Tooltip/Tooltip' +import TextTooltipTemplate from '../TooltipTemplate/TextTooltipTemplate' + +import UnsuccessAlert from '../../images/unsuccess_alert.svg?react' +import Close from '../../images/close.svg?react' + +import './errorMessage.scss' + +const ErrorMessage = ({ closeError = null, message }) => { + return ( +
+
+
+ +
+
{message}
+
+ {closeError && ( + + )} +
+ ) +} + +ErrorMessage.propTypes = { + closeError: PropTypes.func, + message: PropTypes.string.isRequired +} + +export default ErrorMessage diff --git a/src/igz-controls/components/ErrorMessage/errorMessage.scss b/src/igz-controls/components/ErrorMessage/errorMessage.scss new file mode 100644 index 0000000000..9e026f4937 --- /dev/null +++ b/src/igz-controls/components/ErrorMessage/errorMessage.scss @@ -0,0 +1,30 @@ +@use '../../scss/borders'; +@use '../../scss/colors'; + +.error { + display: flex; + justify-content: space-between; + padding: 10px 14px; + color: colors.$amaranth; + background-color: rgba(colors.$amaranth, 0.1); + border: borders.$errorBorder; + + &__data { + display: flex; + align-items: center; + } + + &__message { + margin-right: 10px; + word-break: break-word; + } + + &__icon { + width: 22px; + height: 22px; + margin-right: 10px; + padding: 5px; + background-color: colors.$burntSienna; + border-radius: 50%; + } +} diff --git a/src/igz-controls/components/FormCheckBox/FormCheckBox.jsx b/src/igz-controls/components/FormCheckBox/FormCheckBox.jsx new file mode 100644 index 0000000000..88fc1e5a1e --- /dev/null +++ b/src/igz-controls/components/FormCheckBox/FormCheckBox.jsx @@ -0,0 +1,77 @@ +/* +Copyright 2022 Iguazio Systems Ltd. +Licensed under the Apache License, Version 2.0 (the "License") with +an addition restriction as set forth herein. You may not use this +file except in compliance with the License. You may obtain a copy of +the License at http://www.apache.org/licenses/LICENSE-2.0. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. +In addition, you may not use the software for any purposes that are +illegal under applicable law, and the grant of the foregoing license +under the Apache 2.0 license is conditioned upon your compliance with +such restriction. +*/ +import React, { useRef } from 'react' +import PropTypes from 'prop-types' +import { Field } from 'react-final-form' +import classNames from 'classnames' + +import './formCheckBox.scss' + +let FormCheckBox = ({ + children = null, + className = '', + highlightLabel = false, + label = '', + name, + readOnly = false, + ...inputProps +}) => { + const formFieldClassNames = classNames( + 'form-field-checkbox', + readOnly && 'form-field-checkbox_readonly', + className + ) + const labelClassNames = classNames(highlightLabel && 'highlighted') + const inputRef = useRef(null) + + return ( + + {({ input }) => { + return ( +
+ + +
+ ) + }} +
+ ) +} + +FormCheckBox.propTypes = { + children: PropTypes.node, + className: PropTypes.string, + highlightLabel: PropTypes.bool, + label: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), + name: PropTypes.string.isRequired, + readOnly: PropTypes.bool +} + +FormCheckBox = React.memo(FormCheckBox) + +export default FormCheckBox diff --git a/src/igz-controls/components/FormCheckBox/formCheckBox.scss b/src/igz-controls/components/FormCheckBox/formCheckBox.scss new file mode 100644 index 0000000000..007efbd604 --- /dev/null +++ b/src/igz-controls/components/FormCheckBox/formCheckBox.scss @@ -0,0 +1,91 @@ +@use '../../scss/colors'; +@use '../../scss/mixins'; + +.form-field-checkbox { + display: inline-flex; + align-items: center; + justify-content: flex-start; + color: colors.$primary; + + &_readonly { + @include mixins.radioCheckReadonly; + } + + input[type='checkbox'] { + flex: 0 0 18px; + width: 18px; + height: 18px; + border-radius: 4px; + transition: background 0.2s ease-in-out; + + @include mixins.radioCheckField; + + &::before { + content: ''; + display: block; + position: absolute; + top: 1px; + left: 5px; + width: 6px; + height: 11px; + border-style: solid; + border-color: colors.$white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + } + + &:checked { + background: currentColor; + + &:hover { + background: currentColor; + + &:disabled { + background: currentColor; + } + } + } + + &:disabled { + &:hover { + background: colors.$white; + } + } + + &:not(:disabled):checked { + ~ label { + &.highlighted { + color: colors.$white; + background-color: colors.$malibu; + } + } + + &:hover { + ~ label { + &.highlighted { + background-color: colors.$cornflowerBlue; + } + } + } + } + + ~ label { + &.highlighted { + background-color: colors.$mischka; + font-size: 12px; + font-weight: bold; + margin-left: 10px; + padding: 4px 8px; + border-radius: 4px; + } + } + + &:not(:disabled):hover { + ~ label { + &.highlighted { + background-color: colors.$iron; + } + } + } + } +} diff --git a/src/igz-controls/components/FormChipCell/FormChip/FormChip.jsx b/src/igz-controls/components/FormChipCell/FormChip/FormChip.jsx new file mode 100644 index 0000000000..8ab1539e8a --- /dev/null +++ b/src/igz-controls/components/FormChipCell/FormChip/FormChip.jsx @@ -0,0 +1,113 @@ +/* +Copyright 2022 Iguazio Systems Ltd. +Licensed under the Apache License, Version 2.0 (the "License") with +an addition restriction as set forth herein. You may not use this +file except in compliance with the License. You may obtain a copy of +the License at http://www.apache.org/licenses/LICENSE-2.0. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. +In addition, you may not use the software for any purposes that are +illegal under applicable law, and the grant of the foregoing license +under the Apache 2.0 license is conditioned upon your compliance with +such restriction. +*/ +import React, { useLayoutEffect, forwardRef } from 'react' +import PropTypes from 'prop-types' + +import NewChipForm from '../NewChipForm/NewChipForm' + +import { CHIP_OPTIONS } from '../../../types' + +import './formChip.scss' + +let FormChip = ( + { + chip, + chipIndex, + chipSizeIsRecalculated, + setChipSizeIsRecalculated, + chipOptions = { + background: 'purple', + boldValue: false, + borderRadius: 'primary', + borderColor: 'transparent', + density: 'dense', + font: 'purple' + }, + editConfig, + handleEditChip, + handleRemoveChip, + handleToEditMode, + isDeletable = false, + isEditable = false, + keyName = '', + meta, + setChipsSizes, + setEditConfig, + validationRules = {}, + valueName = '' + }, + ref +) => { + const chipRef = React.useRef() + useLayoutEffect(() => { + if (chipRef.current && setChipsSizes && chipSizeIsRecalculated) { + setChipsSizes(state => ({ + ...state, + [chipIndex]: chipRef.current?.getBoundingClientRect?.()?.width ?? 50 + })) + } + }, [chipIndex, chipSizeIsRecalculated, setChipsSizes]) + + return ( +
handleToEditMode(event, chipIndex, keyName)} ref={chipRef}> + +
+ ) +} + +FormChip = forwardRef(FormChip) + +FormChip.displayName = 'FormChip' + +FormChip.propTypes = { + chip: PropTypes.object.isRequired, + chipSizeIsRecalculated: PropTypes.bool.isRequired, + setChipSizeIsRecalculated: PropTypes.func.isRequired, + chipIndex: PropTypes.number.isRequired, + chipOptions: CHIP_OPTIONS, + editConfig: PropTypes.object.isRequired, + handleEditChip: PropTypes.func.isRequired, + handleRemoveChip: PropTypes.func.isRequired, + handleToEditMode: PropTypes.func.isRequired, + isDeletable: PropTypes.bool, + isEditable: PropTypes.bool, + keyName: PropTypes.string, + meta: PropTypes.object.isRequired, + setChipsSizes: PropTypes.func.isRequired, + setEditConfig: PropTypes.func.isRequired, + validationRules: PropTypes.object, + valueName: PropTypes.string +} + +export default FormChip diff --git a/src/igz-controls/components/FormChipCell/FormChip/formChip.scss b/src/igz-controls/components/FormChipCell/FormChip/formChip.scss new file mode 100644 index 0000000000..61e67e5026 --- /dev/null +++ b/src/igz-controls/components/FormChipCell/FormChip/formChip.scss @@ -0,0 +1,71 @@ +@use '../../../scss/mixins'; + +.chip { + position: relative; + margin: 2px 8px 2px 0; + padding: 4px 8px; + font-size: 14px; + line-height: 16px; + visibility: hidden; + cursor: default; + + &_visible { + visibility: visible; + } + + &_invisible { + visibility: hidden; + height: 30px; + } + + &__content { + display: flex; + align-items: center; + + &-item { + flex: 1 1 50%; + max-width: fit-content; + align-self: flex-start; + } + } + + &__delimiter { + display: flex; + align-items: center; + margin: 0 4px; + } + + &__value { + min-width: 10px; + } + + &.editable { + cursor: pointer; + } + + &.chips_button { + padding: 8px 7px; + width: max-content; + } + + &-background { + @include mixins.chipBackground(false); + } + + &-border { + @include mixins.chipBorder(); + } + + &-density { + @include mixins.chipDensity(false, false); + } + + &-font { + @include mixins.chipsFont(Chip); + } + + &-value_bold { + font-weight: 700; + font-size: 15px; + } +} diff --git a/src/igz-controls/components/FormChipCell/FormChipCell.jsx b/src/igz-controls/components/FormChipCell/FormChipCell.jsx new file mode 100644 index 0000000000..c2553fa20f --- /dev/null +++ b/src/igz-controls/components/FormChipCell/FormChipCell.jsx @@ -0,0 +1,425 @@ +/* +Copyright 2022 Iguazio Systems Ltd. +Licensed under the Apache License, Version 2.0 (the "License") with +an addition restriction as set forth herein. You may not use this +file except in compliance with the License. You may obtain a copy of +the License at http://www.apache.org/licenses/LICENSE-2.0. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. +In addition, you may not use the software for any purposes that are +illegal under applicable law, and the grant of the foregoing license +under the Apache 2.0 license is conditioned upon your compliance with +such restriction. +*/ +import React, { useState, useCallback, useMemo } from 'react' +import lodash, { get, isEmpty, set, isNil } from 'lodash' +import classnames from 'classnames' +import PropTypes from 'prop-types' + +import FormChipCellView from './FormChipCellView' + +import { CHIP_OPTIONS, VISIBLE_CHIPS_MAX_LENGTH } from '../../types' +import { CLICK, TAB, TAB_SHIFT } from '../../constants' +import { areArraysEqual } from '../../utils/common.util' +import { checkPatternsValidity } from '../../utils/validation.util' +import { generateChipsList } from '../../utils/generateChipsList.util' +import { uniquenessError } from './formChipCell.util' +import { useChipCell } from '../../hooks' + +import './formChipCell.scss' + +let FormChipCell = ({ + chipOptions = { + background: 'purple', + boldValue: false, + borderRadius: 'primary', + borderColor: 'transparent', + density: 'dense', + font: 'purple' + }, + className = '', + children, + delimiter = null, + formState, + initialValues, + isDeletable = false, + isEditable = false, + label = null, + name, + onClick = () => {}, + onExitEditModeCallback = null, + shortChips = false, + validationRules = {}, + validator = null, + visibleChipsMaxLength = null, + withInitialParentWidth = false +}) => { + const chipsClassName = classnames('chips', className) + const [chipSizeIsRecalculated, setChipSizeIsRecalculated] = useState(false) + const { + chipsCellRef, + chipsWrapperRef, + handleShowElements, + hiddenChipsCounterRef, + hiddenChipsPopUpRef, + setChipsSizes, + setShowHiddenChips, + showChips, + showHiddenChips, + visibleChipsCount + } = useChipCell(isEditable, visibleChipsMaxLength, withInitialParentWidth) + + const [editConfig, setEditConfig] = useState({ + chipIndex: null, + isEdit: false, + isKeyFocused: false, + isValueFocused: false, + isNewChip: false + }) + + let chips = useMemo(() => { + return isEditable || visibleChipsMaxLength === 'all' + ? { + visibleChips: get(formState.values, name), + hiddenChips: [] + } + : generateChipsList( + get(formState.values, name), + visibleChipsMaxLength ? visibleChipsMaxLength : visibleChipsCount + ) + }, [visibleChipsMaxLength, isEditable, visibleChipsCount, formState.values, name]) + + const checkChipsList = useCallback( + currentChipsList => { + if (areArraysEqual(get(initialValues, name), currentChipsList, ['id'])) { + set(formState.initialValues, name, currentChipsList) + } + + formState.form.mutators.setFieldState(name, { modified: true }) + formState.form.mutators.setFieldState(name, { touched: true }) + }, + [initialValues, name, formState] + ) + + const handleAddNewChip = useCallback( + (event, fields) => { + const fieldsLength = fields.value?.length || 0 + + if (!editConfig.isEdit && !editConfig.chipIndex) { + formState.form.mutators.push(name, { + id: fieldsLength + new Date(), + key: '', + value: '', + delimiter: delimiter + }) + } + + if (showHiddenChips) { + setShowHiddenChips(false) + } + + setEditConfig({ + chipIndex: fieldsLength, + isEdit: true, + isKeyFocused: true, + isValueFocused: false, + isNewChip: true + }) + + event && event.preventDefault() + }, + [ + editConfig.isEdit, + editConfig.chipIndex, + showHiddenChips, + formState.form.mutators, + name, + delimiter, + setShowHiddenChips + ] + ) + + const handleRemoveChip = useCallback( + (event, fields, chipIndex, isOutsideClick = false) => { + checkChipsList( + lodash + .chain(formState) + .get(['values', name]) + .filter((_, index) => index !== chipIndex) + .value() + ) + + if (fields.length === 1) { + formState.form.change(name, []) + } else { + fields.remove(chipIndex) + } + + onExitEditModeCallback && onExitEditModeCallback() + event && !isOutsideClick && event.stopPropagation() + }, + [checkChipsList, formState, name, onExitEditModeCallback] + ) + + const handleEditChip = useCallback( + (event, fields, nameEvent, isOutsideClick) => { + const { key, value } = fields.value[editConfig.chipIndex] + const isChipNotEmpty = !!(key?.trim() && value?.trim()) + + if (nameEvent === CLICK) { + if (!isChipNotEmpty) { + handleRemoveChip(event, fields, editConfig.chipIndex, isOutsideClick) + } + + setEditConfig({ + chipIndex: null, + isEdit: false, + isKeyFocused: false, + isValueFocused: false, + isNewChip: false + }) + isChipNotEmpty && onExitEditModeCallback && onExitEditModeCallback() + } else if (nameEvent === TAB) { + if (!isChipNotEmpty) { + handleRemoveChip(event, fields, editConfig.chipIndex) + } + + setEditConfig(prevState => { + const lastChipSelected = prevState.chipIndex + 1 > fields.value.length - 1 + + isChipNotEmpty && lastChipSelected && onExitEditModeCallback && onExitEditModeCallback() + + return { + chipIndex: lastChipSelected ? null : prevState.chipIndex + 1, + isEdit: !lastChipSelected, + isKeyFocused: !lastChipSelected, + isValueFocused: false, + isNewChip: false + } + }) + } else if (nameEvent === TAB_SHIFT) { + if (!isChipNotEmpty) { + handleRemoveChip(event, fields, editConfig.chipIndex) + } + + setEditConfig(prevState => { + const firstChipIsSelected = prevState.chipIndex === 0 + + isChipNotEmpty && + firstChipIsSelected && + onExitEditModeCallback && + onExitEditModeCallback() + + return { + chipIndex: firstChipIsSelected ? null : prevState.chipIndex - 1, + isEdit: !firstChipIsSelected, + isKeyFocused: false, + isValueFocused: !firstChipIsSelected, + isNewChip: false + } + }) + } + + checkChipsList(get(formState.values, name)) + + if ( + (editConfig.chipIndex > 0 && editConfig.chipIndex < fields.value.length - 1) || + (fields.value.length > 1 && editConfig.chipIndex === 0 && nameEvent !== TAB_SHIFT) || + (fields.value.length > 1 && + editConfig.chipIndex === fields.value.length - 1 && + nameEvent !== TAB) + ) { + event && event.preventDefault() + } + }, + [ + editConfig.chipIndex, + checkChipsList, + formState.values, + name, + onExitEditModeCallback, + handleRemoveChip + ] + ) + + const handleToEditMode = useCallback( + (event, chipIndex, keyName) => { + if (isEditable) { + const { clientX: pointerCoordinateX, clientY: pointerCoordinateY } = event + let isKeyClicked = false + const isClickedInsideInputElement = ( + pointerCoordinateX, + pointerCoordinateY, + inputElement + ) => { + if (inputElement) { + const { + top: topPosition, + left: leftPosition, + right: rightPosition, + bottom: bottomPosition + } = inputElement.getBoundingClientRect() + if (pointerCoordinateX > rightPosition || pointerCoordinateX < leftPosition) + return false + if (pointerCoordinateY > bottomPosition || pointerCoordinateY < topPosition) + return false + + return true + } + } + event.stopPropagation() + + if (event.target.nodeName !== 'INPUT') { + if (event.target.firstElementChild) { + isKeyClicked = isClickedInsideInputElement( + pointerCoordinateX, + pointerCoordinateY, + event.target.firstElementChild + ) + } + } else { + isKeyClicked = event.target.name === keyName + } + + setEditConfig(preState => ({ + ...preState, + chipIndex, + isEdit: true, + isKeyFocused: isKeyClicked, + isValueFocused: !isKeyClicked + })) + } + + onClick && onClick() + }, + [isEditable, onClick] + ) + + const validateFields = fieldsArray => { + if (!fieldsArray) return null + + let errorData = [] + + const uniquenessValidator = (newValue, idx) => { + return !fieldsArray.some(({ key }, index) => { + return newValue === key && index !== idx + }) + } + + if (!isEmpty(validationRules)) { + errorData = fieldsArray.map(chip => { + const [keyValidation, valueValidation] = validateChip(chip) + + if (keyValidation && valueValidation) return { key: keyValidation, value: valueValidation } + + if (keyValidation) return { key: keyValidation } + + if (valueValidation) return { value: valueValidation } + + return null + }) + } + + // uniqueness + fieldsArray.forEach((chip, index) => { + const isUnique = uniquenessValidator(chip.key, index) + + if (!isUnique) { + if (get(errorData, [index, 'key'], false)) { + errorData.at(index).key.push(uniquenessError) + } else { + set(errorData, [index, 'key'], [uniquenessError]) + } + } + }) + + if (isEmpty(errorData) && validator) { + errorData = validator(fieldsArray) + } + + if (errorData.every(label => isNil(label))) { + return null + } + + return errorData + } + + const validateChip = ({ key, value, disabled }) => { + const validateField = (value, field) => { + const [newRules, isValidField] = checkPatternsValidity( + validationRules[field].filter(rule => rule.pattern), + value + ) + + if (isValidField) return null + + const invalidRules = newRules.filter(rule => !rule.isValid) + + return invalidRules.map(rule => ({ name: rule.name, label: rule.label })) + } + + return disabled ? [null, null] : [validateField(key, 'key'), validateField(value, 'value')] + } + + return ( +
+ {label &&
{label}
} +
+ + {children} + +
+
+ ) +} + +FormChipCell.propTypes = { + chipOptions: CHIP_OPTIONS, + children: PropTypes.node, + className: PropTypes.string, + delimiter: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), + formState: PropTypes.object.isRequired, + initialValues: PropTypes.object.isRequired, + isDeletable: PropTypes.bool, + isEditable: PropTypes.bool, + label: PropTypes.string, + name: PropTypes.string.isRequired, + onClick: PropTypes.func, + onExitEditModeCallback: PropTypes.func, + shortChips: PropTypes.bool, + validationRules: PropTypes.object, + validator: PropTypes.func, + visibleChipsMaxLength: VISIBLE_CHIPS_MAX_LENGTH, + withInitialParentWidth: PropTypes.bool +} + +FormChipCell = React.memo(FormChipCell) + +export default FormChipCell diff --git a/src/igz-controls/components/FormChipCell/FormChipCellView.jsx b/src/igz-controls/components/FormChipCell/FormChipCellView.jsx new file mode 100644 index 0000000000..8eb592b430 --- /dev/null +++ b/src/igz-controls/components/FormChipCell/FormChipCellView.jsx @@ -0,0 +1,245 @@ +/* +Copyright 2022 Iguazio Systems Ltd. +Licensed under the Apache License, Version 2.0 (the "License") with +an addition restriction as set forth herein. You may not use this +file except in compliance with the License. You may obtain a copy of +the License at http://www.apache.org/licenses/LICENSE-2.0. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. +In addition, you may not use the software for any purposes that are +illegal under applicable law, and the grant of the foregoing license +under the Apache 2.0 license is conditioned upon your compliance with +such restriction. +*/ +import React, { forwardRef } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import { FieldArray } from 'react-final-form-arrays' +import { isEmpty } from 'lodash' + +import FormChip from './FormChip/FormChip' +import HiddenChipsBlock from './HiddenChipsBlock/HiddenChipsBlock' +import TextTooltipTemplate from '../TooltipTemplate/TextTooltipTemplate' +import Tooltip from '../Tooltip/Tooltip' + +import { CHIP_OPTIONS, VISIBLE_CHIPS_MAX_LENGTH } from '../../types' +import { isEveryObjectValueEmpty } from '../../utils/common.util' +import { uniquenessError } from './formChipCell.util' + +import Add from '../../images/add.svg?react' + +let FormChipCellView = ( + { + chipOptions = { + background: 'purple', + boldValue: false, + borderRadius: 'primary', + borderColor: 'transparent', + density: 'dense', + font: 'purple' + }, + chipSizeIsRecalculated, + setChipSizeIsRecalculated, + children, + chips, + editConfig, + handleAddNewChip, + handleEditChip, + handleRemoveChip, + handleShowElements, + handleToEditMode, + isDeletable = false, + isEditable = false, + name, + setChipsSizes, + setEditConfig, + shortChips = false, + showChips, + showHiddenChips, + validateFields, + validationRules = {}, + visibleChipsMaxLength = null + }, + { chipsCellRef, chipsWrapperRef, hiddenChipsCounterRef, hiddenChipsPopUpRef } +) => { + const buttonAddClassNames = classnames( + 'button-add', + chipOptions.background && `button-add-background_${chipOptions.background}`, + chipOptions.borderColor && `button-add-border_${chipOptions.borderColor}`, + chipOptions.font && `button-add-font_${chipOptions.font}`, + chipOptions.density && `button-add-density_${chipOptions.density}` + ) + const wrapperClassNames = classnames( + 'chips-wrapper', + isEditable && 'fixed-max-width', + chips.visibleChips?.length > 0 && !chipSizeIsRecalculated && 'chip_invisible', + visibleChipsMaxLength === 'all' && 'chips-wrapper_all-visible' + ) + const chipClassNames = classnames( + 'chip', + 'chip__content', + isEditable && 'data-ellipsis', + shortChips && 'chip_short', + chips.hiddenChips && 'chip_hidden', + chipOptions.density && `chip-density_${chipOptions.density}`, + chipOptions.borderRadius && `chip-border_${chipOptions.borderRadius}`, + chipOptions.background && `chip-background_${chipOptions.background}`, + chipOptions.borderColor && `chip-border_${chipOptions.borderColor}`, + chipOptions.font && `chip-font_${chipOptions.font}`, + isEditable && 'editable', + (showChips || isEditable) && 'chip_visible' + ) + + return ( + + {({ fields, meta }) => { + let newValidationRules = { ...validationRules } + + if ( + !isEmpty(validationRules) && + validationRules.key.every(rule => rule.name !== uniquenessError.name) + ) { + newValidationRules = { + ...validationRules, + key: [...validationRules.key, uniquenessError] + } + } + + return ( + (isEditable || !isEveryObjectValueEmpty(fields)) && ( +
+
+ {fields.map((contentItem, index) => { + const chipData = fields.value[index] + + return ( + index < chips.visibleChips?.length && ( +
+ +
+ ) + ) + })} + +
+ {chips.hiddenChips.length > 0 && showHiddenChips && ( + + )} + {chips.hiddenChipsNumber && ( + + {chips.hiddenChipsNumber} + + )} +
+ + {isEditable && ( + + )} + {children} +
+
+ ) + ) + }} +
+ ) +} + +FormChipCellView = forwardRef(FormChipCellView) + +FormChipCellView.displayName = 'FormChipCellView' + +FormChipCellView.propTypes = { + chipOptions: CHIP_OPTIONS, + chipSizeIsRecalculated: PropTypes.bool.isRequired, + setChipSizeIsRecalculated: PropTypes.func.isRequired, + children: PropTypes.node, + chips: PropTypes.object.isRequired, + editConfig: PropTypes.object.isRequired, + formState: PropTypes.object.isRequired, + handleAddNewChip: PropTypes.func.isRequired, + handleEditChip: PropTypes.func.isRequired, + handleRemoveChip: PropTypes.func.isRequired, + handleShowElements: PropTypes.func.isRequired, + handleToEditMode: PropTypes.func.isRequired, + isDeletable: PropTypes.bool, + isEditable: PropTypes.bool, + name: PropTypes.string.isRequired, + setChipsSizes: PropTypes.func.isRequired, + setEditConfig: PropTypes.func.isRequired, + shortChips: PropTypes.bool, + showChips: PropTypes.bool.isRequired, + showHiddenChips: PropTypes.bool.isRequired, + validateFields: PropTypes.func.isRequired, + validationRules: PropTypes.object, + visibleChipsMaxLength: VISIBLE_CHIPS_MAX_LENGTH +} + +export default FormChipCellView diff --git a/src/igz-controls/components/FormChipCell/HiddenChipsBlock/HiddenChipsBlock.jsx b/src/igz-controls/components/FormChipCell/HiddenChipsBlock/HiddenChipsBlock.jsx new file mode 100644 index 0000000000..cf1f3eadaf --- /dev/null +++ b/src/igz-controls/components/FormChipCell/HiddenChipsBlock/HiddenChipsBlock.jsx @@ -0,0 +1,117 @@ +/* +Copyright 2022 Iguazio Systems Ltd. +Licensed under the Apache License, Version 2.0 (the "License") with +an addition restriction as set forth herein. You may not use this +file except in compliance with the License. You may obtain a copy of +the License at http://www.apache.org/licenses/LICENSE-2.0. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. +In addition, you may not use the software for any purposes that are +illegal under applicable law, and the grant of the foregoing license +under the Apache 2.0 license is conditioned upon your compliance with +such restriction. +*/ +import React, { forwardRef, useEffect } from 'react' +import { createPortal } from 'react-dom' +import PropTypes from 'prop-types' +import classnames from 'classnames' + +import Tooltip from '../../Tooltip/Tooltip' +import TextTooltipTemplate from '../../TooltipTemplate/TextTooltipTemplate' + +import { CHIP_OPTIONS } from '../../../types' +import { useHiddenChipsBlock } from '../../../hooks' + +let HiddenChipsBlock = ( + { chipClassNames, chipOptions, chips, handleShowElements, textOverflowEllipsis = false }, + { hiddenChipsCounterRef, hiddenChipsPopUpRef } +) => { + const { hiddenChipsBlockClassNames } = useHiddenChipsBlock( + hiddenChipsCounterRef, + hiddenChipsPopUpRef + ) + + const chipLabelClassNames = classnames('chip__label', textOverflowEllipsis && 'data-ellipsis') + const chipValueClassNames = classnames( + 'chip__value', + textOverflowEllipsis && 'data-ellipsis', + chipOptions.boldValue && 'chip-value_bold' + ) + + const generateChipData = chip => { + return chip.isKeyOnly + ? chip.key + : `${chip.key}${chip.delimiter ? chip.delimiter : ':'} ${chip.value}` + } + + useEffect(() => { + if (chips.length === 0) { + handleShowElements() + } + }) + + return createPortal( +
event.stopPropagation()} + > +
+ {chips?.map(element => { + return ( + + {element.key} + {!element.isKeyOnly && ( + <> + {element.delimiter} + {element.value} + + )} + + ) : ( + generateChipData(element) + ) + } + /> + } + > +
+ {element.key &&
{element.key}
} + {element.value && ( + <> +
{element.delimiter ?? ':'}
+
{element.value}
+ + )} +
+
+ ) + })} +
+
, + document.getElementById('overlay_container') + ) +} + +HiddenChipsBlock = forwardRef(HiddenChipsBlock) + +HiddenChipsBlock.displayName = 'HiddenChipsBlock' + +HiddenChipsBlock.propTypes = { + chipClassNames: PropTypes.string.isRequired, + chipOptions: CHIP_OPTIONS.isRequired, + chips: PropTypes.array.isRequired, + handleShowElements: PropTypes.func.isRequired, + textOverflowEllipsis: PropTypes.bool +} + +export default HiddenChipsBlock diff --git a/src/igz-controls/components/FormChipCell/NewChipForm/NewChipForm.jsx b/src/igz-controls/components/FormChipCell/NewChipForm/NewChipForm.jsx new file mode 100644 index 0000000000..2648ef9493 --- /dev/null +++ b/src/igz-controls/components/FormChipCell/NewChipForm/NewChipForm.jsx @@ -0,0 +1,505 @@ +/* +Copyright 2022 Iguazio Systems Ltd. +Licensed under the Apache License, Version 2.0 (the "License") with +an addition restriction as set forth herein. You may not use this +file except in compliance with the License. You may obtain a copy of +the License at http://www.apache.org/licenses/LICENSE-2.0. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. +In addition, you may not use the software for any purposes that are +illegal under applicable law, and the grant of the foregoing license +under the Apache 2.0 license is conditioned upon your compliance with +such restriction. +*/ +import React, { + useState, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + forwardRef +} from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import { isEmpty, get, isNil, throttle } from 'lodash' + +import NewChipInput from '../NewChipInput/NewChipInput' +import OptionsMenu from '../../../elements/OptionsMenu/OptionsMenu' +import ValidationTemplate from '../../../elements/ValidationTemplate/ValidationTemplate' + +import { CHIP_OPTIONS } from '../../../types' +import { CLICK, TAB, TAB_SHIFT } from '../../../constants' +import { getTextWidth } from '../formChipCell.util' +import { getTransitionEndEventName } from '../../../utils/common.util' + +import Close from '../../../images/close.svg?react' + +import './newChipForm.scss' + +const defaultProps = { + rules: {} +} + +let NewChipForm = ( + { + chip, + chipIndex, + chipOptions, + className = '', + editConfig, + handleRemoveChip, + isDeletable, + isEditable, + keyName, + meta, + onChange, + setChipSizeIsRecalculated, + setEditConfig, + validationRules: rules = defaultProps.rules, + valueName + }, + ref +) => { + const [chipData, setChipData] = useState({ + isKeyOnly: chip.isKeyOnly, + key: chip.key, + value: chip.value, + keyFieldWidth: 0, + valueFieldWidth: 0 + }) + const [selectedInput, setSelectedInput] = useState('key') + const [validationRules, setValidationRules] = useState(rules) + const [showValidationRules, setShowValidationRules] = useState(false) + + const { background, borderColor, borderRadius, density, font } = chipOptions + const minWidthInput = useMemo(() => { + return isEditable ? 25 : 20 + }, [isEditable]) + const minWidthValueInput = useMemo(() => { + return isEditable ? 35 : 20 + }, [isEditable]) + const transitionEndEventName = useMemo(() => getTransitionEndEventName(), []) + + const refInputKey = React.useRef({}) + const refInputValue = React.useRef({}) + const refInputContainer = React.useRef() + const validationRulesRef = React.useRef() + + const labelKeyClassName = classnames( + className, + !editConfig.isKeyFocused && 'item_edited', + !isEmpty(get(meta, ['error', chipIndex, 'key'], [])) && + !isEmpty(chipData.key) && + !chip.disabled && + isEditable && + 'item_edited_invalid' + ) + const labelContainerClassName = classnames( + 'edit-chip-container', + background && `edit-chip-container-background_${background}`, + borderColor && `edit-chip-container-border_${borderColor}`, + font && `edit-chip-container-font_${font}`, + density && `edit-chip-container-density_${density}`, + borderRadius && `edit-chip-container-border_${borderRadius}`, + (editConfig.isEdit || editConfig.isNewChip) && 'edit-chip-container_edited', + isEditable && chip.disabled && 'edit-chip-container_disabled edit-chip-container-font_disabled' + ) + const labelValueClassName = classnames( + 'input-label-value', + !editConfig.isValueFocused && 'item_edited', + !isEmpty(get(meta, ['error', chipIndex, 'value'], [])) && + !isEmpty(chipData.value) && + isEditable && + 'item_edited_invalid' + ) + + const closeButtonClass = classnames( + 'item-icon-close', + !chip.disabled && + ((editConfig.chipIndex === chipIndex && isEditable) || (!isDeletable && !isEditable)) && + 'item-icon-close invisible', + !isEditable && !isDeletable && 'item-icon-close hidden' + ) + + const resizeChip = useCallback(() => { + if (refInputKey.current) { + const currentWidthKeyInput = getTextWidth(refInputKey.current) + 1 + const currentWidthValueInput = getTextWidth(refInputValue.current) + 1 + const maxWidthInput = ref.current?.clientWidth - 50 + const keyEllipsis = currentWidthKeyInput >= maxWidthInput / 2 + const valueEllipsis = currentWidthValueInput >= maxWidthInput / 2 + let keyFieldWidth = null + let valueFieldWidth = null + + if (keyEllipsis && valueEllipsis) { + keyFieldWidth = valueFieldWidth = maxWidthInput / 2 + } else if (keyEllipsis) { + valueFieldWidth = !chipData.value ? minWidthValueInput : currentWidthValueInput + + const remainingPlace = maxWidthInput - valueFieldWidth + + keyFieldWidth = + remainingPlace > currentWidthKeyInput ? currentWidthKeyInput : remainingPlace + } else if (valueEllipsis) { + keyFieldWidth = !chipData.key ? minWidthInput : currentWidthKeyInput + + const remainingPlace = maxWidthInput - keyFieldWidth + + valueFieldWidth = + remainingPlace > currentWidthValueInput ? currentWidthValueInput : remainingPlace + } else { + keyFieldWidth = + !chipData.key || currentWidthKeyInput <= minWidthInput + ? minWidthInput + : currentWidthKeyInput + valueFieldWidth = + !chipData.value || currentWidthValueInput <= minWidthValueInput + ? minWidthValueInput + : currentWidthValueInput + } + + refInputKey.current.style.width = `${keyFieldWidth}px` + + if (!isEmpty(refInputValue.current)) { + refInputValue.current.style.width = `${valueFieldWidth}px` + } + + setChipData(prevState => ({ + ...prevState, + keyFieldWidth, + valueFieldWidth + })) + setChipSizeIsRecalculated(true) + } + }, [ + chipData.key, + chipData.value, + minWidthInput, + minWidthValueInput, + ref, + setChipSizeIsRecalculated + ]) + + useEffect(() => { + if (!ref.current) return + + const element = ref.current + const observer = new ResizeObserver(resizeChip) + + observer.observe(element) + + return () => observer.unobserve(element) + }, [ref, resizeChip]) + + useEffect(() => { + const resizeChipDebounced = throttle(resizeChip, 500) + + if (isEditable) { + window.addEventListener('resize', resizeChipDebounced) + window.addEventListener(transitionEndEventName, resizeChipDebounced) + + return () => { + window.removeEventListener('resize', resizeChipDebounced) + window.removeEventListener(transitionEndEventName, resizeChipDebounced) + } + } + }, [isEditable, resizeChip, transitionEndEventName]) + + useLayoutEffect(() => { + if (!chipData.keyFieldWidth && !chipData.valueFieldWidth) { + resizeChip() + } + }, [chipData.keyFieldWidth, chipData.valueFieldWidth, resizeChip]) + + const outsideClick = useCallback( + (event, forceOutsideClick) => { + if (editConfig.chipIndex === chipIndex) { + const elementPath = event.path ?? event.composedPath?.() + + if (!elementPath.includes(refInputContainer.current) || forceOutsideClick) { + onChange(event, CLICK, true) + window.getSelection().removeAllRanges() + document.activeElement.blur() + } else { + event.stopPropagation() + } + } + }, + [onChange, refInputContainer, chipIndex, editConfig.chipIndex] + ) + + const handleScroll = useCallback( + event => { + if (validationRulesRef?.current && !validationRulesRef.current.contains(event.target)) { + setShowValidationRules(false) + outsideClick(event, true) + } + }, + [outsideClick] + ) + + useEffect(() => { + if (showValidationRules) { + window.addEventListener('scroll', handleScroll, true) + } + return () => { + window.removeEventListener('scroll', handleScroll, true) + } + }, [handleScroll, showValidationRules]) + + useEffect(() => { + if (editConfig.chipIndex === chipIndex) { + if (editConfig.isKeyFocused) { + refInputKey.current.focus() + } else if (editConfig.isValueFocused) { + refInputValue.current.focus() + } + } + }, [ + editConfig.isKeyFocused, + editConfig.isValueFocused, + refInputKey, + refInputValue, + chipIndex, + editConfig.chipIndex + ]) + + useEffect(() => { + if (showValidationRules) { + window.addEventListener('scroll', handleScroll, true) + } + return () => { + window.removeEventListener('scroll', handleScroll, true) + } + }, [handleScroll, showValidationRules]) + + useEffect(() => { + if (editConfig.isEdit) { + document.addEventListener('click', outsideClick, true) + + return () => { + document.removeEventListener('click', outsideClick, true) + } + } + }, [outsideClick, editConfig.isEdit]) + + const focusChip = useCallback( + event => { + if (editConfig.chipIndex === chipIndex && isEditable) { + if (!event.shiftKey && event.key === TAB && editConfig.isValueFocused) { + return onChange(event, TAB) + } else if (event.shiftKey && event.key === TAB && editConfig.isKeyFocused) { + return onChange(event, TAB_SHIFT) + } + } + event.stopPropagation() + }, + [editConfig, onChange, chipIndex, isEditable] + ) + + const handleOnFocus = useCallback( + event => { + const isKeyFocused = event.target.name === keyName + + if (editConfig.chipIndex === chipIndex) { + if (isKeyFocused) { + refInputKey.current.selectionStart = refInputKey.current.selectionEnd + + setEditConfig(prevConfig => ({ + ...prevConfig, + isKeyFocused: true, + isValueFocused: false + })) + } else { + refInputValue.current.selectionStart = refInputValue.current.selectionEnd + + setEditConfig(prevConfig => ({ + ...prevConfig, + isKeyFocused: false, + isValueFocused: true + })) + } + + event && event.stopPropagation() + } else if (isNil(editConfig.chipIndex)) { + if (isKeyFocused) { + refInputKey.current.selectionStart = refInputKey.current.selectionEnd + } else { + refInputValue.current.selectionStart = refInputValue.current.selectionEnd + } + setEditConfig({ + chipIndex, + isEdit: true, + isKeyFocused: isKeyFocused, + isValueFocused: !isKeyFocused + }) + } + }, + [keyName, refInputKey, refInputValue, setEditConfig, editConfig.chipIndex, chipIndex] + ) + + const handleOnChange = useCallback( + event => { + const maxWidthInput = ref.current?.clientWidth - 50 + + event.preventDefault() + + if (event.target.name === keyName) { + const currentWidthKeyInput = getTextWidth(refInputKey.current) + + setChipData(prevState => ({ + ...prevState, + key: refInputKey.current.value, + keyFieldWidth: + refInputKey.current.value.length <= 1 + ? minWidthInput + : currentWidthKeyInput >= maxWidthInput + ? maxWidthInput + : currentWidthKeyInput > minWidthInput + ? currentWidthKeyInput + 2 + : minWidthInput + })) + } else { + const currentWidthValueInput = getTextWidth(refInputValue.current) + + setChipData(prevState => ({ + ...prevState, + value: refInputValue.current.value, + valueFieldWidth: + refInputValue.current.value?.length <= 1 + ? minWidthValueInput + : currentWidthValueInput >= maxWidthInput + ? maxWidthInput + : currentWidthValueInput > minWidthValueInput + ? currentWidthValueInput + 2 + : minWidthValueInput + })) + } + }, + [keyName, minWidthInput, ref, minWidthValueInput] + ) + + useLayoutEffect(() => { + if (editConfig.chipIndex === chipIndex) { + setSelectedInput(editConfig.isKeyFocused ? 'key' : editConfig.isValueFocused ? 'value' : null) + } + }, [editConfig.isKeyFocused, editConfig.isValueFocused, editConfig.chipIndex, chipIndex]) + + useEffect(() => { + if (meta.valid && showValidationRules) { + setShowValidationRules(false) + } + }, [meta.valid, showValidationRules]) + + useEffect(() => { + if (meta.error) { + setValidationRules(prevState => { + return { + ...prevState, + [selectedInput]: prevState[selectedInput]?.map(rule => { + return { + ...rule, + isValid: isEmpty(get(meta, ['error', editConfig.chipIndex, selectedInput], [])) + ? true + : !meta.error[editConfig.chipIndex][selectedInput].some( + err => err && err.name === rule.name + ) + } + }) + } + }) + + !showValidationRules && setShowValidationRules(true) + } + }, [meta, showValidationRules, selectedInput, editConfig.chipIndex]) + + const getValidationRules = useCallback(() => { + return validationRules[selectedInput]?.map(({ isValid = false, label, name }) => { + return + }) + }, [selectedInput, validationRules]) + + return ( +
!chip.disabled && editConfig.isEdit && focusChip(event)} + ref={refInputContainer} + > + + {!chipData.isKeyOnly &&
:
} + {!chipData.isKeyOnly && ( + + )} + + + + {!chip.disabled && + (editConfig.isKeyFocused ? !isEmpty(chipData.key) : !isEmpty(chipData.value)) && + editConfig.chipIndex === chipIndex && + !isEmpty(get(meta, ['error', editConfig.chipIndex, selectedInput], [])) && ( + + {getValidationRules()} + + )} +
+ ) +} + +NewChipForm = forwardRef(NewChipForm) + +NewChipForm.displayName = 'NewChipForm' + +NewChipForm.propTypes = { + chip: PropTypes.object.isRequired, + chipIndex: PropTypes.number.isRequired, + chipOptions: CHIP_OPTIONS.isRequired, + className: PropTypes.string, + editConfig: PropTypes.object.isRequired, + handleRemoveChip: PropTypes.func.isRequired, + isDeletable: PropTypes.bool.isRequired, + isEditable: PropTypes.bool.isRequired, + keyName: PropTypes.string.isRequired, + meta: PropTypes.object.isRequired, + onChange: PropTypes.func.isRequired, + setChipSizeIsRecalculated: PropTypes.func.isRequired, + setEditConfig: PropTypes.func.isRequired, + validationRules: PropTypes.object, + valueName: PropTypes.string.isRequired +} + +export default NewChipForm diff --git a/src/igz-controls/components/FormChipCell/NewChipForm/newChipForm.scss b/src/igz-controls/components/FormChipCell/NewChipForm/newChipForm.scss new file mode 100644 index 0000000000..ac105fab87 --- /dev/null +++ b/src/igz-controls/components/FormChipCell/NewChipForm/newChipForm.scss @@ -0,0 +1,80 @@ +@use '../../../scss/colors'; +@use '../../../scss/mixins'; + +.edit-chip { + &-container { + display: inline-flex; + max-width: 100%; + margin: 2px 0 2px 0; + padding: 0 8px; + font-size: 14px; + line-height: 22px; + + input { + display: flex; + padding: 0; + font-size: 14px; + background-color: transparent; + border: none; + + &[disabled] { + pointer-events: none; + } + + &.item_edited { + &_invalid { + color: colors.$amaranth; + } + } + + &.input-label-key, + &.input-label-value { + &::placeholder { + color: colors.$topaz; + } + } + } + + &-background { + @include mixins.chipBackground(false); + } + + &-border { + @include mixins.chipBorder(); + } + + &-density { + @include mixins.chipDensity(true, false); + } + + &-font { + @include mixins.chipsFont(EditableChip); + } + + button.item-icon-close { + display: flex; + align-items: center; + justify-content: center; + + &.hidden { + display: none; + } + + &.invisible { + visibility: hidden; + } + + svg { + transform: scale(0.7); + } + } + + &_disabled { + cursor: not-allowed; + } + } + + &-separator { + margin-right: 5px; + } +} diff --git a/src/igz-controls/components/FormChipCell/NewChipInput/NewChipInput.jsx b/src/igz-controls/components/FormChipCell/NewChipInput/NewChipInput.jsx new file mode 100644 index 0000000000..e0f529d913 --- /dev/null +++ b/src/igz-controls/components/FormChipCell/NewChipInput/NewChipInput.jsx @@ -0,0 +1,64 @@ +/* +Copyright 2022 Iguazio Systems Ltd. +Licensed under the Apache License, Version 2.0 (the "License") with +an addition restriction as set forth herein. You may not use this +file except in compliance with the License. You may obtain a copy of +the License at http://www.apache.org/licenses/LICENSE-2.0. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. +In addition, you may not use the software for any purposes that are +illegal under applicable law, and the grant of the foregoing license +under the Apache 2.0 license is conditioned upon your compliance with +such restriction. +*/ +import React, { forwardRef } from 'react' +import PropTypes from 'prop-types' +import { Field, useField } from 'react-final-form' + +let NewChipInput = ({ name, onChange, onFocus, ...inputProps }, ref) => { + const { input } = useField(name) + + const handleInputChange = event => { + input.onChange(event) + onChange(event) + } + + const handleInputFocus = event => { + input.onFocus(event) + onFocus(event) + } + + return ( + + {({ input }) => ( + + )} + + ) +} + +NewChipInput = forwardRef(NewChipInput) + +NewChipInput.displayName = 'NewChipInput' + +NewChipInput.propTypes = { + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + onFocus: PropTypes.func.isRequired +} + +export default NewChipInput diff --git a/src/igz-controls/components/FormChipCell/formChipCell.scss b/src/igz-controls/components/FormChipCell/formChipCell.scss new file mode 100644 index 0000000000..c526ac0ecd --- /dev/null +++ b/src/igz-controls/components/FormChipCell/formChipCell.scss @@ -0,0 +1,72 @@ +@use '../../scss/mixins'; + +.chips { + @include mixins.inputSelectField; + + & { + height: auto; + min-width: 0; + } + + &__wrapper { + padding: 12px 16px; + } + + &-wrapper { + display: flex; + flex-flow: row; + align-items: center; + overflow: hidden; + + &.chips-wrapper_all-visible { + flex-wrap: wrap; + } + } + + &-cell { + display: flex; + flex: 1; + align-items: center; + max-width: 100%; + + .fixed-max-width { + max-width: 100%; + } + + .chip { + &-block { + position: relative; + max-width: 100%; + margin-right: calc(#{var(--chipBlockMarginRight)}); + } + } + + .button-add { + display: flex; + align-items: center; + justify-content: center; + margin: 2px 0 2px 0; + border-radius: 32px; + + &-background { + @include mixins.chipBackground(true); + } + + &_border { + @include mixins.chipBorder(); + } + + &-density { + @include mixins.chipDensity(false, true); + } + + &-font { + @include mixins.chipsFont(ButtonAddChip); + } + } + } + + input:disabled { + cursor: default; + } +} diff --git a/src/igz-controls/components/FormChipCell/formChipCell.util.js b/src/igz-controls/components/FormChipCell/formChipCell.util.js new file mode 100644 index 0000000000..d7854322ba --- /dev/null +++ b/src/igz-controls/components/FormChipCell/formChipCell.util.js @@ -0,0 +1,48 @@ +/* +Copyright 2022 Iguazio Systems Ltd. +Licensed under the Apache License, Version 2.0 (the "License") with +an addition restriction as set forth herein. You may not use this +file except in compliance with the License. You may obtain a copy of +the License at http://www.apache.org/licenses/LICENSE-2.0. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. +In addition, you may not use the software for any purposes that are +illegal under applicable law, and the grant of the foregoing license +under the Apache 2.0 license is conditioned upon your compliance with +such restriction. +*/ +export const uniquenessError = { name: 'uniqueness', label: 'Key must be unique' } + +export const getTextWidth = elementWithText => { + if (!elementWithText) { + return 0 + } + const hiddenElementId = 'chips-hidden-element' + let hiddenElement = document.getElementById(hiddenElementId) + + if (!hiddenElement) { + hiddenElement = document.createElement('span') + const styles = { + position: 'absolute', + left: '-10000px', + top: 'auto', + visibility: 'hidden' + } + + for (const [styleName, styleValue] of Object.entries(styles)) { + hiddenElement.style[styleName] = styleValue + } + + hiddenElement.style.font = window.getComputedStyle(elementWithText).font + hiddenElement.id = hiddenElementId + hiddenElement.tabIndex = -1 + document.body.append(hiddenElement) + } + + hiddenElement.textContent = elementWithText.value + + return hiddenElement.offsetWidth ?? 0 +} diff --git a/src/igz-controls/components/FormCombobox/FormCombobox.jsx b/src/igz-controls/components/FormCombobox/FormCombobox.jsx new file mode 100644 index 0000000000..dd8aa97f86 --- /dev/null +++ b/src/igz-controls/components/FormCombobox/FormCombobox.jsx @@ -0,0 +1,514 @@ +/* +Copyright 2022 Iguazio Systems Ltd. +Licensed under the Apache License, Version 2.0 (the "License") with +an addition restriction as set forth herein. You may not use this +file except in compliance with the License. You may obtain a copy of +the License at http://www.apache.org/licenses/LICENSE-2.0. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. +In addition, you may not use the software for any purposes that are +illegal under applicable law, and the grant of the foregoing license +under the Apache 2.0 license is conditioned upon your compliance with +such restriction. +*/ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { Field, useField } from 'react-final-form' +import { isEmpty } from 'lodash' +import PropTypes from 'prop-types' +import classnames from 'classnames' + +import OptionsMenu from '../../elements/OptionsMenu/OptionsMenu' +import ValidationTemplate from '../../elements/ValidationTemplate/ValidationTemplate' +import PopUpDialog from '../PopUpDialog/PopUpDialog' +import TextTooltipTemplate from '../TooltipTemplate/TextTooltipTemplate' +import Tooltip from '../Tooltip/Tooltip' + +import { checkPatternsValidity } from '../../utils/validation.util' +import { useDetectOutsideClick } from '../../hooks' +import { COMBOBOX_SELECT_OPTIONS, COMBOBOX_SUGGESTION_LIST, DENSITY } from '../../types' + +import Arrow from '../../images/arrow.svg?react' +import SearchIcon from '../../images/search.svg?react' +import WarningIcon from '../../images/warning.svg?react' +import ExclamationMarkIcon from '../../images/exclamation-mark.svg?react' + +import './formCombobox.scss' + +const FormCombobox = ({ + comboboxClassName = '', + density = 'normal', + disabled = false, + hideSearchInput = false, + inputDefaultValue = '', + inputPlaceholder = '', + invalidText = 'Invalid', + label = '', + maxSuggestedMatches = 1, + name, + onBlur = null, + onChange = null, + onFocus = null, + required = false, + rules = [], + selectDefaultValue = { + label: '', + id: '', + className: '' + }, + selectOptions, + selectPlaceholder = '', + suggestionList = [], + validator = null, + withoutBorder = false +}) => { + const { input, meta } = useField(name) + const [inputValue, setInputValue] = useState(inputDefaultValue) + const [selectValue, setSelectValue] = useState(selectDefaultValue) + const [dropdownStyle, setDropdownStyle] = useState({ + left: '0px' + }) + const [showSelectDropdown, setShowSelectDropdown] = useState(false) + const [showSuggestionList, setShowSuggestionList] = useState(false) + const [dropdownList, setDropdownList] = useState(suggestionList) + const [searchIsFocused, setSearchIsFocused] = useState(false) + const [isInvalid, setIsInvalid] = useState(false) + const [validationRules, setValidationRules] = useState(rules) + const [showValidationRules, setShowValidationRules] = useState(false) + const comboboxRef = useRef() + const selectRef = useRef() + const inputRef = useRef() + const suggestionListRef = useRef() + useDetectOutsideClick(comboboxRef, () => setShowValidationRules(false)) + + const labelClassNames = classnames('form-field__label', disabled && 'form-field__label-disabled') + const inputClassNames = classnames( + 'form-field-combobox__input', + selectValue.id.length === 0 && 'form-field-combobox__input_hidden' + ) + + useEffect(() => { + setValidationRules(prevState => + prevState.map(rule => ({ + ...rule, + isValid: + !meta.error || !Array.isArray(meta.error) + ? true + : !meta.error.some(err => err.name === rule.name) + })) + ) + }, [meta.error]) + + useEffect(() => { + if (!searchIsFocused) { + if (JSON.stringify(dropdownList) !== JSON.stringify(suggestionList)) { + setDropdownList(suggestionList) + } + } + }, [dropdownList, suggestionList, searchIsFocused]) + + useEffect(() => { + setIsInvalid( + meta.invalid && (meta.validating || meta.modified || (meta.submitFailed && meta.touched)) + ) + }, [meta.invalid, meta.modified, meta.submitFailed, meta.touched, meta.validating]) + + const handleOutsideClick = useCallback( + event => { + if ( + comboboxRef.current && + !comboboxRef.current.contains(event.target) && + suggestionListRef.current && + !suggestionListRef.current.contains(event.target) + ) { + setSearchIsFocused(false) + setShowSelectDropdown(false) + setShowSuggestionList(false) + input.onBlur(new Event('blur')) + onBlur && onBlur(input.value) + } + }, + [input, onBlur] + ) + + const handleScroll = event => { + if (comboboxRef.current && comboboxRef.current.contains(event.target)) return + + if ( + !event.target.closest('.pop-up-dialog') && + !event.target.classList.contains('form-field-combobox') + ) { + setShowValidationRules(false) + setShowSelectDropdown(false) + setShowSuggestionList(false) + inputRef.current.blur() + } + } + + useEffect(() => { + window.addEventListener('click', handleOutsideClick) + + return () => { + window.removeEventListener('click', handleOutsideClick) + } + }, [handleOutsideClick]) + + useEffect(() => { + if (showValidationRules || showSelectDropdown || showSuggestionList) { + window.addEventListener('scroll', handleScroll, true) + } + return () => { + window.removeEventListener('scroll', handleScroll, true) + } + }, [showSelectDropdown, showSuggestionList, showValidationRules]) + + const getValidationRules = () => { + return validationRules.map(({ isValid = false, label, name }) => { + return + }) + } + + const handleInputChange = event => { + const target = event.target + + setDropdownStyle({ + left: `${target.selectionStart < 30 ? target.selectionStart : 30}ch` + }) + + if (searchIsFocused) { + setSearchIsFocused(false) + } + + setInputValue(target.value) + input.onChange(`${selectValue.id}${target.value}`) + onChange && onChange(selectValue.id, target.value) + + if (dropdownList.length > 0) { + setShowSuggestionList(true) + } + } + + const handleSelectOptionClick = selectedOption => { + if (selectedOption.id !== selectValue.id) { + setSelectValue(selectedOption) + input.onChange(selectedOption.id) + setInputValue('') + onChange && onChange(selectedOption.id) + setShowSelectDropdown(false) + inputRef.current.disabled = false + inputRef.current.focus() + } else { + setShowSelectDropdown(false) + inputRef.current.disabled = false + inputRef.current.focus() + } + } + + const handleSuggestionListOptionClick = option => { + const inputValueItems = inputValue.split('/') + const valueIndex = inputValueItems.length - 1 + let formattedValue = option.customDelimiter + ? inputValueItems[valueIndex].replace(new RegExp(`${option.customDelimiter}.*`), '') + + option.id + : option.id + + if (inputValueItems.length <= maxSuggestedMatches - 1) formattedValue += '/' + + inputValueItems[valueIndex] = formattedValue + + if (searchIsFocused) { + setSearchIsFocused(false) + } + + if (inputValueItems.join('/') !== inputValue) { + setInputValue(inputValueItems.join('/')) + input.onChange(`${selectValue.id}${inputValueItems.join('/')}`) + onChange && onChange(selectValue.id, inputValueItems.join('/')) + } + + setShowSuggestionList(false) + inputRef.current.focus() + setDropdownStyle({ + left: `${inputRef.current.selectionStart < 30 ? inputRef.current.selectionStart : 30}ch` + }) + } + + const inputOnFocus = event => { + onFocus && onFocus() + input.onFocus(new Event('focus')) + + if (showSelectDropdown) { + setShowSelectDropdown(false) + } + + // browser need some time to calculate cursor position after onFocus fired + if (!inputRef.current.selectionStart) { + setTimeout(() => { + setDropdownStyle({ + left: `${event.target.selectionStart < 30 ? event.target.selectionStart : 30}ch` + }) + setShowSuggestionList(true) + }) + } else { + setShowSuggestionList(true) + } + } + + const suggestionListSearchChange = event => { + event.persist() + setDropdownList(() => + suggestionList.filter(option => { + return option.id.startsWith(event.target.value) + }) + ) + } + + const toggleSelect = useCallback(() => { + if (showSelectDropdown) { + setShowSelectDropdown(false) + input.onBlur(new Event('blur')) + onBlur && onBlur(input.value) + } else { + setShowSuggestionList(false) + setShowValidationRules(false) + setDropdownStyle({ + left: '0px' + }) + setShowSelectDropdown(true) + input.onFocus(new Event('focus')) + onFocus && onFocus(input.value) + } + }, [input, onBlur, onFocus, showSelectDropdown]) + + const validateField = (value = '', allValues) => { + const valueToValidate = value.startsWith(selectValue.id) + ? value.substring(selectValue.id.length) + : (value ?? '') + let validationError = null + + if (!isEmpty(validationRules)) { + const [newRules, isValidField] = checkPatternsValidity(rules, valueToValidate) + const invalidRules = newRules.filter(rule => !rule.isValid) + + if (!isValidField) { + validationError = invalidRules.map(rule => ({ name: rule.name, label: rule.label })) + } + } + + if (isEmpty(validationError)) { + if (valueToValidate.startsWith(' ')) { + validationError = { name: 'empty', label: invalidText } + } else if (required && valueToValidate.trim().length === 0) { + validationError = { name: 'required', label: 'This field is required' } + } + } + + if (!validationError && validator) { + validationError = validator(value, allValues) + } + + return validationError + } + + const warningIconClick = () => { + setShowValidationRules(state => !state) + setShowSelectDropdown(false) + } + + const comboboxClassNames = classnames( + comboboxClassName, + 'form-field-combobox', + 'form-field', + isInvalid && 'form-field-combobox_invalid' + ) + const iconClassNames = classnames( + showSelectDropdown && 'form-field-combobox__icon_open', + 'form-field-combobox__icon' + ) + const selectValueClassNames = classnames(selectValue.className) + + const wrapperClassNames = classnames( + 'form-field__wrapper', + `form-field__wrapper-${density}`, + disabled && 'form-field__wrapper-disabled', + isInvalid && 'form-field__wrapper-invalid', + withoutBorder && 'without-border' + ) + + return ( + + {({ input, meta }) => ( +
+ {label && ( +
+ +
+ )} +
+
+ +
+
+
+ {selectValue.id} + {selectValue.id.length === 0 && selectPlaceholder && ( +
+ +
+ )} +
+ {showSelectDropdown && ( + +
    + {selectOptions.map(option => { + if (!option.hidden) { + const selectOptionClassNames = classnames( + 'form-field-combobox__dropdown-list-option', + option.className + ) + + return ( +
  • handleSelectOptionClick(option)} + > + {option.label} +
  • + ) + } + })} +
+
+ )} +
+ + {showSuggestionList && (dropdownList.length > 0 || searchIsFocused) && ( + +
+ {!hideSearchInput && ( +
+ setSearchIsFocused(true)} + placeholder="Type to search" + type="text" + /> + +
+ )} +
    + {searchIsFocused && dropdownList.length === 0 ? ( +
  • + No data +
  • + ) : ( + dropdownList.map(value => ( +
  • handleSuggestionListOptionClick(value)} + > + {value.label} +
  • + )) + )} +
+
+
+ )} +
+ {isInvalid && !Array.isArray(meta.error) && ( + } + > + + + )} + {isInvalid && Array.isArray(meta.error) && ( + + )} +
+ {!isEmpty(validationRules) && ( + + {getValidationRules()} + + )} +
+
+ )} +
+ ) +} + +FormCombobox.propTypes = { + comboboxClassName: PropTypes.string, + density: DENSITY, + disabled: PropTypes.bool, + hideSearchInput: PropTypes.bool, + inputDefaultValue: PropTypes.string, + inputPlaceholder: PropTypes.string, + invalidText: PropTypes.string, + label: PropTypes.string, + maxSuggestedMatches: PropTypes.number, + name: PropTypes.string.isRequired, + onBlur: PropTypes.func, + onChange: PropTypes.func, + onFocus: PropTypes.func, + required: PropTypes.bool, + rules: PropTypes.array, + selectDefaultValue: PropTypes.shape({}), + selectOptions: COMBOBOX_SELECT_OPTIONS.isRequired, + selectPlaceholder: PropTypes.string, + suggestionList: COMBOBOX_SUGGESTION_LIST, + validator: PropTypes.func, + withoutBorder: PropTypes.bool +} + +export default FormCombobox diff --git a/src/igz-controls/components/FormCombobox/formCombobox.scss b/src/igz-controls/components/FormCombobox/formCombobox.scss new file mode 100644 index 0000000000..cbc1a45e24 --- /dev/null +++ b/src/igz-controls/components/FormCombobox/formCombobox.scss @@ -0,0 +1,134 @@ +@use '../../scss/colors'; +@use '../../scss/borders'; +@use '../../scss/shadows'; +@use '../../scss/mixins'; + +.form-field.form-field-combobox { + width: 100%; + + .form-field { + @include mixins.inputSelectField; + + &__icons { + .form-field-combobox__icon { + cursor: pointer; + padding: 0; + transition: transform 200ms linear; + + &_open { + transform: rotate(90deg); + transform-origin: center center; + } + } + } + + &-combobox { + &__placeholder { + color: colors.$topaz; + font-size: 15px; + text-align: left; + text-transform: capitalize; + background-color: transparent; + + label { + cursor: inherit; + } + } + + &__select { + padding: 0; + overflow: visible; + + &-header { + display: flex; + flex: 1; + align-items: center; + cursor: pointer; + height: 100%; + } + } + + &__input { + width: 100%; + padding: 0 8px 0 0; + + &_hidden { + flex: 0; + } + } + } + } +} + +.form-field-combobox { + &__search { + width: 100%; + padding: 12px 0; + + &-wrapper { + position: sticky; + top: 0; + display: flex; + align-items: center; + margin: 0 9px; + border-bottom: borders.$dividerBorder; + background-color: colors.$white; + } + } + + &__dropdown { + &-select { + max-width: 220px; + } + + &-suggestions { + max-width: 350px; + } + + &-list { + margin: 0; + padding: 0; + min-width: 140px; + list-style-type: none; + + &-option { + padding: 8px 15px; + word-break: break-all; + cursor: pointer; + + &:hover { + background-color: colors.$alabaster; + } + } + } + + .pop-up-dialog { + width: 100%; + max-height: 250px; + padding: 0; + } + } + + .path-type, + &__dropdown .path-type { + &-store { + color: colors.$amethyst; + } + + &-v3io { + color: colors.$cornflowerBlueTwo; + } + + &-az, + &-gs, + &-http, + &-https, + &-s3 { + color: colors.$sorbus; + } + + &-dbfs { + color: colors.$chateauGreen; + } + } +} diff --git a/src/igz-controls/components/FormInput/FormInput.jsx b/src/igz-controls/components/FormInput/FormInput.jsx new file mode 100644 index 0000000000..31518cd968 --- /dev/null +++ b/src/igz-controls/components/FormInput/FormInput.jsx @@ -0,0 +1,453 @@ +/* +Copyright 2022 Iguazio Systems Ltd. +Licensed under the Apache License, Version 2.0 (the "License") with +an addition restriction as set forth herein. You may not use this +file except in compliance with the License. You may obtain a copy of +the License at http://www.apache.org/licenses/LICENSE-2.0. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. +In addition, you may not use the software for any purposes that are +illegal under applicable law, and the grant of the foregoing license +under the Apache 2.0 license is conditioned upon your compliance with +such restriction. +*/ +import React, { useState, useEffect, useRef, forwardRef } from 'react' +import PropTypes from 'prop-types' +import classNames from 'classnames' +import { isEmpty, isNil } from 'lodash' +import { Field, useField } from 'react-final-form' + +import InputNumberButtons from './InputNumberButtons/InputNumberButtons' +import OptionsMenu from '../../elements/OptionsMenu/OptionsMenu' +import ValidationTemplate from '../../elements/ValidationTemplate/ValidationTemplate' +import { TextTooltipTemplate, Tip, Tooltip } from '../../components' + +import { DENSITY, INPUT_LINK, INPUT_VALIDATION_RULES } from '../../types' +import { checkPatternsValidity, checkPatternsValidityAsync } from '../../utils/validation.util' +import { useDetectOutsideClick, useDebounce } from '../../hooks' +import { validation as ValidationConstants } from '../../constants' + +import ExclamationMarkIcon from '../../images/exclamation-mark.svg?react' +import Popout from '../../images/popout.svg?react' +import WarningIcon from '../../images/warning.svg?react' + +import './formInput.scss' + +const defaultProps = { + iconClick: () => {}, + link: { show: '', value: '' }, + onBlur: () => {}, + onKeyDown: () => {}, + onValidationError: () => {}, + validator: () => {}, + rules: [] +} + +let FormInput = ( + { + async = false, + className = '', + customRequiredLabel = '', + density = 'normal', + disabled = false, + focused = false, + iconClass = '', + iconClick = defaultProps.iconClick, + inputIcon = null, + invalidText = 'This field is invalid', + label = '', + link = defaultProps.link, + name, + onBlur = defaultProps.onBlur, + onFocus, + onKeyDown = defaultProps.onKeyDown, + pattern = null, + required = false, + onValidationError = defaultProps.onValidationError, + suggestionList = [], + step = '1', + tip = '', + type = 'text', + validationRules: rules = defaultProps.rules, + validator = defaultProps.validator, + withoutBorder = false, + ...inputProps + }, + ref +) => { + const { input, meta } = useField(name) + const [isInvalid, setIsInvalid] = useState(false) + const [isFocused, setIsFocused] = useState(false) + const [typedValue, setTypedValue] = useState('') + const [validationPattern] = useState(RegExp(pattern)) + const [validationRules, setValidationRules] = useState(rules) + const [showValidationRules, setShowValidationRules] = useState(false) + const wrapperRef = useRef() + ref ??= wrapperRef + const inputRef = useRef() + const errorsRef = useRef() + const isRequiredRulePresentRef = useRef(false) + useDetectOutsideClick(ref, () => setShowValidationRules(false)) + const debounceAsync = useDebounce() + + const formFieldClassNames = classNames('form-field-input', className) + + const inputWrapperClassNames = classNames( + 'form-field__wrapper', + `form-field__wrapper-${density}`, + disabled && 'form-field__wrapper-disabled', + isInvalid && 'form-field__wrapper-invalid', + withoutBorder && 'without-border' + ) + const labelClassNames = classNames('form-field__label', disabled && 'form-field__label-disabled') + + useEffect(() => { + setTypedValue(String(input.value)) // convert from number to string + }, [input.value]) + + useEffect(() => { + const isInputInvalid = + errorsRef.current && + meta.invalid && + (meta.validating || meta.modified || (meta.submitFailed && meta.touched)) + setIsInvalid(isInputInvalid) + onValidationError(isInputInvalid) + }, [ + meta.invalid, + meta.modified, + meta.submitFailed, + meta.touched, + meta.validating, + onValidationError + ]) + + useEffect(() => { + if (!errorsRef.current) { + if (meta.valid && showValidationRules) { + setShowValidationRules(false) + } + } + }, [meta.valid, showValidationRules]) + + useEffect(() => { + if (showValidationRules) { + window.addEventListener('scroll', handleScroll, true) + } + return () => { + window.removeEventListener('scroll', handleScroll, true) + } + }, [showValidationRules]) + + useEffect(() => { + if (focused) { + inputRef.current.focus() + } + }, [focused]) + + useEffect(() => { + setValidationRules(() => { + isRequiredRulePresentRef.current = false + + return rules.map(rule => { + if (rule.name === ValidationConstants.REQUIRED.NAME) { + isRequiredRulePresentRef.current = true + } + + return { + ...rule, + isValid: + !errorsRef.current || !Array.isArray(errorsRef.current) + ? true + : !errorsRef.current.some(err => err.name === rule.name) + } + }) + }) + }, [rules]) + + const getValidationRules = () => { + return validationRules.map(({ isValid = false, label, name }) => { + return + }) + } + + const isValueEmptyAndValid = value => { + return (!value && !required) || disabled + } + + const handleInputBlur = event => { + input.onBlur && input.onBlur(event) + + if (!event.relatedTarget || !event.relatedTarget?.closest('.form-field__suggestion-list')) { + setIsFocused(false) + onBlur && onBlur(event) + } + } + const handleInputFocus = event => { + input.onFocus && input.onFocus(event) + onFocus && onFocus(event) + setIsFocused(true) + } + + const handleInputKeyDown = event => { + input.onKeyDown && input.onKeyDown(event) + onKeyDown && onKeyDown(event) + } + + const handleScroll = event => { + if (inputRef.current && inputRef.current.contains(event.target)) return + + if ( + !event.target.closest('.options-menu') && + !event.target.classList.contains('form-field-input') + ) { + setShowValidationRules(false) + } + } + + const handleSuggestionClick = item => { + input.onChange && input.onChange(item) + setIsFocused(false) + onBlur() + } + + const toggleValidationRulesMenu = () => { + inputRef.current.focus() + setShowValidationRules(state => !state) + } + + const validateField = (value, allValues) => { + let valueToValidate = isNil(value) ? '' : String(value) + + if (isValueEmptyAndValid(valueToValidate)) return + + let validationError = null + + if (required && valueToValidate.trim().length === 0 && !isRequiredRulePresentRef.current) { + validationError = { + name: 'required', + label: customRequiredLabel || 'This field is required' + } + } else if (!isEmpty(rules) && !async) { + const [newRules, isValidField] = checkPatternsValidity(rules, valueToValidate) + const invalidRules = newRules.filter(rule => !rule.isValid) + + if (!isValidField) { + validationError = invalidRules.map(rule => ({ name: rule.name, label: rule.label })) + } + } + + if (isEmpty(validationError)) { + if (type === 'number') { + if (inputProps.max && +valueToValidate > +inputProps.max) { + validationError = { + name: 'maxValue', + label: `The maximum value must be ${inputProps.max}` + } + } + + if (inputProps.min && +valueToValidate < +inputProps.min) { + validationError = { + name: 'minValue', + label: `The minimum value must be ${inputProps.min}` + } + } + } + if (pattern && !validationPattern.test(valueToValidate)) { + validationError = { name: 'pattern', label: invalidText } + } else if (valueToValidate.startsWith(' ')) { + validationError = { name: 'empty', label: invalidText } + } + } + + if (!validationError && validator) { + validationError = validator(value, allValues) + } + + errorsRef.current = validationError + + return validationError + } + + const validateFieldAsync = debounceAsync(async (value, allValues) => { + let valueToValidate = isNil(value) ? '' : String(value) + + if (isValueEmptyAndValid(valueToValidate)) return + + let validationError = validateField(valueToValidate, allValues) + + if (!isEmpty(rules)) { + const [newRules, isValidField] = await checkPatternsValidityAsync(rules, valueToValidate) + + const invalidRules = newRules.filter(rule => !rule.isValid) + + if (!isValidField) { + validationError = invalidRules.map(rule => ({ name: rule.name, label: rule.label })) + } + } + + errorsRef.current = validationError + + return validationError + }, 400) + + const parseField = val => { + return type === 'number' && val ? parseFloat(val) || val : val + } + + return ( + + {({ input }) => { + return ( +
+ {label && ( +
+ + {link && link.show && typedValue.trim() && ( + + )} +
+ )} +
+
+ +
+
+ {isInvalid && !Array.isArray(errorsRef.current) && ( + + } + > + + + )} + {isInvalid && Array.isArray(errorsRef.current) && ( + + )} + {tip && } + {inputIcon && ( + + {inputIcon} + + )} +
+ {type === 'number' && ( + + )} +
+ {suggestionList?.length > 0 && isFocused && ( +
    + {suggestionList.map((item, index) => { + return ( +
  • { + handleSuggestionClick(item) + }} + tabIndex={index} + dangerouslySetInnerHTML={{ + __html: item.replace(new RegExp(typedValue, 'gi'), match => + match ? `${match}` : match + ) + }} + /> + ) + })} +
+ )} + {!isEmpty(validationRules) && isInvalid && Array.isArray(errorsRef.current) && ( + + {getValidationRules()} + + )} +
+ ) + }} +
+ ) +} + +FormInput = React.memo(forwardRef(FormInput)) + +FormInput.displayName = 'FormInput' + +FormInput.propTypes = { + async: PropTypes.bool, + className: PropTypes.string, + customRequiredLabel: PropTypes.string, + density: DENSITY, + disabled: PropTypes.bool, + focused: PropTypes.bool, + iconClass: PropTypes.string, + iconClick: PropTypes.func, + inputIcon: PropTypes.element, + invalidText: PropTypes.string, + label: PropTypes.string, + link: INPUT_LINK, + max: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + min: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + name: PropTypes.string.isRequired, + onBlur: PropTypes.func, + onFocus: PropTypes.func, + onKeyDown: PropTypes.func, + onValidationError: PropTypes.func, + pattern: PropTypes.string, + placeholder: PropTypes.string, + required: PropTypes.bool, + step: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + suggestionList: PropTypes.arrayOf(PropTypes.string), + tip: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), + type: PropTypes.string, + validationRules: INPUT_VALIDATION_RULES, + validator: PropTypes.func, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + withoutBorder: PropTypes.bool +} + +export default React.memo(FormInput) diff --git a/src/igz-controls/components/FormInput/InputNumberButtons/InputNumberButtons.jsx b/src/igz-controls/components/FormInput/InputNumberButtons/InputNumberButtons.jsx new file mode 100644 index 0000000000..dd37b5d896 --- /dev/null +++ b/src/igz-controls/components/FormInput/InputNumberButtons/InputNumberButtons.jsx @@ -0,0 +1,95 @@ +/* +Copyright 2022 Iguazio Systems Ltd. +Licensed under the Apache License, Version 2.0 (the "License") with +an addition restriction as set forth herein. You may not use this +file except in compliance with the License. You may obtain a copy of +the License at http://www.apache.org/licenses/LICENSE-2.0. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. +In addition, you may not use the software for any purposes that are +illegal under applicable law, and the grant of the foregoing license +under the Apache 2.0 license is conditioned upon your compliance with +such restriction. +*/ +import React from 'react' +import PropTypes from 'prop-types' +import { isNil } from 'lodash' + +import { performFloatOperation } from '../../../utils/math.util' + +import Arrow from '../../../images/range-arrow-small.svg?react' + +import './InputNumberButtons.scss' + +let InputNumberButtons = ({ + disabled = false, + min = null, + max = null, + onChange, + step = 1, + value +}) => { + const handleIncrease = event => { + event.preventDefault() + if (max && value >= max) return + + let newValue = isCurrentValueEmpty() ? step : performFloatOperation(value, step, '+') + newValue = max && newValue > max ? max : newValue + + onChange(newValue) + } + + const handleDecrease = event => { + event.preventDefault() + + if (min && value <= min) return + + let newValue = isCurrentValueEmpty() ? -step : performFloatOperation(value, step, '-') + newValue = min && newValue < min ? min : newValue + + onChange(newValue) + } + + const isCurrentValueEmpty = () => { + return isNil(value) || value === '' + } + + return ( +
+
+ + +
+
+ ) +} + +InputNumberButtons.propTypes = { + disabled: PropTypes.bool, + min: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + max: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + onChange: PropTypes.func.isRequired, + step: PropTypes.number, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired +} + +InputNumberButtons = React.memo(InputNumberButtons) + +export default InputNumberButtons diff --git a/src/igz-controls/components/FormInput/InputNumberButtons/InputNumberButtons.scss b/src/igz-controls/components/FormInput/InputNumberButtons/InputNumberButtons.scss new file mode 100644 index 0000000000..02b24ee7f5 --- /dev/null +++ b/src/igz-controls/components/FormInput/InputNumberButtons/InputNumberButtons.scss @@ -0,0 +1,123 @@ +@use '../../../scss/colors'; +@use '../../../scss/borders'; + +.form-field-range { + align-self: stretch; + + .range { + &__buttons { + display: flex; + flex-direction: column; + justify-content: center; + width: 28px; + height: 100%; + } + + &__button { + display: flex; + width: 100%; + height: calc(50% + 1px); + align-items: center; + justify-content: center; + padding: 0; + background-color: colors.$wildSand; + cursor: pointer; + + svg { + path { + fill: colors.$topaz; + } + } + + &:hover { + background-color: colors.$mercury; + + svg { + path { + fill: colors.$primary; + } + } + } + + &:focus { + border: borders.$focusBorder; + } + + &:active { + background-color: rgba(colors.$black, 0.2); + border: borders.$focusBorder; + + svg { + path { + fill: colors.$primary; + } + } + } + + &:disabled { + cursor: not-allowed; + + svg { + path { + fill: colors.$spunPearl; + } + } + + &:focus { + border: none; + } + + &:hover { + background-color: colors.$wildSand; + } + } + + &-increase { + border-bottom: borders.$transparentBorder; + border-left: borders.$transparentBorder; + border-top-right-radius: 4px; + } + + &-decrease { + border-top: borders.$transparentBorder; + border-left: borders.$transparentBorder; + border-bottom-right-radius: 4px; + } + + .decrease { + transform: rotate(180deg); + } + } + + &-warning { + border: borders.$errorBorder; + + &_asterisk { + position: absolute; + top: 50%; + right: 35px; + color: colors.$amaranth; + transform: translateY(-50%); + } + + .range__button { + &-increase { + border-top: borders.$errorBorder; + border-right: borders.$errorBorder; + } + + &-decrease { + border-right: borders.$errorBorder; + border-bottom: borders.$errorBorder; + } + } + } + + &__warning-icon { + position: absolute; + top: 50%; + right: 30px; + transform: translateY(-50%); + } + } +} diff --git a/src/igz-controls/components/FormInput/formInput.scss b/src/igz-controls/components/FormInput/formInput.scss new file mode 100644 index 0000000000..f761158fed --- /dev/null +++ b/src/igz-controls/components/FormInput/formInput.scss @@ -0,0 +1,75 @@ +@use '../../scss/colors'; +@use '../../scss/shadows'; +@use '../../scss/mixins'; + +.form-field-input { + width: 100%; + + input { + height: inherit; + width: 100%; + padding: 12px 16px; + } + + .form-field { + @include mixins.inputSelectField; + + &__label { + &-icon { + display: inline-flex; + margin-left: 3px; + + & > *, + a { + display: inline-flex; + } + + a { + transform: translateY(-1px); + } + + svg { + width: 12px; + height: 12px; + + path { + fill: colors.$cornflowerBlue; + } + } + } + } + + &__suggestion-list { + position: absolute; + top: 100%; + left: 0; + z-index: 5; + margin: 0; + padding: 7px 0; + background-color: colors.$white; + border-radius: 4px; + box-shadow: shadows.$previewBoxShadow; + + .suggestion-item { + padding: 7px 15px; + color: colors.$mulledWine; + list-style-type: none; + + &:hover { + background-color: colors.$alabaster; + cursor: pointer; + } + } + } + } + + input[type='number'] { + border: none; + -moz-appearance: textfield; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + } + } +} diff --git a/src/igz-controls/components/FormKeyValueTable/FormKeyValueTable.jsx b/src/igz-controls/components/FormKeyValueTable/FormKeyValueTable.jsx new file mode 100644 index 0000000000..78a2972eb1 --- /dev/null +++ b/src/igz-controls/components/FormKeyValueTable/FormKeyValueTable.jsx @@ -0,0 +1,229 @@ +/* +Copyright 2022 Iguazio Systems Ltd. +Licensed under the Apache License, Version 2.0 (the "License") with +an addition restriction as set forth herein. You may not use this +file except in compliance with the License. You may obtain a copy of +the License at http://www.apache.org/licenses/LICENSE-2.0. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. +In addition, you may not use the software for any purposes that are +illegal under applicable law, and the grant of the foregoing license +under the Apache 2.0 license is conditioned upon your compliance with +such restriction. +*/ +import React from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import { FieldArray } from 'react-final-form-arrays' + +import FormSelect from '../../components/FormSelect/FormSelect' +import FormInput from '../../components/FormInput/FormInput' +import Tooltip from '../../components/Tooltip/Tooltip' +import TextTooltipTemplate from '../../components/TooltipTemplate/TextTooltipTemplate' +import FormActionButton from '../../elements/FormActionButton/FormActionButton' +import FormRowActions from '../../elements/FormRowActions/FormRowActions' + +import { useFormTable } from '../../hooks' +import { INPUT_VALIDATION_RULES } from '../../types' + +const FormKeyValueTable = ({ + actionButtonId = '', + addNewItemLabel = 'Add new item', + className = '', + defaultKey = '', + disabled = false, + exitEditModeTriggerItem = null, + fieldsPath, + formState, + isKeyEditable = true, + isKeyRequired = true, + isValueRequired = true, + keyHeader = 'Key', + keyLabel = 'Key', + keyOptions = null, + keyValidationRules = [], + onExitEditModeCallback = () => {}, + valueHeader = 'Value', + valueLabel = 'Value', + valueType = 'text', + valueValidationRules = [] +}) => { + const tableClassNames = classnames( + 'form-table form-key-value-table', + disabled && 'form-table_disabled', + className + ) + const { + addNewRow, + applyChanges, + bottomScrollRef, + deleteRow, + discardOrDelete, + editingItem, + enterEditMode, + isCurrentRowEditing + } = useFormTable(formState, exitEditModeTriggerItem, onExitEditModeCallback) + + const uniquenessValidator = (fields, newValue) => { + return !fields.value.some(({ data: { key } }, index) => { + return newValue.trim() === key.trim() && index !== editingItem.ui.index + }) + } + + const getKeyTextTemplate = keyValue => { + return }>{keyValue} + } + + return ( +
+
+
{keyHeader}
+
{valueHeader}
+
+
+ + {({ fields }) => ( + <> + {fields.map((rowPath, index) => { + const tableRowClassNames = classnames( + 'form-table__row', + isCurrentRowEditing(rowPath) && 'form-table__row_active' + ) + + return editingItem && index === editingItem.ui.index && !disabled ? ( +
+
+ {keyOptions ? ( + + ) : isKeyEditable || editingItem.ui.isNew ? ( + uniquenessValidator(fields, newValue) + } + ]} + /> + ) : ( + getKeyTextTemplate(fields.value[index].data.key) + )} +
+
+ +
+ +
+ ) : ( +
!disabled && enterEditMode(event, fields, fieldsPath, index)} + > +
+ {getKeyTextTemplate(fields.value[index].data.key)} +
+
+ + } + > + {valueType === 'password' ? '*****' : fields.value[index].data.value} + +
+ +
+ ) + })} + +
+
+ ) +} + +FormKeyValueTable.propTypes = { + actionButtonId: PropTypes.string, + addNewItemLabel: PropTypes.string, + className: PropTypes.string, + defaultKey: PropTypes.string, + disabled: PropTypes.bool, + exitEditModeTriggerItem: PropTypes.any, + fieldsPath: PropTypes.string.isRequired, + formState: PropTypes.shape({}).isRequired, + isKeyEditable: PropTypes.bool, + isKeyRequired: PropTypes.bool, + isValueRequired: PropTypes.bool, + keyHeader: PropTypes.string, + keyLabel: PropTypes.string, + keyOptions: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string.isRequired, + id: PropTypes.string.isRequired + }) + ), + keyValidationRules: INPUT_VALIDATION_RULES, + onExitEditModeCallback: PropTypes.func, + valueHeader: PropTypes.string, + valueLabel: PropTypes.string, + valueType: PropTypes.string, + valueValidationRules: INPUT_VALIDATION_RULES +} + +export default FormKeyValueTable diff --git a/src/igz-controls/components/FormOnChange/FormOnChange.jsx b/src/igz-controls/components/FormOnChange/FormOnChange.jsx new file mode 100644 index 0000000000..1abd7afa29 --- /dev/null +++ b/src/igz-controls/components/FormOnChange/FormOnChange.jsx @@ -0,0 +1,60 @@ +/* +Copyright 2019 Iguazio Systems Ltd. + +Licensed under the Apache License, Version 2.0 (the "License") with +an addition restriction as set forth herein. You may not use this +file except in compliance with the License. You may obtain a copy of +the License at http://www.apache.org/licenses/LICENSE-2.0. + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. + +In addition, you may not use the software for any purposes that are +illegal under applicable law, and the grant of the foregoing license +under the Apache 2.0 license is conditioned upon your compliance with +such restriction. +*/ +import React, { useEffect, useState } from 'react' +import { Field } from 'react-final-form' +import PropTypes from 'prop-types' + +const OnChangeState = ({ inputValue, handler }) => { + const [previousValue, setPreviousValue] = useState(inputValue) + + useEffect(() => { + if (inputValue !== previousValue) { + setPreviousValue(inputValue) + handler(inputValue, previousValue) + } + }, [handler, inputValue, previousValue]) + + return null +} + +OnChangeState.propTypes = { + inputValue: PropTypes.any.isRequired, + handler: PropTypes.func.isRequired +} + +const FormOnChange = ({ handler, name }) => { + return ( + } + /> + ) +} + +FormOnChange.propTypes = { + handler: PropTypes.func.isRequired, + name: PropTypes.string.isRequired +} + +export default FormOnChange diff --git a/src/igz-controls/components/FormRadio/FormRadio.jsx b/src/igz-controls/components/FormRadio/FormRadio.jsx new file mode 100644 index 0000000000..4b824e59e8 --- /dev/null +++ b/src/igz-controls/components/FormRadio/FormRadio.jsx @@ -0,0 +1,82 @@ +/* +Copyright 2022 Iguazio Systems Ltd. +Licensed under the Apache License, Version 2.0 (the "License") with +an addition restriction as set forth herein. You may not use this +file except in compliance with the License. You may obtain a copy of +the License at http://www.apache.org/licenses/LICENSE-2.0. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. +In addition, you may not use the software for any purposes that are +illegal under applicable law, and the grant of the foregoing license +under the Apache 2.0 license is conditioned upon your compliance with +such restriction. +*/ +import React from 'react' +import PropTypes from 'prop-types' +import { Field } from 'react-final-form' +import classNames from 'classnames' + +import Tooltip from '../Tooltip/Tooltip' +import TextTooltipTemplate from '../TooltipTemplate/TextTooltipTemplate' + +import './FormRadio.scss' + +let FormRadio = ({ + className = '', + name, + label, + readOnly = false, + tooltip = '', + ...inputProps +}) => { + const formFieldClassNames = classNames( + 'form-field-radio', + readOnly && 'form-field-radio_readonly', + className + ) + + return ( + + {({ input }) => ( +
+ + {tooltip ? ( + }> + + + ) : ( + + )} +
+ )} +
+ ) +} + +FormRadio.propTypes = { + className: PropTypes.string, + label: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + readOnly: PropTypes.bool, + tooltip: PropTypes.string +} + +FormRadio = React.memo(FormRadio) + +export default FormRadio diff --git a/src/igz-controls/components/FormRadio/FormRadio.scss b/src/igz-controls/components/FormRadio/FormRadio.scss new file mode 100644 index 0000000000..49c4f09f85 --- /dev/null +++ b/src/igz-controls/components/FormRadio/FormRadio.scss @@ -0,0 +1,41 @@ +@use '../../scss/colors'; +@use '../../scss/mixins'; + +.form-field-radio { + display: inline-flex; + align-items: center; + justify-content: flex-start; + color: colors.$primary; + margin-right: 15px; + + &_readonly { + @include mixins.radioCheckReadonly; + } + + input[type='radio'] { + width: 16px; + height: 16px; + border-radius: 50%; + + @include mixins.radioCheckField; + + &::before { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 10px; + height: 10px; + border-radius: 50%; + transform: scale(0); + transition: transform 0.2s ease-in-out; + box-shadow: inset 1em 1em currentColor; + } + + &:checked { + &::before { + transform: scale(1); + } + } + } +} diff --git a/src/igz-controls/components/FormRadio/FormRadio.stories.js b/src/igz-controls/components/FormRadio/FormRadio.stories.js new file mode 100644 index 0000000000..10ebfae88f --- /dev/null +++ b/src/igz-controls/components/FormRadio/FormRadio.stories.js @@ -0,0 +1,38 @@ +/* +Copyright 2022 Iguazio Systems Ltd. +Licensed under the Apache License, Version 2.0 (the "License") with +an addition restriction as set forth herein. You may not use this +file except in compliance with the License. You may obtain a copy of +the License at http://www.apache.org/licenses/LICENSE-2.0. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. +In addition, you may not use the software for any purposes that are +illegal under applicable law, and the grant of the foregoing license +under the Apache 2.0 license is conditioned upon your compliance with +such restriction. +*/ +import React from 'react' +import { Form } from 'react-final-form' + +import FormRadio from '/src/lib/components/FormRadio/FormRadio.jsx' + +export default { + title: 'Example/FormRadio', + component: FormRadio +} + +const commonArgs = { + disabled: false, + label: 'label', + name: 'radio' +} + +const Template = args =>
null}>{() => } + +export const Normal = Template.bind({}) +Normal.args = { + ...commonArgs +} diff --git a/src/igz-controls/components/FormSelect/FormSelect.jsx b/src/igz-controls/components/FormSelect/FormSelect.jsx new file mode 100644 index 0000000000..cc086c7b4a --- /dev/null +++ b/src/igz-controls/components/FormSelect/FormSelect.jsx @@ -0,0 +1,426 @@ +/* +Copyright 2022 Iguazio Systems Ltd. +Licensed under the Apache License, Version 2.0 (the "License") with +an addition restriction as set forth herein. You may not use this +file except in compliance with the License. You may obtain a copy of +the License at http://www.apache.org/licenses/LICENSE-2.0. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. +In addition, you may not use the software for any purposes that are +illegal under applicable law, and the grant of the foregoing license +under the Apache 2.0 license is conditioned upon your compliance with +such restriction. +*/ +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' +import PropTypes from 'prop-types' +import classNames from 'classnames' +import { Field, useField } from 'react-final-form' + +import ConfirmDialog from '../ConfirmDialog/ConfirmDialog' +import PopUpDialog from '../PopUpDialog/PopUpDialog' +import SelectOption from '../../elements/SelectOption/SelectOption' +import TextTooltipTemplate from '../TooltipTemplate/TextTooltipTemplate' +import Tooltip from '../Tooltip/Tooltip' + +import { DENSITY, SELECT_OPTIONS } from '../../types' +import { TERTIARY_BUTTON } from '../../constants' + +import Caret from '../../images/dropdown.svg?react' + +import './formSelect.scss' + +let FormSelect = ({ + className = '', + density = 'normal', + disabled = false, + hideSelectedOption = false, + label = '', + multiple = false, + name, + onChange = null, + options, + placeholder = '', + preventWidthOverflow = false, + required = false, + scrollToView = true, + search = false, + selectedItemAction = null, + tooltip = '', + withSelectedIcon = true, + withoutBorder = false +}) => { + const { input, meta } = useField(name) + const [isInvalid, setIsInvalid] = useState(false) + const [isConfirmDialogOpen, setConfirmDialogOpen] = useState(false) + const [isOpen, setIsOpen] = useState(false) + const [searchValue, setSearchValue] = useState('') + const optionsListRef = useRef() + const popUpRef = useRef() + const selectRef = useRef() + const searchRef = useRef() + const { width: selectWidth } = selectRef?.current?.getBoundingClientRect() || {} + + const selectWrapperClassNames = classNames( + 'form-field__wrapper', + `form-field__wrapper-${density}`, + disabled && 'form-field__wrapper-disabled', + isOpen && 'form-field__wrapper-active', + isInvalid && 'form-field__wrapper-invalid', + withoutBorder && 'without-border' + ) + + const selectLabelClassName = classNames( + 'form-field__label', + disabled && 'form-field__label-disabled' + ) + + const selectValueClassName = classNames( + 'form-field__select-value', + !input.value && 'form-field__select-placeholder' + ) + + const selectedOption = options.find(option => option.id === input.value) + + const getFilteredOptions = useCallback( + options => { + return options.filter(option => { + return !search || option.label.toLowerCase().includes(searchValue.toLowerCase()) + }) + }, + [search, searchValue] + ) + + const sortedOptionsList = useMemo(() => { + if (scrollToView) { + return getFilteredOptions(options) + } + + const optionsList = [...options] + + const selectedOption = optionsList.filter((option, idx, arr) => { + if (option.id === input.value) { + arr.splice(idx, 1) + return true + } + return false + }) + + return getFilteredOptions([...selectedOption, ...optionsList]) + }, [input.value, getFilteredOptions, options, scrollToView]) + + const getSelectValue = () => { + if (!input.value || !input.value.length) { + return `Select Option${multiple ? 's' : ''}` + } + + const multipleValue = + multiple && input.value.includes('all') && input.value.length > 1 + ? options + .filter(option => option.id !== 'all') + .filter(option => input.value.includes(option.id)) + .map(option => option.label) + .join(', ') + : options + .filter(option => input.value.includes(option.id)) + .map(option => option.label) + .join(', ') + + return !multiple + ? selectedOption?.label + : input.value.length <= 2 + ? multipleValue + : `${input.value.length} items selected` + } + + useEffect(() => { + setIsInvalid( + meta.invalid && (meta.validating || meta.modified || (meta.submitFailed && meta.touched)) + ) + }, [meta.invalid, meta.modified, meta.submitFailed, meta.touched, meta.validating]) + + const openMenu = useCallback(() => { + if (!isOpen) { + setIsOpen(true) + input.onFocus(new Event('focus')) + } + }, [input, isOpen]) + + const closeMenu = useCallback(() => { + if (isOpen) { + setIsOpen(false) + input.onBlur(new Event('blur')) + } + }, [input, isOpen]) + + const clickHandler = useCallback( + event => { + if (selectRef.current !== event.target.closest('.form-field-select')) { + closeMenu() + } + }, + [closeMenu] + ) + + const handleScroll = useCallback( + event => { + if (!event.target.closest('.options-list__body')) { + closeMenu() + } + }, + [closeMenu] + ) + + useEffect(() => { + if (isOpen) { + window.addEventListener('scroll', handleScroll, true) + } + + window.addEventListener('click', clickHandler) + + return () => { + window.removeEventListener('click', clickHandler) + window.removeEventListener('scroll', handleScroll, true) + } + }, [clickHandler, handleScroll, isOpen]) + + const scrollOptionToView = useCallback(() => { + const selectedOptionEl = optionsListRef.current.querySelector( + `[data-custom-id="${input.value}"]` + ) + + if (!selectedOptionEl) return + + searchValue + ? optionsListRef.current.scrollTo({ top: 0, left: 0, behavior: 'smooth' }) + : setTimeout(() => { + selectedOptionEl.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }) + }, 0) + }, [input.value, searchValue]) + + useEffect(() => { + if (isOpen && optionsListRef.current && scrollToView) { + scrollOptionToView() + } + }, [isOpen, scrollOptionToView, scrollToView]) + + useEffect(() => { + if (isOpen && search && searchRef.current) { + searchRef.current.focus() + } + }, [isOpen, search]) + + const toggleOpen = () => { + if (isOpen) { + closeMenu() + } else { + !disabled && openMenu() + } + } + + const handleCloseSelectBody = useCallback( + event => { + event.stopPropagation() + if (multiple) return + + if ( + !event.target.classList.contains('disabled') && + !event.target.closest('.options-list__search') + ) { + closeMenu() + setSearchValue('') + } + }, + [closeMenu, multiple] + ) + + const handleSelectOptionClick = (selectedOption, option) => { + if (selectedOption !== input.value) { + option.handler && option.handler() + onChange && onChange(selectedOption) + + setTimeout(() => { + input.onChange(selectedOption) + }) + } + } + + const validateField = value => { + if (required) { + return value ? undefined : 'Required' + } + } + + return ( + + {({ input, meta }) => ( + } + hidden={!tooltip} + > +
+ {label && ( +
+ +
+ )} +
+
+ {!hideSelectedOption && ( +
+ {getSelectValue() || placeholder} +
+ )} +
+
+ {input.value && selectedItemAction && ( + <> + {selectedItemAction.handler ? ( + }> + + + ) : ( + {selectedItemAction.icon} + )} + + )} + + + +
+
+ {isConfirmDialogOpen && ( + { + setConfirmDialogOpen(false) + }, + label: 'Cancel', + variant: TERTIARY_BUTTON + }} + closePopUp={() => { + setConfirmDialogOpen(false) + }} + confirmButton={{ + handler: () => { + selectedItemAction.handler(input.value) + setConfirmDialogOpen(false) + }, + label: selectedItemAction.confirm.btnConfirmLabel, + variant: selectedItemAction.confirm.btnConfirmType + }} + header={selectedItemAction.confirm.title} + isOpen={isConfirmDialogOpen} + message={selectedItemAction.confirm.message} + /> + )} + {isOpen && ( + +
+ {search && ( +
+ setSearchValue(event.target.value)} + ref={searchRef} + autoFocus + /> +
+ )} +
    + {sortedOptionsList.map(option => { + return ( + { + handleSelectOptionClick(selectedOption, option) + }} + multiple={multiple} + selectedId={!multiple ? input.value : ''} + withSelectedIcon={withSelectedIcon} + /> + ) + })} +
+
+
+ )} + +
+
+ )} +
+ ) +} + +FormSelect.propTypes = { + className: PropTypes.string, + density: DENSITY, + disabled: PropTypes.bool, + hideSelectedOption: PropTypes.bool, + label: PropTypes.string, + multiple: PropTypes.bool, + name: PropTypes.string.isRequired, + onChange: PropTypes.func, + options: SELECT_OPTIONS.isRequired, + placeholder: PropTypes.string, + preventWidthOverflow: PropTypes.bool, + required: PropTypes.bool, + scrollToView: PropTypes.bool, + search: PropTypes.bool, + selectedItemAction: PropTypes.object, + tooltip: PropTypes.string, + withSelectedIcon: PropTypes.bool, + withoutBorder: PropTypes.bool +} + +FormSelect = React.memo(FormSelect) + +export default FormSelect diff --git a/src/igz-controls/components/FormSelect/formSelect.scss b/src/igz-controls/components/FormSelect/formSelect.scss new file mode 100644 index 0000000000..767e896be1 --- /dev/null +++ b/src/igz-controls/components/FormSelect/formSelect.scss @@ -0,0 +1,98 @@ +@use '../../scss/mixins'; +@use '../../scss/colors'; +@use '../../scss/shadows'; +@use '../../scss/borders'; + +.select-tooltip { + width: 100%; +} + +.form-field-select { + width: 100%; + + .form-field { + @include mixins.inputSelectField; + + &__wrapper { + cursor: pointer; + + &-active { + background: colors.$alabaster; + } + + &-disabled { + cursor: not-allowed; + + .form-field__caret { + path { + fill: colors.$spunPearl; + } + } + } + } + + &__select { + display: flex; + align-items: center; + width: 100%; + padding: 0 20px 0 16px; + + &-value { + display: block; + font-size: 15px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + &-placeholder { + color: colors.$spunPearl; + } + + &-sub_label { + display: block; + margin-left: 10px; + overflow: hidden; + color: colors.$topaz; + white-space: nowrap; + text-overflow: ellipsis; + } + } + } + + &__options-list { + .pop-up-dialog { + width: 100%; + padding: 0; + border-radius: 0; + } + + .options-list { + margin: 0; + padding: 0; + list-style-type: none; + max-height: 250px; + overflow-y: auto; + + &__body { + width: 100%; + color: colors.$mulledWineTwo; + background-color: colors.$white; + border: borders.$primaryBorder; + border-radius: 4px; + box-shadow: shadows.$filterShadow; + } + + &__search { + width: 100%; + + input { + width: 100%; + padding: 10px; + border: none; + border-bottom: borders.$primaryBorder; + } + } + } + } +} diff --git a/src/igz-controls/components/FormTextarea/FormTextarea.jsx b/src/igz-controls/components/FormTextarea/FormTextarea.jsx new file mode 100644 index 0000000000..91e7701c2d --- /dev/null +++ b/src/igz-controls/components/FormTextarea/FormTextarea.jsx @@ -0,0 +1,189 @@ +/* +Copyright 2022 Iguazio Systems Ltd. +Licensed under the Apache License, Version 2.0 (the "License") with +an addition restriction as set forth herein. You may not use this +file except in compliance with the License. You may obtain a copy of +the License at http://www.apache.org/licenses/LICENSE-2.0. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing +permissions and limitations under the License. +In addition, you may not use the software for any purposes that are +illegal under applicable law, and the grant of the foregoing license +under the Apache 2.0 license is conditioned upon your compliance with +such restriction. +*/ +import React, { forwardRef, useEffect, useLayoutEffect, useRef, useState } from 'react' +import classnames from 'classnames' +import PropTypes from 'prop-types' +import { Field, useField } from 'react-final-form' + +import TextTooltipTemplate from '../TooltipTemplate/TextTooltipTemplate' +import Tip from '../Tip/Tip' +import Tooltip from '../Tooltip/Tooltip' + +import ExclamationMarkIcon from '../../images/exclamation-mark.svg?react' + +import './formTextarea.scss' + +let FormTextarea = ( + { + className = '', + disabled = false, + focused = false, + iconClass = '', + invalidText = 'This field is invalid', + label = '', + maxLength = null, + name, + onBlur = () => {}, + onChange = () => {}, + required = false, + rows = 3, + textAreaIcon = null, + tip = '', + withoutBorder = false, + ...textareaProps + }, + ref +) => { + const { input, meta } = useField(name) + const [isInvalid, setIsInvalid] = useState(false) + const [textAreaCount, setTextAreaCount] = useState(input.value.length) + const textAreaRef = useRef() + + const formFieldClassNames = classnames('form-field-textarea', className) + const labelClassNames = classnames('form-field__label', disabled && 'form-field__label-disabled') + const textAreaClassNames = classnames( + 'form-field__wrapper', + disabled && 'form-field__wrapper-disabled', + isInvalid && 'form-field__wrapper-invalid', + withoutBorder && 'without-border' + ) + + useLayoutEffect(() => { + setTextAreaCount(input.value.length) + }, [input.value.length]) + + useEffect(() => { + if (focused) { + textAreaRef.current.focus() + } + }, [focused, textAreaRef]) + + useEffect(() => { + setIsInvalid( + meta.invalid && (meta.validating || meta.modified || (meta.submitFailed && meta.touched)) + ) + }, [meta.invalid, meta.modified, meta.submitFailed, meta.touched, meta.validating]) + + const handleInputBlur = event => { + input.onBlur(event) + onBlur && onBlur(event) + } + + const handleInputChange = event => { + input.onChange(event) + onChange && onChange(event.target.value) + } + + const handleInputFocus = event => { + input.onFocus(event) + } + + const validateField = value => { + const valueToValidate = value ?? '' + let validationError = null + + if (valueToValidate.startsWith(' ')) { + validationError = { name: 'empty', label: invalidText } + } else if (required && valueToValidate.trim().length === 0) { + validationError = { name: 'required', label: 'This field is required' } + } + + return validationError + } + + return ( + + {({ input, meta }) => ( +
+
+ {label && ( + + )} +
+
+
+