diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a8b6dab179..846889b9d5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,7 @@ "@seafile/sdoc-editor": "1.0.4", "@seafile/seafile-calendar": "0.0.12", "@seafile/seafile-editor": "1.0.99", + "@seafile/sf-metadata-ui-component": "0.0.4", "@uiw/codemirror-extensions-langs": "^4.19.4", "@uiw/react-codemirror": "^4.19.4", "chart.js": "2.9.4", @@ -2879,14 +2880,14 @@ "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" }, "node_modules/@emotion/react": { - "version": "11.11.1", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.1.tgz", - "integrity": "sha512-5mlW1DquU5HaxjLkfkGN1GA/fvVGdyHURRiX/0FHl2cfIfRxSOfmxEH5YS43edp0OldZrZ+dkBKbngxcNCdZvA==", + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.4.tgz", + "integrity": "sha512-t8AjMlF0gHpvvxk5mAtCqR4vmxiGHCeJBaQO6gncUSdklELOgtwjerNY2yuJNfwnc6vi16U/+uMF+afIawJ9iw==", "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.11.0", "@emotion/cache": "^11.11.0", - "@emotion/serialize": "^1.1.2", + "@emotion/serialize": "^1.1.3", "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", "@emotion/utils": "^1.2.1", "@emotion/weak-memoize": "^0.3.1", @@ -2902,9 +2903,9 @@ } }, "node_modules/@emotion/serialize": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.2.tgz", - "integrity": "sha512-zR6a/fkFP4EAcCMQtLOhIgpprZOwNmCldtpaISpvz348+DP4Mz8ZoKaGGCQpbzepNIUWbq4w6hNZkwDyKoS+HA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.4.tgz", + "integrity": "sha512-RIN04MBT8g+FnDwgvIUi8czvr1LU1alUMI05LekWB5DGyTm8cCBMCRpq3GqaiyEDRptEXOyXnvZ58GZYu4kBxQ==", "dependencies": { "@emotion/hash": "^0.9.1", "@emotion/memoize": "^0.8.1", @@ -3055,26 +3056,41 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.4.1.tgz", - "integrity": "sha512-jk3WqquEJRlcyu7997NtR5PibI+y5bi+LS3hPmguVClypenMsCY3CBa3LAQnozRCtCrYWSEtAdiskpamuJRFOQ==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.3.tgz", + "integrity": "sha512-1ZpCvYf788/ZXOhRQGFxnYQOVgeU+pi0i+d0Ow34La7qjIXETi6RNswGVKkA6KcDO8/+Ysu2E/CeUmmeEBDvTg==", "dependencies": { - "@floating-ui/utils": "^0.1.1" + "@floating-ui/utils": "^0.2.3" } }, "node_modules/@floating-ui/dom": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.1.tgz", - "integrity": "sha512-KwvVcPSXg6mQygvA1TjbN/gh///36kKtllIF8SUm0qpFj8+rvYrpvlYdL1JoA71SHpDqgSSdGOSoQ0Mp3uY5aw==", + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.6.tgz", + "integrity": "sha512-qiTYajAnh3P+38kECeffMSQgbvXty2VB6rS+42iWR4FPIlZjLK84E9qtLnMTLIpPz2znD/TaFqaiavMUrS+Hcw==", "dependencies": { - "@floating-ui/core": "^1.4.1", - "@floating-ui/utils": "^0.1.1" + "@floating-ui/core": "^1.0.0", + "@floating-ui/utils": "^0.2.3" } }, "node_modules/@floating-ui/utils": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.1.tgz", - "integrity": "sha512-m0G6wlnhm/AX0H12IOWtK8gASEMffnX08RtKkCgTdHb9JpHKGloI7icFfLg9ZmQeavcvR0PKmzxClyuFPSjKWw==" + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.3.tgz", + "integrity": "sha512-XGndio0l5/Gvd6CLIABvsav9HHezgDFFhDfHk1bvLfr9ni8dojqLSvBbotJEjmIwNHL7vK4QzBJTdBRoB+c1ww==" + }, + "node_modules/@formatjs/intl-unified-numberformat": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@formatjs/intl-unified-numberformat/-/intl-unified-numberformat-3.3.7.tgz", + "integrity": "sha512-KnWgLRHzCAgT9eyt3OS34RHoyD7dPDYhRcuKn+/6Kv2knDF8Im43J6vlSW6Hm1w63fNq3ZIT1cFk7RuVO3Psag==", + "deprecated": "We have renamed the package to @formatjs/intl-numberformat", + "dependencies": { + "@formatjs/intl-utils": "^2.3.0" + } + }, + "node_modules/@formatjs/intl-utils": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@formatjs/intl-utils/-/intl-utils-2.3.0.tgz", + "integrity": "sha512-KWk80UPIzPmUg+P0rKh6TqspRw0G6eux1PuJr+zz47ftMaZ9QDwbGzHZbtzWkl5hgayM/qrKRutllRC7D/vVXQ==", + "deprecated": "the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package" }, "node_modules/@gatsbyjs/reach-router": { "version": "1.3.9", @@ -4936,6 +4952,53 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/@seafile/sf-metadata-ui-component": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@seafile/sf-metadata-ui-component/-/sf-metadata-ui-component-0.0.4.tgz", + "integrity": "sha512-Zf8SPVXkG7iQFqaDOtgsrcIVn5l0rfX7c8h4E9yf+Ho6Y/S4Rq3vL/emLUR4syE18OlWlw656oNhsvapeWsQjQ==", + "dependencies": { + "@seafile/seafile-calendar": "0.0.24", + "classnames": "2.3.2", + "dayjs": "1.10.7", + "escape-html": "^1.0.3", + "glamor": "^2.20.40", + "intl-messageformat": "^7.8.4", + "invariant": "^2.2.2", + "is-hotkey": "0.2.0", + "prop-types": "^15.8.1", + "react-app-polyfill": "^3.0.0", + "react-responsive": "9.0.2", + "react-select": "5.7.0", + "react-transition-group": "^4.4.1", + "reactstrap": "8.9.0" + }, + "peerDependencies": { + "@seafile/seafile-calendar": "0.0.24", + "lodash-es": "^4.17.21", + "prop-types": "15.8.1", + "react": "17.0.0", + "react-dom": "17.0.0" + } + }, + "node_modules/@seafile/sf-metadata-ui-component/node_modules/@seafile/seafile-calendar": { + "version": "0.0.24", + "resolved": "https://registry.npmjs.org/@seafile/seafile-calendar/-/seafile-calendar-0.0.24.tgz", + "integrity": "sha512-q1efVDcHAxJ2foMgsR8mQPD6Fbd6ISu2WHRM82P7tO0KPiQNS5pz9V0YVCblgi7da085jaog2iAplJM+vH7xLQ==", + "dependencies": { + "babel-runtime": "6.x", + "classnames": "2.x", + "dayjs": "1.10.7", + "prop-types": "^15.5.8", + "rc-trigger": "^2.2.0", + "rc-util": "^4.1.1", + "react-lifecycles-compat": "^3.0.4" + } + }, + "node_modules/@seafile/sf-metadata-ui-component/node_modules/dayjs": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.7.tgz", + "integrity": "sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig==" + }, "node_modules/@seafile/slate": { "version": "0.91.8", "resolved": "https://registry.npmjs.org/@seafile/slate/-/slate-0.91.8.tgz", @@ -5943,9 +6006,9 @@ "dev": true }, "node_modules/@types/prop-types": { - "version": "15.7.5", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" + "version": "15.7.12", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" }, "node_modules/@types/q": { "version": "1.5.6", @@ -5966,19 +6029,18 @@ "dev": true }, "node_modules/@types/react": { - "version": "18.2.21", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.21.tgz", - "integrity": "sha512-neFKG/sBAwGxHgXiIxnbm3/AAVQ/cMRS93hvBpg8xYRbeQSPVABp9U2bRnPf0iI4+Ucdv3plSxKK+3CW2ENJxA==", + "version": "18.3.3", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", + "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", "dependencies": { "@types/prop-types": "*", - "@types/scheduler": "*", "csstype": "^3.0.2" } }, "node_modules/@types/react-transition-group": { - "version": "4.4.6", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.6.tgz", - "integrity": "sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew==", + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", + "integrity": "sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==", "dependencies": { "@types/react": "*" } @@ -5998,11 +6060,6 @@ "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", "dev": true }, - "node_modules/@types/scheduler": { - "version": "0.16.3", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", - "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==" - }, "node_modules/@types/semver": { "version": "7.5.1", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.1.tgz", @@ -8232,12 +8289,18 @@ } }, "node_modules/call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9725,6 +9788,22 @@ "node": ">= 10" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -9735,10 +9814,11 @@ } }, "node_modules/define-properties": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.0.tgz", - "integrity": "sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dependencies": { + "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" }, @@ -10471,6 +10551,25 @@ "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", "dev": true }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-get-iterator": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", @@ -10577,8 +10676,7 @@ "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, "node_modules/escape-string-regexp": { "version": "1.0.5", @@ -12370,9 +12468,12 @@ } }, "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/function.prototype.name": { "version": "1.1.6", @@ -12419,14 +12520,18 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", - "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", "has-proto": "^1.0.1", - "has-symbols": "^1.0.3" + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -12629,7 +12734,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dev": true, "dependencies": { "get-intrinsic": "^1.1.3" }, @@ -12747,11 +12851,11 @@ } }, "node_modules/has-property-descriptors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", - "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dependencies": { - "get-intrinsic": "^1.1.1" + "es-define-property": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -12894,6 +12998,17 @@ "minimalistic-assert": "^1.0.1" } }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hast-util-embedded": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz", @@ -13954,6 +14069,29 @@ "node": ">= 0.4" } }, + "node_modules/intl-format-cache": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/intl-format-cache/-/intl-format-cache-4.3.1.tgz", + "integrity": "sha512-OEUYNA7D06agqPOYhbTkl0T8HA3QKSuwWh1HiClEnpd9vw7N+3XsQt5iZ0GUEchp5CW1fQk/tary+NsbF3yQ1Q==" + }, + "node_modules/intl-messageformat": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-7.8.4.tgz", + "integrity": "sha512-yS0cLESCKCYjseCOGXuV4pxJm/buTfyCJ1nzQjryHmSehlptbZbn9fnlk1I9peLopZGGbjj46yHHiTAEZ1qOTA==", + "dependencies": { + "intl-format-cache": "^4.2.21", + "intl-messageformat-parser": "^3.6.4" + } + }, + "node_modules/intl-messageformat-parser": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/intl-messageformat-parser/-/intl-messageformat-parser-3.6.4.tgz", + "integrity": "sha512-RgPGwue0mJtoX2Ax8EmMzJzttxjnva7gx0Q7mKJ4oALrTZvtmCeAw5Msz2PcjW4dtCh/h7vN/8GJCxZO1uv+OA==", + "deprecated": "We've written a new parser that's 6x faster and is backwards compatible. Please use @formatjs/icu-messageformat-parser", + "dependencies": { + "@formatjs/intl-unified-numberformat": "^3.2.0" + } + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -22286,16 +22424,19 @@ } }, "node_modules/react-popper/node_modules/deep-equal": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz", - "integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", + "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", "dependencies": { - "is-arguments": "^1.0.4", - "is-date-object": "^1.0.1", - "is-regex": "^1.0.4", - "object-is": "^1.0.1", + "is-arguments": "^1.1.1", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "object-is": "^1.1.5", "object-keys": "^1.1.1", - "regexp.prototype.flags": "^1.2.0" + "regexp.prototype.flags": "^1.5.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -22596,13 +22737,14 @@ "dev": true }, "node_modules/regexp.prototype.flags": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.0.tgz", - "integrity": "sha512-0SutC3pNudRKgquxGoRGIz946MZVHqbNfPjBdxeOhBrdgDKlRoXmYLQN9xRbrR09ZXWeGAdPuif7egofn6v5LA==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", + "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "functions-have-names": "^1.2.3" + "call-bind": "^1.0.6", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.1" }, "engines": { "node": ">= 0.4" @@ -24249,6 +24391,36 @@ "node": ">= 0.8.0" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/set-value": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", @@ -30368,14 +30540,14 @@ "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" }, "@emotion/react": { - "version": "11.11.1", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.1.tgz", - "integrity": "sha512-5mlW1DquU5HaxjLkfkGN1GA/fvVGdyHURRiX/0FHl2cfIfRxSOfmxEH5YS43edp0OldZrZ+dkBKbngxcNCdZvA==", + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.4.tgz", + "integrity": "sha512-t8AjMlF0gHpvvxk5mAtCqR4vmxiGHCeJBaQO6gncUSdklELOgtwjerNY2yuJNfwnc6vi16U/+uMF+afIawJ9iw==", "requires": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.11.0", "@emotion/cache": "^11.11.0", - "@emotion/serialize": "^1.1.2", + "@emotion/serialize": "^1.1.3", "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", "@emotion/utils": "^1.2.1", "@emotion/weak-memoize": "^0.3.1", @@ -30383,9 +30555,9 @@ } }, "@emotion/serialize": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.2.tgz", - "integrity": "sha512-zR6a/fkFP4EAcCMQtLOhIgpprZOwNmCldtpaISpvz348+DP4Mz8ZoKaGGCQpbzepNIUWbq4w6hNZkwDyKoS+HA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.4.tgz", + "integrity": "sha512-RIN04MBT8g+FnDwgvIUi8czvr1LU1alUMI05LekWB5DGyTm8cCBMCRpq3GqaiyEDRptEXOyXnvZ58GZYu4kBxQ==", "requires": { "@emotion/hash": "^0.9.1", "@emotion/memoize": "^0.8.1", @@ -30499,26 +30671,39 @@ "dev": true }, "@floating-ui/core": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.4.1.tgz", - "integrity": "sha512-jk3WqquEJRlcyu7997NtR5PibI+y5bi+LS3hPmguVClypenMsCY3CBa3LAQnozRCtCrYWSEtAdiskpamuJRFOQ==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.3.tgz", + "integrity": "sha512-1ZpCvYf788/ZXOhRQGFxnYQOVgeU+pi0i+d0Ow34La7qjIXETi6RNswGVKkA6KcDO8/+Ysu2E/CeUmmeEBDvTg==", "requires": { - "@floating-ui/utils": "^0.1.1" + "@floating-ui/utils": "^0.2.3" } }, "@floating-ui/dom": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.1.tgz", - "integrity": "sha512-KwvVcPSXg6mQygvA1TjbN/gh///36kKtllIF8SUm0qpFj8+rvYrpvlYdL1JoA71SHpDqgSSdGOSoQ0Mp3uY5aw==", + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.6.tgz", + "integrity": "sha512-qiTYajAnh3P+38kECeffMSQgbvXty2VB6rS+42iWR4FPIlZjLK84E9qtLnMTLIpPz2znD/TaFqaiavMUrS+Hcw==", "requires": { - "@floating-ui/core": "^1.4.1", - "@floating-ui/utils": "^0.1.1" + "@floating-ui/core": "^1.0.0", + "@floating-ui/utils": "^0.2.3" } }, "@floating-ui/utils": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.1.tgz", - "integrity": "sha512-m0G6wlnhm/AX0H12IOWtK8gASEMffnX08RtKkCgTdHb9JpHKGloI7icFfLg9ZmQeavcvR0PKmzxClyuFPSjKWw==" + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.3.tgz", + "integrity": "sha512-XGndio0l5/Gvd6CLIABvsav9HHezgDFFhDfHk1bvLfr9ni8dojqLSvBbotJEjmIwNHL7vK4QzBJTdBRoB+c1ww==" + }, + "@formatjs/intl-unified-numberformat": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@formatjs/intl-unified-numberformat/-/intl-unified-numberformat-3.3.7.tgz", + "integrity": "sha512-KnWgLRHzCAgT9eyt3OS34RHoyD7dPDYhRcuKn+/6Kv2knDF8Im43J6vlSW6Hm1w63fNq3ZIT1cFk7RuVO3Psag==", + "requires": { + "@formatjs/intl-utils": "^2.3.0" + } + }, + "@formatjs/intl-utils": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@formatjs/intl-utils/-/intl-utils-2.3.0.tgz", + "integrity": "sha512-KWk80UPIzPmUg+P0rKh6TqspRw0G6eux1PuJr+zz47ftMaZ9QDwbGzHZbtzWkl5hgayM/qrKRutllRC7D/vVXQ==" }, "@gatsbyjs/reach-router": { "version": "1.3.9", @@ -31934,6 +32119,48 @@ } } }, + "@seafile/sf-metadata-ui-component": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@seafile/sf-metadata-ui-component/-/sf-metadata-ui-component-0.0.4.tgz", + "integrity": "sha512-Zf8SPVXkG7iQFqaDOtgsrcIVn5l0rfX7c8h4E9yf+Ho6Y/S4Rq3vL/emLUR4syE18OlWlw656oNhsvapeWsQjQ==", + "requires": { + "@seafile/seafile-calendar": "0.0.24", + "classnames": "2.3.2", + "dayjs": "1.10.7", + "escape-html": "^1.0.3", + "glamor": "^2.20.40", + "intl-messageformat": "^7.8.4", + "invariant": "^2.2.2", + "is-hotkey": "0.2.0", + "prop-types": "^15.8.1", + "react-app-polyfill": "^3.0.0", + "react-responsive": "9.0.2", + "react-select": "5.7.0", + "react-transition-group": "^4.4.1", + "reactstrap": "8.9.0" + }, + "dependencies": { + "@seafile/seafile-calendar": { + "version": "0.0.24", + "resolved": "https://registry.npmjs.org/@seafile/seafile-calendar/-/seafile-calendar-0.0.24.tgz", + "integrity": "sha512-q1efVDcHAxJ2foMgsR8mQPD6Fbd6ISu2WHRM82P7tO0KPiQNS5pz9V0YVCblgi7da085jaog2iAplJM+vH7xLQ==", + "requires": { + "babel-runtime": "6.x", + "classnames": "2.x", + "dayjs": "1.10.7", + "prop-types": "^15.5.8", + "rc-trigger": "^2.2.0", + "rc-util": "^4.1.1", + "react-lifecycles-compat": "^3.0.4" + } + }, + "dayjs": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.7.tgz", + "integrity": "sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig==" + } + } + }, "@seafile/slate": { "version": "0.91.8", "resolved": "https://registry.npmjs.org/@seafile/slate/-/slate-0.91.8.tgz", @@ -32733,9 +32960,9 @@ "dev": true }, "@types/prop-types": { - "version": "15.7.5", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" + "version": "15.7.12", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" }, "@types/q": { "version": "1.5.6", @@ -32756,19 +32983,18 @@ "dev": true }, "@types/react": { - "version": "18.2.21", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.21.tgz", - "integrity": "sha512-neFKG/sBAwGxHgXiIxnbm3/AAVQ/cMRS93hvBpg8xYRbeQSPVABp9U2bRnPf0iI4+Ucdv3plSxKK+3CW2ENJxA==", + "version": "18.3.3", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", + "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", "requires": { "@types/prop-types": "*", - "@types/scheduler": "*", "csstype": "^3.0.2" } }, "@types/react-transition-group": { - "version": "4.4.6", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.6.tgz", - "integrity": "sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew==", + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", + "integrity": "sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==", "requires": { "@types/react": "*" } @@ -32788,11 +33014,6 @@ "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", "dev": true }, - "@types/scheduler": { - "version": "0.16.3", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", - "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==" - }, "@types/semver": { "version": "7.5.1", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.1.tgz", @@ -34518,12 +34739,15 @@ } }, "call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", "requires": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" } }, "callsites": { @@ -35666,6 +35890,16 @@ "execa": "^5.0.0" } }, + "define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "requires": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + } + }, "define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -35673,10 +35907,11 @@ "dev": true }, "define-properties": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.0.tgz", - "integrity": "sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "requires": { + "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } @@ -36281,6 +36516,19 @@ "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", "dev": true }, + "es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "requires": { + "get-intrinsic": "^1.2.4" + } + }, + "es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, "es-get-iterator": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", @@ -36372,8 +36620,7 @@ "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, "escape-string-regexp": { "version": "1.0.5", @@ -37717,9 +37964,9 @@ "optional": true }, "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" }, "function.prototype.name": { "version": "1.1.6", @@ -37751,14 +37998,15 @@ "dev": true }, "get-intrinsic": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", - "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", "requires": { - "function-bind": "^1.1.1", - "has": "^1.0.3", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", "has-proto": "^1.0.1", - "has-symbols": "^1.0.3" + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" } }, "get-own-enumerable-property-symbols": { @@ -37907,7 +38155,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dev": true, "requires": { "get-intrinsic": "^1.1.3" } @@ -38005,11 +38252,11 @@ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==" }, "has-property-descriptors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", - "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "requires": { - "get-intrinsic": "^1.1.1" + "es-define-property": "^1.0.0" } }, "has-proto": { @@ -38116,6 +38363,14 @@ "minimalistic-assert": "^1.0.1" } }, + "hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "requires": { + "function-bind": "^1.1.2" + } + }, "hast-util-embedded": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz", @@ -38907,6 +39162,28 @@ "side-channel": "^1.0.4" } }, + "intl-format-cache": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/intl-format-cache/-/intl-format-cache-4.3.1.tgz", + "integrity": "sha512-OEUYNA7D06agqPOYhbTkl0T8HA3QKSuwWh1HiClEnpd9vw7N+3XsQt5iZ0GUEchp5CW1fQk/tary+NsbF3yQ1Q==" + }, + "intl-messageformat": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-7.8.4.tgz", + "integrity": "sha512-yS0cLESCKCYjseCOGXuV4pxJm/buTfyCJ1nzQjryHmSehlptbZbn9fnlk1I9peLopZGGbjj46yHHiTAEZ1qOTA==", + "requires": { + "intl-format-cache": "^4.2.21", + "intl-messageformat-parser": "^3.6.4" + } + }, + "intl-messageformat-parser": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/intl-messageformat-parser/-/intl-messageformat-parser-3.6.4.tgz", + "integrity": "sha512-RgPGwue0mJtoX2Ax8EmMzJzttxjnva7gx0Q7mKJ4oALrTZvtmCeAw5Msz2PcjW4dtCh/h7vN/8GJCxZO1uv+OA==", + "requires": { + "@formatjs/intl-unified-numberformat": "^3.2.0" + } + }, "invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -44937,16 +45214,16 @@ }, "dependencies": { "deep-equal": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz", - "integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", + "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", "requires": { - "is-arguments": "^1.0.4", - "is-date-object": "^1.0.1", - "is-regex": "^1.0.4", - "object-is": "^1.0.1", + "is-arguments": "^1.1.1", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "object-is": "^1.1.5", "object-keys": "^1.1.1", - "regexp.prototype.flags": "^1.2.0" + "regexp.prototype.flags": "^1.5.1" } } } @@ -45192,13 +45469,14 @@ "dev": true }, "regexp.prototype.flags": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.0.tgz", - "integrity": "sha512-0SutC3pNudRKgquxGoRGIz946MZVHqbNfPjBdxeOhBrdgDKlRoXmYLQN9xRbrR09ZXWeGAdPuif7egofn6v5LA==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", + "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "functions-have-names": "^1.2.3" + "call-bind": "^1.0.6", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.1" } }, "regexpu-core": { @@ -46451,6 +46729,30 @@ "send": "0.18.0" } }, + "set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "requires": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + } + }, + "set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "requires": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + } + }, "set-value": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index d0ea3ac959..c3660d9c40 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "@seafile/sdoc-editor": "1.0.4", "@seafile/seafile-calendar": "0.0.12", "@seafile/seafile-editor": "1.0.99", + "@seafile/sf-metadata-ui-component": "0.0.4", "@uiw/codemirror-extensions-langs": "^4.19.4", "@uiw/react-codemirror": "^4.19.4", "chart.js": "2.9.4", diff --git a/frontend/src/assets/icons/add-table.svg b/frontend/src/assets/icons/add-table.svg new file mode 100644 index 0000000000..cf0da4e642 --- /dev/null +++ b/frontend/src/assets/icons/add-table.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/check-circle.svg b/frontend/src/assets/icons/check-circle.svg new file mode 100644 index 0000000000..7aa05d359d --- /dev/null +++ b/frontend/src/assets/icons/check-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/close.svg b/frontend/src/assets/icons/close.svg new file mode 100644 index 0000000000..29a0dc8257 --- /dev/null +++ b/frontend/src/assets/icons/close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/creation-time.svg b/frontend/src/assets/icons/creation-time.svg new file mode 100644 index 0000000000..a16d663ec9 --- /dev/null +++ b/frontend/src/assets/icons/creation-time.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/creator.svg b/frontend/src/assets/icons/creator.svg new file mode 100644 index 0000000000..59e75105fc --- /dev/null +++ b/frontend/src/assets/icons/creator.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/drop-down copy.svg b/frontend/src/assets/icons/drop-down copy.svg new file mode 100644 index 0000000000..6f8334191d --- /dev/null +++ b/frontend/src/assets/icons/drop-down copy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/exclamation-circle.svg b/frontend/src/assets/icons/exclamation-circle.svg new file mode 100644 index 0000000000..f048cd9519 --- /dev/null +++ b/frontend/src/assets/icons/exclamation-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/exclamation-triangle.svg b/frontend/src/assets/icons/exclamation-triangle.svg new file mode 100644 index 0000000000..7d21102f73 --- /dev/null +++ b/frontend/src/assets/icons/exclamation-triangle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/filter.svg b/frontend/src/assets/icons/filter.svg new file mode 100644 index 0000000000..d95a21617c --- /dev/null +++ b/frontend/src/assets/icons/filter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/fork-number.svg b/frontend/src/assets/icons/fork-number.svg new file mode 100644 index 0000000000..3e9519de16 --- /dev/null +++ b/frontend/src/assets/icons/fork-number.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/group.svg b/frontend/src/assets/icons/group.svg new file mode 100644 index 0000000000..fac73d4a02 --- /dev/null +++ b/frontend/src/assets/icons/group.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/hide.svg b/frontend/src/assets/icons/hide.svg new file mode 100644 index 0000000000..3f32e8bfb7 --- /dev/null +++ b/frontend/src/assets/icons/hide.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/number.svg b/frontend/src/assets/icons/number.svg new file mode 100644 index 0000000000..61e301fe37 --- /dev/null +++ b/frontend/src/assets/icons/number.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/open.svg b/frontend/src/assets/icons/open.svg new file mode 100644 index 0000000000..378892472f --- /dev/null +++ b/frontend/src/assets/icons/open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/row-height-default.svg b/frontend/src/assets/icons/row-height-default.svg new file mode 100644 index 0000000000..34d652039a --- /dev/null +++ b/frontend/src/assets/icons/row-height-default.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/row-height-double.svg b/frontend/src/assets/icons/row-height-double.svg new file mode 100644 index 0000000000..ab459011f9 --- /dev/null +++ b/frontend/src/assets/icons/row-height-double.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/row-height-quadruple.svg b/frontend/src/assets/icons/row-height-quadruple.svg new file mode 100644 index 0000000000..54ca9efeea --- /dev/null +++ b/frontend/src/assets/icons/row-height-quadruple.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/row-height-triple.svg b/frontend/src/assets/icons/row-height-triple.svg new file mode 100644 index 0000000000..60773c60b7 --- /dev/null +++ b/frontend/src/assets/icons/row-height-triple.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/sort.svg b/frontend/src/assets/icons/sort.svg new file mode 100644 index 0000000000..7f4a8c117f --- /dev/null +++ b/frontend/src/assets/icons/sort.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/text.svg b/frontend/src/assets/icons/text.svg new file mode 100644 index 0000000000..f2862fbf33 --- /dev/null +++ b/frontend/src/assets/icons/text.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/cur-dir-path/dir-path.js b/frontend/src/components/cur-dir-path/dir-path.js index 04d6a60bee..e88e2c8aff 100644 --- a/frontend/src/components/cur-dir-path/dir-path.js +++ b/frontend/src/components/cur-dir-path/dir-path.js @@ -7,6 +7,7 @@ import { Utils } from '../../utils/utils'; import { InternalLinkOperation } from '../operations'; import DirOperationToolBar from '../../components/toolbar/dir-operation-toolbar'; import ViewFileToolbar from '../../components/toolbar/view-file-toolbar'; +import { PRIVATE_FILE_TYPE } from '../../constants'; const propTypes = { repoID: PropTypes.string.isRequired, @@ -62,6 +63,15 @@ class DirPath extends React.Component { return null; } if (index === (pathList.length - 1)) { + if (item === PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES) { + return ( + + / + {gettext('File extended properties')} + + ); + } + return ( / @@ -115,6 +125,13 @@ class DirPath extends React.Component { return pathElem; }; + isViewMetadata = () => { + const { currentPath } = this.props; + const path = currentPath[currentPath.length - 1] === '/' ? currentPath.slice(0, currentPath.length - 1) : currentPath; + const pathList = path.split('/'); + return pathList[pathList.length - 1] === PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES; + }; + render() { let { currentPath, repoName, fileTags } = this.props; let pathElem = this.turnPathToLink(currentPath); @@ -172,7 +189,7 @@ class DirPath extends React.Component { {repoName} } {pathElem} - {this.props.isViewFile && ( + {this.props.isViewFile && !this.isViewMetadata() && ( )} {(this.props.isViewFile && fileTags.length !== 0) && diff --git a/frontend/src/components/dir-view-mode/dir-column-file.js b/frontend/src/components/dir-view-mode/dir-column-file.js index 6e6fd23ccb..3f984445d2 100644 --- a/frontend/src/components/dir-view-mode/dir-column-file.js +++ b/frontend/src/components/dir-view-mode/dir-column-file.js @@ -1,7 +1,8 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { SeafileMetadata } from '../../metadata'; import { Utils } from '../../utils/utils'; -import { gettext, siteRoot } from '../../utils/constants'; +import { gettext, siteRoot, lang, mediaUrl } from '../../utils/constants'; import SeafileMarkdownViewer from '../seafile-markdown-viewer'; const propTypes = { @@ -48,6 +49,22 @@ class DirColumnFile extends React.Component {
{gettext('File does not exist.')}
); } + + if (this.props.content === '__sf-metadata') { + window.sfMetadata = { + siteRoot, + lang, + mediaUrl, + }; + + return ( +
+
+ +
+ ); + } + return ( { this.setState({opNode: node}); - if (Utils.imageCheck(node.object.name)) { + if (Utils.imageCheck(node?.object?.name || '')) { this.showNodeImagePopup(node); return; } @@ -268,7 +268,7 @@ class DirColumnNav extends React.Component { repoID={this.props.repoID} /> - + ); }; diff --git a/frontend/src/components/dir-view-mode/dir-views/index.js b/frontend/src/components/dir-view-mode/dir-views/index.js index 2c35ad62c1..f2e7b8a5b9 100644 --- a/frontend/src/components/dir-view-mode/dir-views/index.js +++ b/frontend/src/components/dir-view-mode/dir-views/index.js @@ -3,14 +3,13 @@ import PropTypes from 'prop-types'; import { gettext } from '../../../utils/constants'; import { Utils } from '../../../utils/utils'; import TreeSection from '../../tree-section'; -import MetadataStatusManagementDialog from '../../metadata-manage/metadata-status-manage-dialog'; -import metadataManagerAPI from '../../metadata-manage/api'; +import { MetadataStatusManagementDialog, MetadataTreeView } from '../../../metadata'; +import metadataAPI from '../../../metadata/api'; import toaster from '../../toast'; -import MetadataViews from '../../metadata-manage/metadata-views'; import './index.css'; -const DirViews = ({ userPerm, repoID }) => { +const DirViews = ({ userPerm, repoID, currentPath, onNodeClick }) => { const enableMetadataManagement = useMemo(() => { return window.app.pageOptions.enableMetadataManagement; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -33,7 +32,7 @@ const DirViews = ({ userPerm, repoID }) => { return; } - const repoMetadataManagementEnabledStatusRes = metadataManagerAPI.getRepoMetadataManagementEnabledStatus(repoID); + const repoMetadataManagementEnabledStatusRes = metadataAPI.getMetadataStatus(repoID); Promise.all([repoMetadataManagementEnabledStatusRes]).then(results => { const [repoMetadataManagementEnabledStatusRes] = results; setMetadataStatus(repoMetadataManagementEnabledStatusRes.data.enabled); @@ -65,7 +64,7 @@ const DirViews = ({ userPerm, repoID }) => { return ( <> - {!loading && metadataStatus && ()} + {!loading && metadataStatus && ()} {showMetadataStatusManagementDialog && ( @@ -77,6 +76,8 @@ const DirViews = ({ userPerm, repoID }) => { DirViews.propTypes = { userPerm: PropTypes.string, repoID: PropTypes.string, + currentPath: PropTypes.string, + onNodeClick: PropTypes.func, }; export default DirViews; diff --git a/frontend/src/components/metadata-manage/metadata-views/index.js b/frontend/src/components/metadata-manage/metadata-views/index.js deleted file mode 100644 index 653db268cf..0000000000 --- a/frontend/src/components/metadata-manage/metadata-views/index.js +++ /dev/null @@ -1,58 +0,0 @@ -import React, { useCallback, useState } from 'react'; -import PropTypes from 'prop-types'; -import { gettext } from '../../../utils/constants'; -import { siteRoot } from '../../../utils/constants'; -import Icon from '../../icon'; - -import './index.css'; - -const MetadataViews = ({ repoID }) => { - const [highlight, setHighlight] = useState(false); - - const onMouseEnter = useCallback(() => { - setHighlight(true); - }, []); - - const onMouseOver = useCallback(() => { - setHighlight(true); - }, []); - - const onMouseLeave = useCallback(() => { - setHighlight(false); - }, []); - - const openView = useCallback(() => { - const server = siteRoot.substring(0, siteRoot.length-1); - window.open(server + '/repos/' + repoID + '/metadata/table-view/', '_blank'); - }, [repoID]); - - return ( -
-
-
-
-
{gettext('File extended properties')}
-
-
- -
-
-
-
-
-
- ); -}; - -MetadataViews.propTypes = { - repoID: PropTypes.string.isRequired, -}; - -export default MetadataViews; diff --git a/frontend/src/constants/index.js b/frontend/src/constants/index.js index 62960ffb5b..12c5633eee 100644 --- a/frontend/src/constants/index.js +++ b/frontend/src/constants/index.js @@ -106,6 +106,10 @@ export const DURATION_DECIMAL_DIGITS = { [DURATION_FORMATS_MAP.H_MM_SS_SSS]: 3, }; +export const PRIVATE_FILE_TYPE = { + FILE_EXTENDED_PROPERTIES: '__file_extended_properties' +}; + const TAG_COLORS = ['#FBD44A', '#EAA775', '#F4667C', '#DC82D2', '#9860E5', '#9F8CF1', '#59CB74', '#ADDF84', '#89D2EA', '#4ECCCB', '#46A1FD', '#C2C2C2']; diff --git a/frontend/src/css/lib-content-view.css b/frontend/src/css/lib-content-view.css index 5f798ac980..eb6acd4b44 100644 --- a/frontend/src/css/lib-content-view.css +++ b/frontend/src/css/lib-content-view.css @@ -11,6 +11,7 @@ width: 100%; height: calc(100% - 48px); border-top: 1px solid #e8e8e8; + transform: translateZ(10px); } .view-mode-container { diff --git a/frontend/src/components/metadata-manage/api.js b/frontend/src/metadata/api.js similarity index 76% rename from frontend/src/components/metadata-manage/api.js rename to frontend/src/metadata/api.js index 50394363c6..95efb72242 100644 --- a/frontend/src/components/metadata-manage/api.js +++ b/frontend/src/metadata/api.js @@ -1,6 +1,6 @@ import axios from 'axios'; import cookie from 'react-cookies'; -import { siteRoot } from '../../utils/constants'; +import { siteRoot } from '../utils/constants'; class MetadataManagerAPI { init({ server, username, password, token }) { @@ -43,22 +43,22 @@ class MetadataManagerAPI { } } - getRepoMetadataManagementEnabledStatus(repoID) { + getMetadataStatus(repoID) { const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/'; return this.req.get(url); } - openRepoMetadataManagement(repoID) { + createMetadata(repoID) { const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/'; return this.req.put(url); } - closeRepoMetadataManagement(repoID) { + deleteMetadata(repoID) { const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/'; return this.req.delete(url); } - getMetadataRecords(repoID, params) { + getMetadata(repoID, params) { const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/records/'; return this.req.get(url, {params: params}); } @@ -72,7 +72,7 @@ class MetadataManagerAPI { return this.req.post(url, data); } - updateMetadataRecord(repoID, recordID, creator, createTime, modifier, modifyTime, parentDir, name) { + updateMetadataRecord = (repoID, recordID, creator, createTime, modifier, modifyTime, parentDir, name) => { const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/records/' + recordID + '/'; const data = { 'creator': creator, @@ -83,16 +83,23 @@ class MetadataManagerAPI { 'name': name, }; return this.req.put(url, data); - } + }; - deleteMetadataRecord(repoID, recordID) { + deleteMetadataRecord = (repoID, recordID) => { const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/records/' + recordID + '/'; return this.req.delete(url); - } + }; + + listUserInfo = (userIds) => { + const url = this.server + '/api/v2.1/user-list/'; + const params = { user_id_list: userIds }; + return this._sendPostRequest(url, params, { headers: { 'Content-type': 'application/json' } }); + }; + } -const metadataManagerAPI = new MetadataManagerAPI(); +const metadataAPI = new MetadataManagerAPI(); const xcsrfHeaders = cookie.load('sfcsrftoken'); -metadataManagerAPI.initForSeahubUsage({ siteRoot, xcsrfHeaders }); +metadataAPI.initForSeahubUsage({ siteRoot, xcsrfHeaders }); -export default metadataManagerAPI; +export default metadataAPI; diff --git a/frontend/src/metadata/index.js b/frontend/src/metadata/index.js new file mode 100644 index 0000000000..321bae7f08 --- /dev/null +++ b/frontend/src/metadata/index.js @@ -0,0 +1,9 @@ +import SeafileMetadata from './metadata-view'; +import MetadataStatusManagementDialog from './metadata-status-manage-dialog'; +import MetadataTreeView from './metadata-tree-view'; + +export { + SeafileMetadata, + MetadataStatusManagementDialog, + MetadataTreeView, +}; diff --git a/frontend/src/components/metadata-manage/metadata-status-manage-dialog/index.css b/frontend/src/metadata/metadata-status-manage-dialog/index.css similarity index 100% rename from frontend/src/components/metadata-manage/metadata-status-manage-dialog/index.css rename to frontend/src/metadata/metadata-status-manage-dialog/index.css diff --git a/frontend/src/components/metadata-manage/metadata-status-manage-dialog/index.js b/frontend/src/metadata/metadata-status-manage-dialog/index.js similarity index 86% rename from frontend/src/components/metadata-manage/metadata-status-manage-dialog/index.js rename to frontend/src/metadata/metadata-status-manage-dialog/index.js index 5f4f3e3b1d..19381d5a49 100644 --- a/frontend/src/components/metadata-manage/metadata-status-manage-dialog/index.js +++ b/frontend/src/metadata/metadata-status-manage-dialog/index.js @@ -1,11 +1,11 @@ import React, { useCallback, useState } from 'react'; import PropTypes from 'prop-types'; import { Modal, ModalHeader, ModalBody, ModalFooter, Button } from 'reactstrap'; -import { gettext } from '../../../utils/constants'; -import Switch from '../../common/switch'; -import metadataManagerAPI from '../api'; -import { Utils } from '../../../utils/utils'; -import toaster from '../../toast'; +import { gettext } from '../../utils/constants'; +import Switch from '../../components/common/switch'; +import metadataAPI from '../api'; +import { Utils } from '../../utils/utils'; +import toaster from '../../components/toast'; import './index.css'; @@ -20,8 +20,8 @@ const MetadataStatusManagementDialog = ({ value: oldValue, repoID, toggle, submi const onSubmit = useCallback(() => { setSubmitting(true); - const apiName = value ? 'openRepoMetadataManagement' : 'closeRepoMetadataManagement'; - metadataManagerAPI[apiName](repoID).then(res => { + const apiName = value ? 'createMetadata' : 'deleteMetadata'; + metadataAPI[apiName](repoID).then(res => { submit(value); toggle(); }).catch(error => { diff --git a/frontend/src/components/metadata-manage/metadata-views/index.css b/frontend/src/metadata/metadata-tree-view/index.css similarity index 100% rename from frontend/src/components/metadata-manage/metadata-views/index.css rename to frontend/src/metadata/metadata-tree-view/index.css diff --git a/frontend/src/metadata/metadata-tree-view/index.js b/frontend/src/metadata/metadata-tree-view/index.js new file mode 100644 index 0000000000..2258ebf5d1 --- /dev/null +++ b/frontend/src/metadata/metadata-tree-view/index.js @@ -0,0 +1,74 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { gettext } from '../../utils/constants'; +import Icon from '../../components/icon'; +import { PRIVATE_FILE_TYPE } from '../../constants'; + +import './index.css'; + +const MetadataTreeView = ({ repoID, currentPath, onNodeClick }) => { + const node = useMemo(() => { + return { + children: [], + path: '/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES, + isExpanded: false, + isLoaded: true, + isPreload: true, + object: { + file_tags: [], + id: PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES, + name: gettext('File extended properties'), + type: PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES, + isDir: () => false, + }, + parentNode: {}, + key: repoID, + }; + }, [repoID]); + const [highlight, setHighlight] = useState(false); + + const onMouseEnter = useCallback(() => { + setHighlight(true); + }, []); + + const onMouseOver = useCallback(() => { + setHighlight(true); + }, []); + + const onMouseLeave = useCallback(() => { + setHighlight(false); + }, []); + + return ( +
+
+
+
onNodeClick(node)} + > +
{gettext('File extended properties')}
+
+
+ +
+
+
+
+
+
+ ); +}; + +MetadataTreeView.propTypes = { + repoID: PropTypes.string.isRequired, + currentPath: PropTypes.string, + onNodeClick: PropTypes.func, +}; + +export default MetadataTreeView; diff --git a/frontend/src/metadata/metadata-view/_basic/constants/column/format.js b/frontend/src/metadata/metadata-view/_basic/constants/column/format.js new file mode 100644 index 0000000000..f5331cd69a --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/constants/column/format.js @@ -0,0 +1,82 @@ +import CellType from './type'; + +const DATE_COLUMN_OPTIONS = [ + CellType.CTIME, CellType.MTIME, +]; +const NUMERIC_COLUMNS_TYPES = [ + +]; +const COLLABORATOR_COLUMN_TYPES = [ + CellType.CREATOR, CellType.LAST_MODIFIER, +]; + +// date +const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD'; +const UTC_FORMAT_DEFAULT = 'YYYY-MM-DDTHH:mm:ss.SSSZ'; +const DATE_UNIT = { + YEAR: 'year', + MONTH: 'month', + WEEK: 'week', + DAY: 'day', + HOUR: 'hour', + HOURS: 'hours', + MINUTE: 'minute', + MINUTES: 'minutes', + SECOND: 'second', +}; +const DATE_FORMAT_MAP = { + YYYY_MM_DD: 'YYYY-MM-DD', + YYYY_MM_DD_HH_MM: 'YYYY-MM-DD HH:mm', + YYYY_MM_DD_HH_MM_SS: 'YYYY-MM-DD HH:mm:ss', +}; + +// number +const DEFAULT_NUMBER_FORMAT = 'number'; + +const NOT_SUPPORT_EDIT_COLUMN_TYPE = [ + CellType.CTIME, + CellType.MTIME, + CellType.CREATOR, + CellType.LAST_MODIFIER, +]; + +const NOT_SUPPORT_EDIT_COLUMN_TYPE_MAP = { + [CellType.CTIME]: true, + [CellType.MTIME]: true, + [CellType.CREATOR]: true, + [CellType.LAST_MODIFIER]: true, +}; + +const MULTIPLE_CELL_VALUE_COLUMN_TYPE_MAP = { + +}; +const SINGLE_CELL_VALUE_COLUMN_TYPE_MAP = { + [CellType.TEXT]: true, + [CellType.CTIME]: true, + [CellType.MTIME]: true, + [CellType.CREATOR]: true, + [CellType.LAST_MODIFIER]: true, +}; + +const DATE_DEFAULT_TYPES = { + SPECIFIC_DATE: 'specific_date', + CURRENT_DATE: 'current_date', + DAYS_BEFORE: 'days_before', + DAYS_AFTER: 'days_after', +}; + +export { + COLLABORATOR_COLUMN_TYPES, + DATE_COLUMN_OPTIONS, + NUMERIC_COLUMNS_TYPES, + DEFAULT_DATE_FORMAT, + UTC_FORMAT_DEFAULT, + DATE_UNIT, + DATE_FORMAT_MAP, + DEFAULT_NUMBER_FORMAT, + DATE_DEFAULT_TYPES, + NOT_SUPPORT_EDIT_COLUMN_TYPE, + NOT_SUPPORT_EDIT_COLUMN_TYPE_MAP, + MULTIPLE_CELL_VALUE_COLUMN_TYPE_MAP, + SINGLE_CELL_VALUE_COLUMN_TYPE_MAP, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/constants/column/icon.js b/frontend/src/metadata/metadata-view/_basic/constants/column/icon.js new file mode 100644 index 0000000000..99b1c55da7 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/constants/column/icon.js @@ -0,0 +1,24 @@ +import CellType from './type'; + +const COLUMNS_ICON_CONFIG = { + [CellType.CREATOR]: 'creator', + [CellType.LAST_MODIFIER]: 'creator', + [CellType.CTIME]: 'creation-time', + [CellType.MTIME]: 'creation-time', + [CellType.DEFAULT]: 'text', + [CellType.TEXT]: 'text', +}; + +const COLUMNS_ICON_NAME = { + [CellType.CREATOR]: 'Creator', + [CellType.LAST_MODIFIER]: 'Last_modifier', + [CellType.CTIME]: 'CTime', + [CellType.MTIME]: 'Last_modified_time', + [CellType.DEFAULT]: 'Text', + [CellType.TEXT]: 'Text', +}; + +export { + COLUMNS_ICON_CONFIG, + COLUMNS_ICON_NAME, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/constants/column/index.js b/frontend/src/metadata/metadata-view/_basic/constants/column/index.js new file mode 100644 index 0000000000..51a8e8d493 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/constants/column/index.js @@ -0,0 +1,27 @@ +import CellType from './type'; +import { + COLUMNS_ICON_CONFIG, + COLUMNS_ICON_NAME, +} from './icon'; + +export { + COLLABORATOR_COLUMN_TYPES, + DATE_COLUMN_OPTIONS, + NUMERIC_COLUMNS_TYPES, + DEFAULT_DATE_FORMAT, + UTC_FORMAT_DEFAULT, + DATE_UNIT, + DATE_FORMAT_MAP, + DEFAULT_NUMBER_FORMAT, + DATE_DEFAULT_TYPES, + NOT_SUPPORT_EDIT_COLUMN_TYPE, + NOT_SUPPORT_EDIT_COLUMN_TYPE_MAP, + MULTIPLE_CELL_VALUE_COLUMN_TYPE_MAP, + SINGLE_CELL_VALUE_COLUMN_TYPE_MAP, +} from './format'; + +export { + CellType, + COLUMNS_ICON_CONFIG, + COLUMNS_ICON_NAME, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/constants/column/type.js b/frontend/src/metadata/metadata-view/_basic/constants/column/type.js new file mode 100644 index 0000000000..6b04be5e40 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/constants/column/type.js @@ -0,0 +1,10 @@ +const CellType = { + DEFAULT: 'default', + TEXT: 'text', + CREATOR: 'creator', + CTIME: 'ctime', + LAST_MODIFIER: 'last-modifier', + MTIME: 'mtime', +}; + +export default CellType; diff --git a/frontend/src/metadata/metadata-view/_basic/constants/filter/filter-column-options.js b/frontend/src/metadata/metadata-view/_basic/constants/filter/filter-column-options.js new file mode 100644 index 0000000000..3b967fb2d9 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/constants/filter/filter-column-options.js @@ -0,0 +1,84 @@ +import { CellType } from '../column'; +import { FILTER_TERM_MODIFIER_TYPE } from './filter-modifier'; +import { FILTER_PREDICATE_TYPE } from './filter-predicate'; + +const textPredicates = [ + FILTER_PREDICATE_TYPE.CONTAINS, + FILTER_PREDICATE_TYPE.NOT_CONTAIN, + FILTER_PREDICATE_TYPE.IS, + FILTER_PREDICATE_TYPE.IS_NOT, + FILTER_PREDICATE_TYPE.EMPTY, + FILTER_PREDICATE_TYPE.NOT_EMPTY, + FILTER_PREDICATE_TYPE.IS_CURRENT_USER_ID, +]; + +const datePredicates = [ + FILTER_PREDICATE_TYPE.IS, + FILTER_PREDICATE_TYPE.IS_WITHIN, + FILTER_PREDICATE_TYPE.IS_BEFORE, + FILTER_PREDICATE_TYPE.IS_AFTER, + FILTER_PREDICATE_TYPE.IS_ON_OR_BEFORE, + FILTER_PREDICATE_TYPE.IS_ON_OR_AFTER, + FILTER_PREDICATE_TYPE.IS_NOT, + FILTER_PREDICATE_TYPE.EMPTY, + FILTER_PREDICATE_TYPE.NOT_EMPTY, +]; + +const dateTermModifiers = [ + FILTER_TERM_MODIFIER_TYPE.TODAY, + FILTER_TERM_MODIFIER_TYPE.TOMORROW, + FILTER_TERM_MODIFIER_TYPE.YESTERDAY, + FILTER_TERM_MODIFIER_TYPE.ONE_WEEK_AGO, + FILTER_TERM_MODIFIER_TYPE.ONE_WEEK_FROM_NOW, + FILTER_TERM_MODIFIER_TYPE.ONE_MONTH_AGO, + FILTER_TERM_MODIFIER_TYPE.ONE_MONTH_FROM_NOW, + FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_AGO, + FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_FROM_NOW, + FILTER_TERM_MODIFIER_TYPE.EXACT_DATE, +]; + +const FILTER_COLUMN_OPTIONS = { + [CellType.TEXT]: { + filterPredicateList: textPredicates, + }, + [CellType.CTIME]: { + filterPredicateList: datePredicates, + filterTermModifierList: dateTermModifiers, + }, + [CellType.MTIME]: { + filterPredicateList: datePredicates, + filterTermModifierList: dateTermModifiers, + }, + [CellType.CREATOR]: { + filterPredicateList: [ + FILTER_PREDICATE_TYPE.CONTAINS, + FILTER_PREDICATE_TYPE.NOT_CONTAIN, + FILTER_PREDICATE_TYPE.INCLUDE_ME, + FILTER_PREDICATE_TYPE.IS, + FILTER_PREDICATE_TYPE.IS_NOT, + ], + }, + [CellType.LAST_MODIFIER]: { + filterPredicateList: [ + FILTER_PREDICATE_TYPE.CONTAINS, + FILTER_PREDICATE_TYPE.NOT_CONTAIN, + FILTER_PREDICATE_TYPE.INCLUDE_ME, + FILTER_PREDICATE_TYPE.IS, + FILTER_PREDICATE_TYPE.IS_NOT, + ], + }, + [CellType.URL]: { + filterPredicateList: [ + FILTER_PREDICATE_TYPE.CONTAINS, + FILTER_PREDICATE_TYPE.NOT_CONTAIN, + FILTER_PREDICATE_TYPE.IS, + FILTER_PREDICATE_TYPE.IS_NOT, + FILTER_PREDICATE_TYPE.EMPTY, + FILTER_PREDICATE_TYPE.NOT_EMPTY, + ], + }, +}; + +export { + FILTER_COLUMN_OPTIONS, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/constants/filter/filter-is-within.js b/frontend/src/metadata/metadata-view/_basic/constants/filter/filter-is-within.js new file mode 100644 index 0000000000..cb2f89f2b9 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/constants/filter/filter-is-within.js @@ -0,0 +1,30 @@ +import { FILTER_TERM_MODIFIER_TYPE } from './filter-modifier'; + +const filterTermModifierNotWithin = [ + FILTER_TERM_MODIFIER_TYPE.EXACT_DATE, + FILTER_TERM_MODIFIER_TYPE.TODAY, + FILTER_TERM_MODIFIER_TYPE.TOMORROW, + FILTER_TERM_MODIFIER_TYPE.YESTERDAY, + FILTER_TERM_MODIFIER_TYPE.ONE_WEEK_AGO, + FILTER_TERM_MODIFIER_TYPE.ONE_WEEK_FROM_NOW, + FILTER_TERM_MODIFIER_TYPE.ONE_MONTH_AGO, + FILTER_TERM_MODIFIER_TYPE.ONE_MONTH_FROM_NOW, + FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_AGO, + FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_FROM_NOW, +]; + +const filterTermModifierIsWithin = [ + FILTER_TERM_MODIFIER_TYPE.THE_PAST_WEEK, + FILTER_TERM_MODIFIER_TYPE.THE_PAST_MONTH, + FILTER_TERM_MODIFIER_TYPE.THE_PAST_YEAR, + FILTER_TERM_MODIFIER_TYPE.THIS_WEEK, + FILTER_TERM_MODIFIER_TYPE.THIS_MONTH, + FILTER_TERM_MODIFIER_TYPE.THIS_YEAR, + FILTER_TERM_MODIFIER_TYPE.THE_NEXT_WEEK, + FILTER_TERM_MODIFIER_TYPE.THE_NEXT_MONTH, + FILTER_TERM_MODIFIER_TYPE.THE_NEXT_YEAR, + FILTER_TERM_MODIFIER_TYPE.THE_NEXT_NUMBERS_OF_DAYS, + FILTER_TERM_MODIFIER_TYPE.THE_PAST_NUMBERS_OF_DAYS, +]; + +export { filterTermModifierNotWithin, filterTermModifierIsWithin }; diff --git a/frontend/src/metadata/metadata-view/_basic/constants/filter/filter-modifier.js b/frontend/src/metadata/metadata-view/_basic/constants/filter/filter-modifier.js new file mode 100644 index 0000000000..0e4d5f0248 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/constants/filter/filter-modifier.js @@ -0,0 +1,54 @@ +import { gettext } from '../../../../../utils/constants'; + +const FILTER_TERM_MODIFIER_TYPE = { + TODAY: 'today', + TOMORROW: 'tomorrow', + YESTERDAY: 'yesterday', + ONE_WEEK_AGO: 'one_week_ago', + ONE_WEEK_FROM_NOW: 'one_week_from_now', + ONE_MONTH_AGO: 'one_month_ago', + ONE_MONTH_FROM_NOW: 'one_month_from_now', + NUMBER_OF_DAYS_AGO: 'number_of_days_ago', + NUMBER_OF_DAYS_FROM_NOW: 'number_of_days_from_now', + EXACT_DATE: 'exact_date', + THE_PAST_WEEK: 'the_past_week', + THE_PAST_MONTH: 'the_past_month', + THE_PAST_YEAR: 'the_past_year', + THE_NEXT_WEEK: 'the_next_week', + THE_NEXT_MONTH: 'the_next_month', + THE_NEXT_YEAR: 'the_next_year', + THE_NEXT_NUMBERS_OF_DAYS: 'the_next_numbers_of_days', + THE_PAST_NUMBERS_OF_DAYS: 'the_past_numbers_of_days', + THIS_WEEK: 'this_week', + THIS_MONTH: 'this_month', + THIS_YEAR: 'this_year', +}; + +const FILTER_TERM_MODIFIER_SHOW = { + [FILTER_TERM_MODIFIER_TYPE.TODAY]: gettext('Today'), + [FILTER_TERM_MODIFIER_TYPE.TOMORROW]: gettext('Tomorrow'), + [FILTER_TERM_MODIFIER_TYPE.YESTERDAY]: gettext('Yesterday'), + [FILTER_TERM_MODIFIER_TYPE.ONE_WEEK_AGO]: gettext('One_week_ago'), + [FILTER_TERM_MODIFIER_TYPE.ONE_WEEK_FROM_NOW]: gettext('One_week_from_now'), + [FILTER_TERM_MODIFIER_TYPE.ONE_MONTH_AGO]: gettext('One_month_ago'), + [FILTER_TERM_MODIFIER_TYPE.ONE_MONTH_FROM_NOW]: gettext('One_month_from_now'), + [FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_AGO]: gettext('Number_of_days_ago'), + [FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_FROM_NOW]: gettext('Number_of_days_from_now'), + [FILTER_TERM_MODIFIER_TYPE.EXACT_DATE]: gettext('Exact_date'), + [FILTER_TERM_MODIFIER_TYPE.THE_PAST_WEEK]: gettext('The_past_week'), + [FILTER_TERM_MODIFIER_TYPE.THE_PAST_MONTH]: gettext('The_past_month'), + [FILTER_TERM_MODIFIER_TYPE.THE_PAST_YEAR]: gettext('The_past_year'), + [FILTER_TERM_MODIFIER_TYPE.THE_NEXT_WEEK]: gettext('The_next_week'), + [FILTER_TERM_MODIFIER_TYPE.THE_NEXT_MONTH]: gettext('The_next_month'), + [FILTER_TERM_MODIFIER_TYPE.THE_NEXT_YEAR]: gettext('The_next_year'), + [FILTER_TERM_MODIFIER_TYPE.THE_NEXT_NUMBERS_OF_DAYS]: gettext('The_next_numbers_of_days'), + [FILTER_TERM_MODIFIER_TYPE.THE_PAST_NUMBERS_OF_DAYS]: gettext('The_past_numbers_of_days'), + [FILTER_TERM_MODIFIER_TYPE.THIS_WEEK]: gettext('This_week'), + [FILTER_TERM_MODIFIER_TYPE.THIS_MONTH]: gettext('This_month'), + [FILTER_TERM_MODIFIER_TYPE.THIS_YEAR]: gettext('This_year'), +}; + +export { + FILTER_TERM_MODIFIER_TYPE, + FILTER_TERM_MODIFIER_SHOW, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/constants/filter/filter-predicate.js b/frontend/src/metadata/metadata-view/_basic/constants/filter/filter-predicate.js new file mode 100644 index 0000000000..b999ca1063 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/constants/filter/filter-predicate.js @@ -0,0 +1,57 @@ +const FILTER_PREDICATE_TYPE = { + CONTAINS: 'contains', + NOT_CONTAIN: 'does_not_contain', + IS: 'is', + IS_NOT: 'is_not', + EQUAL: 'equal', + NOT_EQUAL: 'not_equal', + LESS: 'less', + GREATER: 'greater', + LESS_OR_EQUAL: 'less_or_equal', + GREATER_OR_EQUAL: 'greater_or_equal', + EMPTY: 'is_empty', + NOT_EMPTY: 'is_not_empty', + IS_WITHIN: 'is_within', + IS_BEFORE: 'is_before', + IS_AFTER: 'is_after', + IS_ON_OR_BEFORE: 'is_on_or_before', + IS_ON_OR_AFTER: 'is_on_or_after', + HAS_ANY_OF: 'has_any_of', + HAS_ALL_OF: 'has_all_of', + HAS_NONE_OF: 'has_none_of', + IS_EXACTLY: 'is_exactly', + INCLUDE_ME: 'include_me', + IS_CURRENT_USER_ID: 'is_current_user_ID', + IS_ANY_OF: 'is_any_of', + IS_NONE_OF: 'is_none_of', +}; + +const FILTER_PREDICATE_SHOW = { + [FILTER_PREDICATE_TYPE.CONTAINS]: 'contains', + [FILTER_PREDICATE_TYPE.NOT_CONTAIN]: 'does not contain', + [FILTER_PREDICATE_TYPE.IS]: 'is', + [FILTER_PREDICATE_TYPE.IS_NOT]: 'is not', + [FILTER_PREDICATE_TYPE.EQUAL]: '\u003d', + [FILTER_PREDICATE_TYPE.NOT_EQUAL]: '\u2260', + [FILTER_PREDICATE_TYPE.LESS]: '\u003C', + [FILTER_PREDICATE_TYPE.GREATER]: '\u003E', + [FILTER_PREDICATE_TYPE.LESS_OR_EQUAL]: '\u2264', + [FILTER_PREDICATE_TYPE.GREATER_OR_EQUAL]: '\u2265', + [FILTER_PREDICATE_TYPE.EMPTY]: 'is empty', + [FILTER_PREDICATE_TYPE.NOT_EMPTY]: 'is not empty', + [FILTER_PREDICATE_TYPE.IS_WITHIN]: 'is within...', + [FILTER_PREDICATE_TYPE.IS_BEFORE]: 'is before...', + [FILTER_PREDICATE_TYPE.IS_AFTER]: 'is after...', + [FILTER_PREDICATE_TYPE.IS_ON_OR_BEFORE]: 'is on or before...', + [FILTER_PREDICATE_TYPE.IS_ON_OR_AFTER]: 'is on or after...', + [FILTER_PREDICATE_TYPE.HAS_ANY_OF]: 'has any of...', + [FILTER_PREDICATE_TYPE.HAS_ALL_OF]: 'has all of...', + [FILTER_PREDICATE_TYPE.HAS_NONE_OF]: 'has none of...', + [FILTER_PREDICATE_TYPE.IS_EXACTLY]: 'is exactly...', + [FILTER_PREDICATE_TYPE.IS_CURRENT_USER_ID]: 'is current user\'s ID', +}; + +export { + FILTER_PREDICATE_TYPE, + FILTER_PREDICATE_SHOW, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/constants/filter/index.js b/frontend/src/metadata/metadata-view/_basic/constants/filter/index.js new file mode 100644 index 0000000000..4d68d723ae --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/constants/filter/index.js @@ -0,0 +1,36 @@ +const FILTER_CONJUNCTION_TYPE = { + AND: 'And', + OR: 'Or', +}; + +const FILTER_ERR_MSG = { + INVALID_FILTER: 'invalid filter', + INCOMPLETE_FILTER: 'incomplete filter', + COLUMN_MISSING: 'the column to filter does not exist', + COLUMN_NOT_SUPPORTED: 'the column to filter is not supported', + UNMATCHED_PREDICATE: 'unmatched filter predicate', + UNMATCHED_MODIFIER: 'unmatched filter modifier', + INVALID_TERM: 'invalid filter term', +}; + +export { + FILTER_CONJUNCTION_TYPE, + FILTER_ERR_MSG, +}; + +export { FILTER_COLUMN_OPTIONS } from './filter-column-options'; + +export { + FILTER_TERM_MODIFIER_TYPE, + FILTER_TERM_MODIFIER_SHOW, +} from './filter-modifier'; + +export { + FILTER_PREDICATE_TYPE, + FILTER_PREDICATE_SHOW, +} from './filter-predicate'; + +export { + filterTermModifierIsWithin, + filterTermModifierNotWithin, +} from './filter-is-within'; diff --git a/frontend/src/metadata/metadata-view/_basic/constants/grid-header.js b/frontend/src/metadata/metadata-view/_basic/constants/grid-header.js new file mode 100644 index 0000000000..a107585872 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/constants/grid-header.js @@ -0,0 +1,8 @@ +const HEADER_HEIGHT_TYPE = { + DEFAULT: 'default', + DOUBLE: 'double', +}; + +export { + HEADER_HEIGHT_TYPE, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/constants/group.js b/frontend/src/metadata/metadata-view/_basic/constants/group.js new file mode 100644 index 0000000000..f0ed73d6ad --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/constants/group.js @@ -0,0 +1,58 @@ +import { CellType } from './column'; + +const MAX_GROUP_LEVEL = 3; + +const GROUP_DATE_GRANULARITY = { + DAY: 'day', + WEEK: 'week', + MONTH: 'month', + QUARTAR: 'quartar', + YEAR: 'year', +}; + +const DISPLAY_GROUP_DATE_GRANULARITY = { + [GROUP_DATE_GRANULARITY.DAY]: 'By_day', + [GROUP_DATE_GRANULARITY.WEEK]: 'By_week', + [GROUP_DATE_GRANULARITY.MONTH]: 'By_month', + [GROUP_DATE_GRANULARITY.QUARTAR]: 'By_quarter', + [GROUP_DATE_GRANULARITY.YEAR]: 'By_year', +}; + +const GROUP_GEOLOCATION_GRANULARITY = { + PROVINCE: 'province', + CITY: 'city', + DISTRICT: 'district', + COUNTRY: 'country', +}; + +const DISPLAY_GROUP_GEOLOCATION_GRANULARITY = { + [GROUP_GEOLOCATION_GRANULARITY.PROVINCE]: 'By_province', + [GROUP_GEOLOCATION_GRANULARITY.CITY]: 'By_city', + [GROUP_GEOLOCATION_GRANULARITY.DISTRICT]: 'By_district', +}; + +const SUPPORT_GROUP_COLUMN_TYPES = [ + CellType.TEXT, + CellType.CTIME, + CellType.MTIME, + CellType.CREATOR, + CellType.LAST_MODIFIER, +]; + +const GROUPBY_DATE_GRANULARITY_LIST = [ + GROUP_DATE_GRANULARITY.DAY, + GROUP_DATE_GRANULARITY.WEEK, + GROUP_DATE_GRANULARITY.MONTH, + GROUP_DATE_GRANULARITY.QUARTAR, + GROUP_DATE_GRANULARITY.YEAR, +]; + +export { + MAX_GROUP_LEVEL, + GROUP_DATE_GRANULARITY, + DISPLAY_GROUP_DATE_GRANULARITY, + GROUP_GEOLOCATION_GRANULARITY, + DISPLAY_GROUP_GEOLOCATION_GRANULARITY, + SUPPORT_GROUP_COLUMN_TYPES, + GROUPBY_DATE_GRANULARITY_LIST, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/constants/index.js b/frontend/src/metadata/metadata-view/_basic/constants/index.js new file mode 100644 index 0000000000..040dfff621 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/constants/index.js @@ -0,0 +1,63 @@ +import KeyCodes from './key-codes'; +import * as Z_INDEX from './z-index'; + +export { + CellType, + COLUMNS_ICON_CONFIG, + COLUMNS_ICON_NAME, + COLLABORATOR_COLUMN_TYPES, + DATE_COLUMN_OPTIONS, + NUMERIC_COLUMNS_TYPES, + DEFAULT_DATE_FORMAT, + UTC_FORMAT_DEFAULT, + DATE_UNIT, + DATE_FORMAT_MAP, + DEFAULT_NUMBER_FORMAT, + DATE_DEFAULT_TYPES, + NOT_SUPPORT_EDIT_COLUMN_TYPE, + NOT_SUPPORT_EDIT_COLUMN_TYPE_MAP, + MULTIPLE_CELL_VALUE_COLUMN_TYPE_MAP, + SINGLE_CELL_VALUE_COLUMN_TYPE_MAP, +} from './column'; +export { + FILTER_CONJUNCTION_TYPE, + FILTER_ERR_MSG, + FILTER_COLUMN_OPTIONS, + FILTER_TERM_MODIFIER_TYPE, + FILTER_TERM_MODIFIER_SHOW, + FILTER_PREDICATE_TYPE, + FILTER_PREDICATE_SHOW, + filterTermModifierIsWithin, + filterTermModifierNotWithin, +} from './filter'; +export { + MAX_GROUP_LEVEL, + GROUP_DATE_GRANULARITY, + DISPLAY_GROUP_DATE_GRANULARITY, + GROUP_GEOLOCATION_GRANULARITY, + DISPLAY_GROUP_GEOLOCATION_GRANULARITY, + SUPPORT_GROUP_COLUMN_TYPES, + GROUPBY_DATE_GRANULARITY_LIST, +} from './group'; +export { + HEADER_HEIGHT_TYPE +} from './grid-header'; +export { + REG_STRING_NUMBER_PARTS, + REG_NUMBER_DIGIT, +} from './reg'; +export { + SELECT_OPTION_COLORS, + HIGHLIGHT_COLORS, +} from './select-option'; +export { + SORT_TYPE, + SORT_COLUMN_OPTIONS, + TEXT_SORTER_COLUMN_TYPES, + NUMBER_SORTER_COLUMN_TYPES, +} from './sort'; + +export { + KeyCodes, + Z_INDEX, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/constants/key-codes.js b/frontend/src/metadata/metadata-view/_basic/constants/key-codes.js new file mode 100644 index 0000000000..5aed77093a --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/constants/key-codes.js @@ -0,0 +1,102 @@ +module.exports = { + Backspace: 8, + Tab: 9, + Enter: 13, + Shift: 16, + Ctrl: 17, + Alt: 18, + PauseBreak: 19, + CapsLock: 20, + Escape: 27, + Esc: 27, + Space: 32, + PageUp: 33, + PageDown: 34, + End: 35, + Home: 36, + LeftArrow: 37, + UpArrow: 38, + RightArrow: 39, + DownArrow: 40, + Insert: 45, + Delete: 46, + 0: 48, + 1: 49, + 2: 50, + 3: 51, + 4: 52, + 5: 53, + 6: 54, + 7: 55, + 8: 56, + 9: 57, + a: 65, + b: 66, + c: 67, + d: 68, + e: 69, + f: 70, + g: 71, + h: 72, + i: 73, + j: 74, + k: 75, + l: 76, + m: 77, + n: 78, + o: 79, + p: 80, + q: 81, + r: 82, + s: 83, + t: 84, + u: 85, + v: 86, + w: 87, + x: 88, + y: 89, + z: 90, + LeftWindowKey: 91, + RightWindowKey: 92, + SelectKey: 93, + NumPad0: 96, + NumPad1: 97, + NumPad2: 98, + NumPad3: 99, + NumPad4: 100, + NumPad5: 101, + NumPad6: 102, + NumPad7: 103, + NumPad8: 104, + NumPad9: 105, + Multiply: 106, + Add: 107, + Subtract: 109, + DecimalPoint: 110, + Divide: 111, + F1: 112, + F2: 113, + F3: 114, + F4: 115, + F5: 116, + F6: 117, + F7: 118, + F8: 119, + F9: 120, + F10: 121, + F12: 123, + NumLock: 144, + ScrollLock: 145, + SemiColon: 186, + EqualSign: 187, + Comma: 188, + Dash: 189, + Period: 190, + ForwardSlash: 191, + GraveAccent: 192, + OpenBracket: 219, + BackSlash: 220, + CloseBracket: 221, + SingleQuote: 222, + ChineseInputMethod: 229, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/constants/reg.js b/frontend/src/metadata/metadata-view/_basic/constants/reg.js new file mode 100644 index 0000000000..f63ea51536 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/constants/reg.js @@ -0,0 +1,7 @@ +const REG_STRING_NUMBER_PARTS = /\d+|\D+/g; +const REG_NUMBER_DIGIT = /\d/; + +export { + REG_STRING_NUMBER_PARTS, + REG_NUMBER_DIGIT, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/constants/select-option.js b/frontend/src/metadata/metadata-view/_basic/constants/select-option.js new file mode 100644 index 0000000000..aebae56e03 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/constants/select-option.js @@ -0,0 +1,62 @@ +const SELECT_OPTION_COLORS = [ + { COLOR: '#FFFCB5', BORDER_COLOR: '#E8E79D', TEXT_COLOR: '#212529' }, + { COLOR: '#FFEAB6', BORDER_COLOR: '#ECD084', TEXT_COLOR: '#212529' }, + { COLOR: '#FFD9C8', BORDER_COLOR: '#EFBAA3', TEXT_COLOR: '#212529' }, + { COLOR: '#FFDDE5', BORDER_COLOR: '#EDC4C1', TEXT_COLOR: '#212529' }, + { COLOR: '#FFD4FF', BORDER_COLOR: '#E6B6E6', TEXT_COLOR: '#212529' }, + { COLOR: '#DAD7FF', BORDER_COLOR: '#C3BEEF', TEXT_COLOR: '#212529' }, + { COLOR: '#DDFFE6', BORDER_COLOR: '#BBEBCD', TEXT_COLOR: '#212529' }, + { COLOR: '#DEF7C4', BORDER_COLOR: '#C5EB9E', TEXT_COLOR: '#212529' }, + { COLOR: '#D8FAFF', BORDER_COLOR: '#B4E4E9', TEXT_COLOR: '#212529' }, + { COLOR: '#D7E8FF', BORDER_COLOR: '#BAD1E9', TEXT_COLOR: '#212529' }, + { COLOR: '#B7CEF9', BORDER_COLOR: '#96B2E1', TEXT_COLOR: '#212529' }, + { COLOR: '#E9E9E9', BORDER_COLOR: '#DADADA', TEXT_COLOR: '#212529' }, + { COLOR: '#FBD44A', BORDER_COLOR: '#E5C142', TEXT_COLOR: '#FFFFFF' }, + { COLOR: '#EAA775', BORDER_COLOR: '#D59361', TEXT_COLOR: '#FFFFFF' }, + { COLOR: '#F4667C', BORDER_COLOR: '#DC556A', TEXT_COLOR: '#FFFFFF' }, + { COLOR: '#DC82D2', BORDER_COLOR: '#D166C5', TEXT_COLOR: '#FFFFFF' }, + { COLOR: '#9860E5', BORDER_COLOR: '#844BD2', TEXT_COLOR: '#FFFFFF' }, + { COLOR: '#9F8CF1', BORDER_COLOR: '#8F75E2', TEXT_COLOR: '#FFFFFF' }, + { COLOR: '#59CB74', BORDER_COLOR: '#4EB867', TEXT_COLOR: '#FFFFFF' }, + { COLOR: '#ADDF84', BORDER_COLOR: '#9CCF72', TEXT_COLOR: '#FFFFFF' }, + { COLOR: '#89D2EA', BORDER_COLOR: '#7BC0D6', TEXT_COLOR: '#FFFFFF' }, + { COLOR: '#4ECCCB', BORDER_COLOR: '#45BAB9', TEXT_COLOR: '#FFFFFF' }, + { COLOR: '#46A1FD', BORDER_COLOR: '#3C8FE4', TEXT_COLOR: '#FFFFFF' }, + { COLOR: '#C2C2C2', BORDER_COLOR: '#ADADAD', TEXT_COLOR: '#FFFFFF' }, +]; + +const HIGHLIGHT_COLORS = { + '#FFE8E6': '#FF6052', + '#FFDED5': '#FF714A', + '#FFE7D1': '#FF851A', + '#EED5FF': '#B64DFD', + '#DAD7FF': '#5F4CFF', + '#D7E8FF': '#3C8FFF', + '#D8FAFF': '#41E7FF', + '#DDFFE6': '#16BA51', + '#E9E9E9': '#999999', + '#FBD44A': '#E5C142', + '#EAA775': '#D59361', + '#F4667C': '#DC556A', + '#DC82D2': '#D166C5', + '#9860E5': '#844BD2', + '#9F8CF1': '#8F75E2', + '#59CB74': '#4EB867', + '#ADDF84': '#9CCF72', + '#89D2EA': '#7BC0D6', + '#4ECCCB': '#45BAB9', + '#46A1FD': '#3C8FE4', + '#C2C2C2': '#ADADAD', + '#FFFCB5': '#E8E79D', + '#FFEAB6': '#ECD084', + '#FFD9C8': '#EFBAA3', + '#FFDDE5': '#EDC4C1', + '#FFD4FF': '#E6B6E6', + '#DEF7C4': '#C5EB9E', + '#B7CEF9': '#96B2E1', +}; + +export { + SELECT_OPTION_COLORS, + HIGHLIGHT_COLORS, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/constants/sort.js b/frontend/src/metadata/metadata-view/_basic/constants/sort.js new file mode 100644 index 0000000000..e20d62245f --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/constants/sort.js @@ -0,0 +1,22 @@ +import { CellType } from './column'; + +const SORT_TYPE = { + UP: 'up', + DOWN: 'down', +}; + +const SORT_COLUMN_OPTIONS = [ + CellType.CTIME, + CellType.MTIME, + CellType.TEXT, +]; + +const TEXT_SORTER_COLUMN_TYPES = [CellType.TEXT]; +const NUMBER_SORTER_COLUMN_TYPES = []; + +export { + SORT_TYPE, + SORT_COLUMN_OPTIONS, + TEXT_SORTER_COLUMN_TYPES, + NUMBER_SORTER_COLUMN_TYPES, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/constants/z-index.js b/frontend/src/metadata/metadata-view/_basic/constants/z-index.js new file mode 100644 index 0000000000..6f82782770 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/constants/z-index.js @@ -0,0 +1,135 @@ +// copy from sf-metadata +// Drop Target of top row's z-index is -1. +export const DEFAULT_DROP_TARGET = -1; + +// CellMasks should render in front of the cells +// Unfrozen cells do not have a zIndex specifed +export const CELL_MASK = 1; +export const TABLE_MAIN_INTERVAL = 1; +export const RESIZE_HANDLE = 1; + +export const SEQUENCE_COLUMN = 1; + +// higher than unfrozen header cell(0), RESIZE_HANDLE +export const FROZEN_HEADER_CELL = 2; + +export const GROUP_FROZEN_HEADER = 2; + +export const SCROLL_BAR = 2; + +// In front of CELL_MASK/non-frozen cell(1)、back of the frozen cells (2) +export const GROUP_BACKDROP = 2; + +export const MOBILE_RECORDS_COLUMN_NAMES = 2; + +export const FROZEN_GROUP_CELL = 2; + +// Frozen cells have a zIndex value of 2 so CELL_MASK should have a higher value +export const FROZEN_CELL_MASK = 3; + +// GALLERY_MAIN_HEADER, TABLE_MAIN_INTERVAL is 3 to hide first freeze column +export const GALLERY_MAIN_HEADER = 3; + +// APP_HEADER is 8 than TABLE_HEADER because Logout Popover need to be at the top +export const APP_HEADER = 8; + +// higher than frozen cells(2) +export const GRID_HORIZONTAL_SCROLLBAR = 3; + +// In mobile list mode, row name fixed, so upper components z-index is 3 +export const SEARCH_ALL_TABLES = 3; + +export const MOBILE_TABLES_TABS_CONTAINER = 3; + +export const MOBILE_TABLE_TOOLBAR = 3; + +export const MOBILE_HEADER = 3; + +// need higher than the doms(etc. cell, cell_mask) which behind of the grid header +export const GRID_HEADER = 4; + +export const GRID_FOOTER = 4; + +export const UPLOAD_PROGRESS = 4; + +// frozen column header z-index is 3,row drop target horizontal line shoule appear so z-index is 4 +export const ROW_DROP_TARGET = 4; + +export const VIEW_SIDEBAR_RESIZE_HANDLER = 4; + +// need higher or equal to GRID_HEADER(frozen): 4 +export const TABLE_SETTING_PANEL = 4; + +// higher than PANE_DIVIDER(4) +export const TABLE_TOOLBAR = 5; + +export const TABLE_RIGHT_PANEL = 5; + +// higher than TABLE_TOOLBAR(5) +export const TABLES_TABS_CONTAINER = 6; + +// higher than TABLES_TABS_CONTAINER(6) +export const TABLE_HEADER = 7; + +export const TABLE_COMMENT_CONTAINER = 7; + +// higher than TABLE_HEADER(7) +export const PANE_DIVIDER = 8; + +// EditorContainer is rendered outside the grid and it higher FROZEN_GROUP_CELL(2) and PANE_DIVIDER(8) +export const EDITOR_CONTAINER = 9; + +// APP_LEFT_BAR_COLLAPSE z-index should taller than the APP_HEADER and the PANE_DIVIDER(8) +export const APP_LEFT_BAR_COLLAPSE = 9; + +export const EXPAND_ROW_ICON = 99; + +export const MOBILE_MASK = 100; + +export const ROW_EXPAND_VIEW = 100; + +export const APP_NAV_SLIDER = 100; + +// LINK_RECORDS z-index should higher than EXPAND_ROW_ICON +export const LINK_RECORDS = 100; + +// APP_LEFT_BAR is higher than APP_NAV_SLIDER +export const APP_LEFT_BAR = 101; + +export const MOBILE_APP_NAV = 101; + +export const STATISTIC_DIALOG_MODAL = 800; + +export const STATISTIC_ENLARGE_DIALOG_MODAL = 900; + +export const STATISTIC_RECORDS_DIALOG_MODAL = 1000; + +export const SEARCH_TABLES_DIALOG_MODAL = 1000; + +export const DATE_EDITOR = 1001; + +export const NOTIFICATION_LIST_MODAL = 1046; + +export const TRIGGER_ROWS_MODAL = 1047; + +export const TRIGGER_ROWS_VIEW = 1047; + +export const RECORD_DETAILS_DIALOG = 1048; + +export const CALENDAR_DIALOG_MODAL = 1048; + +export const PRINT_ROW_TYPE_MODAL = 1049; + +export const IMAGE_PREVIEW_LIGHTBOX = 1051; + +export const DROPDOWN_MENU = 1051; + +export const RC_CALENDAR = 1053; + +export const EDIT_COLUMN_POPOVER = 1060; + +export const LARGE_MAP_EDITOR_DIALOG_MODAL = 1061; + +export const TOAST_MANAGER = 999999; + +export const LINK_PICKER = 10; diff --git a/frontend/src/metadata/metadata-view/_basic/index.js b/frontend/src/metadata/metadata-view/_basic/index.js new file mode 100644 index 0000000000..6773942b37 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/index.js @@ -0,0 +1,114 @@ +export { + UserService +} from './services'; +export { + CellType, + COLUMNS_ICON_CONFIG, + COLUMNS_ICON_NAME, + COLLABORATOR_COLUMN_TYPES, + DATE_COLUMN_OPTIONS, + NUMERIC_COLUMNS_TYPES, + DEFAULT_DATE_FORMAT, + UTC_FORMAT_DEFAULT, + DATE_UNIT, + DATE_FORMAT_MAP, + DEFAULT_NUMBER_FORMAT, + DATE_DEFAULT_TYPES, + NOT_SUPPORT_EDIT_COLUMN_TYPE, + NOT_SUPPORT_EDIT_COLUMN_TYPE_MAP, + MULTIPLE_CELL_VALUE_COLUMN_TYPE_MAP, + SINGLE_CELL_VALUE_COLUMN_TYPE_MAP, + FILTER_CONJUNCTION_TYPE, + FILTER_ERR_MSG, + FILTER_COLUMN_OPTIONS, + FILTER_TERM_MODIFIER_TYPE, + FILTER_TERM_MODIFIER_SHOW, + FILTER_PREDICATE_TYPE, + FILTER_PREDICATE_SHOW, + filterTermModifierIsWithin, + filterTermModifierNotWithin, + MAX_GROUP_LEVEL, + GROUP_DATE_GRANULARITY, + DISPLAY_GROUP_DATE_GRANULARITY, + GROUP_GEOLOCATION_GRANULARITY, + DISPLAY_GROUP_GEOLOCATION_GRANULARITY, + SUPPORT_GROUP_COLUMN_TYPES, + REG_STRING_NUMBER_PARTS, + REG_NUMBER_DIGIT, + SELECT_OPTION_COLORS, + HIGHLIGHT_COLORS, + SORT_TYPE, + SORT_COLUMN_OPTIONS, + TEXT_SORTER_COLUMN_TYPES, + NUMBER_SORTER_COLUMN_TYPES, + KeyCodes, + Z_INDEX, + GROUPBY_DATE_GRANULARITY_LIST, + HEADER_HEIGHT_TYPE, +} from './constants'; + +export { + getColumnType, + getColumnsByType, + isDateColumn, + isSupportDateColumnFormat, + getValidFilters, + getValidFiltersWithoutError, + deleteInvalidFilter, + otherDate, + getFormattedFilterOtherDate, + getFormattedFilter, + getFormattedFilters, + creatorFilter, + dateFilter, + textFilter, + filterRow, + filterRows, + deleteInvalidGroupby, + isValidGroupby, + getValidGroupbys, + groupTableRows, + groupViewRows, + isTableRows, + updateTableRowsWithRowsData, + isValidSort, + getValidSorts, + deleteInvalidSort, + getMultipleIndexesOrderbyOptions, + sortDate, + sortText, + sortRowsWithMultiSorts, + sortTableRows, + getTableById, + getTableByName, + getTableByIndex, + getTableColumnByKey, + getTableColumnByName, + getRowById, + getRowsByIds, + isValidEmail, + ValidateFilter, + DATE_MODIFIERS_REQUIRE_TERM, + getViewById, + getViewByName, + isDefaultView, + isFilterView, + isGroupView, + isSortView, + isHiddenColumnsView, + getViewShownColumns, + getGroupByPath, + getType, + isMac, + base64ToFile, + bytesToSize, + getErrorMsg, + DateUtils, + CommonlyUsedHotkey, + LocalStorage, + isFunction, + isEmpty, + isEmptyObject, + debounce, + throttle, +} from './utils'; diff --git a/frontend/src/metadata/metadata-view/_basic/services/index.js b/frontend/src/metadata/metadata-view/_basic/services/index.js new file mode 100644 index 0000000000..b3945491d1 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/services/index.js @@ -0,0 +1,5 @@ +import UserService from './user-service'; + +export { + UserService, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/services/user-service.js b/frontend/src/metadata/metadata-view/_basic/services/user-service.js new file mode 100644 index 0000000000..b5b64c511d --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/services/user-service.js @@ -0,0 +1,68 @@ +const PENDING_INTERVAL = 1000; // 1s + +class UserService { + + constructor({ api, mediaUrl = '' }) { + this.api = api; + this.defaultAvatarUrl = `${mediaUrl}/avatars/default.png`; + this.waitingQueryEmails = []; + this.waitingExecCallbacks = []; + this.emailUserMap = {}; + } + + queryUser = (email, callback) => { + if (!email) return; + this.waitingExecCallbacks.push(callback); + if (this.emailUserMap[email] || this.waitingQueryEmails.includes(email)) return; + this.waitingQueryEmails.push(email); + this.startQueryUsers(); + }; + + queryUsers = (emails, callback) => { + if (!Array.isArray(emails) || emails.length === 0) return; + let validEmails = []; + emails.forEach(email => { + this.waitingExecCallbacks.push(callback); + if (this.emailUserMap[email] || this.waitingQueryEmails.includes(email)) return; + validEmails.push(email); + }); + if (validEmails.length === 0) return; + this.waitingQueryEmails.push(...validEmails); + this.startQueryUsers(); + }; + + startQueryUsers = () => { + if (this.pendingTimer || this.waitingQueryEmails.length === 0) return; + this.pendingTimer = setTimeout(() => { + this.api(this.waitingQueryEmails).then(res => { + const { user_list } = res.data; + user_list.forEach(user => { + this.emailUserMap[user.email] = user; + }); + this.queryUserCallback(); + }).catch(() => { + this.waitingQueryEmails.forEach(email => { + this.emailUserMap[email] = { + email: email, + name: email, + avatar_url: this.defaultAvatarUrl, + }; + }); + this.queryUserCallback(); + }); + clearTimeout(this.pendingTimer); + this.pendingTimer = null; + }, PENDING_INTERVAL); + }; + + queryUserCallback = () => { + this.waitingExecCallbacks.forEach(callback => { + callback(this.emailUserMap); + }); + this.waitingQueryEmails = []; + this.waitingExecCallbacks = []; + }; + +} + +export default UserService; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/column/core.js b/frontend/src/metadata/metadata-view/_basic/utils/column/core.js new file mode 100644 index 0000000000..db50c776b8 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/column/core.js @@ -0,0 +1,27 @@ +/** + * Get column type. + * @param {object} column { type, ... } + * @returns column type + */ +const getColumnType = (column) => { + const { type } = column; + return type; +}; + +/** + * Get columns by type. + * @param {array} columns + * @param {string} columnType + * @returns the target type columns, array + */ +const getColumnsByType = (columns, columnType) => { + if (!Array.isArray(columns) || !columnType) { + return []; + } + return columns.filter((column) => column.type === columnType); +}; + +export { + getColumnType, + getColumnsByType, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/column/date.js b/frontend/src/metadata/metadata-view/_basic/utils/column/date.js new file mode 100644 index 0000000000..7aa1217726 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/column/date.js @@ -0,0 +1,30 @@ +import { getColumnType } from './core'; +import { DATE_COLUMN_OPTIONS, DATE_FORMAT_MAP } from '../../constants/column'; + +/** + * Check whether is date column: + * - column type is date, ctime or mtime etc. + * - column type is formula and result_type is date + * - column type is link/link_fromula and array_type is date, ctime or mtime etc. + * @param {object} column e.g. { type, data } + * @returns true/false, bool + */ +const isDateColumn = (column) => DATE_COLUMN_OPTIONS.includes(getColumnType(column)); + +/** + * Check whether the format is supported in date column + * @param {string} format + * @returns bool + */ +const isSupportDateColumnFormat = (format) => { + if (!format) { + return false; + } + return ( + format === DATE_FORMAT_MAP.YYYY_MM_DD + || format === DATE_FORMAT_MAP.YYYY_MM_DD_HH_MM + || format === DATE_FORMAT_MAP.YYYY_MM_DD_HH_MM_SS + ); +}; + +export { isDateColumn, isSupportDateColumnFormat }; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/column/index.js b/frontend/src/metadata/metadata-view/_basic/utils/column/index.js new file mode 100644 index 0000000000..f58880095f --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/column/index.js @@ -0,0 +1,8 @@ +export { + getColumnType, + getColumnsByType, +} from './core'; +export { + isDateColumn, + isSupportDateColumnFormat, +} from './date'; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/common.js b/frontend/src/metadata/metadata-view/_basic/utils/common.js new file mode 100644 index 0000000000..7c845c0add --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/common.js @@ -0,0 +1,113 @@ +export const getType = (value) => { + return Object.prototype.toString.call(value).slice(8, -1); +}; + +export const isMac = () => { + const platform = navigator.platform; + return (platform === 'Mac68K') || (platform === 'MacPPC') || (platform === 'Macintosh') || (platform === 'MacIntel'); +}; + +export const base64ToFile = (data, fileName) => { + const parts = data.split(';base64,'); + const contentType = parts[0].split(':')[1]; + const raw = window.atob(parts[1]); + const rawLength = raw.length; + const uInt8Array = new Uint8Array(rawLength); + + for (let i = 0; i < rawLength; ++i) { + uInt8Array[i] = raw.charCodeAt(i); + } + + const blob = new Blob([uInt8Array], { type: contentType }); + const file = new File([blob], fileName, { type: contentType }); + return file; +}; + +export const bytesToSize = (bytes) => { + if (typeof(bytes) == 'undefined') return ' '; + + if (bytes < 0) return '--'; + const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; + + if (bytes === 0) return bytes + ' ' + sizes[0]; + + const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1000)), 10); + if (i === 0) return bytes + ' ' + sizes[i]; + return (bytes / (1000 ** i)).toFixed(1) + ' ' + sizes[i]; +}; + +export const getErrorMsg = (error) => { + let errorMsg = ''; + if (error.response) { + if (error.response.status === 403) { + errorMsg = 'Permission_denied'; + } else if (error.response.data && + error.response.data['error_msg']) { + errorMsg = error.response.data['error_msg']; + } else { + errorMsg = 'Error'; + } + } else { + errorMsg = 'Please_check_the_network'; + } + return errorMsg; +}; + +export const isFunction = (functionToCheck) => { + const getType = {}; + return functionToCheck && getType.toString.call(functionToCheck) === '[object Function]'; +}; + +/** + * Check whether the given value is empty + * @param {any} val + * @returns bool + */ +export const isEmpty = (val) => { + if (val === null || val === undefined) return true; + if (val.length !== undefined) return val.length === 0; + if (val instanceof Date) return false; + if (typeof val === 'object') return Object.keys(val).length === 0; + return false; +}; + +/** + * Check whether the object is empty. + * The true will be returned if the "obj" is invalid. + * @param {object} obj + * @returns bool + */ +export const isEmptyObject = (obj) => { + let name; + // eslint-disable-next-line + for (name in obj) { + return false; + } + return true; +}; + +export const debounce = (fn, wait) => { + let timeout = null; + return function () { + if (timeout !== null) clearTimeout(timeout); + timeout = setTimeout(fn, wait); + }; +}; + +export const throttle = (func, delay) => { + let timer = null; + let startTime = Date.now(); + return function () { + let curTime = Date.now(); + let remaining = delay - (curTime - startTime); + let context = this; + let args = arguments; + clearTimeout(timer); + if (remaining <= 0) { + func.apply(context, args); + startTime = Date.now(); + } else { + timer = setTimeout(func, remaining); + } + }; +}; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/date.js b/frontend/src/metadata/metadata-view/_basic/utils/date.js new file mode 100644 index 0000000000..e1f809b09c --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/date.js @@ -0,0 +1,207 @@ +import { + DEFAULT_DATE_FORMAT, + DATE_UNIT, +} from '../constants'; + +const MONTH_QUARTERS = [1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4]; +const FORMATTING_TOKENS = /(\[[^[]*\])|([-:/.()\s]+)|(A|a|YYYY|YY?|MM?M?M?|Do|DD?|hh?|HH?|mm?|ss?|S{1,3}|z|ZZ?)/g; +const MATCH_1_2 = /\d\d?/; // 0 - 99 +const MATCH2 = /\d\d/; // 00 - 99 +const MATCH4 = /\d{4}/; // 0000 - 9999 + +const MATCHER_EXPRESSIONS = { + mm: [MATCH_1_2, DATE_UNIT.MINUTES], + HH: [MATCH_1_2, DATE_UNIT.HOURS], + D: [MATCH_1_2, DATE_UNIT.DAY], + DD: [MATCH2, DATE_UNIT.DAY], + M: [MATCH_1_2, DATE_UNIT.MONTH], + MM: [MATCH2, DATE_UNIT.MONTH], + YYYY: [MATCH4, DATE_UNIT.YEAR], +}; + +const MATCHER_DATE_PARTS = ['YYYY', 'MM', 'M', 'DD', 'D']; + +class DateUtils { + /** + * return the formatted date with target format. + * @param {string|date object} date + * @param {string} format + * @returns formatted date + */ + static format(date, format) { + const dateObject = this.getValidDate(date); + if (!dateObject) { + return ''; + } + const upperCaseFormat = format && format.toUpperCase(); + const year = dateObject.getFullYear(); + const month = dateObject.getMonth() + 1; + const day = dateObject.getDate(); + const displayMonth = month < 10 ? `0${month}` : month; + const displayDay = day < 10 ? `0${day}` : day; + switch (upperCaseFormat) { + case 'YYYY-MM-DD HH:MM:SS': { + const hours = dateObject.getHours(); + const minutes = dateObject.getMinutes(); + const seconds = dateObject.getSeconds(); + const disPlayHours = hours < 10 ? `0${hours}` : hours; + const disPlayMinutes = minutes < 10 ? `0${minutes}` : minutes; + const disPlaySeconds = seconds < 10 ? `0${seconds}` : seconds; + return `${year}-${displayMonth}-${displayDay} ${disPlayHours}:${disPlayMinutes}:${disPlaySeconds}`; + } + case 'YYYY-MM-DD HH:MM': { + const hours = dateObject.getHours(); + const minutes = dateObject.getMinutes(); + const disPlayHours = hours < 10 ? `0${hours}` : hours; + const disPlayMinutes = minutes < 10 ? `0${minutes}` : minutes; + return `${year}-${displayMonth}-${displayDay} ${disPlayHours}:${disPlayMinutes}`; + } + default: { + return `${year}-${displayMonth}-${displayDay}`; + } + } + } + + /** + * returns the formatted date with granularity. + * @param {string|date object} date + * @param {string} granularity + * @returns formatted date + */ + static getDateByGranularity(date, granularity) { + const dateObject = this.getValidDate(date); + if (!dateObject) { + return ''; + } + const upperCaseGranularity = granularity && granularity.toUpperCase(); + const year = dateObject.getFullYear(); + switch (upperCaseGranularity) { + case 'YEAR': { + return `${year}`; + } + case 'QUARTAR': { + const month = dateObject.getMonth(); + const quarter = MONTH_QUARTERS[month]; + return `${year}-Q${quarter}`; + } + case 'MONTH': { + const month = dateObject.getMonth() + 1; + const displayMonth = month < 10 ? `0${month}` : month; + return `${year}-${displayMonth}`; + } + case 'WEEK': { + const weekNum = dateObject.getDay(); + const startOfWeekDay = dateObject.getDate() + (weekNum === 0 ? -6 : 1 - weekNum); + const startOfWeekDate = new Date(year, dateObject.getMonth(), startOfWeekDay); + const month = startOfWeekDate.getMonth() + 1; + const day = startOfWeekDate.getDate(); + const displayMonth = month < 10 ? `0${month}` : month; + const displayDay = day < 10 ? `0${day}` : day; + return `${startOfWeekDate.getFullYear()}-${displayMonth}-${displayDay}`; + } + case 'DAY': { + const month = dateObject.getMonth() + 1; + const day = dateObject.getDate(); + const displayMonth = month < 10 ? `0${month}` : month; + const displayDay = day < 10 ? `0${day}` : day; + return `${year}-${displayMonth}-${displayDay}`; + } + default: { + return ''; + } + } + } + + static isValidDateObject(dateObject) { + return dateObject instanceof Date && !isNaN(dateObject.getTime()); + } + + static getValidDate(date) { + if (!date) { + return null; + } + const isDateTypeString = typeof date === 'string'; + let dateString = date; + let dateObject = date; + if (isDateTypeString) { + if (dateString.split(' ').length > 1 || dateString.includes('T')) { + dateObject = new Date(date); + } else { + // given date is without time precision + dateString = `${date} 00:00:00`; + dateObject = new Date(dateString); + } + } + if (this.isValidDateObject(dateObject)) return dateObject; + if (!isDateTypeString) return null; + + // ios phone and safari browser not support use '2021-09-10 12:30', support '2021/09/10 12:30' + dateObject = new Date(dateString.replace(/-/g, '/')); + if (this.isValidDateObject(dateObject)) return dateObject; + return null; + } + + /** + * @param {string} dateString + * @param {string} format + * @returns Date Object + */ + static parseDateWithFormat(dateString, format) { + if (dateString.includes('T')) { + // ISO 8601 format with "T" separator directly using Date object + const dateObj = new Date(dateString); + return this.isValidDateObject(dateObj) ? dateObj : this.getValidDate(dateString); + } + try { + const parser = this.makeParser(format); + let { + year, month, day, hours, minutes, + } = parser(dateString); + if (!year) { + const nowDate = new Date(); + year = nowDate.getFullYear(); + } + let dateObj = new Date(`${year}-${month}-${day} ${hours || '00'}:${minutes || '00'}`); + if (!this.isValidDateObject(dateObj)) { + return this.getValidDate(dateString); + } + return dateObj; + } catch (err) { + return this.getValidDate(dateString); + } + } + + static makeParser(format) { + // 'YYYY-MM-DD HH:mm'.match(formattingTokens): + // ['YYYY', '-', 'MM', '-', 'DD', ' ', 'HH', ':', 'mm'] + const tokens = (format || DEFAULT_DATE_FORMAT).match(FORMATTING_TOKENS); + const { length: formatPartsLength } = tokens; + return (dateString) => { + const dateParts = dateString.split(' '); + let datePart = dateParts[0] || ''; + let timePart = dateParts[1] || ''; + let time = {}; + for (let i = 0; i < formatPartsLength; i++) { + const token = tokens[i]; + const parseTo = MATCHER_EXPRESSIONS[token]; + if (!parseTo) continue; + const regex = parseTo[0]; + const parserType = parseTo[1]; + if (!parserType) continue; + const isDatePart = MATCHER_DATE_PARTS.includes(token); + let match = isDatePart ? regex.exec(datePart) : regex.exec(timePart); + if (!match) continue; + const value = match[0]; + time[parserType] = value; + if (isDatePart) { + datePart = datePart.replace(value, ''); + } else { + timePart = timePart.replace(value, ''); + } + } + return time; + }; + } +} + +export { DateUtils }; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/filter/core.js b/frontend/src/metadata/metadata-view/_basic/utils/filter/core.js new file mode 100644 index 0000000000..b62dccda37 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/filter/core.js @@ -0,0 +1,273 @@ +import { ValidateFilter } from '../validate/filter'; +import { DateUtils } from '../date'; +import { + FILTER_ERR_MSG, + FILTER_TERM_MODIFIER_TYPE, +} from '../../constants/filter'; +import { CellType } from '../../constants/column'; + +const EXACT_DATE_TERM_MODIFIER_TYPES = [ + FILTER_TERM_MODIFIER_TYPE.TODAY, + FILTER_TERM_MODIFIER_TYPE.TOMORROW, + FILTER_TERM_MODIFIER_TYPE.YESTERDAY, + FILTER_TERM_MODIFIER_TYPE.ONE_WEEK_AGO, + FILTER_TERM_MODIFIER_TYPE.ONE_WEEK_FROM_NOW, + FILTER_TERM_MODIFIER_TYPE.ONE_MONTH_AGO, + FILTER_TERM_MODIFIER_TYPE.ONE_MONTH_FROM_NOW, + FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_AGO, + FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_FROM_NOW, + FILTER_TERM_MODIFIER_TYPE.EXACT_DATE, +]; + +/** + * Get filters which excludes incomplete + * @param {array} filters e.g. [{ column_key, filter_predicate, ... }] + * @param {array} columns + * @returns valid filters, array + */ +const getValidFilters = (filters, columns) => { + if (!Array.isArray(filters) || !Array.isArray(columns)) { + return []; + } + + return filters.filter((filter) => { + const { error_message } = ValidateFilter.validate(filter, columns); + return !error_message || error_message !== FILTER_ERR_MSG.INCOMPLETE_FILTER; + }); +}; + +/** + * Get filters without error messages + * @param {array} filters e.g. [{ column_key, filter_predicate, ... }] + * @param {array} columns + * @returns valid filters, array + */ +const getValidFiltersWithoutError = (filters, columns) => { + if (!Array.isArray(filters) || !Array.isArray(columns)) { + return []; + } + + return filters.filter((filter) => !ValidateFilter.validate(filter, columns).error_message); +}; + +/** + * Generate date for filter + * @param {string} filterTermModifier + * @param {any} filterTerm + * @returns date | date range, object + */ +const otherDate = (filterTermModifier, filterTerm) => { + const today = new Date(); + const year = today.getFullYear(); + const month = today.getMonth(); // use js month representation: 0 - 11 + const day = today.getDate(); + + // 0 1 2 3 4 5 6 7 8 9 10 11 days in every month + let days = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + days[1] = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28; // is leap year + switch (filterTermModifier) { + case FILTER_TERM_MODIFIER_TYPE.TODAY: { + // today, should start at 0:00 and end at 24:00 + return new Date(year, month, day, 0, 0, 0); + } + case FILTER_TERM_MODIFIER_TYPE.TOMORROW: { + return new Date(year, month, day + 1); + } + case FILTER_TERM_MODIFIER_TYPE.YESTERDAY: { + return new Date(year, month, day - 1); + } + case FILTER_TERM_MODIFIER_TYPE.ONE_WEEK_AGO: { + return new Date(year, month, day - 7); + } + case FILTER_TERM_MODIFIER_TYPE.ONE_WEEK_FROM_NOW: { + return new Date(year, month, day + 7); + } + case FILTER_TERM_MODIFIER_TYPE.ONE_MONTH_AGO: { + const pastMonth = month - 1; + const monthDaysIndex = month === 0 ? 11 : pastMonth; + const currentDay = day > days[monthDaysIndex] ? days[monthDaysIndex] : day; + return new Date(year, pastMonth, currentDay); + } + case FILTER_TERM_MODIFIER_TYPE.ONE_MONTH_FROM_NOW: { + const nextMonth = month + 1; + const monthDaysIndex = month === 11 ? 0 : nextMonth; + const currentDay = day > days[monthDaysIndex] ? days[monthDaysIndex] : day; + return new Date(year, nextMonth, currentDay); + } + case FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_AGO: { + return new Date(year, month, day - Number(filterTerm)); + } + case FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_FROM_NOW: { + return new Date(year, month, day + Number(filterTerm)); + } + case FILTER_TERM_MODIFIER_TYPE.EXACT_DATE: { + return new Date(filterTerm); + } + case FILTER_TERM_MODIFIER_TYPE.THE_PAST_WEEK: { + const weekDay = today.getDay() !== 0 ? today.getDay() : 7; + return { + startDate: new Date(year, month, day - weekDay - 6), + endDate: new Date(year, month, day - weekDay), + }; + } + case FILTER_TERM_MODIFIER_TYPE.THIS_WEEK: { + const weekDay = today.getDay() !== 0 ? today.getDay() : 7; + return { + startDate: new Date(year, month, day - weekDay + 1), + endDate: new Date(year, month, day - weekDay + 7), + }; + } + case FILTER_TERM_MODIFIER_TYPE.THE_NEXT_WEEK: { + const weekDay = today.getDay() !== 0 ? today.getDay() : 7; + return { + startDate: new Date(year, month, day - weekDay + 8), + endDate: new Date(year, month, day - weekDay + 14), + }; + } + case FILTER_TERM_MODIFIER_TYPE.THE_PAST_MONTH: { + const pastMonth = month - 1; + return { + startDate: new Date(year, pastMonth, 1), + endDate: new Date(year, pastMonth, days[month === 0 ? 11 : pastMonth]), + }; + } + case FILTER_TERM_MODIFIER_TYPE.THIS_MONTH: { + return { + startDate: new Date(year, month, 1), + endDate: new Date(year, month, days[month]), + }; + } + case FILTER_TERM_MODIFIER_TYPE.THE_NEXT_MONTH: { + const nextMonth = month + 1; + return { + startDate: new Date(year, nextMonth, 1), + endDate: new Date(year, nextMonth, month === 11 ? days[0] : days[nextMonth]), + }; + } + case FILTER_TERM_MODIFIER_TYPE.THE_PAST_YEAR: { + const pastYear = year - 1; + return { + startDate: new Date(pastYear, 0, 1), // The computer's month starts at 0. + endDate: new Date(pastYear, 11, 31), + }; + } + case FILTER_TERM_MODIFIER_TYPE.THIS_YEAR: { + return { + startDate: new Date(year, 0, 1), + endDate: new Date(year, 11, 31), + }; + } + case FILTER_TERM_MODIFIER_TYPE.THE_NEXT_YEAR: { + const nextYear = year + 1; + return { + startDate: new Date(nextYear, 0, 1), + endDate: new Date(nextYear, 11, 31), + }; + } + case FILTER_TERM_MODIFIER_TYPE.THE_NEXT_NUMBERS_OF_DAYS: { + return { + startDate: new Date(year, month, day + 1, 0, 0, 0), + endDate: new Date(year, month, day + Number(filterTerm)), + }; + } + case FILTER_TERM_MODIFIER_TYPE.THE_PAST_NUMBERS_OF_DAYS: { + return { + startDate: new Date(year, month, day - Number(filterTerm)), + endDate: new Date(year, month, day, 0, 0, 0), + }; + } + default: { + return {}; + } + } +}; + +/** + * Generate formatted date for filter + * @param {string} filterTermModifier + * @param {any} filterTerm + * @returns formatted date | date range, object + */ +const getFormattedFilterOtherDate = (filterTermModifier, filterTerm) => { + const _otherDate = otherDate(filterTermModifier, filterTerm); + if (EXACT_DATE_TERM_MODIFIER_TYPES.includes(filterTermModifier)) { + return DateUtils.format(_otherDate); + } + + const { startDate, endDate } = _otherDate; + return { + startDate: startDate ? DateUtils.format(startDate) : '', + endDate: endDate ? DateUtils.format(endDate) : '', + }; +}; + +/** + * Format filter with other_date, linked_column etc. + * @param {object} filter e.g. { filter_term, filter_term_modifier, ... } + * @param {object} column + * @returns formatted filter + */ +const getFormattedFilter = (filter, column) => { + const { filter_term, filter_term_modifier } = filter; + let { type: columnType } = column; + let formattedFilter = filter; + switch (columnType) { + case CellType.CTIME: + case CellType.MTIME: { + formattedFilter.other_date = getFormattedFilterOtherDate(filter_term_modifier, filter_term); + break; + } + default: { + break; + } + } + return formattedFilter; +}; + +/** + * Get formatted filters with other_date, linked_column etc. + * @param {array} filters [{ filter_term, filter_term_modifier, column, ... }] + * @returns formatted filters, array + */ +const getFormattedFilters = (filters) => ( + filters.map((filter) => ( + getFormattedFilter(filter, filter.column) + )) +); + +/** + * Get filters without error messages and formatted with filter column + * @param {array} filters e.g. [{ column_key, filter_predicate, ... }] + * @param {array} columns + * @returns filters, array + */ +const deleteInvalidFilter = (filters, columns) => { + if (!Array.isArray(filters) || filters.length === 0) { + return []; + } + let cleanFilters = []; + filters.forEach((filter) => { + const { column_key } = filter; + const { error_message } = ValidateFilter.validate(filter, columns); + if (error_message) { + if (error_message !== FILTER_ERR_MSG.INCOMPLETE_FILTER) { + throw new Error(error_message); + } + } else { + const filterColumn = columns.find((column) => column.key === column_key); + const newFilter = { ...filter, column: filterColumn }; + cleanFilters.push(newFilter); + } + }); + return cleanFilters; +}; + +export { + getValidFilters, + getValidFiltersWithoutError, + deleteInvalidFilter, + otherDate, + getFormattedFilterOtherDate, + getFormattedFilter, + getFormattedFilters, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/creator.js b/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/creator.js new file mode 100644 index 0000000000..b47891e803 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/creator.js @@ -0,0 +1,49 @@ +import { FILTER_PREDICATE_TYPE } from '../../../constants/filter/filter-predicate'; + +/** + * Filter creator + * @param {string} email + * @param {string} filter_predicate + * @param {array} filter_term e.g. [ collaborator.email, ... ] + * @param {string} username + * @returns bool + */ +const creatorFilter = (email, { filter_predicate, filter_term }, username) => { + switch (filter_predicate) { + case FILTER_PREDICATE_TYPE.CONTAINS: { + if (!Array.isArray(filter_term)) { + return true; + } + if (!email) { + return false; + } + return filter_term.findIndex((filterEmail) => filterEmail === email) > -1; + } + case FILTER_PREDICATE_TYPE.NOT_CONTAIN: { + if (!Array.isArray(filter_term) || !email) { + return true; + } + return filter_term.findIndex((filterEmail) => filterEmail === email) < 0; + } + case FILTER_PREDICATE_TYPE.INCLUDE_ME: { + return email === username; + } + case FILTER_PREDICATE_TYPE.IS: { + if (!filter_term) return true; + if (!Array.isArray(filter_term)) return email === filter_term; + return email === filter_term[0]; + } + case FILTER_PREDICATE_TYPE.IS_NOT: { + if (!filter_term) return true; + if (!Array.isArray(filter_term)) return email !== filter_term; + return email !== filter_term[0]; + } + default: { + return false; + } + } +}; + +export { + creatorFilter, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/date.js b/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/date.js new file mode 100644 index 0000000000..ae852a0c9a --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/date.js @@ -0,0 +1,95 @@ +import { DateUtils } from '../../date'; +import { FILTER_PREDICATE_TYPE } from '../../../constants/filter/filter-predicate'; +import { FILTER_TERM_MODIFIER_TYPE } from '../../../constants/filter/filter-modifier'; + +/** + * Filter date + * @param {string} date + * @param {string} filter_predicate + * @param {string} filter_term_modifier + * @param {any} filter_term date string or number etc. + * @param {string|object} other_date date string or { startDate, endDate } + * @returns bool + */ +const dateFilter = (date, { + filter_predicate, filter_term_modifier, filter_term, other_date, +}) => { + switch (filter_predicate) { + case FILTER_PREDICATE_TYPE.IS: { + return ( + (filter_term_modifier === FILTER_TERM_MODIFIER_TYPE.EXACT_DATE && !filter_term) + || DateUtils.format(date) === other_date + ); + } + case FILTER_PREDICATE_TYPE.IS_WITHIN: { + if (filter_term_modifier === FILTER_TERM_MODIFIER_TYPE.EXACT_DATE && !filter_term) { + return true; + } + if (!date) { + return false; + } + const { startDate, endDate } = other_date; + const currentDate = DateUtils.format(date); + return currentDate >= startDate && currentDate <= endDate; + } + case FILTER_PREDICATE_TYPE.IS_BEFORE: { + if (filter_term_modifier === FILTER_TERM_MODIFIER_TYPE.EXACT_DATE && !filter_term) { + return true; + } + if (!date || !DateUtils.getValidDate(date)) { + return false; + } + + return DateUtils.format(date) < other_date; + } + case FILTER_PREDICATE_TYPE.IS_AFTER: { + if (filter_term_modifier === FILTER_TERM_MODIFIER_TYPE.EXACT_DATE && !filter_term) { + return true; + } + if (!date || !DateUtils.getValidDate(date)) { + return false; + } + return DateUtils.format(date) > other_date; + } + case FILTER_PREDICATE_TYPE.IS_ON_OR_BEFORE: { + if (filter_term_modifier === FILTER_TERM_MODIFIER_TYPE.EXACT_DATE && !filter_term) { + return true; + } + if (!date || !DateUtils.getValidDate(date)) { + return false; + } + return DateUtils.format(date) <= other_date; + } + case FILTER_PREDICATE_TYPE.IS_ON_OR_AFTER: { + if (filter_term_modifier === FILTER_TERM_MODIFIER_TYPE.EXACT_DATE && !filter_term) { + return true; + } + if (!date || !DateUtils.getValidDate(date)) { + return false; + } + return DateUtils.format(date) >= other_date; + } + case FILTER_PREDICATE_TYPE.IS_NOT: { + if (filter_term_modifier === FILTER_TERM_MODIFIER_TYPE.EXACT_DATE && !filter_term) { + return true; + } + if (!date || !DateUtils.getValidDate(date)) { + return false; + } + return DateUtils.format(date) !== other_date; + } + case FILTER_PREDICATE_TYPE.EMPTY: { + return !(date && DateUtils.getValidDate(date)); + } + case FILTER_PREDICATE_TYPE.NOT_EMPTY: { + return !!(date && DateUtils.getValidDate(date)); + } + default: { + return false; + } + } +}; + +export { + dateFilter, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/index.js b/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/index.js new file mode 100644 index 0000000000..e0ec6fa562 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/index.js @@ -0,0 +1,3 @@ +export { creatorFilter } from './creator'; +export { dateFilter } from './date'; +export { textFilter } from './text'; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/text.js b/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/text.js new file mode 100644 index 0000000000..a02b25528e --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/text.js @@ -0,0 +1,52 @@ +import { FILTER_PREDICATE_TYPE } from '../../../constants/filter/filter-predicate'; + +/** + * Filter text + * @param {string} text + * @param {string} filter_predicate + * @param {string} filter_term + * @param {string} userId + * @returns bool + */ +const textFilter = (text, { filter_predicate, filter_term }, userId) => { + switch (filter_predicate) { + case FILTER_PREDICATE_TYPE.CONTAINS: { + if (!filter_term) { + return true; + } + if (!text) { + return false; + } + return text.toString().toLowerCase().indexOf(filter_term.toLowerCase()) > -1; + } + case FILTER_PREDICATE_TYPE.NOT_CONTAIN: { + if (!filter_term || !text) { + return true; + } + return text.toString().toLowerCase().indexOf(filter_term.toLowerCase()) < 0; + } + case FILTER_PREDICATE_TYPE.IS: { + return !filter_term || text === filter_term; + } + case FILTER_PREDICATE_TYPE.IS_NOT: { + return !filter_term || text !== filter_term; + } + case FILTER_PREDICATE_TYPE.EMPTY: { + return !text; + } + case FILTER_PREDICATE_TYPE.NOT_EMPTY: { + return !!text; + } + case FILTER_PREDICATE_TYPE.IS_CURRENT_USER_ID: { + if (!userId) return false; + return text === userId; + } + default: { + return false; + } + } +}; + +export { + textFilter, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-row.js b/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-row.js new file mode 100644 index 0000000000..c5bfd6bf94 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-row.js @@ -0,0 +1,86 @@ +import { + getFormattedFilters, +} from './core'; +import { + creatorFilter, + dateFilter, + textFilter, +} from './filter-column'; +import { + FILTER_CONJUNCTION_TYPE, +} from '../../constants/filter'; +import { DateUtils } from '../date'; +import { CellType, DATE_FORMAT_MAP } from '../../constants/column'; + +const getFilterResult = (row, filter, { username, userId }) => { + const { column_key, column } = filter; + let cellValue = row[column_key]; + switch (column.type) { + case CellType.CTIME: + case CellType.MTIME: { + cellValue = DateUtils.format(cellValue, DATE_FORMAT_MAP.YYYY_MM_DD_HH_MM_SS); + return dateFilter(cellValue, filter); + } + case CellType.TEXT: { + return textFilter(cellValue, filter, userId); + } + case CellType.LAST_MODIFIER: + case CellType.CREATOR: { + return creatorFilter(cellValue, filter, username); + } + default: { + return false; + } + } +}; + +/** + * Filter row + * @param {object} row e.g. { _id, .... } + * @param {string} filterConjunction e.g. 'And' | 'Or' + * @param {array} filters e.g. [{ column_key, filter_predicate, ... }, ...] + * @param {object} formulaRow + * @param {string} username + * @param {string} userId + * @param {object} userDepartmentIdsMap e.g. { current_user_department_ids: [8, 10], current_user_department_and_sub_ids: [8, 10, 12, 34] } + * @returns filter result, bool + */ +const filterRow = (row, filterConjunction, filters, { username = '', userId } = {}) => { + if (filterConjunction === FILTER_CONJUNCTION_TYPE.AND) { + return filters.every((filter) => ( + getFilterResult(row, filter, { username, userId }) + )); + } + if (filterConjunction === FILTER_CONJUNCTION_TYPE.OR) { + return filters.some((filter) => ( + getFilterResult(row, filter, { username, userId }) + )); + } + return false; +}; + +/** + * Filter rows + * @param {string} filterConjunction e.g. 'And' | 'Or' + * @param {array} filters e.g. [{ column_key, filter_predicate, ... }, ...] + * @param {array} rows e.g. [{ _id, .... }, ...] + * @param {string} username + * @param {string} userId + * @returns filtered rows ids, array + */ +const filterRows = (filterConjunction, filters, rows, { username, userId }) => { + let filteredRows = []; + const formattedFilters = getFormattedFilters(filters); + rows.forEach((row) => { + const rowId = row._id; + if (filterRow(row, filterConjunction, formattedFilters, { username, userId })) { + filteredRows.push(rowId); + } + }); + return filteredRows; +}; + +export { + filterRow, + filterRows, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/filter/index.js b/frontend/src/metadata/metadata-view/_basic/utils/filter/index.js new file mode 100644 index 0000000000..4badc1bde5 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/filter/index.js @@ -0,0 +1,20 @@ +export { + getValidFilters, + getValidFiltersWithoutError, + deleteInvalidFilter, + otherDate, + getFormattedFilterOtherDate, + getFormattedFilter, + getFormattedFilters, +} from './core'; + +export { + creatorFilter, + dateFilter, + textFilter, +} from './filter-column'; + +export { + filterRow, + filterRows, +} from './filter-row'; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/group/core.js b/frontend/src/metadata/metadata-view/_basic/utils/group/core.js new file mode 100644 index 0000000000..925bc68fb2 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/group/core.js @@ -0,0 +1,74 @@ +import { CellType } from '../../constants/column'; +import { + GROUP_DATE_GRANULARITY, + SUPPORT_GROUP_COLUMN_TYPES, +} from '../../constants/group'; + +/** + * Check is valid groupby + * @param {object} groupby e.g. { column_key, count_type, sort_type, ... } + * @param {array} columns + * @returns bool + */ +const isValidGroupby = (groupby, columns) => { + if (!groupby || !Array.isArray(columns)) return false; + + const { column_key } = groupby; + const groupbyColumn = columns.find((column) => column.key === column_key); + if (!groupbyColumn) { + return false; + } + + return SUPPORT_GROUP_COLUMN_TYPES.includes(groupbyColumn.type); +}; + +/** + * Get valid groupbys + * @param {array} groupbys e.g. [{ column_key, count_type, ... }, ...] + * @param {array} columns + * @returns valid groupbys, array + */ +const getValidGroupbys = (groupbys, columns) => { + if (!Array.isArray(groupbys) || !Array.isArray(columns)) { + return []; + } + + return groupbys.filter((groupby) => isValidGroupby(groupby, columns)); +}; + +/** + * Get valid and formatted groupbys + * @param {array} groupbys e.g. [{ column_key, count_type, ... }, ...] + * @param {array} columns + * @param {object} currentTable e.g. { _id, ... } + * @param {object} value e.g. { tables, collaborators } + * @returns valid and formatted groupbys + */ +const deleteInvalidGroupby = (groupbys, columns) => { + const validGroupbys = getValidGroupbys(groupbys, columns); + let cleanGroupbys = []; + validGroupbys.forEach((groupby) => { + const { column_key: groupbyColumnKey, count_type } = groupby; + const groupbyColumn = columns.find((column) => groupbyColumnKey === column.key); + const { type: columnType } = groupbyColumn; + let newGroupby = { ...groupby, column: groupbyColumn }; + switch (columnType) { + case CellType.CTIME: + case CellType.MTIME: { + newGroupby.count_type = count_type || GROUP_DATE_GRANULARITY.MONTH; + break; + } + default: { + break; + } + } + cleanGroupbys.push(newGroupby); + }); + return cleanGroupbys; +}; + +export { + deleteInvalidGroupby, + isValidGroupby, + getValidGroupbys, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/group/group-row.js b/frontend/src/metadata/metadata-view/_basic/utils/group/group-row.js new file mode 100644 index 0000000000..9ea0dd913e --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/group/group-row.js @@ -0,0 +1,250 @@ +import { getRowsByIds } from '../table/row'; +import { DateUtils } from '../date'; +import { + sortDate, + sortText, +} from '../sort/sort-column'; +import { MAX_GROUP_LEVEL } from '../../constants/group'; +import { + CellType, + DATE_COLUMN_OPTIONS, + MULTIPLE_CELL_VALUE_COLUMN_TYPE_MAP, + SINGLE_CELL_VALUE_COLUMN_TYPE_MAP, +} from '../../constants/column'; +import { + SORT_COLUMN_OPTIONS, + SORT_TYPE, + TEXT_SORTER_COLUMN_TYPES, +} from '../../constants/sort'; + +const _getCellValue = (row, groupby) => { + const { column_key } = groupby; + let cellValue = row[column_key]; + return cellValue; +}; + +const _getFormattedCellValue = (cellValue, groupby) => { + const { column, count_type: countType } = groupby; + const { type: columnType } = column; + switch (columnType) { + case CellType.TEXT: + case CellType.LAST_MODIFIER: + case CellType.CREATOR: { + return cellValue || null; + } + case CellType.CTIME: + case CellType.MTIME: { + return DateUtils.getDateByGranularity(cellValue, countType) || null; + } + default: { + return null; + } + } +}; + +const _getStrCellValue = (cellValue, columnType) => { + let sCellValue = null; + if (SINGLE_CELL_VALUE_COLUMN_TYPE_MAP[columnType]) { + sCellValue = typeof cellValue === 'string' ? cellValue : String(cellValue); + } else if (MULTIPLE_CELL_VALUE_COLUMN_TYPE_MAP[columnType]) { + sCellValue = [...cellValue].sort().toString(); + } + return sCellValue; +}; + +const _findGroupIndexWithMultipleGroupbys = (sCellValue, cellValue2GroupIndexMap, groupsLength) => { + const target = cellValue2GroupIndexMap[sCellValue]; + if (target && target.index > -1) { + return target.index; + } + + // eslint-disable-next-line + cellValue2GroupIndexMap[sCellValue] = {}; + + // eslint-disable-next-line + cellValue2GroupIndexMap[sCellValue].subgroups = {}; + + // eslint-disable-next-line + cellValue2GroupIndexMap[sCellValue].index = groupsLength; + return -1; +}; + +const _findGroupIndex = (sCellValue, cellValue2GroupIndexMap, groupsLength) => { + const index = cellValue2GroupIndexMap[sCellValue]; + if (index > -1) { + return index; + } + + // eslint-disable-next-line + cellValue2GroupIndexMap[sCellValue] = groupsLength; + return -1; +}; + +const getSortedGroups = (groups, groupbys, level) => { + const sortFlag = 0; + const { column, sort_type } = groupbys[level]; + const { type: columnType } = column; + const normalizedSortType = sort_type || SORT_TYPE.UP; + groups.sort((currGroupRow, nextGroupRow) => { + let { cell_value: currCellVal } = currGroupRow; + let { cell_value: nextCellVal } = nextGroupRow; + if (SORT_COLUMN_OPTIONS.includes(columnType)) { + let sortResult; + if (TEXT_SORTER_COLUMN_TYPES.includes(columnType)) { + sortResult = sortText(currCellVal, nextCellVal, normalizedSortType); + } else if (DATE_COLUMN_OPTIONS.includes(columnType)) { + sortResult = sortDate(currCellVal, nextCellVal, normalizedSortType); + } + return sortFlag || sortResult; + } + if (currCellVal === '') return 1; + if (nextCellVal === '') return -1; + return 0; + }); + + // for nested group. + const isNestedGroup = Array.isArray(groups[0].subgroups) && groups[0].subgroups.length > 0; + if (isNestedGroup) { + const nextLevel = level + 1; + + // eslint-disable-next-line + groups = groups.map((group) => { + const sortedSubgroups = getSortedGroups(group.subgroups, groupbys, nextLevel); + return { + ...group, + subgroups: sortedSubgroups, + }; + }); + } + return groups; +}; + +const groupRowsWithMultipleGroupbys = (groupbys, rows, value) => { + const validGroupbys = groupbys.length > MAX_GROUP_LEVEL + ? groupbys.slice(0, MAX_GROUP_LEVEL) + : [...groupbys]; + let groups = []; + let cellValue2GroupIndexMap = {}; + rows.forEach((row) => { + const rowId = row._id; + let updatedGroup; + let updateCellValue2GroupIndexMap; + for (let level = 0; level < validGroupbys.length; level++) { + const currentGroupby = validGroupbys[level]; + const { column, column_key } = currentGroupby; + const { type: columnType } = column; + const cellValue = _getCellValue(row, currentGroupby); + const formattedValue = _getFormattedCellValue(cellValue, currentGroupby); + const sCellValue = _getStrCellValue(formattedValue, columnType); + const group = { + cell_value: formattedValue, + original_cell_value: cellValue, + row_ids: null, + column_key, + subgroups: [], + summaries: {}, + }; + if (level === 0) { + let groupedRowIndex = _findGroupIndexWithMultipleGroupbys(sCellValue, cellValue2GroupIndexMap, groups.length); + updateCellValue2GroupIndexMap = cellValue2GroupIndexMap[sCellValue].subgroups; + if (groupedRowIndex < 0) { + groups.push(group); + updatedGroup = groups[groups.length - 1]; + } else { + updatedGroup = groups[groupedRowIndex]; + } + } else { + let groupedRowIndex = _findGroupIndexWithMultipleGroupbys(sCellValue, updateCellValue2GroupIndexMap, updatedGroup.subgroups.length); + updateCellValue2GroupIndexMap = updateCellValue2GroupIndexMap[sCellValue].subgroups; + if (groupedRowIndex < 0) { + updatedGroup.subgroups.push(group); + updatedGroup = updatedGroup.subgroups[updatedGroup.subgroups.length - 1]; + } else { + updatedGroup = updatedGroup.subgroups[groupedRowIndex]; + } + + // update row_ids in the deepest group. + if (level === validGroupbys.length - 1) { + if (!updatedGroup.row_ids) { + updatedGroup.row_ids = [rowId]; + } else { + updatedGroup.row_ids.push(rowId); + } + } + } + } + }); + + groups = getSortedGroups(groups, validGroupbys, value, 0); + + return groups; +}; + +/** + * Group table rows + * @param {array} groupbys e.g. [{ column_key, count_type, column, ... }, ...] + * @param {array} rows e.g. [{ _id, ... }, ...] + * @param {object} value e.g. { collaborators, ... } + * @returns groups: [{ + * cell_value, original_cell_value, column_key, + row_ids, subgroups, summaries, ...}, ...], array + */ +const groupTableRows = (groupbys, rows) => { + if (groupbys.length === 0) { + return []; + } + if (groupbys.length > 1) { + return groupRowsWithMultipleGroupbys(groupbys, rows); + } + const groupby = groupbys[0]; + const { column_key, column } = groupby; + const { type: columnType } = column; + let groups = []; + let cellValue2GroupIndexMap = {}; + rows.forEach((r) => { + const cellValue = _getCellValue(r, groupby); + const formattedValue = _getFormattedCellValue(cellValue, groupby); + const sCellValue = _getStrCellValue(formattedValue, columnType); + let groupedRowIndex = _findGroupIndex(sCellValue, cellValue2GroupIndexMap, groups.length); + if (groupedRowIndex > -1) { + groups[groupedRowIndex].row_ids.push(r._id); + } else { + groups.push({ + cell_value: formattedValue, + original_cell_value: cellValue, + column_key, + row_ids: [r._id], + subgroups: null, + summaries: {}, + }); + } + }); + + // sort groups + groups = getSortedGroups(groups, groupbys, 0); + + return groups; +}; + +/** + * Group view rows + * @param {array} groupbys e.g. [{ column_key, count_type, column, ... }, ...] + * @param {object} table e.g. { id_row_map, ... } + * @param {array} rowsIds e.g. [ row._id, ...] + * @param {object} value e.g. { collaborators, ... } + * @returns groups: [{ + * cell_value, original_cell_value, column_key, + row_ids, subgroups, summaries, ...}, ...], array + */ +const groupViewRows = (groupbys, table, rowsIds) => { + if (rowsIds.length === 0) { + return []; + } + let rowsData = getRowsByIds(table, rowsIds); + return groupTableRows(groupbys, rowsData); +}; + +export { + groupTableRows, + groupViewRows, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/group/index.js b/frontend/src/metadata/metadata-view/_basic/utils/group/index.js new file mode 100644 index 0000000000..d0b85a63e3 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/group/index.js @@ -0,0 +1,10 @@ +export { + deleteInvalidGroupby, + isValidGroupby, + getValidGroupbys, +} from './core'; + +export { + groupTableRows, + groupViewRows, +} from './group-row'; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/hotkey.js b/frontend/src/metadata/metadata-view/_basic/utils/hotkey.js new file mode 100644 index 0000000000..f6d483bac5 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/hotkey.js @@ -0,0 +1,27 @@ +import isHotkey from 'is-hotkey'; + +export const isModS = isHotkey('mod+s'); +export const isModZ = isHotkey('mod+z'); +export const isModL = isHotkey('mod+l'); +export const isModF = isHotkey('mod+f'); +export const isModP = isHotkey('mod+p'); +export const isModG = isHotkey('mod+g'); +export const isModDot = isHotkey('mod+.'); +export const isModComma = isHotkey('mod+,'); +export const isModSlash = isHotkey('mod+/'); +export const isModBackslash = isHotkey('mod+\''); +export const isModSemicolon = isHotkey('mod+;'); +export const isModUp = isHotkey('mod+up'); +export const isModDown = isHotkey('mod+down'); +export const isModLeft = isHotkey('mod+left'); +export const isModRight = isHotkey('mod+right'); +export const isModShiftZ = isHotkey('mod+shift+z'); +export const isModShiftG = isHotkey('mod+shift+g'); +export const isModShiftDot = isHotkey('mod+shift+.'); +export const isModShiftComma = isHotkey('mod+shift+,'); +export const isShiftEnter = isHotkey('shift+enter'); +export const isShiftModEnter = isHotkey('shift+mod+enter'); +export const isOptPageUp = isHotkey('opt+pageup'); +export const isOptPageDown = isHotkey('opt+pagedown'); +export const isSpace = isHotkey('space'); +export const isEnter = isHotkey('enter'); diff --git a/frontend/src/metadata/metadata-view/_basic/utils/index.js b/frontend/src/metadata/metadata-view/_basic/utils/index.js new file mode 100644 index 0000000000..533340f0cb --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/index.js @@ -0,0 +1,88 @@ +import * as CommonlyUsedHotkey from './hotkey'; +import LocalStorage from './local-storage'; + +export { + getColumnType, + getColumnsByType, + isDateColumn, + isSupportDateColumnFormat, +} from './column'; +export { + getValidFilters, + getValidFiltersWithoutError, + deleteInvalidFilter, + otherDate, + getFormattedFilterOtherDate, + getFormattedFilter, + getFormattedFilters, + creatorFilter, + dateFilter, + textFilter, + filterRow, + filterRows, +} from './filter'; +export { + deleteInvalidGroupby, + isValidGroupby, + getValidGroupbys, + groupTableRows, + groupViewRows, +} from './group'; +export { + isTableRows, + updateTableRowsWithRowsData, +} from './row'; +export { + isValidSort, + getValidSorts, + deleteInvalidSort, + getMultipleIndexesOrderbyOptions, + sortDate, + sortText, + sortRowsWithMultiSorts, + sortTableRows, +} from './sort'; +export { + getTableById, + getTableByName, + getTableByIndex, + getTableColumnByKey, + getTableColumnByName, + getRowById, + getRowsByIds, +} from './table'; +export { + isValidEmail, + ValidateFilter, + DATE_MODIFIERS_REQUIRE_TERM, +} from './validate'; +export { + getViewById, + getViewByName, + isDefaultView, + isFilterView, + isGroupView, + isSortView, + isHiddenColumnsView, + getViewShownColumns, + getGroupByPath, +} from './view'; +export { + getType, + isMac, + base64ToFile, + bytesToSize, + getErrorMsg, + isFunction, + isEmpty, + isEmptyObject, + debounce, + throttle, +} from './common'; +export { + DateUtils +} from './date'; +export { + CommonlyUsedHotkey, + LocalStorage, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/local-storage.js b/frontend/src/metadata/metadata-view/_basic/utils/local-storage.js new file mode 100644 index 0000000000..33e1cccd9f --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/local-storage.js @@ -0,0 +1,28 @@ +class LocalStorage { + + constructor(baseName) { + this.baseName = baseName || 'sf-metadata'; + } + + getStorage() { + try { + return JSON.parse(window.localStorage.getItem(this.baseName) || '{}'); + } catch (error) { + return ''; + } + } + + setItem(key, value) { + const storage = this.getStorage(); + const newValue = { ...storage, [key]: value }; + return window.localStorage.setItem(this.baseName, JSON.stringify(newValue)); + } + + getItem(key) { + const storage = this.getStorage(); + return storage[key]; + } + +} + +export default LocalStorage; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/row/core.js b/frontend/src/metadata/metadata-view/_basic/utils/row/core.js new file mode 100644 index 0000000000..a93aff0905 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/row/core.js @@ -0,0 +1,31 @@ +import { getTableById } from '../table/core'; + +/** + * Check is table rows + * @param {array} rows e.g. table rows: [{ _id, xxx }, ...] | view rows: [ row._id, ... ] + * @returns bool + */ +const isTableRows = (rows) => ( + Array.isArray(rows) && typeof rows[0] === 'object' +); + +const updateTableRowsWithRowsData = (tables, tableId, rowsData = []) => { + let table = getTableById(tables, tableId); + let idRowDataMap = {}; + rowsData.forEach((rowData) => idRowDataMap[rowData._id] = rowData); + table.rows.forEach((row, index) => { + const rowId = row._id; + const newRowData = idRowDataMap[rowId]; + if (!newRowData) { + return; + } + const newRow = Object.assign({}, row, newRowData); + table.rows[index] = newRow; + table.id_row_map[rowId] = newRow; + }); +}; + +export { + isTableRows, + updateTableRowsWithRowsData, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/row/index.js b/frontend/src/metadata/metadata-view/_basic/utils/row/index.js new file mode 100644 index 0000000000..cd8b4b64b9 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/row/index.js @@ -0,0 +1,4 @@ +export { + isTableRows, + updateTableRowsWithRowsData, +} from './core'; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/sort/core.js b/frontend/src/metadata/metadata-view/_basic/utils/sort/core.js new file mode 100644 index 0000000000..02bc4a5c29 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/sort/core.js @@ -0,0 +1,70 @@ +import { SORT_COLUMN_OPTIONS } from '../../constants/sort'; + +/** + * Check is valid sort + * @param {object} sort e.g. { column_key, sort_type, ... } + * @param {array} columns + * @returns bool + */ +const isValidSort = (sort, columns) => { + const sortByColumn = sort && columns.find((column) => column.key === sort.column_key); + if (!sortByColumn) return false; + + return SORT_COLUMN_OPTIONS.includes(sortByColumn.type); +}; + +/** + * Get valid sorts + * 1. sort column is exist or not + * 2. valid sort type + * @param {array} sorts e.g. [{ column_key, sort_type, ... }, ...] + * @param {array} columns + * @returns valid sorts, array + */ +const getValidSorts = (sorts, columns) => { + if (!Array.isArray(sorts) || !Array.isArray(columns)) return []; + + return sorts.filter((sort) => isValidSort(sort, columns)); +}; + +/** + * Get sorted option index of the "optionIds" + * @param {array} optionIds + * @param {object} option_id_index_map e.g. {[option.id]: 0, ...} + * @returns sorted options index, array + */ +const getMultipleIndexesOrderbyOptions = (optionIds, option_id_index_map) => { + let indexArr = []; + optionIds.forEach((optionId) => { + const index = option_id_index_map[optionId]; + if (index > -1) { + indexArr.push(index); + } + }); + return indexArr.sort(); +}; + +/** + * Get valid and formatted sorts + * @param {array} sorts e.g. [{ column_key, sort_type, ... }, ...] + * @param {array} columns + * @returns valid and formatted sorts, array + */ +const deleteInvalidSort = (sorts, columns) => { + const validSorts = getValidSorts(sorts, columns); + let cleanSorts = []; + validSorts.forEach((sort) => { + const { column_key } = sort; + const sortColumn = columns.find((column) => column.key === column_key); + let newSort = { ...sort, column: sortColumn }; + cleanSorts.push(newSort); + }); + return cleanSorts; +}; + +export { + isValidSort, + getValidSorts, + deleteInvalidSort, + getMultipleIndexesOrderbyOptions, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/sort/index.js b/frontend/src/metadata/metadata-view/_basic/utils/sort/index.js new file mode 100644 index 0000000000..c10224914a --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/sort/index.js @@ -0,0 +1,16 @@ +export { + isValidSort, + getValidSorts, + deleteInvalidSort, + getMultipleIndexesOrderbyOptions, +} from './core'; + +export { + sortDate, + sortText, +} from './sort-column'; + +export { + sortRowsWithMultiSorts, + sortTableRows, +} from './sort-row'; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-column/date.js b/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-column/date.js new file mode 100644 index 0000000000..17adc2fb76 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-column/date.js @@ -0,0 +1,30 @@ +import { SORT_TYPE } from '../../../constants/sort'; + +/** + * Sort date + * @param {string} leftDate e.g. '2023-07-31' + * @param {string} nextDate + * @param {string} sortType e.g. 'up' | 'down' + * @returns number + */ +const sortDate = (leftDate, rightDate, sortType) => { + const emptyLeftDate = !leftDate; + const emptyRightDate = !rightDate; + if (emptyLeftDate && emptyRightDate) return 0; + if (emptyLeftDate) return 1; + if (emptyRightDate) return -1; + + if (leftDate > rightDate) { + return sortType === SORT_TYPE.UP ? 1 : -1; + } + + if (leftDate < rightDate) { + return sortType === SORT_TYPE.UP ? -1 : 1; + } + + return 0; +}; + +export { + sortDate, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-column/index.js b/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-column/index.js new file mode 100644 index 0000000000..c310d38dd5 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-column/index.js @@ -0,0 +1,6 @@ + +export { sortDate } from './date'; +export { + compareString, + sortText, +} from './text'; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-column/text.js b/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-column/text.js new file mode 100644 index 0000000000..aecebf2f6a --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-column/text.js @@ -0,0 +1,77 @@ +import { + REG_NUMBER_DIGIT, + REG_STRING_NUMBER_PARTS, +} from '../../../constants/reg'; +import { SORT_TYPE } from '../../../constants/sort'; + +/** + * Compare strings + * @param {string} leftString + * @param {string} rightString + * @returns number + */ +const compareString = (leftString, rightString) => { + if (!leftString && !rightString) return 0; + if (!leftString) return -1; + if (!rightString) return 1; + if (typeof leftString !== 'string' || typeof rightString !== 'string') return 0; + + let leftStringParts = leftString.match(REG_STRING_NUMBER_PARTS); + let rightStringParts = rightString.match(REG_STRING_NUMBER_PARTS); + let len = Math.min(leftStringParts.length, rightStringParts.length); + let isDigitPart; + let leftStringPart; + let rightStringPart; + + // Loop through each substring part to canCompare the overall strings. + for (let i = 0; i < len; i++) { + leftStringPart = leftStringParts[i]; + rightStringPart = rightStringParts[i]; + isDigitPart = REG_NUMBER_DIGIT.test(leftStringPart) && REG_NUMBER_DIGIT.test(rightStringPart); + + if (isDigitPart) { + leftStringPart = parseInt(leftStringPart); + rightStringPart = parseInt(rightStringPart); + if (leftStringPart > rightStringPart) { + return 1; + } + if (leftStringPart < rightStringPart) { + return -1; + } + } + if (leftStringPart !== rightStringPart) { + return leftString.localeCompare(rightString); + } + } + return leftString.localeCompare(rightString); +}; + +/** + * Sort text + * @param {string} leftText + * @param {string} rightText + * @param {string} sortType e.g. 'up' | 'down + * @returns number + */ +const sortText = (leftText, rightText, sortType) => { + const emptyLeftText = !leftText; + const emptyRightText = !rightText; + if (emptyLeftText && emptyRightText) { + return 0; + } + if (emptyLeftText) { + return 1; + } + if (emptyRightText) { + return -1; + } + if (rightText === leftText) { + return 0; + } + return sortType === SORT_TYPE.UP ? compareString(leftText, rightText) : -1 * compareString(leftText, rightText); +}; + +export { + compareString, + sortText, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-row.js b/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-row.js new file mode 100644 index 0000000000..aab69321ff --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-row.js @@ -0,0 +1,51 @@ +import { deleteInvalidSort } from './core'; +import { + sortDate, + sortText, +} from './sort-column'; +import { DATE_COLUMN_OPTIONS } from '../../constants/column'; + +/** + * Sort rows with multiple sorts + * @param {array} tableRows e.g. [{ _id, [column.key]: '', ...}, ...] + * @param {array} sorts e.g. [{ column_key, sort_type, column, ... }, ...] + * @param {object} value e.g. { collaborators, ... } + */ +const sortRowsWithMultiSorts = (tableRows, sorts) => { + tableRows.sort((currentRow, nextRow) => { + let initValue = 0; + sorts.forEach((sort) => { + const { column_key, sort_type, column } = sort; + const { type: columnType } = column; + let currCellVal = currentRow[column_key]; + let nextCellVal = nextRow[column_key]; + if (DATE_COLUMN_OPTIONS.includes(columnType)) { + initValue = initValue || sortDate(currCellVal, nextCellVal, sort_type); + } else { + initValue = initValue || sortText(currCellVal, nextCellVal, sort_type); + } + }); + return initValue; + }); +}; + +/** + * Get sorted rows ids from table rows with multiple sorts + * @param {array} sorts e.g. [{ column_key, sort_type, column, ... }, ...] + * @param {array} rows e.g. [{ _id, [column.key]: '', ...}, ...] + * @param {array} columns e.g. [{ key, type, ... }, ...] + * @param {object} value e.g. { collaborators, ... } + * @returns sorted rows ids, array + */ +const sortTableRows = (sorts, rows, columns) => { + if (!Array.isArray(rows) || rows.length === 0) return []; + const sortRows = rows.slice(0); + const validSorts = deleteInvalidSort(sorts, columns); + sortRowsWithMultiSorts(sortRows, validSorts); + return sortRows.map((row) => row._id); +}; + +export { + sortRowsWithMultiSorts, + sortTableRows, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/table/column.js b/frontend/src/metadata/metadata-view/_basic/utils/table/column.js new file mode 100644 index 0000000000..1d9393b542 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/table/column.js @@ -0,0 +1,26 @@ +/** + * Get column by key from table + * @param {object} table + * @param {string} columnKey + * @returns column, object + */ +const getTableColumnByKey = (table, columnKey) => { + if (!table || !Array.isArray(table.columns) || !columnKey) return null; + return table.columns.find((column) => column.key === columnKey); +}; + +/** + * Get table column by name + * @param {object} table + * @param {string} columnName + * @returns column, object + */ +const getTableColumnByName = (table, columnName) => { + if (!table || !Array.isArray(table.columns) || !columnName) return null; + return table.columns.find((column) => column.name === columnName); +}; + +export { + getTableColumnByKey, + getTableColumnByName, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/table/core.js b/frontend/src/metadata/metadata-view/_basic/utils/table/core.js new file mode 100644 index 0000000000..fbe44db1de --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/table/core.js @@ -0,0 +1,32 @@ +/** + * Get table by id + * @param {array} tables + * @param {string} tableId + * @returns table, object + */ +const getTableById = (tables, tableId) => { + if (!Array.isArray(tables) || !tableId) return null; + return tables.find((table) => table._id === tableId); +}; + +/** + * Get table by name + * @param {array} tables + * @param {string} tableName + * @returns table, object + */ +const getTableByName = (tables, tableName) => { + if (!Array.isArray(tables) || !tableName) return null; + return tables.find((table) => table.name === tableName); +}; + +const getTableByIndex = (tables, tableIndex) => { + if (!Array.isArray(tables) || tableIndex < 0) return null; + return tables[tableIndex]; +}; + +export { + getTableById, + getTableByName, + getTableByIndex, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/table/index.js b/frontend/src/metadata/metadata-view/_basic/utils/table/index.js new file mode 100644 index 0000000000..413e4923f2 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/table/index.js @@ -0,0 +1,5 @@ +export { + getTableById, getTableByName, getTableByIndex, +} from './core'; +export { getTableColumnByKey, getTableColumnByName } from './column'; +export { getRowById, getRowsByIds } from './row'; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/table/row.js b/frontend/src/metadata/metadata-view/_basic/utils/table/row.js new file mode 100644 index 0000000000..a20131ee72 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/table/row.js @@ -0,0 +1,26 @@ +/** + * Get table row by id + * @param {object} table + * @param {string} rowId the id of row + * @returns row, object + */ +const getRowById = (table, rowId) => { + if (!table || !table.id_row_map || !rowId) return null; + return table.id_row_map[rowId]; +}; + +/** + * Get table rows by ids + * @param {object} table { id_row_map, ... } + * @param {array} rowsIds [ row._id, ... ] + * @returns rows, array + */ +const getRowsByIds = (table, rowsIds) => { + if (!table || !table.id_row_map || !Array.isArray(rowsIds)) return []; + return rowsIds.map((rowId) => table.id_row_map[rowId]).filter(Boolean); +}; + +export { + getRowById, + getRowsByIds, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/validate/email.js b/frontend/src/metadata/metadata-view/_basic/utils/validate/email.js new file mode 100644 index 0000000000..602f879511 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/validate/email.js @@ -0,0 +1,10 @@ +/** + * Check email format is valid. + * @param {string} email + * @returns true/false, bool + */ +const isValidEmail = (email) => ( + /^[A-Za-z0-9]+([-_.][A-Za-z0-9]+)*@([A-Za-z0-9]+[-.])+[A-Za-z0-9]{2,20}$/.test(email) +); + +export { isValidEmail }; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/validate/filter.js b/frontend/src/metadata/metadata-view/_basic/utils/validate/filter.js new file mode 100644 index 0000000000..1144551227 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/validate/filter.js @@ -0,0 +1,297 @@ +import { CellType, COLLABORATOR_COLUMN_TYPES } from '../../constants/column'; +import { + FILTER_COLUMN_OPTIONS, + FILTER_TERM_MODIFIER_TYPE, + FILTER_PREDICATE_TYPE, + filterTermModifierIsWithin, + filterTermModifierNotWithin, + FILTER_ERR_MSG, +} from '../../constants/filter'; +import { isDateColumn } from '../column/date'; + +const TERM_TYPE_MAP = { + NUMBER: 'number', + STRING: 'string', + BOOLEAN: 'boolean', + ARRAY: 'array', +}; + +const TEXT_COLUMN_TYPES = [CellType.TEXT, CellType.STRING]; + +const CHECK_EMPTY_PREDICATES = [FILTER_PREDICATE_TYPE.EMPTY, FILTER_PREDICATE_TYPE.NOT_EMPTY]; + +const DATE_MODIFIERS_REQUIRE_TERM = [ + FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_AGO, + FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_FROM_NOW, + FILTER_TERM_MODIFIER_TYPE.THE_NEXT_NUMBERS_OF_DAYS, + FILTER_TERM_MODIFIER_TYPE.THE_PAST_NUMBERS_OF_DAYS, + FILTER_TERM_MODIFIER_TYPE.EXACT_DATE, +]; + +const MODIFIERS_REQUIRE_NUMERIC_TERM = [ + FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_AGO, + FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_FROM_NOW, + FILTER_TERM_MODIFIER_TYPE.THE_NEXT_NUMBERS_OF_DAYS, + FILTER_TERM_MODIFIER_TYPE.THE_PAST_NUMBERS_OF_DAYS, +]; + +class ValidateFilter { + /** + * Check filter is valid. The error_message from returns will be null if the filter is valid. + * 1.incomplete filter which should be ignored + * - column_key: required + * - filter_predicate: required + * - filter_term_modifier: determined by the column to filter with + * - filter_term: determined by filter_predicate / the column to filter with + * 2.illegal filter + * - column missing: cannot find the column to filter + * - column not support: the column to filter is not support + * - mismatch: filter_predicate, filter_term_modifier mismatch + * - wrong data type: filter_term with wrong data type + * @param {object} filter e.g. { column_key, filter_term, ... } + * @param {array} columns e.g. [{ key, name, ... }, ...] + * @param {bool} isValidTerm No longer to validate filter term if false. default as false + * @returns { error_message }, object + */ + static validate(filter, columns, isValidTerm = true) { + const { + column_key, filter_predicate, filter_term_modifier, filter_term, + } = filter; + const { error_message: column_error_message } = this.validateColumn(column_key, columns); + if (column_error_message) { + return { error_message: column_error_message }; + } + + const filterColumn = columns.find((column) => column.key === column_key); + const { + error_message: predicate_error_message, + } = this.validatePredicate(filter_predicate, filterColumn); + if (predicate_error_message) { + return { error_message: predicate_error_message }; + } + + if (this.isFilterOnlyWithPredicate(filter_predicate, filterColumn)) { + return { error_message: null }; + } + + const { + error_message: modifier_error_message, + } = this.validateModifier(filter_term_modifier, filter_predicate, filterColumn); + if (modifier_error_message) { + return { error_message: modifier_error_message }; + } + + if (this.isFilterOnlyWithModifier(filter_term_modifier, filterColumn)) { + return { error_message: null }; + } + + if (isValidTerm) { + const { + error_message: term_error_message, + } = this.validateTerm(filter_term, filter_predicate, filter_term_modifier, filterColumn); + if (term_error_message) { + return { error_message: term_error_message }; + } + } + + return { error_message: null }; + } + + static validateColumn(column_key, columns) { + if (!column_key) { + return { error_message: FILTER_ERR_MSG.INCOMPLETE_FILTER }; + } + const filterColumn = columns.find((column) => column.key === column_key); + + if (!filterColumn) { + return { error_message: FILTER_ERR_MSG.COLUMN_MISSING }; + } + + if (!this.isValidColumnType(filterColumn)) { + return { error_message: FILTER_ERR_MSG.COLUMN_NOT_SUPPORTED }; + } + return { error_message: null }; + } + + /** + * the column to filter must be available + */ + static validatePredicate(predicate, filterColumn) { + if (!predicate) { + return { error_message: FILTER_ERR_MSG.INCOMPLETE_FILTER }; + } + const { type: columnType } = filterColumn; + const filterConfigs = FILTER_COLUMN_OPTIONS[columnType]; + const { filterPredicateList: predicateList } = filterConfigs; + if (!predicateList.includes(predicate)) { + return { error_message: FILTER_ERR_MSG.UNMATCHED_PREDICATE }; + } + return { error_message: null }; + } + + static validatePredicateWithArrayType(predicate, filterColumn) { + const { data } = filterColumn; + const { array_type } = data; + + // Only support: is + if (array_type === CellType.CHECKBOX || array_type === CellType.BOOL) { + return this.validatePredicate(predicate, { type: CellType.CHECKBOX }); + } + + // Filter predicate should support: is_empty/is_not_empty(excludes checkbox and bool) + if (CHECK_EMPTY_PREDICATES.includes(predicate)) { + return true; + } + if (array_type === CellType.SINGLE_SELECT || array_type === CellType.DEPARTMENT_SINGLE_SELECT) { + return this.validatePredicate(predicate, { type: CellType.MULTIPLE_SELECT }); + } + if (COLLABORATOR_COLUMN_TYPES.includes(array_type)) { + return this.validatePredicate(predicate, { type: CellType.COLLABORATOR }); + } + return this.validatePredicate(predicate, { type: array_type }); + } + + /** + * filter predicate must be available. + * filterColumn the column to filter must be available + */ + static isFilterOnlyWithPredicate(predicate, filterColumn) { + if (CHECK_EMPTY_PREDICATES.includes(predicate)) { + return true; + } + + const { type: columnType } = filterColumn; + const { IS_CURRENT_USER_ID, INCLUDE_ME } = FILTER_PREDICATE_TYPE; + if (predicate === IS_CURRENT_USER_ID && TEXT_COLUMN_TYPES.includes(columnType)) { + return true; + } + if (predicate === INCLUDE_ME && COLLABORATOR_COLUMN_TYPES.includes(columnType)) { + return true; + } + return false; + } + + /** + * filter predicate must be available. + * the column to filter must be available + */ + static validateModifier(modifier, predicate, filterColumn) { + if (!isDateColumn(filterColumn)) { + return { error_message: null }; + } + if (!modifier) { + return { error_message: FILTER_ERR_MSG.INCOMPLETE_FILTER }; + } + if (predicate === FILTER_PREDICATE_TYPE.IS_WITHIN) { + if (filterTermModifierIsWithin.includes(modifier)) { + return { error_message: null }; + } + } else if (filterTermModifierNotWithin.includes(modifier)) { + return { error_message: null }; + } + return { error_message: FILTER_ERR_MSG.UNMATCHED_MODIFIER }; + } + + /** + * filter predicate must be available. + * filter modifier must be available. + * the column to filter must be available + */ + static isFilterOnlyWithModifier(modifier, filterColumn) { + if (isDateColumn(filterColumn)) { + return !DATE_MODIFIERS_REQUIRE_TERM.includes(modifier); + } + return false; + } + + static validateTerm(term, predicate, modifier, filterColumn) { + if (this.isTermMissing(term)) { + return { error_message: FILTER_ERR_MSG.INCOMPLETE_FILTER }; + } + + if (!this.isValidTerm(term, predicate, modifier, filterColumn)) { + return { error_message: FILTER_ERR_MSG.INVALID_TERM }; + } + return { error_message: null }; + } + + static isTermMissing(term) { + return (!term && term !== 0 && term !== false) + || (Array.isArray(term) && term.length === 0); + } + + static isValidTerm(term, predicate, modifier, filterColumn) { + switch (filterColumn.type) { + case CellType.TEXT: { + return this.isValidTermType(term, TERM_TYPE_MAP.STRING); + } + + case CellType.CHECKBOX: + case CellType.BOOL: { + return this.isValidTermType(term, TERM_TYPE_MAP.BOOLEAN); + } + case CellType.COLLABORATOR: + case CellType.CREATOR: + case CellType.LAST_MODIFIER: { + return this.isValidTermType(term, TERM_TYPE_MAP.ARRAY); + } + case CellType.DATE: + case CellType.CTIME: + case CellType.MTIME: { + if (MODIFIERS_REQUIRE_NUMERIC_TERM.includes(modifier)) { + return this.isValidTermType(term, TERM_TYPE_MAP.NUMBER); + } + return this.isValidTermType(term, TERM_TYPE_MAP.STRING); + } + default: { + return false; + } + } + } + + static isValidTermType(term, type) { + if (type === TERM_TYPE_MAP.ARRAY) { + return Array.isArray(term) && term.length > 0; + } + if (type === CellType.NUMBER) { + // is a number or a number string + // eslint-disable-next-line + return typeof term === type || !isNaN(Number(term)); + } + // eslint-disable-next-line + return typeof term === type; + } + + static isValidTermWithArrayType(term, predicate, modifier, filterColumn) { + const { data } = filterColumn; + const { array_type, array_data } = data; + if (array_type === CellType.SINGLE_SELECT) { + return this.isValidTerm(term, predicate, modifier, { + type: CellType.MULTIPLE_SELECT, data: array_data, + }); + } + if (array_type === CellType.DEPARTMENT_SINGLE_SELECT) { + return this.isValidTermType(term, TERM_TYPE_MAP.ARRAY); + } + if (COLLABORATOR_COLUMN_TYPES.includes(array_type)) { + return this.isValidTerm(term, predicate, modifier, { type: CellType.COLLABORATOR }); + } + return this.isValidTerm(term, predicate, modifier, { type: array_type, data: array_data }); + } + + static isValidColumnType(filterColumn) { + const { type: columnType } = filterColumn; + // eslint-disable-next-line + return FILTER_COLUMN_OPTIONS.hasOwnProperty(columnType); + } + + static isValidSelectedOptions(selectedOptionIds, options) { + const validSelectedOptions = options.filter((option) => selectedOptionIds.includes(option.id)); + return selectedOptionIds.length === validSelectedOptions.length; + } +} + +export { + ValidateFilter, + DATE_MODIFIERS_REQUIRE_TERM, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/validate/index.js b/frontend/src/metadata/metadata-view/_basic/utils/validate/index.js new file mode 100644 index 0000000000..45cd4da56f --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/validate/index.js @@ -0,0 +1,5 @@ +export { isValidEmail } from './email'; +export { + ValidateFilter, + DATE_MODIFIERS_REQUIRE_TERM, +} from './filter'; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/view/core.js b/frontend/src/metadata/metadata-view/_basic/utils/view/core.js new file mode 100644 index 0000000000..6293f2fe6a --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/view/core.js @@ -0,0 +1,98 @@ +import { + getValidFilters, +} from '../filter/core'; +import { getValidGroupbys } from '../group/core'; +import { getValidSorts } from '../sort/core'; + +/** + * Get view by id + * @param {array} views e.g. [{ _id, ... }, ...] + * @param {string} viewId + * @returns view, object + */ +const getViewById = (views, viewId) => { + if (!Array.isArray(views) || !viewId) return null; + return views.find((view) => view._id === viewId); +}; + +/** + * Get view by name + * @param {array} views + * @param {string} viewName + * @returns view, object + */ +const getViewByName = (views, viewName) => { + if (!Array.isArray(views) || !viewName) return null; + return views.find((view) => view.name === viewName); +}; + +/** + * Check whether the view contains filters + * @param {object} view e.g. { filters, ... } + * @param {array} columns + * @returns bool + */ +const isFilterView = (view, columns) => { + const validFilters = getValidFilters(view.filters, columns); + return validFilters.length > 0; +}; + +/** + * Check whether the view contains groupbys + * @param {object} view e.g. { groupbys, ... } + * @param {array} columns + * @returns bool + */ +const isGroupView = (view, columns) => { + const validGroupbys = getValidGroupbys(view.groupbys, columns); + return validGroupbys.length > 0; +}; + +/** + * Check whether the view contains sorts + * @param {object} view e.g. { sorts, ... } + * @param {array} columns + * @returns bool + */ +const isSortView = (view, columns) => { + const validSorts = getValidSorts(view.sorts, columns); + return validSorts.length > 0; +}; + +/** + * Check whether the view has hidden columns + * @param {object} view e.g. { hidden_columns, ... } + * @returns bool + */ +const isHiddenColumnsView = (view) => { + const { hidden_columns } = view || {}; + return Array.isArray(hidden_columns) && hidden_columns.length > 0; +}; + +/** + * Check is default view which no contains filters, sorts, groupbys etc. + * @param {object} view e.g. { filters, groupbys, sorts, ... } + * @param {array} columns + * @returns bool + */ +const isDefaultView = (view, columns) => ( + !isFilterView(view, columns) && !isSortView(view, columns) && !isGroupView(view, columns) +); + +const getViewShownColumns = (view, columns) => { + if (!Array.isArray(columns)) return []; + if (!isHiddenColumnsView(view)) return columns; + const { hidden_columns } = view; + return columns.filter((column) => !hidden_columns.includes(column.key)); +}; + +export { + getViewById, + getViewByName, + isDefaultView, + isFilterView, + isGroupView, + isSortView, + isHiddenColumnsView, + getViewShownColumns, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/view/group.js b/frontend/src/metadata/metadata-view/_basic/utils/view/group.js new file mode 100644 index 0000000000..28a0de0577 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/view/group.js @@ -0,0 +1,39 @@ +/** + * Get group by paths + * @param {array} paths e.g. [ 0, 1, 2 ] + * @param {array} groups grouped rows + * @returns group, object + */ +const getGroupByPath = (paths, groups) => { + if (!Array.isArray(paths) || !Array.isArray(groups)) { + return null; + } + + const level0GroupIndex = paths[0]; + if (level0GroupIndex < 0 || level0GroupIndex >= groups.length) { + return null; + } + + let level = 1; + let foundGroup = groups[level0GroupIndex]; + while (level < paths.length) { + if (!foundGroup) { + break; + } + const subGroups = foundGroup.subgroups; + const currentLevelGroupIndex = paths[level]; + if ( + !Array.isArray(subGroups) + || (currentLevelGroupIndex < 0 || currentLevelGroupIndex >= subGroups.length) + ) { + break; + } + foundGroup = subGroups[currentLevelGroupIndex]; + level += 1; + } + return foundGroup; +}; + +export { + getGroupByPath, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/view/index.js b/frontend/src/metadata/metadata-view/_basic/utils/view/index.js new file mode 100644 index 0000000000..194b522f70 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/view/index.js @@ -0,0 +1,14 @@ +export { + getViewById, + getViewByName, + isDefaultView, + isFilterView, + isGroupView, + isSortView, + isHiddenColumnsView, + getViewShownColumns, +} from './core'; + +export { + getGroupByPath, +} from './group'; diff --git a/frontend/src/metadata/metadata-view/components/cell-formatter/index.js b/frontend/src/metadata/metadata-view/components/cell-formatter/index.js new file mode 100644 index 0000000000..3c230c2f03 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/cell-formatter/index.js @@ -0,0 +1,27 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Formatter } from '@seafile/sf-metadata-ui-component'; +import { useCollaborators } from '../../hooks'; + +const CellFormatter = ({ readonly, value, field, }) => { + const { collaborators, collaboratorsCache, updateCollaboratorsCache } = useCollaborators(); + return ( + + ); +}; + +CellFormatter.propTypes = { + readonly: PropTypes.bool, + value: PropTypes.any, + field: PropTypes.object.isRequired, +}; + +export default CellFormatter; diff --git a/frontend/src/metadata/metadata-view/components/data-process-setter/filter-setter.jsx b/frontend/src/metadata/metadata-view/components/data-process-setter/filter-setter.jsx new file mode 100644 index 0000000000..30b5a3114f --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/data-process-setter/filter-setter.jsx @@ -0,0 +1,94 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import deepCopy from 'deep-copy'; +import { Icon } from '@seafile/sf-metadata-ui-component'; +import { getValidFilters, CommonlyUsedHotkey } from '../../_basic'; +import { gettext } from '../../../../utils/constants'; + +const propTypes = { + wrapperClass: PropTypes.string, + filtersClassName: PropTypes.string, + target: PropTypes.string, + isNeedSubmit: PropTypes.bool, + filterConjunction: PropTypes.string, + filters: PropTypes.array, + columns: PropTypes.array, + onFiltersChange: PropTypes.func, + collaborators: PropTypes.array, + isPre: PropTypes.bool, +}; + +class FilterSetter extends React.Component { + + static defaultProps = { + target: 'sf-metadata-filter-popover', + isNeedSubmit: false, + }; + + constructor(props) { + super(props); + this.state = { + isShowFilterSetter: false, + }; + } + + onKeyDown = (e) => { + e.stopPropagation(); + if (CommonlyUsedHotkey.isEnter(e) || CommonlyUsedHotkey.isSpace(e)) this.onFilterSetterToggle(); + }; + + onFilterSetterToggle = () => { + this.setState({ isShowFilterSetter: !this.state.isShowFilterSetter }); + }; + + update = (update) => { + const { filters, filter_conjunction } = update || {}; + const { columns } = this.props; + const valid_filters = getValidFilters(filters, columns); + + this.props.onFiltersChange(valid_filters, filter_conjunction); + }; + + render() { + const { + wrapperClass, filters, columns, isNeedSubmit, + // collaborators, filtersClassName, filterConjunction, + } = this.props; + if (!columns) return null; + // const { isShowFilterSetter } = this.state; + const validFilters = deepCopy(getValidFilters(filters || [], columns)); + const filtersLength = validFilters ? validFilters.length : 0; + let filterMessage = isNeedSubmit ? gettext('Preset filter') : gettext('Filter'); + if (filtersLength === 1) { + filterMessage = isNeedSubmit ? gettext('1 preset filter') : gettext('1 filter'); + } else if (filtersLength > 1) { + filterMessage = isNeedSubmit ? gettext('Preset filters') : gettext('Filters'); + filterMessage = filtersLength + ' ' + filterMessage; + } + let labelClass = wrapperClass || ''; + labelClass = (labelClass && filtersLength > 0) ? labelClass + ' active' : labelClass; + return ( + <> +
+
+ + {filterMessage} +
+
+ + ); + } +} + +FilterSetter.propTypes = propTypes; + +export default FilterSetter; diff --git a/frontend/src/metadata/metadata-view/components/data-process-setter/groupby-setter.jsx b/frontend/src/metadata/metadata-view/components/data-process-setter/groupby-setter.jsx new file mode 100644 index 0000000000..46fc8c7d9b --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/data-process-setter/groupby-setter.jsx @@ -0,0 +1,76 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { Icon } from '@seafile/sf-metadata-ui-component'; +import { CommonlyUsedHotkey } from '../../_basic'; +import { gettext } from '../../utils'; + +class GroupbySetter extends Component { + + static defaultProps = { + target: 'sf-metadata-groupby-popover', + isNeedSubmit: false, + }; + + constructor(props) { + super(props); + this.state = { + isShowGroupbySetter: false, + }; + } + + onKeyDown = (e) => { + if (CommonlyUsedHotkey.isEnter(e) || CommonlyUsedHotkey.isSpace(e)) this.onGroupbySetterToggle(); + }; + + onGroupbySetterToggle = () => { + this.setState({ isShowGroupbySetter: !this.state.isShowGroupbySetter }); + }; + + render() { + const { columns, groupbys, wrapperClass } = this.props; + if (!columns) return null; + + const groupbysLength = groupbys ? groupbys.length : 0; + const activated = groupbysLength > 0; + let groupbyMessage = gettext('Group'); + if (groupbysLength === 1) { + groupbyMessage = gettext('Grouped by 1 column'); + } else if (groupbysLength > 1) { + groupbyMessage = gettext('Grouped by xxx columns').replace('xxx', groupbysLength); + } + let labelClass = wrapperClass || ''; + labelClass = (labelClass && activated) ? labelClass + ' active' : labelClass; + + return ( + <> +
+
+ + {groupbyMessage} +
+
+ + ); + } +} + +GroupbySetter.propTypes = { + wrapperClass: PropTypes.string, + columns: PropTypes.array, + groupbys: PropTypes.array, // valid groupbys + modifyGroupbys: PropTypes.func, + target: PropTypes.string, + isNeedSubmit: PropTypes.bool, +}; + +export default GroupbySetter; diff --git a/frontend/src/metadata/metadata-view/components/data-process-setter/hide-column-setter.jsx b/frontend/src/metadata/metadata-view/components/data-process-setter/hide-column-setter.jsx new file mode 100644 index 0000000000..ffe2203e25 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/data-process-setter/hide-column-setter.jsx @@ -0,0 +1,70 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Icon } from '@seafile/sf-metadata-ui-component'; +import { CommonlyUsedHotkey } from '../../_basic'; +import { gettext } from '../../utils'; + +class HideColumnSetter extends Component { + + constructor(props) { + super(props); + this.state = { + isHideColumnSetterShow: false, + }; + } + + onKeyDown = (e) => { + if (CommonlyUsedHotkey.isEnter(e) || CommonlyUsedHotkey.isSpace(e)) this.onHideColumnToggle(); + }; + + onHideColumnToggle = () => { + this.setState({ isHideColumnSetterShow: !this.state.isHideColumnSetterShow }); + }; + + render() { + const { columns, wrapperClass, target, localShownColumnKeys } = this.props; + if (!columns) return null; + let message = gettext('Hide columns'); + const hiddenColumns = columns.filter((column) => !localShownColumnKeys.includes(column.key)); + const hiddenColumnsLength = hiddenColumns.length; + if (hiddenColumnsLength === 1) { + message = gettext('1 hidden column'); + } else if (hiddenColumnsLength > 1) { + message = gettext('xxx hidden columns').replace('xxx', hiddenColumnsLength); + } + let labelClass = wrapperClass || ''; + labelClass = (labelClass && hiddenColumnsLength > 0) ? labelClass + ' active' : labelClass; + + return ( + <> +
+
+ + {message} +
+
+ + ); + } +} + +HideColumnSetter.propTypes = { + wrapperClass: PropTypes.string, + target: PropTypes.string, + page: PropTypes.object, + shownColumnKeys: PropTypes.array, + localShownColumnKeys: PropTypes.array, + columns: PropTypes.array, + modifyHiddenColumns: PropTypes.func, +}; + +export default HideColumnSetter; diff --git a/frontend/src/metadata/metadata-view/components/data-process-setter/index.js b/frontend/src/metadata/metadata-view/components/data-process-setter/index.js new file mode 100644 index 0000000000..89cbfa66d3 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/data-process-setter/index.js @@ -0,0 +1,13 @@ +import FilterSetter from './filter-setter'; +import SortSetter from './sort-setter'; +import GroupbySetter from './groupby-setter'; +import PreHideColumnSetter from './pre-hide-column-setter'; +import HideColumnSetter from './hide-column-setter'; + +export { + FilterSetter, + SortSetter, + GroupbySetter, + PreHideColumnSetter, + HideColumnSetter, +}; diff --git a/frontend/src/metadata/metadata-view/components/data-process-setter/pre-hide-column-setter.jsx b/frontend/src/metadata/metadata-view/components/data-process-setter/pre-hide-column-setter.jsx new file mode 100644 index 0000000000..347b565d72 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/data-process-setter/pre-hide-column-setter.jsx @@ -0,0 +1,72 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Icon } from '@seafile/sf-metadata-ui-component'; +import { gettext } from '../../utils'; + +class PreHideColumnSetter extends React.Component { + + constructor(props) { + super(props); + this.state = { + isShowHideColumnSetter: false, + shownColumnKeys: props.shownColumnKeys || [], + }; + } + + UNSAFE_componentWillReceiveProps(nextProps) { + const { shownColumnKeys } = nextProps; + if (shownColumnKeys !== this.props.shownColumnKeys) { + this.setState({ + isShowHideColumnSetter: false, + shownColumnKeys, + }); + } + } + + onHideColumnToggle = () => { + const { isShowHideColumnSetter } = this.state; + if (isShowHideColumnSetter) { + const { shownColumnKeys } = this.state; + this.props.onSettingUpdate(shownColumnKeys); + } + this.setState({ isShowHideColumnSetter: !isShowHideColumnSetter }); + }; + + modifyHiddenColumns = (shownColumnKeys) => { + this.setState({ shownColumnKeys }); + }; + + render() { + const { columns, wrapperClass } = this.props; + if (!columns) return null; + const { shownColumnKeys } = this.state; + const shown_column_keys = shownColumnKeys || []; + const hiddenColumns = columns.filter((column) => !shown_column_keys.includes(column.key)); + const hiddenColumnsLength = hiddenColumns.length; + let message = gettext('Preset hide columns'); + if (hiddenColumnsLength === 1) { + message = gettext('1 preset hidden column'); + } else if (hiddenColumnsLength > 1) { + message = gettext('xxx preset hidden columns').replace('xxx', hiddenColumnsLength); + } + let settingClass = wrapperClass || ''; + settingClass = (settingClass && hiddenColumnsLength > 0) ? settingClass + ' active' : settingClass; + return ( +
+
+ + {message} +
+
+ ); + } +} + +PreHideColumnSetter.propTypes = { + shownColumnKeys: PropTypes.array, + columns: PropTypes.array, + onSettingUpdate: PropTypes.func.isRequired, + wrapperClass: PropTypes.string, +}; + +export default PreHideColumnSetter; diff --git a/frontend/src/metadata/metadata-view/components/data-process-setter/sort-setter.jsx b/frontend/src/metadata/metadata-view/components/data-process-setter/sort-setter.jsx new file mode 100644 index 0000000000..11327dcd46 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/data-process-setter/sort-setter.jsx @@ -0,0 +1,84 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Icon } from '@seafile/sf-metadata-ui-component'; +import { getValidSorts, CommonlyUsedHotkey } from '../../_basic'; +import { gettext } from '../../utils'; + +const propTypes = { + wrapperClass: PropTypes.string, + target: PropTypes.string, + isNeedSubmit: PropTypes.bool, + sorts: PropTypes.array, + columns: PropTypes.array, + onSortsChange: PropTypes.func, +}; + +class SortSetter extends Component { + + static defaultProps = { + target: 'sf-metadata-sort-popover', + isNeedSubmit: false, + }; + + constructor(props) { + super(props); + this.state = { + isSortPopoverShow: false, + }; + } + + onSortToggle = () => { + this.setState({ isSortPopoverShow: !this.state.isSortPopoverShow }); + }; + + onKeyDown = (e) => { + e.stopPropagation(); + if (CommonlyUsedHotkey.isEnter(e) || CommonlyUsedHotkey.isSpace(e)) this.onSortToggle(); + }; + + update = (update) => { + const { sorts } = update || {}; + this.props.onSortsChange(sorts); + }; + + render() { + const { sorts, columns, isNeedSubmit, wrapperClass } = this.props; + if (!columns) return null; + const validSorts = getValidSorts(sorts || [], columns); + const sortsLength = validSorts ? validSorts.length : 0; + + let sortMessage = isNeedSubmit ? gettext('Preset sort') : gettext('Sort'); + if (sortsLength === 1) { + sortMessage = isNeedSubmit ? gettext('1 preset sort') : gettext('1 sort'); + } else if (sortsLength > 1) { + sortMessage = isNeedSubmit ? gettext('xxx preset sorts') : gettext('xxx sorts'); + sortMessage = sortMessage.replace('xxx', sortsLength); + } + let labelClass = wrapperClass || ''; + labelClass = (labelClass && sortsLength > 0) ? labelClass + ' active' : labelClass; + + return ( + <> +
+
+ + {sortMessage} +
+
+ + ); + } +} + +SortSetter.propTypes = propTypes; + +export default SortSetter; diff --git a/frontend/src/metadata/metadata-view/components/delete-confirm-dialog/index.js b/frontend/src/metadata/metadata-view/components/delete-confirm-dialog/index.js new file mode 100644 index 0000000000..464908b9bc --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/delete-confirm-dialog/index.js @@ -0,0 +1,28 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Button, Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap'; +import { gettext } from '../../utils'; + +const DeleteConfirmDialog = ({ title, content, onToggle, onSubmit }) => { + return ( + + {title} + +

{gettext('Are you sure to delete ') + content}

+
+ + + + +
+ ); +}; + +DeleteConfirmDialog.propTypes = { + title: PropTypes.string.isRequired, + content: PropTypes.string, + onToggle: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, +}; + +export default DeleteConfirmDialog; diff --git a/frontend/src/metadata/metadata-view/components/index.js b/frontend/src/metadata/metadata-view/components/index.js new file mode 100644 index 0000000000..f4e738015a --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/index.js @@ -0,0 +1,9 @@ +import DeleteConfirmDialog from './delete-confirm-dialog'; +import RecordDetailsDialog from './record-details-dialog'; +import Table from './table'; + +export { + DeleteConfirmDialog, + RecordDetailsDialog, + Table, +}; diff --git a/frontend/src/metadata/metadata-view/components/popover/filter-popover/index.css b/frontend/src/metadata/metadata-view/components/popover/filter-popover/index.css new file mode 100644 index 0000000000..afcfce1e9d --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/popover/filter-popover/index.css @@ -0,0 +1,30 @@ +.filter-popover .popover { + max-width: none; + min-width: 300px; +} + +.filter-popover .popover-add-tool { + border-top: none; + color: #666666; +} + +.filter-popover .popover-add-tool.disabled { + color: #c2c2c2; +} + +.filter-popover .popover-add-tool.disabled:hover { + cursor: not-allowed; + background: #fff; +} + +.filter-popover .popover-add-tool .popover-add-icon { + margin-right: 14px; +} + +.filter-popover .filter-popover-footer { + display: flex; + align-items: center; + justify-content: flex-end; + padding: 1rem; + border-top: 1px solid #e9ecef; +} diff --git a/frontend/src/metadata/metadata-view/components/popover/filter-popover/index.js b/frontend/src/metadata/metadata-view/components/popover/filter-popover/index.js new file mode 100644 index 0000000000..600cfdc6f4 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/popover/filter-popover/index.js @@ -0,0 +1,211 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import isHotkey from 'is-hotkey'; +import { Button, UncontrolledPopover } from 'reactstrap'; +import { CustomizeAddTool } from '@seafile/sf-metadata-ui-component'; +import { + FILTER_COLUMN_OPTIONS, + getValidFilters, +} from '../../../_basic'; +import { getEventClassName } from '../../../utils'; +import { getFilterByColumn } from '../../../utils/filters-utils'; +import FiltersList from './widgets'; +import { EVENT_BUS_TYPE } from '../../../constants'; +import { gettext } from '../../../utils'; + +import './index.css'; + +/** + * filter = { + * column_key: '', + * filter_predicate: '', + * filter_term: '', + * filter_term_modifier: '', + * } + */ +class FilterPopover extends Component { + + static defaultProps = { + filtersClassName: '', + placement: 'auto-start', + }; + + constructor(props) { + super(props); + this.state = { + filters: getValidFilters(props.filters, props.columns), + filterConjunction: props.filterConjunction || 'And', + }; + this.isSelectOpen = false; + } + + componentDidMount() { + document.addEventListener('mousedown', this.hideDTablePopover, true); + document.addEventListener('keydown', this.onHotKey); + this.unsubscribeOpenSelect = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.OPEN_SELECT, this.setSelectStatus); + } + + componentWillUnmount() { + document.removeEventListener('mousedown', this.hideDTablePopover, true); + document.removeEventListener('keydown', this.onHotKey); + this.unsubscribeOpenSelect(); + } + + onHotKey = (e) => { + if (isHotkey('esc', e) && !this.isSelectOpen) { + e.preventDefault(); + this.props.hidePopover(); + } + }; + + setSelectStatus = (status) => { + this.isSelectOpen = status; + }; + + hideDTablePopover = (e) => { + if (this.dtablePopoverRef && !getEventClassName(e).includes('popover') && !this.dtablePopoverRef.contains(e.target)) { + this.props.hidePopover(e); + e.preventDefault(); + e.stopPropagation(); + return false; + } + }; + + isNeedSubmit = () => { + return this.props.isNeedSubmit; + }; + + update = (filters) => { + if (this.isNeedSubmit()) { + const isSubmitDisabled = false; + this.setState({ filters, isSubmitDisabled }); + return; + } + this.setState({ filters }, () => { + const update = { filters, filter_conjunction: this.state.filterConjunction }; + this.props.update(update); + }); + }; + + deleteFilter = (filterIndex, scheduleUpdate) => { + const filters = this.state.filters.slice(0); + filters.splice(filterIndex, 1); + if (filters.length === 0) { + scheduleUpdate(); + } + this.update(filters); + }; + + updateFilter = (filterIndex, updated) => { + const filters = this.state.filters.slice(0); + filters[filterIndex] = updated; + this.update(filters); + }; + + updateFilterConjunction = (conjunction) => { + if (this.isNeedSubmit()) { + const isSubmitDisabled = false; + this.setState({ filterConjunction: conjunction, isSubmitDisabled }); + return; + } + this.setState({ filterConjunction: conjunction }, () => { + const update = { filters: this.state.filters, filter_conjunction: conjunction }; + this.props.update(update); + }); + }; + + addFilter = (scheduleUpdate) => { + let { columns } = this.props; + let defaultColumn = columns[0]; + if (!FILTER_COLUMN_OPTIONS[defaultColumn.type]) { + defaultColumn = columns.find((c) => FILTER_COLUMN_OPTIONS[c.type]); + } + if (!defaultColumn) return; + let filter = getFilterByColumn(defaultColumn); + const filters = this.state.filters.slice(0); + if (filters.length === 0) { + scheduleUpdate(); + } + filters.push(filter); + this.update(filters); + }; + + onClosePopover = () => { + this.props.hidePopover(); + }; + + onSubmitFilters = () => { + const { filters, filterConjunction } = this.state; + const update = { filters, filter_conjunction: filterConjunction }; + this.props.update(update); + this.props.hidePopover(); + }; + + onPopoverInsideClick = (e) => { + e.stopPropagation(); + }; + + render() { + const { target, columns, placement } = this.props; + const { filters, filterConjunction } = this.state; + const canAddFilter = columns.length > 0; + return ( + + {({ scheduleUpdate }) => ( +
this.dtablePopoverRef = ref} onClick={this.onPopoverInsideClick} className={this.props.filtersClassName}> + + this.addFilter(scheduleUpdate) : () => {}} + footerName={gettext('Add filter')} + addIconClassName="popover-add-icon" + /> + {this.isNeedSubmit() && ( +
+ + +
+ )} +
+ )} +
+ ); + } +} + +FilterPopover.propTypes = { + placement: PropTypes.string, + filtersClassName: PropTypes.string, + target: PropTypes.string.isRequired, + isNeedSubmit: PropTypes.bool, + isLocked: PropTypes.bool, + columns: PropTypes.array.isRequired, + filterConjunction: PropTypes.string, + filters: PropTypes.array, + collaborators: PropTypes.array, + isPre: PropTypes.bool, + hidePopover: PropTypes.func, + update: PropTypes.func, +}; + +export default FilterPopover; diff --git a/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/collaborator-filter.js b/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/collaborator-filter.js new file mode 100644 index 0000000000..fb62649d72 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/collaborator-filter.js @@ -0,0 +1,114 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import intl from 'react-intl-universal'; +import { CustomizeSelect } from '@seafile/sf-metadata-ui-component'; +import { FILTER_PREDICATE_TYPE } from '../../../../_basic'; + +const propTypes = { + filterIndex: PropTypes.number, + filterTerm: PropTypes.oneOfType([PropTypes.array, PropTypes.string]), // Make the current bug execution the correct code, this can restore in this Component + filter_predicate: PropTypes.string, + collaborators: PropTypes.array, + onSelectCollaborator: PropTypes.func, + isLocked: PropTypes.bool, + placeholder: PropTypes.string, +}; + +class CollaboratorFilter extends Component { + + constructor(props) { + super(props); + this.supportMultipleSelectOptions = [ + FILTER_PREDICATE_TYPE.HAS_ANY_OF, + FILTER_PREDICATE_TYPE.HAS_ALL_OF, + FILTER_PREDICATE_TYPE.HAS_NONE_OF, + FILTER_PREDICATE_TYPE.IS_EXACTLY, + ]; + } + + createCollaboratorOptions = (filterIndex, collaborators, filterTerm) => { + return collaborators.map((collaborator) => { + let isSelected = filterTerm.findIndex(item => item === collaborator.email) > -1; + return { + value: { filterIndex, columnOption: collaborator }, + label: ( + +
+
+
+ + {collaborator.name} + + {collaborator.name} + +
+
+
+ {isSelected && } +
+
+
+ ) + }; + }); + }; + + onClick = (e, collaborator) => { + e.stopPropagation(); + this.props.onSelectCollaborator({ columnOption: collaborator }); + }; + + render() { + let { filterIndex, filterTerm, collaborators, placeholder, filter_predicate } = this.props; + let isSupportMultipleSelect = this.supportMultipleSelectOptions.indexOf(filter_predicate) > -1 ? true : false; + let selectedCollaborators = Array.isArray(filterTerm) && filterTerm.length > 0 && filterTerm.map((item) => { + let collaborator = collaborators.find(c => c.email === item); + if (!collaborator) return null; + return ( +
+ + {collaborator.name} + + {collaborator.name} + + + { + this.onClick(e, collaborator); + }}> + + + +
+ ); + }); + let value = selectedCollaborators ? { label: (<>{selectedCollaborators}) } : {}; + let options = Array.isArray(filterTerm) ? this.createCollaboratorOptions(filterIndex, collaborators, filterTerm) : []; + return ( + + ); + } +} + +CollaboratorFilter.propTypes = propTypes; + +export default CollaboratorFilter; diff --git a/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/filter-calendar.js b/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/filter-calendar.js new file mode 100644 index 0000000000..24d36c4d0f --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/filter-calendar.js @@ -0,0 +1,184 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import Calendar from '@seafile/seafile-calendar'; +import DatePicker from '@seafile/seafile-calendar/lib/Picker'; +import { translateCalendar } from '../../../../utils/date-translate'; +import { getDateColumnFormat } from '../../../../utils/column-utils'; +import dayjs from '../../../../utils/dayjs'; +import 'dayjs/locale/zh-cn'; +import 'dayjs/locale/en-gb'; + +import '@seafile/seafile-calendar/assets/index.css'; + +let now = dayjs(); + +const propTypes = { + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + filterColumn: PropTypes.object.isRequired, + isReadOnly: PropTypes.bool, +}; + +class FilterCalendar extends Component { + + constructor(props) { + super(props); + this.state = { + open: false, + value: null + }; + const DataFormat = getDateColumnFormat(props.filterColumn).trim(); + // Minutes and seconds are not supported at present + this.columnDataFormat = DataFormat.split(' ')[0]; + this.calendarContainerRef = React.createRef(); + this.defaultCalendarValue = null; + } + + componentDidMount() { + const iszhcn = (window.app && window.app.config && window.app.config.lang === 'zh-cn'); + if (iszhcn) { + now = now.locale('zh-cn'); + } else { + now = now.locale('en-gb'); + } + this.defaultCalendarValue = now.clone(); + const { value } = this.props; + if (value && dayjs(value).isValid()) { + let validValue = dayjs(value).isValid() ? dayjs(value) : dayjs(this.defaultCalendarValue); + this.setState({ + value: iszhcn ? dayjs(validValue).locale('zh-cn') : dayjs(validValue).locale('en-gb') + }); + } + } + + handleMouseDown = (e) => { + e.preventDefault(); + }; + + onChange = (value) => { + const { onChange } = this.props; + const searchFormat = 'YYYY-MM-DD'; + this.setState({ + value + }, () => { + if (this.state.value) { + onChange(this.state.value.format(searchFormat)); + } + }); + }; + + onClear = () => { + this.setState({ + value: null + }, () => { + this.setState({ + open: true + }); + }); + }; + + onOpenChange = (open) => { + this.setState({ + open, + }); + }; + + onReadOnlyFocus = () => { + if (!this.state.open && this.state.isMouseDown) { + this.setState({ + isMouseDown: false, + }); + } else { + this.setState({ + open: true, + }); + } + }; + + getCalendarContainer = () => { + return this.calendarContainerRef.current; + }; + + getCalendarFormat = () => { + let calendarFormat = []; + if (this.columnDataFormat.indexOf('YYYY-MM-DD') > -1) { + let newColumnDataFormat = this.columnDataFormat.replace('YYYY-MM-DD', 'YYYY-M-D'); + calendarFormat = [this.columnDataFormat, newColumnDataFormat]; + } else if (this.columnDataFormat.indexOf('DD/MM/YYYY') > -1) { + let newColumnDataFormat = this.columnDataFormat.replace('DD/MM/YYYY', 'D/M/YYYY'); + calendarFormat = [this.columnDataFormat, newColumnDataFormat]; + } else { + calendarFormat = [this.columnDataFormat]; + } + return calendarFormat; + }; + + render() { + const { isReadOnly } = this.props; + const state = this.state; + if (isReadOnly) return ( + + ); + const calendarFormat = this.getCalendarFormat(); + const clearStyle = { + position: 'absolute', + top: '15px', + left: '225px', + color: 'gray', + fontSize: '12px' + }; + const clearIcon = React.createElement('i', { className: 'item-icon sf-metadata-font sf-metadata-icon-x', style: clearStyle }); + const calendar = ( + + ); + return ( +
+ + { + ({ value }) => { + return ( + + +
+ + ); + } + } + +
+ ); + } +} + +FilterCalendar.propTypes = propTypes; + +export default FilterCalendar; diff --git a/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/filter-item-utils.js b/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/filter-item-utils.js new file mode 100644 index 0000000000..4d22c32501 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/filter-item-utils.js @@ -0,0 +1,91 @@ +import React, { Fragment } from 'react'; +import { Icon } from '@seafile/sf-metadata-ui-component'; +import { COLUMNS_ICON_CONFIG, FILTER_TERM_MODIFIER_SHOW } from '../../../../_basic'; +import { gettext } from '../../../../utils'; + +class FilterItemUtils { + + static generatorColumnOption(column) { + if (!column) return null; + const { type, name } = column; + return { + value: { column }, + label: ( + + + {name} + + ) + }; + } + + static generatorPredicateOption(filterPredicate) { + return { + value: { filterPredicate }, + label: {gettext(filterPredicate)} + }; + } + + static generatorTermModifierOption(filterTermModifier) { + return { + value: { filterTermModifier }, + label: {FILTER_TERM_MODIFIER_SHOW[filterTermModifier]} + }; + } + + static generatorSingleSelectOption(option, selectedOption) { + return { + value: { columnOption: option }, + label: ( +
+
{option.name}
+
+ {selectedOption?.id === option.id && } +
+
+ ) + }; + } + + static generatorMultipleSelectOption(option, filterTerm) { + return { + value: { columnOption: option }, + label: ( +
+
{option.name}
+
+ {filterTerm.indexOf(option.id) > -1 && } +
+
+ ) + }; + } + + static generatorConjunctionOptions() { + return [ + { + value: { filterConjunction: 'And' }, + label: ({gettext('And')}) + }, + { + value: { filterConjunction: 'Or' }, + label: ({gettext('Or')}) + } + ]; + } + + static getActiveConjunctionOption(conjunction) { + if (conjunction === 'And') { + return { + value: { filterConjunction: 'And' }, + label: ({gettext('And')}) + }; + } + return { + value: { filterConjunction: 'Or' }, + label: ({gettext('Or')}) + }; + } +} + +export default FilterItemUtils; diff --git a/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/filter-item.js b/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/filter-item.js new file mode 100644 index 0000000000..0077bc2f01 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/filter-item.js @@ -0,0 +1,512 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { UncontrolledTooltip } from 'reactstrap'; +import { CustomizeSelect, IconBtn, SearchInput } from '@seafile/sf-metadata-ui-component'; +import { + CellType, + FILTER_PREDICATE_TYPE, + FILTER_TERM_MODIFIER_TYPE, + filterTermModifierIsWithin, + isDateColumn, + FILTER_ERR_MSG, +} from '../../../../_basic'; +import CollaboratorFilter from './collaborator-filter'; +import FilterCalendar from './filter-calendar'; +import FilterItemUtils from './filter-item-utils'; +import { + getFilterByColumn, getUpdatedFilterBySelectSingle, getUpdatedFilterBySelectMultiple, + getUpdatedFilterByCreator, getUpdatedFilterByCollaborator, getColumnOptions, getUpdatedFilterByPredicate, +} from '../../../../utils/filters-utils'; +import { isCheckboxColumn } from '../../../../utils/column-utils'; +import { gettext } from '../../../../utils'; +import { DELETED_OPTION_BACKGROUND_COLOR, DELETED_OPTION_TIPS } from '../../../../constants'; + +const propTypes = { + index: PropTypes.number.isRequired, + filter: PropTypes.object.isRequired, + filterColumn: PropTypes.object.isRequired, + filterConjunction: PropTypes.string.isRequired, + conjunctionOptions: PropTypes.array.isRequired, + filterColumnOptions: PropTypes.array.isRequired, + value: PropTypes.object, + deleteFilter: PropTypes.func.isRequired, + updateFilter: PropTypes.func.isRequired, + updateConjunction: PropTypes.func.isRequired, + collaborators: PropTypes.array, + errMsg: PropTypes.string, +}; + +const EMPTY_PREDICATE = [FILTER_PREDICATE_TYPE.EMPTY, FILTER_PREDICATE_TYPE.NOT_EMPTY]; + +class FilterItem extends React.Component { + + constructor(props) { + super(props); + this.state = { + filterTerm: props.filter.filter_term, + enterRateItemIndex: 0, + }; + this.filterPredicateOptions = null; + this.filterTermModifierOptions = null; + + this.filterToolTip = React.createRef(); + this.invalidFilterTip = React.createRef(); + + this.initSelectOptions(props); + } + + UNSAFE_componentWillReceiveProps(nextProps) { + const { filter } = this.props; + if (nextProps.filter !== filter) { + this.initSelectOptions(nextProps); + this.setState({ + filterTerm: nextProps.filter.filter_term, + }); + } + } + + shouldComponentUpdate(nextProps) { + const currentProps = this.props; + const shouldUpdated = ( + nextProps.index !== currentProps.index || + nextProps.filter !== currentProps.filter || + nextProps.filterColumn !== currentProps.filterColumn || + nextProps.filterConjunction !== currentProps.filterConjunction || + nextProps.conjunctionOptions !== currentProps.conjunctionOptions || + nextProps.filterColumnOptions !== currentProps.filterColumnOptions + ); + return shouldUpdated; + } + + initSelectOptions = (props) => { + const { filter, filterColumn, value } = props; + let { filterPredicateList, filterTermModifierList } = getColumnOptions(filterColumn, value); + // The value of the calculation formula column does not exist in the shared view + this.filterPredicateOptions = filterPredicateList ? filterPredicateList.map(predicate => { + return FilterItemUtils.generatorPredicateOption(predicate); + }).filter(item => item) : []; + + const { filter_predicate } = filter; + if (isDateColumn(filterColumn)) { + if (filter_predicate === FILTER_PREDICATE_TYPE.IS_WITHIN) { + filterTermModifierList = filterTermModifierIsWithin; + } + this.filterTermModifierOptions = filterTermModifierList.map(termModifier => { + return FilterItemUtils.generatorTermModifierOption(termModifier); + }); + } + }; + + onDeleteFilter = (event) => { + event.nativeEvent.stopImmediatePropagation(); + const { index } = this.props; + this.props.deleteFilter(index); + }; + + resetState = (filter) => { + this.setState({ filterTerm: filter.filter_term }); + }; + + onSelectConjunction = (value) => { + const { filterConjunction } = this.props; + if (filterConjunction === value.filterConjunction) { + return; + } + this.props.updateConjunction(value.filterConjunction); + }; + + onSelectColumn = (value) => { + const { index, filter } = this.props; + const { column } = value; + if (column.key === filter.column_key) return; + + let newFilter = getFilterByColumn(column, filter); + if (!newFilter) return; + + this.resetState(newFilter); + this.props.updateFilter(index, newFilter); + }; + + onSelectPredicate = (value) => { + const { index, filter, filterColumn } = this.props; + const { filterPredicate } = value; + if (filter.filter_predicate === filterPredicate) { + return; + } + let newFilter = getUpdatedFilterByPredicate(filter, filterColumn, filterPredicate); + this.resetState(newFilter); + this.props.updateFilter(index, newFilter); + }; + + onSelectTermModifier = (value) => { + const { index, filter } = this.props; + const { filterTermModifier } = value; + const inputRangeLabel = [ + FILTER_TERM_MODIFIER_TYPE.EXACT_DATE, + FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_AGO, + FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_FROM_NOW, + FILTER_TERM_MODIFIER_TYPE.THE_NEXT_NUMBERS_OF_DAYS, + FILTER_TERM_MODIFIER_TYPE.THE_PAST_NUMBERS_OF_DAYS + ]; + if (filter.filter_term_modifier === filterTermModifier) { + return; + } + let filter_term = filter.filter_term; + if (inputRangeLabel.indexOf(filter.filter_term_modifier) > -1) { + filter_term = ''; + } + let newFilter = Object.assign({}, filter, { filter_term_modifier: filterTermModifier, filter_term }); + this.resetState(newFilter); + this.props.updateFilter(index, newFilter); + }; + + onSelectSingle = (value) => { + const { index, filter } = this.props; + const { columnOption: option } = value; + if (filter.filter_term === option.id) { + return; + } + + let newFilter = getUpdatedFilterBySelectSingle(filter, option); + this.resetState(newFilter); + this.props.updateFilter(index, newFilter); + }; + + onSelectMultiple = (value) => { + const { index, filter } = this.props; + const { columnOption: option } = value; + + let newFilter = getUpdatedFilterBySelectMultiple(filter, option); + this.resetState(newFilter); + this.props.updateFilter(index, newFilter); + }; + + onSelectCollaborator = (value) => { + const { index, filter } = this.props; + const { columnOption: collaborator } = value; + let newFilter = getUpdatedFilterByCollaborator(filter, collaborator); + this.resetState(newFilter); + this.props.updateFilter(index, newFilter); + }; + + onSelectCreator = (value) => { + const { index, filter } = this.props; + const { columnOption: collaborator } = value; + let newFilter = getUpdatedFilterByCreator(filter, collaborator); + // the predicate is 'is' or 'is not' + if (!newFilter) { + return; + } + this.resetState(newFilter); + this.props.updateFilter(index, newFilter); + + }; + + onFilterTermCheckboxChanged = (e) => { + this.onFilterTermChanged(e.target.checked); + }; + + onFilterTermTextChanged = (value) => { + this.onFilterTermChanged(value); + }; + + onFilterTermNumberChanged = () => { + const value = this.numberEditor.getValue(); + this.onFilterTermChanged(Object.values(value)[0]); + }; + + onFilterTermChanged = (newFilterTerm) => { + const { index, filter } = this.props; + const { filterTerm } = this.state; + if (newFilterTerm !== filterTerm) { + this.setState({ filterTerm: newFilterTerm }); + let newFilter = Object.assign({}, filter, { filter_term: newFilterTerm }); + this.props.updateFilter(index, newFilter); + } + }; + + onMouseEnterRateItem = (index) => { + this.setState({ enterRateItemIndex: index }); + }; + + onMouseLeaveRateItem = () => { + this.setState({ enterRateItemIndex: 0 }); + }; + + onChangeRateNumber = (index) => { + this.onFilterTermChanged(index); + }; + + getInputComponent = (type) => { + const { filterTerm } = this.state; + if (type === 'text') { + return ( + + ); + } else if (type === 'checkbox') { + return ( + + ); + } + }; + + renderConjunction = () => { + const { index, filterConjunction, conjunctionOptions } = this.props; + switch (index) { + case 0: { + return null; + } + case 1: { + const activeConjunction = FilterItemUtils.getActiveConjunctionOption(filterConjunction); + return ( + + ); + } + default: { + return ( + {gettext(filterConjunction)} + ); + } + } + + }; + + renderMultipleSelectOption = (options = [], filterTerm) => { + const { filter } = this.props; + const { filter_predicate } = filter; + let isSupportMultipleSelect = false; + // The first two options are used for single selection, and the last four options are used for multiple selection + const supportMultipleSelectOptions = [ + FILTER_PREDICATE_TYPE.IS_ANY_OF, + FILTER_PREDICATE_TYPE.IS_NONE_OF, + FILTER_PREDICATE_TYPE.HAS_ANY_OF, + FILTER_PREDICATE_TYPE.HAS_ALL_OF, + FILTER_PREDICATE_TYPE.HAS_NONE_OF, + FILTER_PREDICATE_TYPE.IS_EXACTLY + ]; + if (supportMultipleSelectOptions.includes(filter_predicate)) { + isSupportMultipleSelect = true; + } + const className = 'select-option-name multiple-select-option'; + let labelArray = []; + if (Array.isArray(options) && Array.isArray(filterTerm)) { + filterTerm.forEach((item) => { + let inOption = options.find(option => option.id === item); + let optionStyle = { margin: '0 10px 0 0' }; + let optionName = null; + if (inOption) { + optionName = inOption.name; + optionStyle.background = inOption.color; + optionStyle.color = inOption.textColor || null; + } else { + optionStyle.background = DELETED_OPTION_BACKGROUND_COLOR; + optionName = gettext(DELETED_OPTION_TIPS); + } + labelArray.push( + + {optionName} + + ); + }); + } + const selectedOptionNames = labelArray.length > 0 ? { label: ({labelArray}) } : {}; + + const dataOptions = options.map(option => { + return FilterItemUtils.generatorMultipleSelectOption(option, filterTerm); + }); + return ( + + ); + }; + + renderFilterTerm = (filterColumn) => { + const { index, filter, collaborators } = this.props; + const { type } = filterColumn; + const { filter_term, filter_predicate, filter_term_modifier } = filter; + // predicate is empty or not empty + if (EMPTY_PREDICATE.includes(filter_predicate)) { + return null; + } + + // the cell value will be date + // 1. DATE + // 2. CTIME: create-time + // 3. MTIME: modify-time + // 4. FORMULA: result_type is date + if (isDateColumn(filterColumn)) { + const inputRangeLabel = [ + FILTER_TERM_MODIFIER_TYPE.EXACT_DATE, + FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_AGO, + FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_FROM_NOW, + FILTER_TERM_MODIFIER_TYPE.THE_NEXT_NUMBERS_OF_DAYS, + FILTER_TERM_MODIFIER_TYPE.THE_PAST_NUMBERS_OF_DAYS + ]; + if (inputRangeLabel.indexOf(filter_term_modifier) > -1) { + if (filter_term_modifier === 'exact_date') { + return ( + + ); + } + return this.getInputComponent('text'); + } + return null; + } + + switch (type) { + case CellType.TEXT: + case CellType.URL: { // The data in the formula column is a date type that has been excluded + if (filter_predicate === FILTER_PREDICATE_TYPE.IS_CURRENT_USER_ID) { + return null; + } + return this.getInputComponent('text'); + } + case CellType.CREATOR: + case CellType.LAST_MODIFIER: { + if (filter_predicate === FILTER_PREDICATE_TYPE.INCLUDE_ME) { + return null; + } + const creators = collaborators; + return ( + + ); + } + default: { + return null; + } + } + }; + + isRenderErrorTips = () => { + const { errMsg } = this.props; + return errMsg && errMsg !== FILTER_ERR_MSG.INCOMPLETE_FILTER; + }; + + renderErrorMessage = () => { + if (!this.isRenderErrorTips()) { + return null; + } + return ( +
+ + + {gettext('Invalid filter')} + +
+ ); + }; + + render() { + const { filterPredicateOptions, filterTermModifierOptions } = this; + const { filter, filterColumn, filterColumnOptions } = this.props; + const { filter_predicate, filter_term_modifier } = filter; + const activeColumn = FilterItemUtils.generatorColumnOption(filterColumn); + const activePredicate = FilterItemUtils.generatorPredicateOption(filter_predicate); + let activeTermModifier = null; + let _isCheckboxColumn = false; + if (isDateColumn(filterColumn)) { + activeTermModifier = FilterItemUtils.generatorTermModifierOption(filter_term_modifier); + } else if (isCheckboxColumn(filterColumn)) { + _isCheckboxColumn = true; + } + const isContainPredicate = [FILTER_PREDICATE_TYPE.CONTAINS, FILTER_PREDICATE_TYPE.NOT_CONTAIN].includes(filter_predicate); + const isRenderErrorTips = this.isRenderErrorTips(); + const showToolTip = isContainPredicate && !isRenderErrorTips; + + // current predicate is not empty + const isNeedShowTermModifier = !EMPTY_PREDICATE.includes(filter_predicate); + + return ( +
+
+ +
+
+
+ {this.renderConjunction()} +
+
+
+ +
+
+ +
+ {isDateColumn(filterColumn) && isNeedShowTermModifier && ( +
+ +
+ )} +
+ {this.renderFilterTerm(filterColumn)} +
+ {showToolTip && +
+ + + {gettext('Filter tip message')} + +
+ } + {this.renderErrorMessage()} +
+
+
+ ); + } +} + +FilterItem.propTypes = propTypes; + +export default FilterItem; diff --git a/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/index.css b/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/index.css new file mode 100644 index 0000000000..0591e2031a --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/index.css @@ -0,0 +1,321 @@ +.filters-list { + min-height: 120px; + max-height: 100%; + padding: 15px; +} + +.filters-list.empty-filters-container { + min-height: 80px; + padding: 16px; +} + +.filters-list.empty-filters-container .empty-filters-list { + padding-left: 0; +} + +.filters-list .empty-filters-list { + padding: 0.25rem; + font-size: 14px; + color: #666666; +} + +.filters-list .filter-item { + display: flex; + align-items: center; + padding: 0.25rem 0; +} + +.filters-list .filter-item .condition { + display: flex; + flex: 1; +} + +.filters-list .filter-item .condition > div { + height: 38px; + line-height: 38px; + margin-left: 0.5rem; +} + +.filters-list .filter-item .condition > div:first-child { + margin-left: 0; +} + +.filters-list .filter-item .filter-term { + max-width: 300px; +} + +.filters-list .filter-item .filter-term .option-group-content .option.option-active .sf-metadata-font { + color: #798d99; +} + +.filters-list .filter-conjunction { + width: 72px; +} + +.filters-list .filter-conjunction-readonly { + width: 52px; +} + +.filters-list .filter-container { + width: calc(100% - 72px); + display: flex; +} + +.filters-list .sf-metadata-select .selected-option-show { + width: calc(100% - 20px); + height: 20px; +} + +.filters-list .sf-metadata-select .selected-option { + width: auto; + overflow-x: auto; +} + +.filters-list .sf-metadata-select .sf-metadata-icon-drop-down { + margin-left: 0.5rem; +} + +.filters-list .selected-conjunction-show { + padding: 0 10px; + color: #666666; +} + +.filters-list .filter-column { + max-width: 150px; +} + +.filter-term .selector-multiple-select .option, +.filter-term .selector-single-select .option { + height: 30px; + padding: 0 10px; +} + +.filter-term .selector-multiple-select .select-option-name, +.filter-term .selector-single-select .select-option-name { + margin-top: 5px; +} + +.filter-term .selector-single-select .option:hover { + color: #212529; + background-color: #f7f7f7; +} + +.filter-term .selector-single-select .option:hover .select-option-name, +.filter-term .selector-multiple-select .option:hover .select-option-name, +.filter-term .selector-collaborator .option:hover .select-option-name { + color: unset; +} + +.filter-term .selector-collaborator .sf-metadata-icon-drop-down { + padding-left: 5px; +} + +.filters-list .selector-collaborator .selected-option-show { + text-overflow: unset; +} + +.filters-list .selector-multiple-select .option:hover, +.filters-list .selector-multiple-select .option.option-active, +.filters-list .selector-collaborator .option:hover, +.filters-list .selector-collaborator .option.option-active { + color: #212529; + background-color: #f7f7f7; +} + +.filters-list .selector-multiple-select .option.option-active .select-option-name, +.filters-list .selector-collaborator .option.option-active .select-option-name { + color: #212529; +} + +.filters-list .selected-option .multiple-select-option, +.filters-list .selected-option .single-select-option { + margin: 0; + display: inline-block; +} + +.filters-list .filter-term input { + display: flex; + width: 100%; + height: 38px; + background-color: #ffffff; + padding-left: 8px; + padding-right: 8px; + outline: none; + border-radius: 3px; + font-size: 0.875rem; +} + +.filters-list .filter-term input:hover { + border-color: rgb(179, 179, 179); +} + +.filters-list .filter-term input.disabled:hover { + border-color: rgba(0, 40, 100, 0.12); +} + +.filters-list .filter-term input:hover:focus { + border-color: #1991eb; +} + +.filters-list .filter-term input:focus { + color: #495057; + background-color: #fff; + border-color: #1991eb; + outline: 0; + box-shadow: 0 0 0 2px rgba(70, 127, 207, 0.25); +} + +.filters-list .filter-term .date-picker-container input:focus { + color: #495057; + background-color: #fff; + border: 1px solid #fff; + outline: 0; +} + +.filters-list .filter-term .date-picker-container table tr { + height: 30px; +} + +.filters-list .filter-term input[type='checkbox'] { + width: inherit; +} + +.filters-list .filter-term input[type='checkbox']:focus { + border: 0; + box-shadow: none; +} + +.filter-term .filter-rate-list { + display: flex; + padding: 0 5px; + border: 1px solid rgba(0, 40, 100, 0.12); +} + +.filters-list .delete-filter { + width: 12px; + height: 20px; + margin-right: 14px; + text-align: center; +} + +.filters-list .delete-filter:hover { + cursor: pointer; +} + +.filters-list .delete-filter .sf-metadata-icon-fork-number { + display: inline-block; + font-size: 12px; + color: #999; +} + +.filters-list .multiple-option-name { + display: flex; + align-items: center; +} + +.filters-list .multiple-check-icon, +.filters-list .collaborator-check-icon { + display: inline-flex; + width: 20px; + height: 20px; + align-items: center; + text-align: center; +} + +.filters-list .multiple-check-icon .sf-metadata-icon-check-mark, +.filters-list .collaborator-check-icon .sf-metadata-icon-check-mark { + font-size: 12px; + color: #798d99; +} + +.user-select-item, +.collaborator { + display: inline-flex; + align-items: center; + height: 20px; + margin-right: 10px; + padding: 0 8px 0 2px; + font-size: 13px; + border-radius: 10px; + background: #eaeaea; +} + +.filters-list .collaborator-show { + flex: 1; +} + +.filters-list .collaborator-avatar-container { + width: 16px; +} + +.filters-list .collaborator-avatar { + width: 16px; + height: 16px; + transform: translateY(-1px); + border-radius: 50%; +} + +.filters-list .option .collaborator-avatar { + transform: translateY(-2px); +} + +.filters-list .collaborator-name { + margin-left: 5px; + max-width: 200px; +} + +.filters-list .option-collaborator { + display: flex; +} + +.filters-list .collaborator-container { + flex: 1; +} + +.filters-list .popover-add-tool { + border-top: none; + color: #666666; +} + +.filters-list .popover-add-tool .popover-add-icon { + margin-right: 14px; +} + +.filters-list .option-group { + max-height: 360px; + overflow: auto; +} + +.filters-list .filter-item .sf-metadata-icon-fork-number:hover { + color: #666666; +} + +.filters-list .filter-container-readonly .sf-metadata-select .selected-option-show, +.filters-list .filter-conjunction-readonly .sf-metadata-select .selected-option-show { + width: 100%; +} + +.filters-list .filter-checkbox-predicate .sf-metadata-select .selected-option-show { + width: 100%; +} + +.dropdown-item .collaborator, +.filters-list .option-group .option-group-content .collaborator { + background-color: unset; +} + +.filters-list .sf-metadata-select .selected-option-show .remove-container { + display: none; +} + +.filter-header-icon { + display: inline-block; + padding: 0 0.3125rem; + margin-left: -0.3125rem; +} + +.filter-header-icon .sf-metadata-font { + font-size: 14px; + color: #aaa; + cursor: default; +} diff --git a/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/index.js b/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/index.js new file mode 100644 index 0000000000..83cc771601 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/index.js @@ -0,0 +1,129 @@ +import React, { Component } from 'react'; +import classnames from 'classnames'; +import PropTypes from 'prop-types'; +import { + FILTER_COLUMN_OPTIONS, + ValidateFilter, +} from '../../../../_basic'; +import FilterItemUtils from './filter-item-utils'; +import FilterItem from './filter-item'; +import { getColumnByKey } from '../../../../utils/column-utils'; + +import './index.css'; + +const propTypes = { + isLocked: PropTypes.bool, + className: PropTypes.string, + filters: PropTypes.array, + columns: PropTypes.array.isRequired, + filterConjunction: PropTypes.string.isRequired, + updateFilter: PropTypes.func.isRequired, + deleteFilter: PropTypes.func.isRequired, + updateFilterConjunction: PropTypes.func, + emptyPlaceholder: PropTypes.string, + value: PropTypes.object, + collaborators: PropTypes.array, + scheduleUpdate: PropTypes.func, + isPre: PropTypes.bool, +}; + +class FiltersList extends Component { + + constructor(props) { + super(props); + this.conjunctionOptions = null; + this.columnOptions = null; + } + + UNSAFE_componentWillReceiveProps(nextProps) { + if (nextProps.columns !== this.props.columns) { + this.columnOptions = null; + } + } + + updateFilter = (filterIndex, updatedFilter) => { + if (!updatedFilter) return; + this.props.updateFilter(filterIndex, updatedFilter); + }; + + deleteFilter = (index) => { + const { scheduleUpdate } = this.props; + this.props.deleteFilter(index, scheduleUpdate); + }; + + updateConjunction = (filterConjunction) => { + this.props.updateFilterConjunction(filterConjunction); + }; + + getConjunctionOptions = () => { + if (!this.conjunctionOptions) { + this.conjunctionOptions = FilterItemUtils.generatorConjunctionOptions(); + } + return this.conjunctionOptions; + }; + + getFilterColumns = () => { + const { columns } = this.props; + return columns.filter(column => { + let { type } = column; + return Object.prototype.hasOwnProperty.call(FILTER_COLUMN_OPTIONS, type); + }); + }; + + getColumnOptions = () => { + if (!this.columnOptions) { + const filterColumns = this.getFilterColumns(); + this.columnOptions = filterColumns.map(column => { + return FilterItemUtils.generatorColumnOption(column); + }); + } + return this.columnOptions; + }; + + renderFilterItem = (filter, index, errMsg, filterColumn) => { + const { filterConjunction, value } = this.props; + const conjunctionOptions = this.getConjunctionOptions(); + const columnOptions = this.getColumnOptions(); + return ( + + ); + }; + + render() { + let { filters, className, emptyPlaceholder, columns } = this.props; + const isEmpty = filters.length === 0; + return ( +
+ {isEmpty &&
{emptyPlaceholder}
} + {!isEmpty && + filters.map((filter, index) => { + const { column_key } = filter; + const { error_message } = ValidateFilter.validate(filter, columns); + const filterColumn = getColumnByKey(column_key, columns) || {}; + return this.renderFilterItem(filter, index, error_message, filterColumn); + }) + } +
+ ); + } +} + +FiltersList.propTypes = propTypes; + +export default FiltersList; diff --git a/frontend/src/metadata/metadata-view/components/popover/groupby-popover/index.css b/frontend/src/metadata/metadata-view/components/popover/groupby-popover/index.css new file mode 100644 index 0000000000..2edda2c52e --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/popover/groupby-popover/index.css @@ -0,0 +1,110 @@ +.groupby-popover .popover { + max-width: none; + min-width: 400px; +} + +.groupby-popover .groupbys { + min-height: 80px; + max-height: 300px; + padding: 15px; +} + +.groupby-popover .empty-groupbys-container { + min-height: 80px; + padding: 16px; +} + +.groupby-popover .groupbys .groupby-item { + display: flex; + align-items: center; + padding: 0.25rem 0; +} + +.groupby-popover .groupby-item .option-group { + overflow: auto; + max-height: 360px; +} + +.groupby-popover .groupby-item .condition { + display: flex; + flex: 1 1; +} + +.groupby-popover .groupby-item .condition > div { + height: 38px; + line-height: 38px; +} + +.groupby-popover .groupby-item .groupby-column { + width: 150px; +} + +.groupby-popover .groupby-item .groupby-count-type, +.groupby-popover .groupby-item .groupby-predicate { + width: 130px; +} + +.groupby-popover .groupby-item .sf-metadata-icon-exclamation-triangle { + color: rgb(205, 32, 31); +} + +.groupby-popover .column-icon { + display: inline-block; + padding: 0 0.3125rem; + margin-left: -0.3125rem; +} + +.groupby-popover .column-icon .sf-metadata-font { + font-size: 14px; + color: #aaa; + cursor: default; +} + +.groupby-popover .delete-groupby { + width: 12px; + height: 20px; + margin-right: 14px; + text-align: center; +} + +.groupby-popover .empty-groupbys { + color: #666666; +} + +.groupby-popover .delete-groupby .sf-metadata-icon-fork-number { + display: inline-block; + font-size: 12px; + color: #999; + cursor: pointer; +} + +.groupby-popover .delete-groupby .sf-metadata-icon-fork-number:hover { + color: #666666; +} + +.groupby-popover .popover-add-tool { + border-top: none; + color: #666666; +} + +.groupby-popover .popover-add-tool .popover-add-icon { + margin-right: 14px; +} + +.groupby-popover .groupbys-tools { + display: flex; + justify-content: flex-end; + margin-top: 20px; + padding: 0 15px 15px; + font-size: 14px; + color: #666666; +} + +.groupby-popover .groupbys-tools .groupbys-tool-item:first-child { + margin-right: 20px; +} + +.groupby-popover .groupbys-tools .groupbys-tool-item:hover { + cursor: pointer; + color: #666666; +} diff --git a/frontend/src/metadata/metadata-view/components/popover/groupby-popover/index.jsx b/frontend/src/metadata/metadata-view/components/popover/groupby-popover/index.jsx new file mode 100644 index 0000000000..20c3c722ca --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/popover/groupby-popover/index.jsx @@ -0,0 +1,317 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import intl from 'react-intl-universal'; +import { UncontrolledPopover, Button } from 'reactstrap'; +import { + COLUMNS_ICON_CONFIG, + DISPLAY_GROUP_DATE_GRANULARITY, + DISPLAY_GROUP_GEOLOCATION_GRANULARITY, + MAX_GROUP_LEVEL, + SORT_TYPE, +} from 'sf-metadata-utils'; +import CommonAddTool from '../../common/common-add-tool'; +import GroupbyItem from '../groupby-popover-widgets/groupby-item'; +import GroupbyService from '../../services/groupby-service'; +import { isEsc } from '../../utils/hotkey'; +import { getColumnByKey } from '../../../utils/column-utils'; +import { getEventClassName } from '../../utils/utils'; +import { generateDefaultGroupby, getDefaultCountType, getGroupbyColumns } from '../../../utils/groupby-utils'; +import eventBus from '../../../utils/event-bus'; +import { GROUPBY_ACTION_TYPE, GROUPBY_DATE_GRANULARITY_LIST, GROUPBY_GEOLOCATION_GRANULARITY_LIST } from '../../constants/groupby'; +import { EVENT_BUS_TYPE } from '../../../constants'; + +import './index.css'; + +class GroupbyPopover extends Component { + + constructor(props) { + super(props); + const { groupbys, columns } = this.props; + this.groupbyService = new GroupbyService({ groupbys }); + this.columnsOptions = this.createColumnsOptions(columns); + this.geoCountTypeOptions = this.createGeoCountTypeOptions(); + this.dateCountTypeOptions = this.createDateCountTypeOptions(); + this.sortTypeOptions = this.createSortTypeOptions(); + this.state = { + groupbys: this.groupbyService.getGroupbys(), + }; + this.isSelectOpen = false; + } + + componentDidMount() { + document.addEventListener('click', this.hideDTablePopover, true); + document.addEventListener('keydown', this.onHotKey); + this.unsubscribeOpenSelect = eventBus.subscribe(EVENT_BUS_TYPE.OPEN_SELECT, this.setSelectStatus); + } + + componentDidUpdate(prevProps) { + const { columns } = this.props; + if (columns !== prevProps.columns) { + this.columnsOptions = this.createColumnsOptions(columns); + } + } + + componentWillUnmount() { + document.removeEventListener('click', this.hideDTablePopover, true); + document.removeEventListener('keydown', this.onHotKey); + this.unsubscribeOpenSelect(); + } + + hideDTablePopover = (e) => { + if (this.groupbysWrapper && !getEventClassName(e).includes('popover') && !this.groupbysWrapper.contains(e.target)) { + this.props.onGroupbyPopoverToggle(); + e.preventDefault(); + e.stopPropagation(); + return false; + } + }; + + onHotKey = (e) => { + if (isEsc(e) && !this.isSelectOpen) { + e.preventDefault(); + this.props.onGroupbyPopoverToggle(); + } + }; + + setSelectStatus = (status) => { + this.isSelectOpen = status; + }; + + createColumnsOptions = (columns = []) => { + const validColumns = getGroupbyColumns(columns); + return validColumns.map((column) => { + const { type, name } = column; + return { + value: { column }, + label: ( + + + {name} + + ) + }; + }); + }; + + createGeoCountTypeOptions = () => { + return GROUPBY_GEOLOCATION_GRANULARITY_LIST.map(granularity => { + const displayGranularity = DISPLAY_GROUP_GEOLOCATION_GRANULARITY[granularity]; + if (!displayGranularity) { + return null; + } + return { + value: { countType: granularity }, + label: {intl.get(displayGranularity)}, + }; + }).filter(Boolean); + }; + + createDateCountTypeOptions = () => { + return GROUPBY_DATE_GRANULARITY_LIST.map(granularity => { + const displayGranularity = DISPLAY_GROUP_DATE_GRANULARITY[granularity]; + if (!displayGranularity) { + return null; + } + return { + value: { countType: granularity }, + label: {intl.get(DISPLAY_GROUP_DATE_GRANULARITY[granularity])}, + }; + }).filter(Boolean); + }; + + createSortTypeOptions = () => { + return [ + { + value: { sortType: SORT_TYPE.UP }, + label: {intl.get(SORT_TYPE.UP)} + }, + { + value: { sortType: SORT_TYPE.DOWN }, + label: {intl.get(SORT_TYPE.DOWN)} + }, + ]; + }; + + addGroupby = (scheduleUpdate) => { + const { groupbys } = this.state; + // When the size of the popover is changed, need use scheduleUpdate to reposition the popover + if (groupbys.length === 0) { + scheduleUpdate(); + } + const groupbyColumns = this.columnsOptions.map(option => option.value.column); + const groupby = generateDefaultGroupby(groupbyColumns); + this.groupbyService.update(GROUPBY_ACTION_TYPE.ADD, { groupby }); + this.updateGroups(); + }; + + deleteGroupby = (index, event, scheduleUpdate) => { + event.nativeEvent.stopImmediatePropagation(); + this.groupbyService.update(GROUPBY_ACTION_TYPE.DELETE, { index }); + // use scheduleUpdate to reposition the popover + scheduleUpdate(); + this.updateGroups(); + }; + + isNeedSubmit = () => { + return this.props.isNeedSubmit; + }; + + selectColumn = ({ column }, index) => { + const { groupbys } = this.state; + const updatedGroupby = groupbys[index]; + const newColumnKey = column.key; + if (newColumnKey === updatedGroupby.column_key) { + return; + } + const newGroupby = { + ...updatedGroupby, + column_key: newColumnKey, + sort_type: SORT_TYPE.UP, + count_type: getDefaultCountType(column), + }; + this.groupbyService.update(GROUPBY_ACTION_TYPE.UPDATE, { index, groupby: newGroupby }); + this.updateGroups(); + }; + + selectCountType = ({ countType }, index) => { + const { groupbys } = this.state; + const updatedGroupby = groupbys[index]; + if (countType === updatedGroupby.count_type) { + return; + } + const newGroupby = { + ...updatedGroupby, + count_type: countType, + }; + this.groupbyService.update(GROUPBY_ACTION_TYPE.UPDATE, { index, groupby: newGroupby }); + this.updateGroups(); + }; + + selectSortType = ({ sortType }, index) => { + const { groupbys } = this.state; + const updatedGroupby = groupbys[index]; + if (sortType === updatedGroupby.sort_type) { + return; + } + const newGroupby = { + ...updatedGroupby, + sort_type: sortType, + }; + this.groupbyService.update(GROUPBY_ACTION_TYPE.UPDATE, { index, groupby: newGroupby }); + this.updateGroups(); + }; + + submitDefaultGroupbys = () => { + const { groupbys } = this.state; + this.props.modifyGroupbys(groupbys); + this.props.onGroupbyPopoverToggle(); + }; + + updateGroups = () => { + const groupbys = this.groupbyService.getGroupbys(); + this.setState({ groupbys }, () => { + if (this.isNeedSubmit()) return; + this.props.modifyGroupbys(groupbys); + }); + }; + + onPopoverInsideClick = (e) => { + e.stopPropagation(); + }; + + renderGroupbys = (scheduleUpdate) => { + const { columns } = this.props; + const { groupbys } = this.state; + return groupbys.map((groupby, index) => { + const column = getColumnByKey(groupby.column_key, columns) || {}; + return ( + + ); + }); + }; + + onHideAllGroups = () => { + eventBus.dispatch(EVENT_BUS_TYPE.COLLAPSE_ALL_GROUPS); + }; + + onShowAllGroups = () => { + eventBus.dispatch(EVENT_BUS_TYPE.EXPAND_ALL_GROUPS); + }; + + render() { + const { target } = this.props; + const { groupbys } = this.state; + const groupbysLen = Array.isArray(groupbys) ? groupbys.length : 0; + const isEmpty = groupbysLen === 0; + return ( + + {({ scheduleUpdate }) => ( +
this.groupbysWrapper = ref} + onClick={this.onPopoverInsideClick} + > +
+ {isEmpty ? +
{intl.get('No_groupings')}
: + this.renderGroupbys(scheduleUpdate) + } +
+ {groupbysLen < MAX_GROUP_LEVEL && + this.addGroupby(scheduleUpdate)} + footerName={intl.get('Add_group')} + className='popover-add-tool' + addIconClassName='popover-add-icon' + /> + } + {!isEmpty && +
+ {intl.get('Collapse_all')} + {intl.get('Expand_all')} +
+ } + {this.isNeedSubmit() && ( +
+ + +
+ )} +
+ )} +
+ ); + } +} + +GroupbyPopover.propTypes = { + target: PropTypes.oneOfType([PropTypes.string, PropTypes.object, PropTypes.node]), + groupbys: PropTypes.array, + columns: PropTypes.array, + onGroupbyPopoverToggle: PropTypes.func, + modifyGroupbys: PropTypes.func, + isNeedSubmit: PropTypes.bool, +}; + +export default GroupbyPopover; diff --git a/frontend/src/metadata/metadata-view/components/popover/groupby-popover/widgets/groupby-item.jsx b/frontend/src/metadata/metadata-view/components/popover/groupby-popover/widgets/groupby-item.jsx new file mode 100644 index 0000000000..e47acd31cc --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/popover/groupby-popover/widgets/groupby-item.jsx @@ -0,0 +1,125 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { Tooltip } from 'reactstrap'; +import { CustomizeSelect } from '@seafile/sf-metadata-ui-component'; +import { + COLUMNS_ICON_CONFIG, + SORT_COLUMN_OPTIONS, + isDateColumn, +} from '../../../_basic'; +import { getSelectedCountType, isShowGroupCountType } from '../../../utils/groupby-utils'; +import { gettext } from '../../../utils'; + +class GroupbyItem extends Component { + + constructor(props) { + super(props); + this.state = { + tooltipOpen: false + }; + this.filterToolTip = React.createRef(); + } + + toggleTipMessage = () => { + this.setState({ tooltipOpen: !this.state.tooltipOpen }); + }; + + getCountTypeOptions = (column) => { + const { dateCountTypeOptions } = this.props; + if (isDateColumn(column)) { + return dateCountTypeOptions; + } + }; + + renderTipMessage = () => { + const { column } = this.props; + const { tooltipOpen } = this.state; + const page = window.app.getPage(); + const { shown_column_keys } = page || {}; + + if (!shown_column_keys || !Array.isArray(shown_column_keys) || shown_column_keys.includes(column.key)) { + return null; + } + + return ( +
+ + + {gettext('Group tip message')} + +
+ ); + }; + + render() { + const { index, column, groupby, columnsOptions, sortTypeOptions, scheduleUpdate } = this.props; + const { name, type: columnType } = column; + const { sort_type } = groupby; + const selectedColumn = { + label: ( + + + {name} + + ) + }; + const countTypeOptions = this.getCountTypeOptions(column); + const selectedCountType = getSelectedCountType(column, groupby.count_type); + const selectedSortType = sort_type && sortTypeOptions.find(option => option.value.sortType === sort_type); + return ( +
+
this.props.onDeleteGroupby(index, e, scheduleUpdate)}> + +
+
+
+ this.props.onSelectColumn(value, index)} + options={columnsOptions} + searchable={true} + searchPlaceholder={gettext('Search column')} + noOptionsPlaceholder={gettext('No results')} + /> +
+ {isShowGroupCountType(column) && ( +
+ {gettext(selectedCountType)} } : ''} + onSelectOption={(value) => this.props.onSelectCountType(value, index)} + options={countTypeOptions} + /> +
+ )} +
+ {(!column.key || SORT_COLUMN_OPTIONS.includes(columnType)) && + this.props.onSelectSortType(value, index)} + options={sortTypeOptions} + /> + } +
+ {this.renderTipMessage()} +
+
+ ); + } +} + +GroupbyItem.propTypes = { + index: PropTypes.number, + column: PropTypes.object, + groupby: PropTypes.object, + columnsOptions: PropTypes.array, + geoCountTypeOptions: PropTypes.array, + dateCountTypeOptions: PropTypes.array, + sortTypeOptions: PropTypes.array, + onDeleteGroupby: PropTypes.func, + onSelectColumn: PropTypes.func, + onSelectCountType: PropTypes.func, + onSelectSortType: PropTypes.func, + scheduleUpdate: PropTypes.func, +}; + +export default GroupbyItem; diff --git a/frontend/src/metadata/metadata-view/components/popover/hidden-column-popover/hide-column-item.js b/frontend/src/metadata/metadata-view/components/popover/hidden-column-popover/hide-column-item.js new file mode 100644 index 0000000000..71ddff8c79 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/popover/hidden-column-popover/hide-column-item.js @@ -0,0 +1,65 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Icon, Switch } from '@seafile/sf-metadata-ui-component'; + +class HideColumnItem extends React.PureComponent { + + static defaultProps = { + readonly: false, + }; + + constructor(props) { + super(props); + this.state = { + setting: null + }; + } + + static getDerivedStateFromProps(nextProps, prevState) { + if (JSON.stringify(nextProps.setting) !== JSON.stringify(prevState.setting)) { + return { setting: nextProps.setting }; + } + return null; + } + + onUpdateFieldSetting = (event) => { + event.nativeEvent.stopImmediatePropagation(); + const value = event.target.checked; + const { setting } = this.state; + if (setting.isChecked === value) { + return; + } + const newSetting = Object.assign({}, setting, { isChecked: value }); + this.setState({ setting: newSetting }, () => { + this.props.onUpdateFieldSetting(newSetting); + }); + }; + + render() { + const { setting } = this.state; + const { readonly } = this.props; + const placeholder = ( + <> + + {setting.columnName} + + ); + return ( + + ); + } +} + +HideColumnItem.propTypes = { + readonly: PropTypes.bool, + setting: PropTypes.object.isRequired, + onUpdateFieldSetting: PropTypes.func.isRequired, +}; + +export default HideColumnItem; diff --git a/frontend/src/metadata/metadata-view/components/popover/hidden-column-popover/index.css b/frontend/src/metadata/metadata-view/components/popover/hidden-column-popover/index.css new file mode 100644 index 0000000000..98bbf0af0b --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/popover/hidden-column-popover/index.css @@ -0,0 +1,3 @@ +.hidden-column-popover .custom-switch .custom-switch-description { + width: 192px; +} diff --git a/frontend/src/metadata/metadata-view/components/popover/hidden-column-popover/index.jsx b/frontend/src/metadata/metadata-view/components/popover/hidden-column-popover/index.jsx new file mode 100644 index 0000000000..d90a119ae0 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/popover/hidden-column-popover/index.jsx @@ -0,0 +1,203 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import intl from 'react-intl-universal'; +import { UncontrolledPopover } from 'reactstrap'; +import isHotkey from 'is-hotkey'; +import { COLUMNS_ICON_CONFIG } from '../../../_basic'; +import HideColumnItem from '../hide-column-popover-widgets/hide-column-item'; +import { getEventClassName } from '../../../utils/utils'; + +import './index.css'; + +class HideColumnPopover extends React.Component { + + static defaultProps = { + readonly: false, + }; + + constructor(props) { + super(props); + this.state = { + fieldSettings: [], + searchVal: '', + }; + } + + componentDidMount() { + document.addEventListener('click', this.hidePopover, true); + document.addEventListener('keydown', this.onHotKey); + } + + componentWillUnmount() { + document.removeEventListener('click', this.hidePopover, true); + document.removeEventListener('keydown', this.onHotKey); + } + + hidePopover = (e) => { + if (this.popoverRef && !getEventClassName(e).includes('popover') && !this.popoverRef.contains(e.target)) { + this.props.onPopoverToggle(e); + e.preventDefault(); + e.stopPropagation(); + return false; + } + }; + + onHotKey = (e) => { + if (isHotkey('esc', e)) { + e.preventDefault(); + this.props.onPopoverToggle(); + } + }; + + static getDerivedStateFromProps(nextProps, preState) { + const { columns, shownColumnKeys } = nextProps; + let fieldSettings = columns.map(column => { + return { + key: column.key, + isChecked: shownColumnKeys.includes(column.key), + columnName: column.name, + columnIcon: COLUMNS_ICON_CONFIG[column.type], + }; + }); + // table page cannot hide first column + fieldSettings.shift(); + return { fieldSettings: fieldSettings }; + } + + onChooseAllColumns = () => { + const { columns } = this.props; + let shownColumnKeys = []; + const { fieldSettings } = this.state; + const newFieldSettings = fieldSettings.map(setting => { + setting.isChecked = true; + shownColumnKeys.push(setting.key); + return setting; + }); + shownColumnKeys.unshift(columns[0].key); + this.setState({ fieldSettings: newFieldSettings }, () => { + this.props.modifyHiddenColumns(shownColumnKeys); + }); + }; + + onHideAllColumns = () => { + const { columns } = this.props; + const newFieldSettings = this.state.fieldSettings.map(setting => { + setting.isChecked = false; + return setting; + }); + // table page cannot hide first column + const shownColumnKeys = [columns[0].key]; + this.setState({ fieldSettings: newFieldSettings }, () => { + this.props.modifyHiddenColumns(shownColumnKeys); + }); + }; + + onUpdateFieldSetting = (columnSetting) => { + const { columns } = this.props; + const { fieldSettings } = this.state; + let shownColumnKeys = []; + const newFieldSettings = fieldSettings.map(setting => { + if (setting.key === columnSetting.key) { + setting = columnSetting; + } + if (setting.isChecked) { + shownColumnKeys.push(setting.key); + } + return setting; + }); + // table page cannot hide first column + if (!shownColumnKeys.includes(columns[0].key)) { + shownColumnKeys.unshift(columns[0].key); + } + this.setState({ fieldSettings: newFieldSettings }, () => { + this.props.modifyHiddenColumns(shownColumnKeys); + }); + }; + + onPopoverInsideClick = (e) => { + e.stopPropagation(); + }; + + onChangeSearch = (event) => { + let { searchVal } = this.state; + if (searchVal === event.target.value) { + return; + } + searchVal = event.target.value; + this.setState({ searchVal }); + }; + + getFilteredColumns = () => { + let { searchVal, fieldSettings } = this.state; + searchVal = searchVal.toLowerCase(); + if (!searchVal) { + return fieldSettings; + } + return fieldSettings.filter((setting) => { + return setting.columnName.toLowerCase().includes(searchVal); + }); + }; + + render() { + const { target, readonly } = this.props; + const fieldSettings = this.getFilteredColumns(); + const isEmpty = fieldSettings.length === 0 ? true : false; + return ( + +
this.popoverRef = ref} onClick={this.onPopoverInsideClick}> +
+
+ +
+ {isEmpty && +
+
{intl.get('No_columns_available_to_be_hidden')}
+
+ } + {!isEmpty && + <> +
+ {fieldSettings.map(setting => { + return ( + + ); + })} +
+ {(!this.state.searchVal && !readonly) && +
+
{intl.get('Hide_all')}
+
{intl.get('Show_all')}
+
+ } + + } +
+
+
+ ); + } +} + +HideColumnPopover.propTypes = { + target: PropTypes.string.isRequired, + shownColumnKeys: PropTypes.array.isRequired, + columns: PropTypes.array.isRequired, + modifyHiddenColumns: PropTypes.func.isRequired, + onPopoverToggle: PropTypes.func.isRequired, + readonly: PropTypes.bool, +}; + +export default HideColumnPopover; diff --git a/frontend/src/metadata/metadata-view/components/popover/index.js b/frontend/src/metadata/metadata-view/components/popover/index.js new file mode 100644 index 0000000000..7760756bfc --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/popover/index.js @@ -0,0 +1,13 @@ +import FilterPopover from './filter-popover'; +import SortPopover from './sort-popover'; +import GroupbyPopover from './groupby-popover'; +import HideColumnPopover from './hide-column-popover'; +import ColumnPermissionPopover from './column-permission-popover'; + +export { + FilterPopover, + SortPopover, + GroupbyPopover, + HideColumnPopover, + ColumnPermissionPopover +}; diff --git a/frontend/src/metadata/metadata-view/components/popover/sort-popover/index.css b/frontend/src/metadata/metadata-view/components/popover/sort-popover/index.css new file mode 100644 index 0000000000..27d10859bc --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/popover/sort-popover/index.css @@ -0,0 +1,83 @@ +.sort-popover .popover { + max-width: none; + min-width: 400px; +} + +.sort-popover .sorts-list { + min-height: 120px; + max-height: 100%; + padding: 15px; +} + +.sort-popover .sorts-list .option-group { + overflow: auto; + max-height: 360px; +} + +.sort-popover .empty-sorts-container { + min-height: 80px; + padding: 16px; +} + +.sorts-list .sort-item { + display: flex; + align-items: center; + padding: 0.25rem 0; +} + +.sort-item .condition { + display: flex; + flex: 1 1; +} + +.sort-item .condition > div { + height: 38px; + line-height: 38px; +} + +.sort-item .sort-column { + width: 150px; +} + +.sort-item .sort-predicate { + width: 130px; +} + +.sorts-list .delete-sort { + width: 12px; + height: 20px; + margin-right: 14px; + text-align: center; +} + +.sorts-list .empty-sorts-list { + color: #666666; +} + +.delete-sort .sf-metadata-icon-fork-number { + display: inline-block; + font-size: 12px; + color: #999; + cursor: pointer; +} + +.sorts-list .delete-sort .sf-metadata-icon-fork-number:hover { + color: #666666; +} + +.sort-popover .popover-add-tool { + border-top: none; + color: #666666; +} + +.sort-popover .popover-add-tool .popover-add-icon { + margin-right: 14px; +} + +.sort-popover .sort-popover-footer { + display: flex; + align-items: center; + justify-content: flex-end; + padding: 1rem; + border-top: 1px solid #e9ecef; +} diff --git a/frontend/src/metadata/metadata-view/components/popover/sort-popover/index.jsx b/frontend/src/metadata/metadata-view/components/popover/sort-popover/index.jsx new file mode 100644 index 0000000000..c22a326065 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/popover/sort-popover/index.jsx @@ -0,0 +1,283 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import intl from 'react-intl-universal'; +import isHotkey from 'is-hotkey'; +import { Button, UncontrolledPopover } from 'reactstrap'; +import { + COLUMNS_ICON_CONFIG, + SORT_COLUMN_OPTIONS, + SORT_TYPE, +} from 'sf-metadata-utils'; +import { DTableCustomizeSelect } from 'sf-metadata-ui-component'; +import CommonAddTool from '../../common/common-add-tool'; +import { execSortsOperation, getDisplaySorts, isSortsEmpty, SORT_OPERATION } from '../sort-popover-widgets/sort-utils'; +import { getEventClassName } from '../../utils/utils'; +import { getColumnByKey } from '../../../utils/column-utils'; +import eventBus from '../../../utils/event-bus'; +import { EVENT_BUS_TYPE } from '../../../constants'; + +import './index.css'; + +const SORT_TYPES = [SORT_TYPE.UP, SORT_TYPE.DOWN]; + +const propTypes = { + target: PropTypes.string.isRequired, + isNeedSubmit: PropTypes.bool, + sorts: PropTypes.array, + columns: PropTypes.array.isRequired, + onSortComponentToggle: PropTypes.func, + update: PropTypes.func, + readonly: PropTypes.bool, +}; + +class SortPopover extends Component { + + static defaultProps = { + readonly: false, + }; + + constructor(props) { + super(props); + const { sorts, columns } = this.props; + this.sortTypeOptions = this.createSortTypeOptions(); + this.columnsOptions = this.createColumnsOptions(columns); + this.state = { + sorts: getDisplaySorts(sorts, columns), + }; + this.isSelectOpen = false; + } + + componentDidMount() { + document.addEventListener('click', this.hideDTablePopover, true); + document.addEventListener('keydown', this.onHotKey); + this.unsubscribeOpenSelect = eventBus.subscribe(EVENT_BUS_TYPE.OPEN_SELECT, this.setSelectStatus); + } + + componentWillUnmount() { + document.removeEventListener('click', this.hideDTablePopover, true); + document.removeEventListener('keydown', this.onHotKey); + this.unsubscribeOpenSelect(); + } + + hideDTablePopover = (e) => { + if (this.sortPopoverRef && !getEventClassName(e).includes('popover') && !this.sortPopoverRef.contains(e.target)) { + this.props.onSortComponentToggle(e); + e.preventDefault(); + e.stopPropagation(); + return false; + } + }; + + isNeedSubmit = () => { + return this.props.isNeedSubmit; + }; + + onHotKey = (e) => { + if (isHotkey('esc', e) && !this.isSelectOpen) { + e.preventDefault(); + this.props.onSortComponentToggle(); + } + }; + + setSelectStatus = (status) => { + this.isSelectOpen = status; + }; + + UNSAFE_componentWillReceiveProps(nextProps) { + const newColumns = nextProps.columns; + if (newColumns !== this.props.columns) { + this.columnsOptions = this.createColumnsOptions(newColumns); + } + } + + addSort = () => { + const { sorts } = this.state; + const newSorts = execSortsOperation(SORT_OPERATION.ADD_SORT, { sorts }); + this.updateSorts(newSorts); + }; + + deleteSort = (event, index) => { + event.nativeEvent.stopImmediatePropagation(); + const sorts = this.state.sorts.slice(0); + const newSorts = execSortsOperation(SORT_OPERATION.DELETE_SORT, { sorts, index }); + this.updateSorts(newSorts); + }; + + onSelectColumn = (value, index) => { + const sorts = this.state.sorts.slice(0); + const newColumnKey = value.column.key; + if (newColumnKey === sorts[index].column_key) { + return; + } + const newSorts = execSortsOperation(SORT_OPERATION.MODIFY_SORT_COLUMN, { sorts, index, column_key: newColumnKey }); + this.updateSorts(newSorts); + }; + + onSelectSortType = (value, index) => { + const sorts = this.state.sorts.slice(0); + const newSortType = value.sortType; + if (newSortType === sorts[index].sort_type) { + return; + } + const newSorts = execSortsOperation(SORT_OPERATION.MODIFY_SORT_TYPE, { sorts, index, sort_type: newSortType }); + this.updateSorts(newSorts); + }; + + updateSorts = (sorts) => { + if (this.isNeedSubmit()) { + const isSubmitDisabled = false; + this.setState({ sorts, isSubmitDisabled }); + return; + } + this.setState({ sorts }, () => { + this.handleSortAnimation(); + }); + }; + + handleSortAnimation = () => { + const update = { sorts: this.state.sorts }; + this.props.update(update); + }; + + onClosePopover = () => { + this.props.onSortComponentToggle(); + }; + + onSubmitSorts = () => { + const { sorts } = this.state; + const update = { sorts: sorts }; + this.props.update(update); + this.props.onSortComponentToggle(); + }; + + createColumnsOptions = (columns = []) => { + const sortableColumns = columns.filter(column => SORT_COLUMN_OPTIONS.includes(column.type)); + return sortableColumns.map((column) => { + const { type, name } = column; + return { + value: { column }, + label: ( + + + {name} + + ) + }; + }); + }; + + createSortTypeOptions = () => { + return SORT_TYPES.map(sortType => { + return { + value: { sortType }, + label: {intl.get(sortType)} + }; + }); + }; + + renderSortsList = () => { + const { columns } = this.props; + const { sorts } = this.state; + return sorts.map((sort, index) => { + const column = getColumnByKey(sort.column_key, columns) || {}; + return this.renderSortItem(column, sort, index); + }); + }; + + renderSortItem = (column, sort, index) => { + let { name, type } = column; + const { readonly } = this.props; + let selectedColumn = { + label: ( + + + {name} + + ) + }; + + let selectedTypeShow = sort.sort_type; + let selectedSortType = selectedTypeShow && { + label: {intl.get(selectedTypeShow)} + }; + + return ( +
+ {!readonly && +
this.deleteSort(event, index)}> + +
+ } +
+
+ this.onSelectColumn(value, index)} + options={this.columnsOptions} + searchable={true} + searchPlaceholder={intl.get('Search_column')} + noOptionsPlaceholder={intl.get('No_results')} + /> +
+
+ this.onSelectSortType(value, index)} + options={this.sortTypeOptions} + /> +
+
+
+ ); + }; + + onPopoverInsideClick = (e) => { + e.stopPropagation(); + }; + + render() { + const { target, readonly } = this.props; + const { sorts } = this.state; + const isEmpty = isSortsEmpty(sorts); + return ( + +
this.sortPopoverRef = ref} onClick={this.onPopoverInsideClick}> +
+ {isEmpty ? +
{intl.get('No_sorts')}
: + this.renderSortsList() + } +
+ {!readonly && + + } + {(this.isNeedSubmit() && !readonly) && ( +
+ + +
+ )} +
+
+ ); + } +} + +SortPopover.propTypes = propTypes; + +export default SortPopover; diff --git a/frontend/src/metadata/metadata-view/components/popover/sort-popover/sort-utils.js b/frontend/src/metadata/metadata-view/components/popover/sort-popover/sort-utils.js new file mode 100644 index 0000000000..4caa68f1eb --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/popover/sort-popover/sort-utils.js @@ -0,0 +1,63 @@ +import { + SORT_TYPE, + isValidSort, +} from '../../../_basic'; + +export const SORT_OPERATION = { + ADD_SORT: 'add_sort', + DELETE_SORT: 'delete_sort', + MODIFY_SORT_COLUMN: 'modify_sort_column', + MODIFY_SORT_TYPE: 'modify_sort_type', +}; + +export const getDisplaySorts = (sorts, columns) => { + if (!Array.isArray(sorts) || !Array.isArray(columns)) { + return []; + } + return sorts.filter((sort) => !sort.column_key || isValidSort(sort, columns)); +}; + +export const isSortsEmpty = (sorts) => { + return !sorts || sorts.length === 0; +}; + +export const execSortsOperation = (action, payload) => { + const { sorts: updatedSorts } = payload; + switch (action) { + case SORT_OPERATION.ADD_SORT: { + const newSort = { + column_key: null, + sort_type: SORT_TYPE.UP, + }; + updatedSorts.push(newSort); + return updatedSorts; + } + case SORT_OPERATION.DELETE_SORT: { + const { index } = payload; + updatedSorts.splice(index, 1); + return updatedSorts; + } + case SORT_OPERATION.MODIFY_SORT_COLUMN: { + const { index, column_key } = payload; + const newSort = { + column_key: column_key, + sort_type: SORT_TYPE.UP, + }; + updatedSorts[index] = newSort; + return updatedSorts; + } + case SORT_OPERATION.MODIFY_SORT_TYPE: { + const { index, sort_type } = payload; + const updatedSort = updatedSorts[index]; + const newSort = { + column_key: updatedSort.column_key, + sort_type: sort_type, + }; + updatedSorts[index] = newSort; + return updatedSorts; + } + default: { + return updatedSorts; + } + } +}; diff --git a/frontend/src/metadata/metadata-view/components/record-details-dialog/index.js b/frontend/src/metadata/metadata-view/components/record-details-dialog/index.js new file mode 100644 index 0000000000..9c710b036f --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/record-details-dialog/index.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { RecordDetails } from '@seafile/sf-metadata-ui-component'; +import { useCollaborators, useMetadata, useRecordDetails } from '../../hooks'; +import { COLUMNS_ICON_CONFIG } from '../../_basic'; + +const RecordDetailsDialog = () => { + const { isShowRecordDetails, recordDetails, closeRecordDetails } = useRecordDetails(); + const { collaborators, collaboratorsCache, updateCollaboratorsCache } = useCollaborators(); + const { metadata } = useMetadata(); + if (!isShowRecordDetails) return null; + const props = { + collaborators, + collaboratorsCache, + updateCollaboratorsCache, + queryUserAPI: window.sfMetadataContext.userService.queryUser, + record: recordDetails, + fields: metadata.columns, + fieldIconConfig: COLUMNS_ICON_CONFIG, + onToggle: closeRecordDetails, + }; + return (); +}; + +export default RecordDetailsDialog; diff --git a/frontend/src/metadata/metadata-view/components/scrollbar/horizontal-scrollbar.js b/frontend/src/metadata/metadata-view/components/scrollbar/horizontal-scrollbar.js new file mode 100644 index 0000000000..8792096770 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/scrollbar/horizontal-scrollbar.js @@ -0,0 +1,73 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Z_INDEX } from '../../_basic'; + +const propTypes = { + innerWidth: PropTypes.number, + onScrollbarScroll: PropTypes.func.isRequired, + onScrollbarMouseUp: PropTypes.func.isRequired, +}; + +class HorizontalScrollbar extends React.Component { + + isSelfScroll = true; + + setScrollLeft = (scrollLeft) => { + this.isSelfScroll = false; + this.container.scrollLeft = scrollLeft; + }; + + onScroll = (event) => { + // only update grid's scrollLeft via scroll by itself. + // e.g. forbid to update grid's scrollLeft when the scrollbar's scrollLeft changed by other component + event.stopPropagation(); + if (!this.isSelfScroll) { + this.isSelfScroll = true; + return; + } + const { scrollLeft } = event.target; + this.props.onScrollbarScroll(scrollLeft); + return; + }; + + getScrollbarStyle = () => { + return { width: this.props.innerWidth }; + }; + + getContainerStyle = () => { + return { zIndex: Z_INDEX.SCROLL_BAR }; + }; + + setScrollbarRef = (ref) => { + this.scrollbar = ref; + }; + + setContainerRef = (ref) => { + this.container = ref; + }; + + render() { + if (!this.props.innerWidth) { + return null; + } + + const containerStyle = this.getContainerStyle(); + const scrollbarStyle = this.getScrollbarStyle(); + + return ( +
+
+
+ ); + } +} + +HorizontalScrollbar.propTypes = propTypes; + +export default HorizontalScrollbar; diff --git a/frontend/src/metadata/metadata-view/components/scrollbar/index.js b/frontend/src/metadata/metadata-view/components/scrollbar/index.js new file mode 100644 index 0000000000..b866e9c69e --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/scrollbar/index.js @@ -0,0 +1,8 @@ +import RightScrollbar from './right-scrollbar'; +import HorizontalScrollbar from './horizontal-scrollbar'; +import './scrollbar.css'; + +export { + RightScrollbar, + HorizontalScrollbar, +}; diff --git a/frontend/src/metadata/metadata-view/components/scrollbar/right-scrollbar.js b/frontend/src/metadata/metadata-view/components/scrollbar/right-scrollbar.js new file mode 100644 index 0000000000..668adc6bd2 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/scrollbar/right-scrollbar.js @@ -0,0 +1,98 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { HEADER_HEIGHT_TYPE, isEmptyObject, Z_INDEX } from '../../_basic'; +import { GRID_HEADER_DEFAULT_HEIGHT, GRID_HEADER_DOUBLE_HEIGHT } from '../../constants'; + +const propTypes = { + table: PropTypes.object.isRequired, + onScrollbarScroll: PropTypes.func.isRequired, + onScrollbarMouseUp: PropTypes.func.isRequired, +}; + +class RightScrollbar extends React.Component { + + isSelfScroll = true; + + setScrollTop = (scrollTop) => { + this.isSelfScroll = false; + this.rightScrollContainer.scrollTop = scrollTop; + }; + + onScroll = (event) => { + event.stopPropagation(); + + // only update canvas's scrollTop via scroll by itself. + // e.g. forbid to update canvas's scrollTop when the scrollbar's scrollTop changed by other component + if (!this.isSelfScroll) { + this.isSelfScroll = true; + return; + } + const { scrollTop } = event.target; + this.props.onScrollbarScroll(scrollTop); + }; + + onMouseUp = (event) => { + if (this.props.onScrollbarMouseUp) { + this.props.onScrollbarMouseUp(event); + } + }; + + getScrollbarStyle = () => { + const component = window.sfMetadataBody; + if (component && component.resultRef) { + const resultRef = component.resultRef; + return { height: resultRef.scrollHeight }; + } + return {}; + }; + + getGridHeaderHeight = () => { + const headerSettings = this.props.table.header_settings || {}; + const headerHeight = isEmptyObject(headerSettings) ? HEADER_HEIGHT_TYPE.DEFAULT : headerSettings.header_height; + const height = headerHeight === HEADER_HEIGHT_TYPE.DOUBLE ? GRID_HEADER_DOUBLE_HEIGHT : GRID_HEADER_DEFAULT_HEIGHT; + return height; + }; + + getContainerStyle = () => { + const style = {}; + const component = window.sfMetadataBody; + if (component && component.resultContentRef) { + style.height = component.resultContentRef.clientHeight; + style.zIndex = Z_INDEX.SCROLL_BAR; + } + /* page-header + seatable-app-header + table-header-top + first row(grid-header) */ + style.top = 50 + 10 + 48 + this.getGridHeaderHeight(); + /* sf-metadata-wrapper have 10px margin */ + style.right = '10px'; + return style; + }; + + setScrollbarRef = (ref) => { + this.scrollbar = ref; + }; + + setContainerRef = (ref) => { + this.rightScrollContainer = ref; + }; + + render() { + const containerStyle = this.getContainerStyle(); + const scrollbarStyle = this.getScrollbarStyle(); + + return ( +
+
+
+ ); + } +} + +RightScrollbar.propTypes = propTypes; + +export default RightScrollbar; diff --git a/frontend/src/metadata/metadata-view/components/scrollbar/scrollbar.css b/frontend/src/metadata/metadata-view/components/scrollbar/scrollbar.css new file mode 100644 index 0000000000..e46971423e --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/scrollbar/scrollbar.css @@ -0,0 +1,32 @@ +.sf-metadata-result-container.windows-browser::-webkit-scrollbar, +.sf-metadata-result-table-content::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} + +.horizontal-scrollbar-container { + height: 20px; + width: 100%; + position: absolute; + left: 0; + right: 0; + bottom: 30px; + background-color: transparent; + overflow: auto; +} + +.horizontal-scrollbar-inner { + height: 1px; +} + +.right-scrollbar-container { + width: 20px; + position: fixed; + overflow: auto; + transition: right 0.25s ease-in-out 0s; +} + +.right-scrollbar-container .right-scrollbar-inner { + width: 1px; +} diff --git a/frontend/src/metadata/metadata-view/components/table/container.js b/frontend/src/metadata/metadata-view/components/table/container.js new file mode 100644 index 0000000000..bf4232c1f0 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/container.js @@ -0,0 +1,187 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { toaster } from '@seafile/sf-metadata-ui-component'; +import { EVENT_BUS_TYPE } from '../../constants'; +import { CommonlyUsedHotkey, getErrorMsg } from '../../_basic'; +import { gettext } from '../../utils'; +import { useMetadata } from '../../hooks'; +import TableTool from './table-tool'; +import TableMain from './table-main'; +import RecordDetailsDialog from '../record-details-dialog'; + +import './index.css'; + +const Container = () => { + const [isLoadingMore, setLoadingMore] = useState(false); + const { metadata, errorMsg, extendMetadataRows } = useMetadata(); + const containerRef = useRef(null); + + const onKeyDown = useCallback((event) => { + if (event.target.className.includes('editor-main')) return; + if (CommonlyUsedHotkey.isModF(event)) { + event.preventDefault(); + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SEARCH_CELLS); + return; + } + }, []); + + const isGroupView = useCallback(() => { + // todo + return false; + }, []); + + const onSelectCell = useCallback(() => { + // todo + }, []); + + const tableChanged = useCallback(() => { + // todo + }, []); + + const handleTableError = useCallback((error) => { + const errorMsg = getErrorMsg(error); + toaster.danger(gettext(errorMsg)); + }, []); + + const updateMetadata = useCallback(() => { + // todo + }, []); + + const loadMore = useCallback(() => { + if (!metadata.hasMore) return; + setLoadingMore(true); + extendMetadataRows((flag) => { + setLoadingMore(false); + }); + }, [metadata, extendMetadataRows]); + + const modifyRecords = useCallback((rowIds, idRowUpdates, idOriginalRowUpdates, idOldRowData, idOriginalOldRowData, isCopyPaste = false) => { + // todo: store op + }, []); + + const modifyRecord = useCallback((rowId, updates, oldRowData, originalUpdates, originalOldRowData) => { + const rowIds = [rowId]; + const idRowUpdates = { [rowId]: updates }; + const idOriginalRowUpdates = { [rowId]: originalUpdates }; + const idOldRowData = { [rowId]: oldRowData }; + const idOriginalOldRowData = { [rowId]: originalOldRowData }; + modifyRecords(rowIds, idRowUpdates, idOriginalRowUpdates, idOldRowData, idOriginalOldRowData); + }, [modifyRecords]); + + const getAdjacentRowsIds = useCallback((rowIds) => { + const rowIdsLen = metadata.row_ids.length; + let rowIdsInOrder = []; + let upperRowIds = []; + let belowRowIds = []; + let rowIdMap = {}; + rowIds.forEach(rowId => rowIdMap[rowId] = rowId); + metadata.row_ids.forEach((rowId, index) => { + if (!rowIdMap[rowId]) { + return; + } + const upperRowId = index === 0 ? null : metadata.row_ids[index - 1]; + const belowRowId = index === rowIdsLen - 1 ? null : metadata.row_ids[index + 1]; + rowIdsInOrder.push(rowId); + upperRowIds.push(upperRowId); + belowRowIds.push(belowRowId); + }); + return { rowIdsInOrder, upperRowIds, belowRowIds }; + }, [metadata]); + + const modifyFilters = useCallback((filters, filterConjunction) => { + // modifyFilters + }, []); + + const modifySorts = useCallback((sorts) => { + // modifySorts + }, []); + + const modifyGroupbys = useCallback(() => { + // modifyGroupbys + }, []); + + const modifyHiddenColumns = useCallback(() => { + // modifyHiddenColumns + }, []); + + const recordGetterById = useCallback((recordId) => { + return metadata.id_row_map[recordId]; + }, [metadata]); + + const recordGetter = useCallback((recordIndex) => { + const recordId = metadata.row_ids[recordIndex]; + return recordId && recordGetterById(recordId); + }, [metadata, recordGetterById]); + + const groupRecordGetter = useCallback((groupRecordIndex) => { + if (!window.sfMetadataBody || !window.sfMetadataBody.getGroupRecordByIndex) { + return null; + } + const groupRecord = window.sfMetadataBody.getGroupRecordByIndex(groupRecordIndex); + const recordId = groupRecord.rowId; + return recordId && recordGetterById(recordId); + }, [recordGetterById]); + + const recordGetterByIndex = useCallback(({ isGroupView, groupRecordIndex, recordIndex }) => { + if (isGroupView) groupRecordGetter(groupRecordIndex); + return recordGetter(recordIndex); + }, [groupRecordGetter, recordGetter]); + + const getTableContentWidth = useCallback(() => { + return containerRef?.current?.offsetWidth || 0; + }, [containerRef]); + + const getTableContentLeft = useCallback(() => { + return containerRef?.current?.getBoundingClientRect()?.left || 0; + }, [containerRef]); + + useEffect(() => { + document.addEventListener('keydown', onKeyDown); + const unsubscribeSelectCell = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.SELECT_CELL, onSelectCell); + const unsubscribeServerTableChanged = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.SERVER_TABLE_CHANGED, tableChanged); + const unsubscribeTableChanged = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.LOCAL_TABLE_CHANGED, tableChanged); + const unsubscribeHandleTableError = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.TABLE_ERROR, handleTableError); + const unsubscribeUpdateRows = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.UPDATE_TABLE_ROWS, updateMetadata); + return () => { + document.removeEventListener('keydown', onKeyDown); + unsubscribeSelectCell(); + unsubscribeServerTableChanged(); + unsubscribeTableChanged(); + unsubscribeHandleTableError(); + unsubscribeUpdateRows(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> +
+ +
+ {errorMsg && (
{gettext(errorMsg)}
)} + {!errorMsg && ( +
+ +
+ )} +
+
+ + + + ); + +}; + +export default Container; diff --git a/frontend/src/metadata/metadata-view/components/table/index.css b/frontend/src/metadata/metadata-view/components/table/index.css new file mode 100644 index 0000000000..c0a16bddd3 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/index.css @@ -0,0 +1,1048 @@ +.sf-metadata-wrapper { + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + position: relative; + border: none; + height: 100%; + width: 100%; + overflow: hidden; +} + +.sf-metadata-wrapper .table-left-operations, +.sf-metadata-wrapper .table-right-operations { + display: flex; + align-items: center; +} + +.sf-metadata-wrapper .seatable-app-header.searcher-active .table-right-operations { + width: 100%; + margin-right: 10px; +} + +.sf-metadata-wrapper .seatable-app-header .table-left-operations .setting-item { + margin-right: 0 !important; +} + +.sf-metadata-wrapper .table-left-operations .custom-filter-label { + padding: 0 0.5rem; +} + +.sf-metadata-wrapper .table-left-operations .custom-sort-label, +.sf-metadata-wrapper .table-left-operations .custom-hide-column-label, +.sf-metadata-wrapper .table-left-operations .custom-groupby-label { + padding: 0 0 0 0.5rem; +} + +.sf-metadata-wrapper .table-left-operations .custom-filter-label.active { + background-color: #d1f7c4; +} + +.sf-metadata-wrapper .table-left-operations .custom-sort-label.active { + background-color: #f5d9bc; +} + +.sf-metadata-wrapper .table-left-operations .groupbys-setting-btn.active { + background-color: #dad7ff; +} + +.sf-metadata-wrapper .table-left-operations .custom-hide-column-label.active { + background-color: #d7e8ff; +} + +.sf-metadata-wrapper .table-left-operations .custom-filter-label.active:hover, +.sf-metadata-wrapper .table-left-operations .custom-sort-label.active:hover, +.sf-metadata-wrapper .table-left-operations .custom-hide-column-label.active:hover, +.sf-metadata-wrapper .table-left-operations .groupbys-setting-btn.active:hover { + box-shadow: inset 0 0 0 2px rgb(0 0 0 / 10%); +} + +.sf-metadata-wrapper .table-right-operations .new-record-btn button { + display: flex; + align-items: center; + justify-content: center; + height: 23px; + font-weight: 400; + border-color: rgba(0, 0, 0, 0.05); +} + +.sf-metadata-wrapper .table-right-operations .more-operation-add-record { + padding: 0; +} + +.sf-metadata-wrapper .table-right-operations .more-operation-add-record:not(:disabled):not(.disabled):active:focus { + box-shadow: none; +} + +.sf-metadata-wrapper .table-right-operations .more-operation-add-record .dropdown { + display: inline-block; + width: 100%; + height: 100%; +} + +.sf-metadata-wrapper .table-right-operations .add-record-dropdown-menu { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; +} + +.sf-metadata-dropdown-menu.add-record { + margin-top: 4px; +} + +.sf-metadata-wrapper .table-right-operations .more-operation-add-record .dropdown .sf-metadata-dropdown-menu { + margin-top: 2px; +} + +.sf-metadata-wrapper .table-right-operations .more-operation-add-record .toggle-icon { + display: inline-block; + font-size: 12px; + transform: scale(0.8); + margin-top: 1px; +} + +.sf-metadata-wrapper .table-right-operations .new-record { + font-size: 14px; + line-height: 1.5rem; +} + +.sf-metadata-wrapper .table-right-operations .table-search-box .input-icon-addon.search-poll-button { + display: flex; + right: 25px; + height: 30px; + line-height: 30px; + left: auto; + text-align: center; + font-size: 12px; + min-width: 35px; + pointer-events: all; +} + +.sf-metadata-wrapper .table-right-operations .table-search-box .search-poll-button .search-description { + height: 30px; + line-height: 30px; + color: #666666; +} + +.search-poll-button .sf-metadata-font { + font-size: 12px; + cursor: pointer; + color: #212529; +} + +.mobile-search-exchange-btn { + width: 30px; + height: 30px; + line-height: 30px; + background-color: #e5e5e5; + color: #212529; + display: block; +} + +.mobile-search-exchange-btn:hover { + background-color: #ededed; + color: #666666; +} + +.mobile-search-exchange-btn.mobile-search-upward { + border-radius: 2px 0 0 2px; + transform: scale(0.8, 0.8) translateX(8px); +} + +.mobile-search-exchange-btn.mobile-search-backward { + border-radius: 0 2px 2px 0; + transform: scale(0.8, 0.8); +} + +.search-text-clear { + cursor: pointer; + min-width: 25px; + pointer-events: all; + font-style: normal; + font-size: 18px; + font-weight: 700; + text-align: center; + line-height: 30px; + height: 30px; + color: #999; +} + +.search-text-clear:hover { + color: #212529; +} + +.sf-metadata-result.success { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + overflow: hidden; + height: 100%; + width: fit-content; + max-width: 100%; +} + +.sf-metadata-result-container { + flex: 1; + overflow-x: scroll; + overflow-y: hidden; +} + +.sf-metadata-result-content { + height: 100%; + min-width: 100%; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + position: relative; + overflow: hidden; + background: #f5f5f5; + user-select: none; +} + +.group-level-2 .sf-metadata-result-content { + background-color: #ededed; +} + +.group-level-3 .sf-metadata-result-content { + background-color: #e3e3e3; +} + +.group-level-4 .sf-metadata-result-content { + background-color: #dedede; +} + +.static-sf-metadata-result-content { + width: 100%; + position: relative; + background: #f9f9f9; + border-bottom: 1px solid #ddd; + z-index: 3; +} + +.static-sf-metadata-result-content .sf-metadata-result-table-row { + position: relative; + width: fit-content; + background-color: #f9f9f9; +} + +.record-HeaderCell__draggable { + position: absolute; + top: 0px; + right: -2px; + width: 5px; + border-radius: 3px; + margin: 3px 0px; + height: 80%; + cursor: col-resize; + z-index: 1; +} + +.record-HeaderCell__draggable:hover { + background-color: #2d7ff9; +} + +.record-header-cell .sf-metadata-result-table-cell { + overflow: unset; +} + +.sf-metadata-result-table-row { + width: 100%; + border-bottom: 1px solid #ddd; + display: inline-flex; + margin-top: 0; + margin-bottom: 0; + transition: all 0.3s; +} + +.static-sf-metadata-result-content.grid-header .sf-metadata-result-table-row { + border-bottom: none; +} + +.table-btn-add-record-row { + border-bottom: none; +} + +.table-btn-add-record-row .table-btn-add-record, +.table-btn-add-record-row .table-btn-add-record-row-filler { + border-bottom: 1px solid #ddd; +} + +.table-btn-add-record-row .canvas-groups-rows .sf-metadata-result-table-row:hover .sf-metadata-result-table-cell, +.sf-metadata-result-table-row:hover .sf-metadata-result-table-cell, +.sf-metadata-result-table-row:hover .column { + background-color: #f9f9f9; +} + +.sf-metadata-result-table-row:hover .cell-selected { + background-color: #fff; +} + +.sf-metadata-result-table:hover .sf-metadata-result-table-cell.cell-highlight { + background-color: rgb(239, 199, 151) !important; +} + +.sf-metadata-result-table:hover .sf-metadata-result-table-cell.cell-current-highlight { + background-color: rgb(240, 159, 63) !important; +} + +.sf-metadata-result-table-row.row-selected .sf-metadata-result-table-cell, +.sf-metadata-result-table-row.row-selected .column { + background-color: #dbecfa !important; +} + +.sf-metadata-result-table-content .sf-metadata-result-table-row { + background-color: #fff; +} + +.sf-metadata-result-table-cell.index { + width: 90px; + height: 100%; + padding: 0; +} + +.sf-metadata-result-table-cell.column { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 8px; +} + +.sf-metadata-result-table-cell { + position: absolute; + height: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border-right: 1px solid #eee; + padding: 6px 8px; + display: flex; + justify-content: flex-start; +} + +.sf-metadata-result-content .frozen-columns .sf-metadata-result-table-cell { + position: relative; +} + +.sf-metadata-result-table-cell.table-cell-uneditable { + background-color: #f7f7f7; +} + +.sf-metadata-result-column-content { + height: 100%; + width: 100%; + text-align: left; + line-height: 32px; +} + +.sf-metadata-result-column-content.row-index { + text-align: center; +} + +.sf-metadata-result-column-content .sf-metadata-font { + font-size: 14px; + color: #aaa; +} + +.sf-metadata-result-column-content .header-name { + overflow: hidden; +} + +.sf-metadata-result-column-content .header-name-text { + line-height: 1.5; + overflow: hidden; + text-overflow: ellipsis; +} + +.sf-metadata-result-column-content .header-name-text.double { + white-space: normal; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + max-height: 46px; + height: fit-content; +} + +.sf-metadata-result-table-cell .select-cell-checkbox-container, +.sf-metadata-result-table-cell .select-all-checkbox-container { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + flex: 1; +} + +.sf-metadata-result-table-cell .select-cell-checkbox-container:hover, +.sf-metadata-result-table-cell .select-all-checkbox-container:hover, +.sf-metadata-result-table-cell .select-cell-checkbox-container input:hover, +.sf-metadata-result-table-cell .select-all-checkbox-container input:hover { + cursor: pointer; +} + +.sf-metadata-result-table-cell .select-cell-checkbox { + width: 12px; + height: 12px; +} + +.sf-metadata-result-table-content { + flex: 1 1; + position: relative; + overflow-y: scroll; + padding-bottom: 150px; +} + +.sf-metadata-result-table-content .sf-metadata-result-loading { + position: absolute; + left: 50vw; +} + +.sf-metadata-result-table { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; +} + +.sf-metadata-result-footer { + position: relative; + height: 30px; + width: 100%; + overflow: hidden; + line-height: 30px; + background-color: #f9f9f9; + border-top: 1px solid #ddd; + display: flex; + flex-shrink: 0; +} + +.sf-metadata-result-table-cell .cell-file-add { + height: 31px; + line-height: 31px; + text-align: center; + color: #666666; + box-sizing: border-box; + position: relative; + top: -5px; + left: -8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.draging-file-to-cell .cell-file-add { + border: 1px dashed #f09f3f; + background-color: #fdf5eb !important; +} + +.sf-metadata-result-table-cell .file-cell-content { + flex: 1; + display: flex; + align-items: center; +} + +.group-level-2 .sf-metadata-result-footer { + background-color: #fafafa; +} + +.group-level-3 .sf-metadata-result-footer { + background-color: #f1f1f1; +} + +.group-level-4 .sf-metadata-result-footer { + background-color: #eee; +} + +.table-main-interval { + height: 20px; + position: relative; + width: 100%; + background-color: #f9f9f9; +} + +/* cell comment num */ +.sf-metadata-result-table-cell.row-comment-cell { + align-items: center; +} + +.sf-metadata-result-table-cell.row-comment-cell .sf-metadata-ui.cell-formatter-container { + padding-right: 26px; +} + +.row-comment-cell .row-comment-content { + position: absolute; + right: 0; + color: #ccc; + cursor: pointer; +} + +.row-comment-cell .row-comment-content .row-comment-icon { + margin-right: -9px; +} + +.row-comment-cell .row-comment-content .row-comment-count { + color: #fff; + transform: scale(0.5) translateY(5px); + border-radius: 50%; + display: inline-block; + text-align: center; + min-width: 25px; + min-height: 25px; + background: #969696; + margin-left: -3px; + font-size: 16px; +} + +/* cell formatter */ +.sf-metadata-result-table-cell .sf-metadata-ui.cell-formatter-container { + height: 100%; + line-height: 20px; +} + +.sf-metadata-result-table-cell .sf-metadata-ui.cell-formatter-container.sf-metadata-file-formatter { + overflow: visible; +} + +.sf-metadata-result-table-cell .sf-metadata-ui.cell-formatter-container.formula-formatter { + flex: 1; +} + +.sf-metadata-result-table-cell .sf-metadata-ui.cell-formatter-container .image-item { + height: auto; + width: auto; + max-height: 28px; +} + +.sf-metadata-result-table-cell .links-formatter .sf-metadata-ui.cell-formatter-container { + width: auto; +} + +.sf-metadata-result-table-cell .multiple-select-formatter, +.sf-metadata-result-table-cell .single-select-formatter { + line-height: 1; +} + +.row-detail-item .button-formatter.cell-formatter-container, +.row-detail-item .sf-metadata-button-formatter.cell-formatter-container, +.sf-metadata-result-table-cell .sf-metadata-button-formatter.cell-formatter-container { + margin-top: 6px; + text-align: center; + height: 26px; + line-height: 14px; + min-width: 80px; + max-width: 100%; + width: fit-content; + display: inline-flex; + justify-content: center; + align-items: center; + padding-left: 0.5rem; + padding-right: 0.5rem; + border-radius: 3px; + border-width: 2px; + font-weight: 500; + font-size: 14px; + font-family: inherit; + cursor: pointer; + letter-spacing: 0.03em; +} + +.row-detail-item .sf-metadata-button-formatter.cell-formatter-container.disabled, +.sf-metadata-result-table-cell .sf-metadata-button-formatter.cell-formatter-container.disabled { + cursor: default; +} + +.sf-metadata-result-table-cell .sf-metadata-button-formatter.cell-formatter-container { + margin: -3px auto; +} + +.row-detail-item .sf-metadata-button-formatter .loading-icon, +.sf-metadata-result-table-cell .sf-metadata-button-formatter .loading-icon { + width: 16px; + height: 16px; +} + +.row-detail-item .sf-metadata-button-formatter .button-formatter-btn-bg, +.sf-metadata-result-table-cell .sf-metadata-button-formatter .button-formatter-btn-bg { + width: 11px; + height: 11px; + border-radius: 5px; + position: absolute; +} + +.sf-metadata-result-table-cell .sf-metadata-date-formatter, +.sf-metadata-result-table-cell .sf-metadata-number-formatter, +.sf-metadata-result-table-cell .sf-metadata-duration-formatter { + width: 100%; + text-align: right; +} + +.sf-metadata-result-table-cell .sf-metadata-long-text-formatter { + width: 100%; +} + +.sf-metadata-result-table-cell .geolocation-formatter, +.sf-metadata-result-table-cell .sf-metadata-text-formatter, +.sf-metadata-result-table-cell .sf-metadata-url-formatter, +.sf-metadata-result-table-cell .sf-metadata-email-formatter, +.sf-metadata-result-table-cell .sf-metadata-long-text-formatter .long-text-content { + overflow: hidden !important; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sf-metadata-result-table-cell .sf-metadata-url-formatter { + text-decoration: none; +} + +.sf-metadata-result-table-cell .sf-metadata-ui.long-text-formatter { + height: 100%; +} + +.sf-metadata-result-table-cell .sf-metadata-checkbox-formatter { + display: flex; + justify-content: center; + align-items: center; +} + +.sf-metadata-file-formatter .file-item-icon { + margin-right: 4px; +} + +.sf-metadata-result-table-cell .sf-metadata-collaborator-formatter, +.sf-metadata-result-table-cell .sf-metadata-creator-formatter, +.sf-metadata-result-table-cell .sf-metadata-last-modifier-formatter { + display: flex; + align-items: center; +} + +.sf-metadata-result-table-cell.sf-metadata-result-table-digital-sign-cell { + padding: 0 8px; +} + +.sf-metadata-result-table-cell .digital-sign-formatter .image-item { + height: 28px; +} + +.sf-metadata-result-table-cell .collaborator-avatar, +.sf-metadata-result-table-cell .sf-metadata-ui.collaborator-item .collaborator-avatar { + height: 16px; + width: 16px; + margin-left: 0; + transform: translateY(0px); +} + +.sf-metadata-result-table-cell .select-all-checkbox-container .sf-metadata-icon-partially-selected { + cursor: pointer; + font-size: 12px; + color: #2b76f6; +} + +.sf-metadata-result-table-cell .header-action-cell-placeholder { + /* same width as the button of record expand */ + width: 20px; + height: 20px; +} + +.sf-metadata-result-table-cell .mobile-select-all-container { + height: 14px; + width: 14px; + display: inline-flex; + margin-bottom: 0; +} + +.sf-metadata-result-table-cell .mobile-select-all-container .mobile-select-all-checkbox { + display: none; + z-index: 99999; +} + +.sf-metadata-result-table-cell .mobile-select-all-container .select-all-checkbox-show { + height: 14px; + width: 14px; + border: 1px solid #aaa; + border-radius: 2px; +} + +.sf-metadata-result-table-cell .mobile-select-all-container .mobile-select-all-checkbox:checked + .select-all-checkbox-show { + border: unset; + background-color: #3b88fd; + position: relative; +} + +.sf-metadata-result-table-cell .mobile-select-all-container .mobile-select-all-checkbox:checked + .select-all-checkbox-show::before { + content: ''; + position: absolute; + top: 2px; + left: 4px; + width: 5px; + height: 8px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +.sf-metadata-result-table-cell .mobile-select-all-container .sf-metadata-icon-partially-selected { + font-size: 14px; + line-height: 1; +} + +.tooltip-inner { + max-width: 242px; + font-weight: lighter; + text-align: start; + background-color: #303133; +} + +.sf-metadata-link-formatter { + display: flex; + flex-wrap: nowrap; +} + +/* link */ +.row-detail-item .sf-metadata-link-formatter { + align-items: center; + overflow: hidden; + flex-wrap: nowrap; + width: 100%; + height: auto; +} + +.sf-metadata-link-formatter .sf-metadata-link-item { + flex-shrink: 0; + height: 20px; + margin-right: 4px; + padding: 0 6px; + font-size: 13px; + max-width: 100%; + background: #eceff4; + border-radius: 3px; + align-items: center; + vertical-align: middle; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + line-height: 20px; + width: fit-content; +} + +.sf-metadata-link-formatter .sf-metadata-checkbox-item { + width: fit-content; + margin-right: 10px; + line-height: 36px; +} + +.sf-metadata-link-formatter .sf-metadata-long-text-item { + display: inline-flex; + margin-right: 10px; + font-size: 13px; + max-width: 100%; + flex-shrink: 0; +} + +/* records count */ +.sf-metadata-result.success .sf-metadata-result-footer { + height: 32px; + width: 100%; + overflow: hidden; + line-height: 32px; + background-color: #f9f9f9; + border-top: 1px solid #ddd; + padding: 0 8px; +} + +.sf-metadata-result.success .sf-metadata-result-footer .tip { + color: #666666; +} + +.add-item-btn { + display: flex; + align-items: center; + height: 40px; + font-size: 14px; + font-weight: 500; + border-top: 1px solid #dedede; + background: #fff; + padding: 0 1rem; + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; +} + +.add-item-btn:hover { + cursor: pointer; + background: #f5f5f5; +} + +.add-item-btn .sf-metadata-icon-add-table { + margin-right: 10px; + font-size: 12px; + font-weight: 600; + transform: translateY(1px); +} + +.formula-formatter-content-item { + margin-right: 10px; + font-size: 13px; + max-width: 100%; + display: inline-flex; +} + +.formula-formatter-content-item.simple-cell-formatter { + height: 20px; + padding: 0 8px; + align-items: center; + background: #eceff4; + border-radius: 3px; + color: #212529; +} + +.row-detail-item .formula-formatter-content-item.simple-cell-formatter .text-formatter { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.formula-formatter-content-item.simple-cell-text-formatter { + height: fit-content; + min-height: 20px; + padding: 0 8px; + align-items: center; + background: #eceff4; + border-radius: 3px; +} + +.formula-ellipsis .sf-metadata-font { + font-size: 12px; +} + +.formula-formatter-content-item .checkbox { + transform: unset; +} + +.formula-formatter-content-item .collaborator { + margin-right: 0; +} + +.checkbox-editor-rows-container .grid-checkbox-row-checkbox { + margin-top: 0; +} + +/* common */ +.sf-metadata-result-container.horizontal-scroll .table-last--frozen { + box-shadow: 2px 0 5px -2px rgb(136 136 136 / 30%) !important; +} + +.table-last--frozen { + border-right: 1px solid #cacaca !important; +} + +.row-locked .actions-cell::after { + position: absolute; + top: 0; + right: 0; + content: ''; + width: 0; + height: 0; + border-top: 10px solid #f25041; + border-left: 10px solid transparent; +} + +/* table cell editor */ +.table-cell-editor .number-editor, +.table-cell-editor .duration-editor { + text-align: right; +} + +.table-cell-editor .checkbox-editor-rows-container { + align-items: center; +} + +.table-cell-editor .rate-editor .rate-item { + padding-right: 0; +} + +.rdg-editor-container .rate-editor { + width: 100%; + height: calc(100% - 1px); + border: 2px solid #66afe9; + border-right: none; + border-bottom: none; + /* background-color: #fff; */ + box-sizing: border-box; + padding: 0 8px 2px 6px; + align-items: center; +} + +.rdg-editor-container .checkbox-editor-rows-container { + justify-content: center; +} + +.geolocation-editor-container { + background-color: #ffffff; + box-shadow: 0 0 5px #ccc; + border-radius: 4px; + position: relative; + display: inline-block; + min-width: max-content; + width: 400px; +} + +.geolocation-editor-container .geolocation-selector-container { + position: absolute; + min-width: 400px; + top: 100%; + left: 0; + background-color: #ffffff; + box-shadow: 0 0 5px #ccc; + min-height: 165px; +} + +.geolocation-editor-container .geolocation-selector-header { + height: 45px; + display: flex; + border-bottom: 1px solid #ccc; + padding: 5px 20px 0 20px; + align-items: flex-end; +} + +.geolocation-editor-container .geolocation-selector-header-item { + border: 1px solid #ccc; + height: 35px; + margin-right: 10px; + display: flex; + margin-bottom: -1px; + border-radius: 3px 3px 0 0; + padding: 10px; + line-height: 15px; + cursor: pointer; + font-size: 14px; + word-break: keep-all; +} + +.geolocation-editor-container .selected-geolocation-selector-header-item { + border-bottom: 1px solid #fff; +} + +.geolocation-editor-container .geolocation-map-editor { + height: 384px; + width: 500px; + display: flex; + flex-direction: column; +} + +@media screen and (max-width: 991.8px) { + .sf-metadata-result-table-cell .select-cell-checkbox { + width: 14px; + height: 14px; + } +} + +/* btn-add-record */ +.table-btn-add-record-row .table-btn-add-record { + display: flex; + align-items: center; + padding-left: 22px; + cursor: pointer; + border-right: 1px solid #ddd; +} + +.table-btn-add-record-row .table-btn-add-record-icon { + font-size: 24px; +} + +.table-btn-add-record-row-filler { + flex: 1; + border-right: 1px solid #eee; + background-color: #fff; +} + +/* custom table filters */ +.table-filters .filters-list .filter-item .filter-term { + max-width: unset; +} + +.table-filters .filters-list .sf-metadata-select.custom-select { + max-width: 700px; +} + +.table-filters .filters-list .single-select-option, +.table-filters .filters-list .multiple-select-option { + max-width: 250px; +} + +.sf-metadata-wrapper .sf-metadata-main, +.sf-metadata-wrapper .seatable-app-header { + border-left: 1px solid #ddd; + border-right: 1px solid #ddd; +} + +.sf-metadata-wrapper .sf-metadata-main { + flex: 1; + overflow: hidden; +} + +.sf-metadata-wrapper .sf-metadata-main .sf-metadata-container { + height: 100%; + width: 100%; + overflow: hidden; +} + +.table-header-top { + z-index: 7; + transform: translateZ(1000px); + height: 10px; + background-color: #f5f5f5; +} + +.sf-metadata-result-table .group-header-left .formatter-show { + display: inline-flex; + flex: 1; + width: calc(100% - 15px); + position: relative; +} + +.sf-metadata-result-table .group-header-left .sf-metadata-link-formatter { + display: inline-flex; + flex-wrap: nowrap; +} + +.sf-metadata-result-table .group-header-left .sf-metadata-link-formatter .sf-metadata-link-item { + max-width: 230px; +} + +.record-header-cell .sf-metadata-dropdown-menu { + padding: 0 5px; + width: 20px; +} + +.table-more-operations-dropdown-toggle { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 .5rem; + transition: all .1s ease-in; + border-radius: 3px; +} + +.table-more-operations-dropdown-toggle:hover { + background-color: #f1f1f1; + cursor: pointer; +} + +.table-more-operations-dropdown-toggle .sf-metadata-font { + font-size: 14px; + line-height: 22px; + color: #555; +} + +.sf-metadata-wrapper .table-main-container { + display: flex; + flex: 1 1; + flex-direction: column; + height: 100%; + width: 100%; +} diff --git a/frontend/src/metadata/metadata-view/components/table/index.js b/frontend/src/metadata/metadata-view/components/table/index.js new file mode 100644 index 0000000000..4f90b3d94a --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/index.js @@ -0,0 +1,15 @@ +import React from 'react'; +import { CenteredLoading } from '@seafile/sf-metadata-ui-component'; +import { useMetadata } from '../../hooks'; +import Container from './container'; + +import './index.css'; + +const Table = () => { + const { isLoading } = useMetadata(); + + if (isLoading) return (); + return (); +}; + +export default Table; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/index.css b/frontend/src/metadata/metadata-view/components/table/table-main/index.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/index.js b/frontend/src/metadata/metadata-view/components/table/table-main/index.js new file mode 100644 index 0000000000..4830cbf688 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/index.js @@ -0,0 +1,65 @@ +import React, { useCallback, useMemo } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import Records from './records'; +import { GROUP_VIEW_OFFSET } from '../../../constants'; +import GridUtils from '../../../utils/grid-utils'; +import { useRecordDetails } from '../../../hooks'; + +const TableMain = ({ metadata, modifyRecord, modifyRecords, loadMore, loadAll, searchResult, ...params }) => { + const gridUtils = useMemo(() => { + return new GridUtils(metadata, { modifyRecord, modifyRecords }); + }, [metadata, modifyRecord, modifyRecords]); + const { openRecordDetails } = useRecordDetails(); + + const groupbysCount = useMemo(() => { + const groupbys = metadata?.groupbys || []; + return groupbys.length; + }, [metadata]); + + const groupOffset = useMemo(() => { + return groupbysCount * GROUP_VIEW_OFFSET; + }, [groupbysCount]); + + const updateRecord = useCallback(({ rowId, updates, originalUpdates, oldRowData, originalOldRowData }) => { + modifyRecord && modifyRecord(rowId, updates, oldRowData, originalUpdates, originalOldRowData); + }, [modifyRecord]); + + const updateRecords = useCallback(({ recordIds, idRecordUpdates, idOriginalRecordUpdates, idOldRecordData, idOriginalOldRecordData, isCopyPaste = false }) => { + modifyRecords && modifyRecords(recordIds, idRecordUpdates, idOriginalRecordUpdates, idOldRecordData, idOriginalOldRecordData, isCopyPaste); + }, [modifyRecords]); + + return ( +
0 })}> + +
+ ); + +}; + +TableMain.propTypes = { + metadata: PropTypes.object.isRequired, + modifyRecord: PropTypes.func, + modifyRecords: PropTypes.func, + loadMore: PropTypes.func, + loadAll: PropTypes.func, + searchResult: PropTypes.object, +}; + +export default TableMain; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/actions-cell.jsx b/frontend/src/metadata/metadata-view/components/table/table-main/records/actions-cell.jsx new file mode 100644 index 0000000000..fd6508e75e --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/actions-cell.jsx @@ -0,0 +1,100 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { Tooltip } from 'reactstrap'; +import { SEQUENCE_COLUMN_WIDTH } from '../../../../constants'; +import { isMobile, gettext } from '../../../../utils'; +import { Icon } from '@seafile/sf-metadata-ui-component'; + +class ActionsCell extends Component { + + constructor(props) { + super(props); + this.state = { + isLockedRowTooltipShow: false, + }; + } + + onCellMouseEnter = () => { + const { isLocked } = this.props; + if (!isLocked || isMobile) return; + this.timer = setTimeout(() => { + this.setState({ isLockedRowTooltipShow: true }); + }, 500); + }; + + onCellMouseLeave = () => { + const { isLocked } = this.props; + if (!isLocked || isMobile) return; + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + this.setState({ isLockedRowTooltipShow: false }); + }; + + getLockedRowTooltip = () => { + const { recordId } = this.props; + return ( + + {gettext('The row is locked and cannot be modified')} + + ); + }; + + render() { + const { isSelected, isLastFrozenCell, index, height, recordId } = this.props; + const cellStyle = { + height, + width: SEQUENCE_COLUMN_WIDTH, + minWidth: SEQUENCE_COLUMN_WIDTH, + }; + return ( +
+ {!isSelected &&
{index + 1}
} +
+
+ + +
+
+ + + + {/* {this.getLockedRowTooltip()} */} +
+ ); + } +} + +ActionsCell.propTypes = { + isLocked: PropTypes.bool, + isSelected: PropTypes.bool, + isLastFrozenCell: PropTypes.bool, + recordId: PropTypes.string, + index: PropTypes.number, + height: PropTypes.number, + onSelectRecord: PropTypes.func, + onRowExpand: PropTypes.func, +}; + +export default ActionsCell; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/btn-add-record.jsx b/frontend/src/metadata/metadata-view/components/table/table-main/records/btn-add-record.jsx new file mode 100644 index 0000000000..8ef42af51b --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/btn-add-record.jsx @@ -0,0 +1,123 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { UncontrolledTooltip } from 'reactstrap'; +import { SEQUENCE_COLUMN } from '../../../../_basic'; +import { gettext } from '../../../../utils'; + +class BtnAddRecord extends React.Component { + + componentDidMount() { + this.initFrozenColumnsStyle(); + } + + shouldComponentUpdate(nextProps) { + return ( + nextProps.isGroupView !== this.props.isGroupView || + nextProps.groupPathString !== this.props.groupPathString || + nextProps.height !== this.props.height || + nextProps.width !== this.props.width || + nextProps.top !== this.props.top || + nextProps.left !== this.props.left || + nextProps.iconWidth !== this.props.iconWidth || + nextProps.lastFrozenColumnKey !== this.props.lastFrozenColumnKey || + nextProps.scrollLeft !== this.props.scrollLeft + ); + } + + initFrozenColumnsStyle = () => { + const { isGroupView, lastFrozenColumnKey, scrollLeft } = this.props; + let style = { + position: 'absolute', + marginLeft: scrollLeft > 0 ? scrollLeft + 'px' : '0px', + }; + if (isGroupView) { + if (!lastFrozenColumnKey) { + style.marginLeft = '0px'; + } + } + this.frozenColumns.style.position = style.position; + this.frozenColumns.style.marginLeft = style.marginLeft; + }; + + renderBtn = () => { + const { height, iconWidth, lastFrozenColumnKey, isGroupView, groupPathString } = this.props; + let iconStyle = { + height, + width: iconWidth, + zIndex: SEQUENCE_COLUMN, + }; + if (isGroupView) { + if (!lastFrozenColumnKey) { + iconStyle.marginLeft = '0px'; + } + } + const btnClassName = classnames('table-btn-add-record', { 'table-last--frozen': isGroupView || !lastFrozenColumnKey }); + let btnId = 'btn_table_add_record__frozen'; + if (isGroupView) { + btnId += groupPathString; + } + return ( + <> +
this.frozenColumns = ref} + onClick={this.props.onAddRecord} + id={btnId} + > + + + + {gettext('Add record')} + +
+
+ + +
+ + ); + }; + + render() { + const { isGroupView, height, top, left, width } = this.props; + let btnStyle = { + height, + width, + }; + if (isGroupView) { + btnStyle.top = top; + btnStyle.left = left; + } + return ( +
+ {this.renderBtn()} +
+
+ ); + } +} + +BtnAddRecord.propTypes = { + isGroupView: PropTypes.bool, + groupPathString: PropTypes.string, + height: PropTypes.number, + width: PropTypes.number, + iconWidth: PropTypes.number, + top: PropTypes.number, + left: PropTypes.number, + scrollLeft: PropTypes.number, + lastFrozenColumnKey: PropTypes.string, + onAddRecord: PropTypes.func, +}; + +export default BtnAddRecord; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/column-dropdown-item.jsx b/frontend/src/metadata/metadata-view/components/table/table-main/records/column-dropdown-item.jsx new file mode 100644 index 0000000000..11ba4c6e36 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/column-dropdown-item.jsx @@ -0,0 +1,91 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { UncontrolledTooltip, DropdownItem } from 'reactstrap'; +import { Icon } from 'seafile/sf-metadata-ui-component'; + +export default class ColumnDropdownItem extends Component { + + static propTypes = { + onClick: PropTypes.func.isRequired, + onMouseEnter: PropTypes.func.isRequired, + disabled: PropTypes.bool.isRequired, + id: PropTypes.string.isRequired, + iconSvg: PropTypes.string, + iconClassName: PropTypes.string, + menuText: PropTypes.string.isRequired, + disabledText: PropTypes.string.isRequired, + className: PropTypes.string, + }; + + static defaultProps = { + onClick: () => {}, + onMouseEnter: () => {}, + disabled: false, + className: '', + }; + + state = { + canToolTip: false, + }; + + componentDidMount() { + if (this.props.disabled) { + this.setState({ canToolTip: true }); + } + } + + onClick = (e) => { + e.preventDefault(); + }; + + renderIcon = () => { + const { iconSvg, iconClassName } = this.props; + if (iconClassName) { + return ; + } + if (iconSvg) { + return ; + } + return null; + }; + + render() { + const { disabled, id, menuText, disabledText, className } = this.props; + + if (!disabled) { + return ( + + {this.renderIcon()} + {menuText} + + ); + } + + return ( + + {this.renderIcon()} + {menuText} + {this.state.canToolTip && + + {disabledText} + + } + + ); + } +} diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-container-left.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-container-left.js new file mode 100644 index 0000000000..88fff7dbbe --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-container-left.js @@ -0,0 +1,75 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import GroupHeaderLeft from './group-header-left'; +import { Z_INDEX } from '../../../../../_basic'; + +class GroupContainerLeft extends Component { + + fixedFrozenDOMs = (scrollLeft, scrollTop) => { + if (this.leftContainer) { + this.leftContainer.style.position = 'fixed'; + this.leftContainer.style.marginLeft = '0px'; + this.leftContainer.style.marginTop = (-scrollTop) + 'px'; + } + }; + + setContainerRef = (ref) => { + this.leftContainer = ref; + }; + + cancelFixFrozenDOMs = (scrollLeft) => { + if (this.leftContainer) { + this.leftContainer.style.position = 'absolute'; + this.leftContainer.style.marginLeft = scrollLeft + 'px'; + this.leftContainer.style.marginTop = '0px'; + } + }; + + render() { + const { + isExpanded, maxLevel, group, formulaRow, leftPaneWidth, height, + firstColumnFrozen, lastColumnFrozen, firstColumnKey, + } = this.props; + let containerStyle = { + zIndex: firstColumnFrozen ? Z_INDEX.GROUP_FROZEN_HEADER : 0, + width: leftPaneWidth, + height, + }; + + return ( +
+ this.leftHeader = ref} + isExpanded={isExpanded} + firstColumnFrozen={firstColumnFrozen} + lastColumnFrozen={lastColumnFrozen} + firstColumnKey={firstColumnKey} + width={leftPaneWidth} + maxLevel={maxLevel} + group={group} + formulaRow={formulaRow} + onExpandGroupToggle={this.props.onExpandGroupToggle} + /> +
+ ); + } +} + +GroupContainerLeft.propTypes = { + isExpanded: PropTypes.bool, + firstColumnFrozen: PropTypes.bool, + lastColumnFrozen: PropTypes.bool, + firstColumnKey: PropTypes.string, + maxLevel: PropTypes.number, + group: PropTypes.object, + formulaRow: PropTypes.object, + leftPaneWidth: PropTypes.number, + height: PropTypes.number, + onExpandGroupToggle: PropTypes.func, +}; + +export default GroupContainerLeft; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-container-right.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-container-right.js new file mode 100644 index 0000000000..5f6a8fe052 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-container-right.js @@ -0,0 +1,56 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import GroupHeaderRight from './group-header-right'; + +class GroupContainerRight extends Component { + + fixedFrozenDOMs = (scrollLeft, scrollTop) => { + this.rightHeader && this.rightHeader.fixedFrozenDOMs(scrollLeft, scrollTop); + }; + + cancelFixFrozenDOMs = (scrollLeft) => { + this.rightHeader && this.rightHeader.cancelFixFrozenDOMs(scrollLeft); + }; + + render() { + const { + group, isExpanded, columns, summaryConfigs, rightPaneWidth, leftPaneWidth, height, + groupOffsetLeft, lastFrozenColumnKey, + } = this.props; + const groupContainerRightStyle = { + left: leftPaneWidth, + width: rightPaneWidth, + height, + }; + + return ( +
+ this.rightHeader = ref} + groupOffsetLeft={groupOffsetLeft} + lastFrozenColumnKey={lastFrozenColumnKey} + group={group} + isExpanded={isExpanded} + columns={columns} + summaryConfigs={summaryConfigs} + getTableContentLeft={this.props.getTableContentLeft} + /> +
+ ); + } +} + +GroupContainerRight.propTypes = { + group: PropTypes.object, + isExpanded: PropTypes.bool, + columns: PropTypes.array, + summaryConfigs: PropTypes.object, + rightPaneWidth: PropTypes.number, + leftPaneWidth: PropTypes.number, + height: PropTypes.number, + groupOffsetLeft: PropTypes.number, + lastFrozenColumnKey: PropTypes.string, + getTableContentLeft: PropTypes.func, +}; + +export default GroupContainerRight; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-container/index.css b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-container/index.css new file mode 100644 index 0000000000..828f03d97b --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-container/index.css @@ -0,0 +1,464 @@ +.canvas-groups-rows { + position: relative; + overflow: hidden; +} + +.canvas-groups-rows .sf-metadata-result-table-cell { + background-color: #fff; +} + +.canvas-groups-rows .group-item { + position: absolute; + overflow: hidden; +} + +.canvas-groups-rows .group-container-left, +.canvas-groups-rows .group-container-right { + position: absolute; + height: 100%; +} + +/* border-radius of group container */ +.canvas-groups-rows .group-item .group-container-left { + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; +} + +.canvas-groups-rows .group-item .group-container-right { + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; +} + +/* background of group container */ +.canvas-groups-rows .group-level-1 .group-container-left, +.canvas-groups-rows .group-level-1 .group-container-right { + background-color: #f7f7f7; +} + +.canvas-groups-rows .group-level-2 .group-container-left, +.canvas-groups-rows .group-level-2 .group-container-right { + background-color: #ededed; +} + +.canvas-groups-rows .group-level-3 .group-container-left, +.canvas-groups-rows .group-level-3 .group-container-right { + background-color: #e3e3e3; +} + +/* border-color of group container */ +.canvas-groups-rows .group-level-2 .group-container-left { + border-left: 1px solid #c1c1c1; +} + +.canvas-groups-rows .group-level-3 .group-container-left { + border-left: 1px solid #c1c1c1; +} + +.canvas-groups-rows .group-level-2 .group-container-right { + border-right: 1px solid #c1c1c1; +} + +.canvas-groups-rows .group-level-3 .group-container-right { + border-right: 1px solid #c1c1c1; +} + +.canvas-groups-rows .expanded-group.group-level-2 .group-container-left, +.canvas-groups-rows .expanded-group.group-level-2 .group-container-right { + border-bottom: 1px solid #c1c1c1; +} + +.canvas-groups-rows .expanded-group.group-level-3 .group-container-left, +.canvas-groups-rows .expanded-group.group-level-3 .group-container-right { + border-bottom: 1px solid #c1c1c1; +} + +/* border-color of group header */ +.canvas-groups-rows .group-level-1 .group-header-left { + border-left: 1px solid #cacaca; +} + +.canvas-groups-rows .group-level-1 .group-header-right { + border-right: 1px solid #cacaca; +} + +.canvas-groups-rows .group-level-1 .group-header-left, +.canvas-groups-rows .group-level-1 .group-header-right { + border-top: 1px solid #cacaca; + border-bottom: 1px solid #cacaca; +} + +.canvas-groups-rows .group-level-2 .group-header-left, +.canvas-groups-rows .group-level-2 .group-header-right { + border-top: 1px solid #c1c1c1; + border-bottom: 1px solid #c1c1c1; +} + +.canvas-groups-rows .group-level-3 .group-header-left, +.canvas-groups-rows .group-level-3 .group-header-right { + border-top: 1px solid #c1c1c1; + border-bottom: 1px solid #c1c1c1; +} + +.canvas-groups-rows .expanded-group .group-header-left, +.canvas-groups-rows .expanded-group .group-header-right { + border-bottom: none; +} + +/* group backdrop */ +.canvas-groups-rows .group-item .group-backdrop { + position: absolute; + left: 0; +} + +.group-level-2 .canvas-groups-rows .group-item .group-backdrop { + background-color: #ededed; +} + +.group-level-3 .canvas-groups-rows .group-item .group-backdrop { + background-color: #e3e3e3; +} + +.group-level-4 .canvas-groups-rows .group-item .group-backdrop { + background-color: #dedede; +} + +/* group-header-left */ +.canvas-groups-rows .group-header-left { + height: 100%; + width: 100%; + position: absolute; + display: flex; + align-items: center; + border-top-left-radius: 5px; + background-color: inherit; + overflow: hidden; +} + +.canvas-groups-rows .folded-group .group-header-left { + border-bottom-left-radius: 5px; +} + +.canvas-groups-rows.single-column .group-level-1 .group-container-left, +.canvas-groups-rows.single-column .group-level-1 .group-header-cell { + border-top-right-radius: 5px; +} + +.canvas-groups-rows.single-column .group-level-1 .group-header-cell { + border-right: 1px solid #cacaca !important; +} + +.canvas-groups-rows.single-column .folded-group .group-container-left, +.canvas-groups-rows.single-column .folded-group .group-header-cell, +.canvas-groups-rows.single-column.frozen .table-btn-add-record { + border-bottom-right-radius: 5px; +} + +/* group header cell */ +.canvas-groups-rows .group-level-1 .group-header-cell { + border-right: 1px solid #ededed; +} + +.canvas-groups-rows .group-level-2 .group-header-cell { + border-right: 1px solid #e5e5e5; +} + +.canvas-groups-rows .group-level-3 .group-header-cell { + border-right: 1px solid #dadada; +} + +.canvas-groups-rows .group-container-right .group-header-cell:last-child { + border-right: none; +} + +.canvas-groups-rows.all-columns-frozen .group-level-2 .table-last--frozen, +.canvas-groups-rows.all-columns-frozen .group-level-3 .table-last--frozen { + border-right: none !important; +} + +/* group expand */ +.canvas-groups-rows .group-expand { + padding: 0 0.5rem; +} + +.canvas-groups-rows .group-expand span { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; +} + +.canvas-groups-rows .group-expand span:hover { + cursor: pointer; +} + +.canvas-groups-rows .group-expand-icon { + font-size: 12px; + color: #666666; +} + +/* group title */ +.canvas-groups-rows .group-title { + display: flex; + flex-direction: column; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.canvas-groups-rows .group-title .group-column-name { + font-size: 12px; + color: #666666; + font-weight: 500; +} + +.canvas-groups-rows .group-title .group-cell-value { + display: flex; + font-weight: 500; + width: min-content; +} + +.canvas-groups-rows .group-title .links-formatter .formatter-show { + min-height: unset; +} + +.group-title .links-formatter .link { + max-width: unset; +} + +.group-title .links-formatter .link-name { + padding-right: 0.25rem; +} + +.canvas-groups-rows .group-title .multiple-select-option, +.canvas-groups-rows .group-title .single-select-option { + margin: 0 10px 0 0; +} + +.canvas-groups-rows .group-title .multiple-select-option:last-child, +.canvas-groups-rows .group-title .single-select-option:last-child { + margin-right: 0; +} + +.canvas-groups-rows .collaborator-avatar, +.canvas-groups-rows .sf-metadata-ui.collaborator-item .collaborator-avatar { + height: 16px; + width: 16px; + margin-left: 0; + transform: translateY(0px); +} + +.canvas-groups-rows .sf-metadata-ui.cell-formatter-container { + overflow: unset; +} + +/* group rows count */ +.canvas-groups-rows .group-rows-count { + position: absolute; + top: 0; + right: 0; + bottom: 0; +} + +.group-rows-count-content { + display: inline-flex; + align-items: center; + margin: 0 15px 0 20px; + height: 100%; +} + +.canvas-groups-rows .group-item.group-level-1 .group-rows-count { + background: linear-gradient(to right, hsla(0, 0%, 97%, 0), hsl(0, 0%, 97%) 18%); +} + +.canvas-groups-rows .group-item.group-level-2 .group-rows-count { + background: linear-gradient(to right, hsla(0, 0%, 93%, 0), hsl(0, 0%, 93%) 18%); +} + +.canvas-groups-rows .group-item.group-level-3 .group-rows-count { + background: linear-gradient(to right, hsla(0, 0%, 89%, 0), hsl(0, 0%, 89%) 18%); +} + +.canvas-groups-rows .group-rows-count .count-title { + margin-right: 4px; + color: #666666; +} + +/* group-header-right */ +.canvas-groups-rows .group-header-right { + width: 100%; + position: absolute; + display: inline-flex; + overflow: hidden; + border-top-right-radius: 5px; + background-color: inherit; + overflow: hidden; +} + +.canvas-groups-rows .folded-group .group-header-right { + border-bottom-right-radius: 5px; +} + +.canvas-groups-rows:not(.single-column) .group-level-2 .group-header-right { + border-left: 1px solid #e5e5e5; +} + +.canvas-groups-rows:not(.single-column) .group-level-3 .group-header-right { + border-left: 1px solid #dadada; +} + +/* group summary */ +.canvas-groups-rows .summary-item { + display: flex; + justify-content: flex-end; + align-items: center; + height: 100%; + background-color: inherit; +} + +/* group-row-cell */ +.canvas-groups-rows .sf-metadata-result-table-row { + position: absolute; + border-top: none; + border-bottom: none; +} + +.sf-metadata-result-table-content .canvas-groups-rows .sf-metadata-result-table-row { + background-color: transparent; + overflow: hidden; +} + +.canvas-groups-rows .sf-metadata-result-table-row .sf-metadata-result-table-cell { + border-top: 1px solid #ddd; +} + +/* border color of last cell within group view */ +.canvas-groups-rows.disabled-add-record .sf-metadata-result-table-row.sf-metadata-last-table-row .sf-metadata-result-table-cell { + border-bottom: 1px solid #cacaca; +} + +.canvas-groups-rows .table-btn-add-record-row .table-btn-add-record, +.canvas-groups-rows .table-btn-add-record-row .table-btn-add-record-row-filler { + border-top: 1px solid #ddd; + border-bottom: 1px solid #cacaca; +} + +.canvas-groups-rows .table-btn-add-record-row { + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; + background-color: #fff; +} + +.canvas-groups-rows .table-btn-add-record-row .table-btn-add-record { + border-left: 1px solid #cacaca; + border-bottom-left-radius: 5px; + background-color: #fff; +} + +.canvas-groups-rows.frozen .frozen-btn-add-record-wrapper, +.canvas-groups-rows.frozen .table-btn-add-record.frozen-columns, +.canvas-groups-rows.frozen .table-result-table-cell.actions-cell { + z-index: 2 !important; +} + +.canvas-groups-rows .table-btn-add-record-row-filler { + border-right: 1px solid #cacaca; + border-bottom-right-radius: 5px; +} + +.frozen-columns.table-btn-add-record { + border-radius: 0; +} + +.canvas-groups-rows .actions-cell { + border-left: 1px solid #cacaca; +} + +.canvas-groups-rows .sf-metadata-result-table-row .last-cell { + border-right: 1px solid #cacaca; +} + +.canvas-groups-rows.disabled-add-record .sf-metadata-last-table-row, +.canvas-groups-rows.disabled-add-record .sf-metadata-last-table-row .actions-cell { + border-bottom-left-radius: 5px; +} + +.canvas-groups-rows.disabled-add-record .sf-metadata-last-table-row, +.canvas-groups-rows.disabled-add-record .sf-metadata-last-table-row .last-cell { + border-bottom-right-radius: 5px; +} + +/* animation */ +.canvas-groups-rows.animation { + transition-property: height; + -webkit-transition-property: height; + -moz-transition-property: height; + transition-duration: 0.3s; + -webkit-transition-duration: 0.3s; + -moz-transition-duration: 0.3s; + transition-timing-function: ease-out; + -webkit-transition-timing-function: ease-out; + -moz-transition-timing-function: ease-out; + transition-delay: 0s; + -webkit-transition-delay: 0s; + -moz-transition-delay: 0s; +} + +.canvas-groups-rows.animation .group-item, +.canvas-groups-rows.animation .sf-metadata-result-table-row { + transition-property: top; + -webkit-transition-property: top; + -moz-transition-property: top; + transition-duration: 0.3s; + -webkit-transition-duration: 0.3s; + -moz-transition-duration: 0.3s; + transition-timing-function: ease-out; + -webkit-transition-timing-function: ease-out; + -moz-transition-timing-function: ease-out; + transition-delay: 0s; + -webkit-transition-delay: 0s; + -moz-transition-delay: 0s; +} + +.canvas-groups-rows.animation .group-item, +.canvas-groups-rows.animation .sf-metadata-result-table-row, +.canvas-groups-rows.animation .sf-metadata-result-table-row .table-btn-add-record .canvas-groups-rows.animation .sf-metadata-result-table-row .table-btn-add-record-row-filler { + transition-property: height, top; + -webkit-transition-property: height, top; + -moz-transition-property: height, top; +} + +.canvas-groups-rows .group-item.folding { + transition-property: none; + -webkit-transition-property: none; + -moz-transition-property: none; +} + +.canvas-groups-rows.single-column .group-item { + border-radius: 5px; +} + +.canvas-groups-rows.animation .group-item .group-backdrop, +.canvas-groups-rows.animation .group-item .group-container-left, +.canvas-groups-rows.animation .group-item .group-container-right { + transition-property: height; + -webkit-transition-property: height; + -moz-transition-property: height; + transition-duration: 0.3s; + -webkit-transition-duration: 0.3s; + -moz-transition-duration: 0.3s; + transition-timing-function: ease-out; + -webkit-transition-timing-function: ease-out; + -moz-transition-timing-function: ease-out; + transition-delay: 0s; + -webkit-transition-delay: 0s; + -moz-transition-delay: 0s; +} + +.canvas-groups-rows .group-item.folding .group-container-left, +.canvas-groups-rows .group-item.folding .group-container-right { + transition-property: none; + -webkit-transition-property: none; + -moz-transition-property: none; +} diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-container/index.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-container/index.js new file mode 100644 index 0000000000..fb2d1f4cbe --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-container/index.js @@ -0,0 +1,166 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import joinClasses from 'classnames'; +import GroupContainerLeft from '../group-container-left'; +import GroupContainerRight from '../group-container-right'; +import { isMobile } from '../../../../../../utils'; +import { isFrozen } from '../../../../../../utils/column-utils'; +import { GROUP_VIEW_OFFSET, SEQUENCE_COLUMN_WIDTH } from '../../../../../../constants'; +import { Z_INDEX } from '../../../../../../_basic'; + +import './index.css'; + +class GroupContainer extends Component { + + shouldComponentUpdate(nextProps) { + return ( + nextProps.groupPathString !== this.props.groupPathString || + nextProps.group !== this.props.group || + nextProps.width !== this.props.width || + nextProps.height !== this.props.height || + nextProps.top !== this.props.top || + nextProps.columns !== this.props.columns || + nextProps.rowHeight !== this.props.rowHeight || + nextProps.isExpanded !== this.props.isExpanded || + nextProps.scrollLeft !== this.props.scrollLeft || + nextProps.lastFrozenColumnKey !== this.props.lastFrozenColumnKey || + nextProps.summaryConfigs !== this.props.summaryConfigs + ); + } + + componentDidMount() { + if (this.props.lastFrozenColumnKey && !isMobile) { + this.checkScroll(); + } + } + + checkScroll() { + const { scrollLeft } = this.props; + this.cancelFixFrozenDOMs(scrollLeft); + } + + fixedFrozenDOMs = (scrollLeft, scrollTop) => { + if (this.backDrop) { + const tableContentLeft = this.props.getTableContentLeft(); + this.backDrop.style.position = 'fixed'; + this.backDrop.style.marginLeft = tableContentLeft + 'px'; + this.backDrop.style.marginTop = (-scrollTop) + 'px'; + } + + this.leftContainer && this.leftContainer.fixedFrozenDOMs(scrollLeft, scrollTop); + this.rightContainer && this.rightContainer.fixedFrozenDOMs(scrollLeft, scrollTop); + }; + + cancelFixFrozenDOMs = (scrollLeft) => { + if (this.backDrop) { + this.backDrop.style.position = 'absolute'; + this.backDrop.style.marginLeft = scrollLeft - GROUP_VIEW_OFFSET + 'px'; + this.backDrop.style.marginTop = '0px'; + } + + this.leftContainer && this.leftContainer.cancelFixFrozenDOMs(scrollLeft); + this.rightContainer && this.rightContainer.cancelFixFrozenDOMs(scrollLeft); + }; + + setContainer = (node) => { + this.group = node; + }; + + setBackDrop = (node) => { + this.backDrop = node; + }; + + onExpandGroupToggle = () => { + const { groupPathString } = this.props; + this.props.onExpandGroupToggle(groupPathString); + }; + + render() { + const { + group, columns, width, isExpanded, folding, summaryConfigs, height, backdropHeight, top, + groupOffsetLeft, lastFrozenColumnKey, maxLevel, + } = this.props; + const { left, level } = group; + const firstLevelGroup = level === 1; + const groupClassName = joinClasses( + 'group-item', + `group-level-${level}`, + isExpanded ? 'expanded-group' : 'folded-group', + folding ? 'folding' : '', + ); + + const firstColumn = columns[0] || {}; + const firstColumnFrozen = isFrozen(firstColumn); + const firstColumnWidth = firstColumn.width || 0; + const leftPaneWidth = SEQUENCE_COLUMN_WIDTH + firstColumnWidth + (firstLevelGroup ? 0 : ((level - 1) * GROUP_VIEW_OFFSET - 1)); + const rightPaneWidth = width - leftPaneWidth; + const groupItemStyle = { + height, + width, + top, + left + }; + let backDropStyle = { + height: backdropHeight, + width: leftPaneWidth + GROUP_VIEW_OFFSET, + zIndex: Z_INDEX.GROUP_BACKDROP + }; + + return ( +
+ {(level === maxLevel && firstColumnFrozen && !isMobile) && +
+ } + this.leftContainer = ref} + group={group} + firstColumnFrozen={firstColumnFrozen} + lastColumnFrozen={firstColumn.key === lastFrozenColumnKey} + leftPaneWidth={leftPaneWidth} + height={height} + isExpanded={isExpanded} + firstColumnKey={firstColumn.key} + maxLevel={maxLevel} + onExpandGroupToggle={this.onExpandGroupToggle} + /> + this.rightContainer = ref} + group={group} + isExpanded={isExpanded} + leftPaneWidth={leftPaneWidth} + rightPaneWidth={rightPaneWidth} + height={height} + groupOffsetLeft={groupOffsetLeft} + lastFrozenColumnKey={lastFrozenColumnKey} + columns={columns} + summaryConfigs={summaryConfigs} + getTableContentLeft={this.props.getTableContentLeft} + /> +
+ ); + } +} + +GroupContainer.propTypes = { + group: PropTypes.object, + groupPathString: PropTypes.string, + folding: PropTypes.bool, + columns: PropTypes.array, + rowHeight: PropTypes.number, + width: PropTypes.number, + height: PropTypes.number, + backdropHeight: PropTypes.number, + top: PropTypes.number, + groupOffsetLeft: PropTypes.number, + formulaRow: PropTypes.object, + lastFrozenColumnKey: PropTypes.string, + isExpanded: PropTypes.bool, + scrollLeft: PropTypes.number, + maxLevel: PropTypes.number, + summaryConfigs: PropTypes.object, + getTableContentLeft: PropTypes.func, + onExpandGroupToggle: PropTypes.func, + updateSummaryConfig: PropTypes.func, +}; + +export default GroupContainer; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-header-cell.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-header-cell.js new file mode 100644 index 0000000000..98b2237480 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-header-cell.js @@ -0,0 +1,70 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { isFrozen } from '../../../../../utils/column-utils'; +import { GROUP_HEADER_HEIGHT, SEQUENCE_COLUMN_WIDTH } from '../../../../../constants'; +import { Z_INDEX } from '../../../../../_basic'; + +class GroupHeaderCell extends React.PureComponent { + + fixedFrozenDOMs = (scrollLeft, scrollTop) => { + if (this.headerCell) { + const { firstColumnWidth, groupOffsetLeft } = this.props; + const tableContentLeft = this.props.getTableContentLeft(); + this.headerCell.style.position = 'fixed'; + this.headerCell.style.marginLeft = (SEQUENCE_COLUMN_WIDTH + firstColumnWidth + groupOffsetLeft + tableContentLeft) + 'px'; + this.headerCell.style.marginTop = (-scrollTop) + 'px'; + } + }; + + cancelFixFrozenDOMs = (scrollLeft) => { + if (this.headerCell) { + this.headerCell.style.position = 'absolute'; + this.headerCell.style.marginLeft = scrollLeft + 'px'; + this.headerCell.style.marginTop = 0; + } + }; + + getStyle = () => { + let { offsetLeft, column, isExpanded } = this.props; + const style = { + position: 'absolute', + width: column.width, + height: GROUP_HEADER_HEIGHT - (isExpanded ? 1 : 2), // header height - border-top(1px) - !isExpanded && border-bottom(1px) + left: offsetLeft + }; + if (isFrozen(column)) { + style.zIndex = Z_INDEX.GROUP_FROZEN_HEADER; + } + return style; + }; + + render() { + const { column, isLastFrozenColumn } = this.props; + return ( +
this.headerCell = ref} + className={classnames('summary-item group-header-cell', { + 'table-last--frozen': isLastFrozenColumn + })} + style={this.getStyle()} + data-column_key={column.key} + > +
+ ); + } +} + +GroupHeaderCell.propTypes = { + column: PropTypes.object.isRequired, + isExpanded: PropTypes.bool, + isLastFrozenColumn: PropTypes.bool, + firstColumnWidth: PropTypes.number, + offsetLeft: PropTypes.number.isRequired, + groupOffsetLeft: PropTypes.number, + summary: PropTypes.object, + summaryMethod: PropTypes.string, + getTableContentLeft: PropTypes.func, +}; + +export default GroupHeaderCell; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-header-left.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-header-left.js new file mode 100644 index 0000000000..4141a4b18d --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-header-left.js @@ -0,0 +1,66 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import GroupTitle from './group-title'; +import { Z_INDEX } from '../../../../../_basic'; +import { GROUP_HEADER_HEIGHT } from '../../../../../constants'; +import { gettext } from '../../../../../utils'; + +class GroupHeaderLeft extends Component { + + render() { + const { + isExpanded, firstColumnFrozen, lastColumnFrozen, firstColumnKey, maxLevel, + group, width, + } = this.props; + const { column, count, level, cell_value, original_cell_value } = group; + const expandIconClassName = classnames( + 'group-expand-icon', + 'sf-metadata-font', + isExpanded ? 'sf-metadata-icon-drop-down' : 'sf-metadata-icon-right-slide', + ); + const groupHeaderLeftStyle = { + zIndex: firstColumnFrozen && Z_INDEX.GROUP_FROZEN_HEADER, + height: GROUP_HEADER_HEIGHT, + width, + }; + + return ( +
this.groupHeaderLeft = ref} + className={classnames('group-header-left group-header-cell', { 'table-last--frozen': lastColumnFrozen })} + style={groupHeaderLeftStyle} + data-column_key={firstColumnKey} + > +
+ +
+ +
+
+ {level === maxLevel && {gettext('Count')}} + {count} +
+
+
+ ); + } +} + +GroupHeaderLeft.propTypes = { + isExpanded: PropTypes.bool, + firstColumnFrozen: PropTypes.bool, + lastColumnFrozen: PropTypes.bool, + firstColumnKey: PropTypes.string, + maxLevel: PropTypes.number, + group: PropTypes.object, + formulaRow: PropTypes.object, + width: PropTypes.number, + onExpandGroupToggle: PropTypes.func, +}; + +export default GroupHeaderLeft; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-header-right.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-header-right.js new file mode 100644 index 0000000000..6bcd895ddd --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-header-right.js @@ -0,0 +1,85 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import GroupHeaderCell from './group-header-cell'; +import { isFrozen } from '../../../../../utils/column-utils'; +import { GROUP_HEADER_HEIGHT } from '../../../../../constants'; + +class GroupHeaderRight extends Component { + + headerCells = {}; + + setHeaderCellRef = (key) => (node) => { + this.headerCells[key] = node; + }; + + fixedFrozenDOMs = (scrollLeft, scrollTop) => { + this.props.columns.forEach((column) => { + const headerCell = this.headerCells[column.key]; + if (isFrozen(column) && headerCell) { + headerCell.fixedFrozenDOMs(scrollLeft, scrollTop); + } + }); + }; + + cancelFixFrozenDOMs = (scrollLeft) => { + this.props.columns.forEach((column) => { + const headerCell = this.headerCells[column.key]; + if (isFrozen(column) && headerCell) { + headerCell.cancelFixFrozenDOMs(scrollLeft); + } + }); + }; + + getGroupSummaries = () => { + const { + group, isExpanded, columns, groupOffsetLeft, lastFrozenColumnKey, summaryConfigs, + } = this.props; + const summaryColumns = columns.slice(1); // get column from 2 index + const firstColumnWidth = columns[0] ? columns[0].width : 0; + let offsetLeft = 0; + return summaryColumns.map((column, index) => { + const { key } = column; + const summaryMethod = summaryConfigs && summaryConfigs[key] ? summaryConfigs[key] : 'Sum'; + const summary = group.summaries[key]; + if (index !== 0) { + offsetLeft += summaryColumns[index - 1].width; + } + + return ( + + ); + }); + }; + + render() { + return ( +
+ {this.getGroupSummaries()} +
+ ); + } +} + +GroupHeaderRight.propTypes = { + group: PropTypes.object, + isExpanded: PropTypes.bool, + groupOffsetLeft: PropTypes.number, + lastFrozenColumnKey: PropTypes.string, + columns: PropTypes.array, + summaryConfigs: PropTypes.object, + getTableContentLeft: PropTypes.func, +}; + +export default GroupHeaderRight; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-title.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-title.js new file mode 100644 index 0000000000..0efba7ff60 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-title.js @@ -0,0 +1,72 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { + CellType +} from '../../../../../_basic'; +import { DELETED_OPTION_TIPS } from '../../../../../constants'; +import { gettext } from '../../../../../utils'; +import CellFormatter from '../../../../cell-formatter'; + +class GroupTitle extends Component { + + constructor(props) { + super(props); + this.state = { + creator: null, + }; + this.EmptyDOM = `(${gettext('Empty')})`; + this.deletedOptionTips = gettext(DELETED_OPTION_TIPS); + this.collaborators = window.sfMetadataContext.getCollaboratorsFromCache(); + } + + getOptionColors = () => { + const { dtableUtils } = window.app; + return dtableUtils.getOptionColors(); + }; + + renderCollaborator = (collaborator) => { + const { email, avatar_url, name } = collaborator || {}; + return ( +
+ + {name} + + {name} +
+ ); + }; + + renderGroupCellVal = (column, cellValue, originalCellValue = null) => { + const { type } = column; + switch (type) { + case CellType.CREATOR: + case CellType.LAST_MODIFIER: { + if (!originalCellValue) return this.EmptyDOM; + return ( + + ); + } + default: { + return cellValue || this.EmptyDOM; + } + } + }; + + render() { + const { column, originalCellValue, cellValue } = this.props; + return ( +
+
{column.name}
+
{this.renderGroupCellVal(column, cellValue, originalCellValue)}
+
+ ); + } +} + +GroupTitle.propTypes = { + originalCellValue: PropTypes.any, + cellValue: PropTypes.any, + column: PropTypes.object, +}; + +export default GroupTitle; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/header-actions-cell.jsx b/frontend/src/metadata/metadata-view/components/table/table-main/records/header-actions-cell.jsx new file mode 100644 index 0000000000..1e5124b566 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/header-actions-cell.jsx @@ -0,0 +1,48 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import SelectAll from './select-all'; +import { SEQUENCE_COLUMN_WIDTH } from '../../../../constants'; + +class HeaderActionsCell extends Component { + + render() { + const { + isMobile, hasSelectedRecord, isSelectedAll, isLastFrozenCell, groupOffsetLeft, height + } = this.props; + const columnCellClass = 'sf-metadata-result-table-cell column'; + const columnCellStyle = { + height, + width: SEQUENCE_COLUMN_WIDTH + groupOffsetLeft, + minWidth: SEQUENCE_COLUMN_WIDTH + groupOffsetLeft, + }; + return ( +
+ +
+
+ ); + } +} + +HeaderActionsCell.propTypes = { + isMobile: PropTypes.bool, + hasSelectedRecord: PropTypes.bool, + isSelectedAll: PropTypes.bool, + isLastFrozenCell: PropTypes.bool, + height: PropTypes.number, + groupOffsetLeft: PropTypes.number, + selectNoneRecords: PropTypes.func, + selectAllRecords: PropTypes.func, +}; + +export default HeaderActionsCell; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/header-cell.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/header-cell.js new file mode 100644 index 0000000000..59c55e4bcb --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/header-cell.js @@ -0,0 +1,164 @@ +import React, { createRef, Component } from 'react'; +import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { UncontrolledTooltip, Tooltip } from 'reactstrap'; +import { Icon } from '@seafile/sf-metadata-ui-component'; +import { COLUMNS_ICON_CONFIG, COLUMNS_ICON_NAME } from '../../../../_basic'; +import ResizeColumnHandle from './resize-column-handle'; +import { SUPPORT_BATCH_DOWNLOAD_TYPES, TABLE_SUPPORT_EDIT_TYPE_MAP, EVENT_BUS_TYPE } from '../../../../constants'; +import HeaderDropdownMenu from './header-dropdown-menu'; +import { gettext } from '../../../../utils'; + +class HeaderCell extends Component { + + static defaultProps = { + style: null, + }; + + static propTypes = { + groupOffsetLeft: PropTypes.number, + height: PropTypes.number, + column: PropTypes.object, + style: PropTypes.object, + frozen: PropTypes.bool, + isLastFrozenCell: PropTypes.bool, + isHideTriangle: PropTypes.bool, + resizeColumnWidth: PropTypes.func, + downloadColumnAllFiles: PropTypes.func, + }; + + constructor(props) { + super(props); + this.descriptionRef = createRef(); + this.uneditableTip = createRef(); + this.eventBus = window.sfMetadataContext.eventBus; + this.state = { + tooltipOpen: false, + isMenuShow: false, + }; + } + + headerCellRef = (node) => this.headerCell = node; + + getWidthFromMouseEvent = (e) => { + let right = e.pageX || (e.touches && e.touches[0] && e.touches[0].pageX) || (e.changedTouches && e.changedTouches[e.changedTouches.length - 1].pageX); + if (e.pageX === 0) { + right = 0; + } + const left = ReactDOM.findDOMNode(this.headerCell).getBoundingClientRect().left; + return right - left; + }; + + onDrag = (e) => { + const width = this.getWidthFromMouseEvent(e); + if (width > 0) { + this.props.resizeColumnWidth(this.props.column, width); + } + }; + + onIconTooltipToggle = () => { + this.setState({ tooltipOpen: !this.state.tooltipOpen }); + }; + + handleHeaderCellClick = (column) => { + this.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_COLUMN, column); + }; + + checkDropdownAvailable = () => { + const { isHideTriangle, column } = this.props; + if (isHideTriangle) { + return false; + } + if (SUPPORT_BATCH_DOWNLOAD_TYPES.includes(column.type)) { + return true; + } + return false; + }; + + toggleHeaderDropDownMenu = () => { + this.setState({ isMenuShow: !this.state.isMenuShow }); + }; + + render() { + const { frozen, groupOffsetLeft, column, isLastFrozenCell, height } = this.props; + const { left, width, description, key, name, type } = column; + const canEditable = window.sfMetadataContext.canModifyCell(column); + const style = Object.assign({ width, maxWidth: width, minWidth: width, height }, this.props.style); + if (!frozen) { + style.left = left + groupOffsetLeft; + } + const headerIconTooltip = COLUMNS_ICON_NAME[type]; + + return ( +
+
this.handleHeaderCellClick(column, frozen)} + > +
+ + + + + {gettext(headerIconTooltip)} + +
+ {name} +
+
+ {(TABLE_SUPPORT_EDIT_TYPE_MAP[type] && !canEditable) && + + + {gettext('No editing permission')} + + + } + {description && + <> + + + + {description} + + + } + {this.checkDropdownAvailable() && + + } + +
+
+ ); + } +} + +export default HeaderCell; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/header-dropdown-menu.jsx b/frontend/src/metadata/metadata-view/components/table/table-main/records/header-dropdown-menu.jsx new file mode 100644 index 0000000000..98f56abe0c --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/header-dropdown-menu.jsx @@ -0,0 +1,109 @@ +import React, { Fragment, createRef } from 'react'; +import PropTypes from 'prop-types'; +import { Dropdown, DropdownToggle, DropdownMenu } from 'reactstrap'; +import { ModalPortal } from '@seafile/sf-metadata-ui-component'; +import { isMobile } from '../../../../utils'; +import { isFrozen } from '../../../../utils/column-utils'; +import { gettext } from '../../../../../../utils/constants'; + +class HeaderDropdownMenu extends React.Component { + + constructor(props) { + super(props); + this.state = { + top: 0, + left: 0, + }; + this.headerDropDownMenuRef = createRef(); + } + + toggle = (event) => { + event && event.preventDefault(); + event && event.stopPropagation(); + + let { column, height, isMenuShow } = this.props; + + let top = height - 5; // height - container padding - menu margin + let left = - (column.width - 30); // column width - container width - padding + this.setState({ top, left }); + let targetDom = event.target; + if (isMenuShow && typeof targetDom.className === 'string' && targetDom.className.includes('disabled')) { + return; + } + this.props.toggleHeaderDropDownMenu(); + }; + + hideSubMenu = () => {}; + + onDownloadAllFiles = () => { + const { column } = this.props; + this.props.downloadColumnAllFiles(column); + }; + + getMenuStyle = () => { + if (isFrozen(this.props.column)) { + return { transform: 'none' }; + } + let { top, left } = this.state; + + return { + top, + left, + transform: 'none', + }; + }; + + renderUpperMenu = () => { + let upperMenu = []; + return upperMenu; + }; + + renderDropdownMenu = () => { + const menuStyle = this.getMenuStyle(); + + return ( + +
this.dropdownDom = ref}> + {this.renderUpperMenu().map((item, index) => { + return {item}; + })} +
+
+ ); + }; + + render() { + const { isMenuShow } = this.props; + + return ( + + + + + {isMenuShow && !isMobile && + +
{this.renderDropdownMenu()}
+
+ } +
+ ); + } +} + +HeaderDropdownMenu.propTypes = { + isMenuShow: PropTypes.bool, + column: PropTypes.object, + height: PropTypes.number, + toggleHeaderDropDownMenu: PropTypes.func, + downloadColumnAllFiles: PropTypes.func, +}; + +export default HeaderDropdownMenu; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/index.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/index.js new file mode 100644 index 0000000000..36d19dc2c1 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/index.js @@ -0,0 +1,774 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import RecordsHeader from './records-header'; +import RecordsBody from './records-body'; +import RecordsGroupBody from './records-group-body'; +import RecordsFooter from './record-footer'; +import { HorizontalScrollbar } from '../../../scrollbar'; +import { recalculate } from '../../../../utils/column-utils'; +import { SEQUENCE_COLUMN_WIDTH, CANVAS_RIGHT_INTERVAL, GROUP_ROW_TYPE, EVENT_BUS_TYPE } from '../../../../constants'; +import { + isWindowsBrowser, isWebkitBrowser, isMobile, getEventClassName, + addClassName, removeClassName, +} from '../../../../utils'; +import RecordMetrics from '../../../../utils/record-metrics'; +import { isShiftKeyDown } from '../../../../utils/keyboard-utils'; +import { getVisibleBoundaries } from '../../../../utils/viewport'; +import { getColOverScanEndIdx, getColOverScanStartIdx } from '../../../../utils/grid'; +import { setColumnOffsets } from '../../../../utils/column-utils'; + +class Records extends Component { + + constructor(props) { + super(props); + this.scrollTop = 0; + this.isScrollByScrollbar = false; + const scrollLeft = window.sfMetadataContext.localStorage.getItem('scroll_left'); + this.scrollLeft = scrollLeft ? Number(scrollLeft) : 0; + this.lastScrollLeft = this.scrollLeft; + this.initPosition = { idx: -1, rowIdx: -1, groupRecordIndex: -1 }; + const columnMetrics = this.createColumnMetrics(props); + const initHorizontalScrollState = this.getHorizontalScrollState({ gridWidth: props.tableContentWidth, columnMetrics, scrollLeft: 0 }); + this.state = { + columnMetrics, + recordMetrics: this.createRowMetrics(), + lastRowIdxUiSelected: { groupRecordIndex: -1, recordIndex: -1 }, + touchStartPosition: {}, + selectedRange: { + topLeft: this.initPosition, + bottomRight: this.initPosition, + }, + ...initHorizontalScrollState, + }; + this.isWindows = isWindowsBrowser(); + this.isWebkit = isWebkitBrowser(); + } + + componentDidMount() { + window.addEventListener('popstate', this.onPopState); + document.addEventListener('copy', this.onCopyCells); + document.addEventListener('paste', this.onPasteCells); + if (window.isMobile) { + window.addEventListener('touchstart', this.onTouchStart); + window.addEventListener('touchend', this.onTouchEnd); + } else { + document.addEventListener('mousedown', this.onMouseDown); + } + this.unsubscribeSelectNone = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.SELECT_NONE, this.selectNone); + this.getScrollPosition(); + this.checkExpandRow(); + } + + UNSAFE_componentWillReceiveProps(nextProps) { + const { columns, tableContentWidth } = nextProps; + if ( + this.props.columns !== columns + ) { + const columnMetrics = this.createColumnMetrics(nextProps); + this.updateHorizontalScrollState({ + columnMetrics, + scrollLeft: this.lastScrollLeft, + gridWidth: tableContentWidth, + }); + this.setState({ columnMetrics }); + } else if (this.props.tableContentWidth !== tableContentWidth) { + this.updateHorizontalScrollState({ + columnMetrics: this.state.columnMetrics, + scrollLeft: this.lastScrollLeft, + gridWidth: tableContentWidth, + }); + } + + } + + componentWillUnmount() { + window.removeEventListener('popstate', this.onPopState); + document.removeEventListener('copy', this.onCopyCells); + document.removeEventListener('paste', this.onPasteCells); + if (window.isMobile) { + window.removeEventListener('touchstart', this.onTouchStart); + window.removeEventListener('touchend', this.onTouchEnd); + } else { + document.removeEventListener('mousedown', this.onMouseDown); + } + + this.clearSetAbsoluteTimer(); + this.setState = (state, callback) => { + return; + }; + } + + createColumnMetrics = (props) => { + const { columns, table } = props; + return recalculate(columns, [], table._id); + }; + + createRowMetrics = (props = this.props) => { + return { + idSelectedRecordMap: {}, + }; + }; + + setScrollLeft = (scrollLeft) => { + this.resultContainerRef.scrollLeft = scrollLeft; + }; + + resizeColumnWidth = (column, width) => { + if (width < 50) return; + const { table, columns, } = this.props; + const newColumn = Object.assign({}, column, { width }); + const index = columns.findIndex(item => item.key === column.key); + let updateColumns = columns.slice(0); + updateColumns[index] = newColumn; + updateColumns = setColumnOffsets(updateColumns); + const columnMetrics = recalculate(updateColumns, columns, table._id); + this.setState({ columnMetrics }, () => { + const oldValue = localStorage.getItem('pages_columns_width'); + let pagesColumnsWidth = {}; + if (oldValue) { + pagesColumnsWidth = JSON.parse(oldValue); + } + const page = window.app.getPage(); + const { id: pageId } = page; + let pageColumnsWidth = pagesColumnsWidth[pageId] || {}; + const key = `${table._id}-${column.key}`; + pageColumnsWidth[key] = width; + const updated = Object.assign({}, pagesColumnsWidth, { [pageId]: pageColumnsWidth }); + localStorage.setItem('pages_columns_width', JSON.stringify(updated)); + }); + }; + + getScrollPosition = () => { + let scrollLeft = window.sfMetadataContext.localStorage.getItem('scroll_left') + ''; + let scrollTop = window.sfMetadataContext.localStorage.getItem('scroll_top') + ''; + if (scrollLeft && scrollTop) { + if (this.bodyRef) { + scrollLeft = Number(scrollLeft); + scrollTop = Number(scrollTop); + this.bodyRef.setScrollTop(scrollTop); + this.setScrollLeft(scrollLeft); + this.handleHorizontalScroll(scrollLeft, scrollTop); + } + } + }; + + checkExpandRow = async () => { + // todo + }; + + storeScrollPosition = () => { + const scrollTop = this.bodyRef.getScrollTop(); + const scrollLeft = this.getScrollLeft(); + window.sfMetadataContext.localStorage.setItem('scroll_left', scrollLeft); + this.storeScrollTop(scrollTop); + }; + + storeScrollTop = (scrollTop) => { + window.sfMetadataContext.localStorage.setItem('scroll_top', scrollTop); + }; + + onContentScroll = (e) => { + const { scrollLeft } = e.target; + const scrollTop = this.bodyRef.getScrollTop(); + const deltaX = this.scrollLeft - scrollLeft; + const deltaY = this.scrollTop - scrollTop; + this.scrollLeft = scrollLeft; + if (deltaY !== 0) { + this.scrollTop = scrollTop; + } + + // table horizontal scroll, set first column freeze + if (deltaY === 0 && (deltaX !== 0 || scrollLeft === 0)) { + this.handleHorizontalScroll(scrollLeft, scrollTop); + } + this.storeScrollPosition(); + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.CLOSE_EDITOR); + }; + + handleHorizontalScroll = (scrollLeft, scrollTop) => { + if (isMobile) { + this.updateHorizontalScrollState({ + scrollLeft, + columnMetrics: this.state.columnMetrics, + gridWidth: this.props.tableContentWidth, + }); + return; + } + + // update classnames after scroll + const originClassName = this.resultContainerRef ? this.resultContainerRef.className : ''; + let newClassName; + if (scrollLeft > 0) { + newClassName = addClassName(originClassName, 'horizontal-scroll'); + } else { + newClassName = removeClassName(originClassName, 'horizontal-scroll'); + } + if (newClassName !== originClassName && this.resultContainerRef) { + this.resultContainerRef.className = newClassName; + } + + this.lastScrollLeft = scrollLeft; + + this.handleFrozenDOMsPosition(scrollLeft, scrollTop); + + this.recordsFooterRef.setSummaryScrollLeft(scrollLeft); + if (!this.isScrollByScrollbar) { + this.handleScrollbarScroll(scrollLeft); + } + if (this.bodyRef && this.bodyRef.interactionMask) { + this.bodyRef.setScrollLeft(scrollLeft); + } + + this.updateHorizontalScrollState({ + scrollLeft, + columnMetrics: this.state.columnMetrics, + gridWidth: this.props.tableContentWidth, + }); + }; + + handleFrozenDOMsPosition = (scrollLeft, scrollTop) => { + const { lastFrozenColumnKey } = this.state.columnMetrics; + if (this.props.isGroupView && !lastFrozenColumnKey) { + return; // none-frozen columns under group view + } + + this.clearSetAbsoluteTimer(); + this.setFixed(scrollLeft, scrollTop); + this.timer = setTimeout(() => { + this.setAbsolute(scrollLeft, scrollTop); + }, 100); + }; + + handleScrollbarScroll = (scrollLeft) => { + if (!this.horizontalScrollbar) return; + if (!this.isScrollByScrollbar) { + this.setHorizontalScrollbarScrollLeft(scrollLeft); + return; + } + this.isScrollByScrollbar = false; + }; + + onHorizontalScrollbarScroll = (scrollLeft) => { + this.isScrollByScrollbar = true; + this.setScrollLeft(scrollLeft); + }; + + onHorizontalScrollbarMouseUp = () => { + this.isScrollByScrollbar = false; + }; + + setHorizontalScrollbarScrollLeft = (scrollLeft) => { + this.horizontalScrollbar && this.horizontalScrollbar.setScrollLeft(scrollLeft); + }; + + setFixed = (left, top) => { + this.bodyRef.recordFrozenRefs.forEach(dom => { + if (!dom) return; + dom.frozenColumns.style.position = 'fixed'; + dom.frozenColumns.style.marginLeft = '0px'; + dom.frozenColumns.style.marginTop = '-' + top + 'px'; + }); + + this.bodyRef.frozenBtnAddRecordRefs.forEach(dom => { + if (!dom) return; + dom.frozenColumns.style.position = 'fixed'; + dom.frozenColumns.style.marginLeft = '0px'; + dom.frozenColumns.style.marginTop = '-' + top + 'px'; + }); + + if (this.bodyRef.fixFrozenDoms) { + this.bodyRef.fixFrozenDoms(left, top); + } + }; + + setAbsolute = (left) => { + const { isGroupView } = this.props; + const { lastFrozenColumnKey } = this.state.columnMetrics; + if (isGroupView && !lastFrozenColumnKey) { + return; + } + + this.bodyRef.recordFrozenRefs.forEach(dom => { + if (!dom) return; + dom.frozenColumns.style.position = 'absolute'; + dom.frozenColumns.style.marginLeft = left + 'px'; + dom.frozenColumns.style.marginTop = '0px'; + }); + + this.bodyRef.frozenBtnAddRecordRefs.forEach(dom => { + if (!dom) return; + dom.frozenColumns.style.position = 'absolute'; + dom.frozenColumns.style.marginLeft = left + 'px'; + dom.frozenColumns.style.marginTop = '0px'; + }); + + if (this.bodyRef.cancelFixFrozenDOMs) { + this.bodyRef.cancelFixFrozenDOMs(left); + } + + if (this.bodyRef && this.bodyRef.interactionMask) { + this.bodyRef.cancelSetScrollLeft(); + } + }; + + clearSetAbsoluteTimer = () => { + if (!this.timer) { + return; + } + clearTimeout(this.timer); + this.timer = null; + }; + + getScrollLeft = () => { + if (isMobile) { + return 0; + } + return this.scrollLeft || 0; + }; + + getScrollTop = () => { + if (isMobile) { + return 0; + } + return this.scrollTop || 0; + }; + + setHorizontalScrollbarRef = (ref) => { + this.horizontalScrollbar = ref; + }; + + setResultContainerRef = (ref) => { + this.resultContainerRef = ref; + }; + + updateSelectedRange = (selectedRange) => { + this.setState({ selectedRange }); + }; + + onClickContainer = (e) => { + let classNames = getEventClassName(e); + if (classNames.includes('sf-metadata-result-content') || classNames.includes('sf-metadata-result-table-content')) { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.CLOSE_EDITOR); + } + }; + + onCellClick = (cell) => { + if (cell) { + const currentPosition = { ...cell }; + this.updateSelectedRange({ + topLeft: currentPosition, + bottomRight: currentPosition, + }); + } + this.onDeselectAllRecords(); + }; + + onCellRangeSelectionUpdated = (selectedRange) => { + this.onCellClick(); + this.updateSelectedRange(selectedRange); + }; + + onPopState = () => { + this.checkExpandRow(); + }; + + onCopyCells = (e) => { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.COPY_CELLS, e); + }; + + onPasteCells = (e) => { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.PASTE_CELLS, e); + }; + + onTouchStart = (e) => { + const outsideDom = ['canvas', 'group-canvas']; + if (e.target && outsideDom.includes(e.target.id)) { + let touchStartPosition = { + startX: e.changedTouches[0].clientX, + startY: e.changedTouches[0].clientY, + }; + this.setState({ touchStartPosition }); + } + }; + + onTouchEnd = (e) => { + const outsideDom = ['canvas', 'group-canvas']; + if (e.target && outsideDom.includes(e.target.id)) { + let { clientX, clientY } = e.changedTouches[0]; + let { touchStartPosition } = this.state; + if (Math.abs(touchStartPosition.startX - clientX) < 5 && Math.abs(touchStartPosition.startY - clientY) < 5) { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_NONE); + } + } + }; + + onMouseDown = (e) => { + const validClassName = getEventClassName(e); + if (validClassName.indexOf('sf-metadata-result-table-cell') > -1) { + return; + } + const outsideDom = ['canvas', 'group-canvas']; + if (outsideDom.includes(e.target.id) || validClassName.includes('sf-metadata-result-content')) { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_NONE); + } + }; + + selectNone = () => { + this.setState({ + selectedRange: { + topLeft: this.initPosition, + bottomRight: this.initPosition + }, + }); + + // clear selected records + this.onDeselectAllRecords(); + }; + + onSelectRecord = ({ groupRecordIndex, recordIndex }, e) => { + e.stopPropagation(); + if (isShiftKeyDown(e)) { + this.selectRecordWithShift({ groupRecordIndex, recordIndex }); + return; + } + const { isGroupView } = this.props; + const { recordMetrics } = this.state; + const operateRecord = this.props.recordGetterByIndex({ isGroupView, groupRecordIndex, recordIndex }); + if (!operateRecord) { + return; + } + + const operateRecordId = operateRecord._id; + if (RecordMetrics.isRecordSelected(operateRecordId, recordMetrics)) { + this.deselectRecord(operateRecordId); + this.setState({ lastRowIdxUiSelected: { groupRecordIndex: -1, recordIndex: -1 } }); + return; + } + this.selectRecord(operateRecordId); + this.setState({ lastRowIdxUiSelected: { groupRecordIndex, recordIndex } }); + }; + + selectRecordWithShift = ({ groupRecordIndex, recordIndex }) => { + const { recordIds, isGroupView } = this.props; + const { lastRowIdxUiSelected, recordMetrics } = this.state; + let selectedRecordIds = []; + if (isGroupView) { + if (!window.sfMetadataBody || !window.sfMetadataBody.getGroupMetrics) { + return; + } + const groupMetrics = window.sfMetadataBody.getGroupMetrics(); + const { groupRows } = groupMetrics; + const groupRecordIndexes = [groupRecordIndex, lastRowIdxUiSelected.groupRecordIndex].sort((a, b) => a - b); + for (let i = groupRecordIndexes[0]; i <= groupRecordIndexes[1]; i++) { + const groupRow = groupRows[i]; + const { type } = groupRow; + if (type !== GROUP_ROW_TYPE.ROW) { + continue; + } + selectedRecordIds.push(groupRow.rowId); + } + } else { + const operateRecordId = recordIds[recordIndex]; + if (!operateRecordId) { + return; + } + const lastSelectedRecordIndex = lastRowIdxUiSelected.recordIndex; + if (lastSelectedRecordIndex < 0) { + this.selectRecord(operateRecordId); + this.setState({ lastRowIdxUiSelected: { groupRecordIndex: -1, recordIndex } }); + return; + } + if (recordIndex === lastSelectedRecordIndex || RecordMetrics.isRecordSelected(operateRecordId, recordMetrics)) { + this.deselectRecord(operateRecordId); + this.setState({ lastRowIdxUiSelected: { groupRecordIndex: -1, recordIndex: -1 } }); + return; + } + selectedRecordIds = this.getRecordIdsBetweenRange({ start: lastSelectedRecordIndex, end: recordIndex }); + } + + if (selectedRecordIds.length === 0) { + return; + } + this.selectRecordsById(selectedRecordIds); + this.setState({ lastRowIdxUiSelected: { groupRecordIndex, recordIndex } }); + }; + + getRecordIdsBetweenRange = ({ start, end }) => { + const { recordIds: propsRecordIds } = this.props; + const startIndex = Math.min(start, end); + const endIndex = Math.max(start, end); + let recordIds = []; + for (let i = startIndex; i <= endIndex; i++) { + const recordId = propsRecordIds[i]; + if (recordId) { + recordIds.push(recordId); + } + } + return recordIds; + }; + + selectRecord = (recordId) => { + const { recordMetrics } = this.state; + if (RecordMetrics.isRecordSelected(recordId, recordMetrics)) { + return; + } + let updatedRecordMetrics = { ...recordMetrics }; + RecordMetrics.selectRecord(recordId, updatedRecordMetrics); + this.setState({ + recordMetrics: updatedRecordMetrics, + }); + }; + + selectRecordsById = (recordIds) => { + const { recordMetrics } = this.state; + const unSelectedRecordIds = recordIds.filter(recordId => !RecordMetrics.isRecordSelected(recordId, recordMetrics)); + if (unSelectedRecordIds.length === 0) { + return; + } + let updatedRecordMetrics = { ...recordMetrics }; + RecordMetrics.selectRecordsById(recordIds, updatedRecordMetrics); + this.setState({ + recordMetrics: updatedRecordMetrics, + }); + }; + + deselectRecord = (recordId) => { + const { recordMetrics } = this.state; + if (!RecordMetrics.isRecordSelected(recordId, recordMetrics)) { + return; + } + let updatedRecordMetrics = { ...recordMetrics }; + RecordMetrics.deselectRecord(recordId, updatedRecordMetrics); + this.setState({ + recordMetrics: updatedRecordMetrics, + }); + }; + + selectAllRecords = () => { + const { recordIds, isGroupView } = this.props; + const { recordMetrics } = this.state; + let updatedRecordMetrics = { ...recordMetrics }; + let selectedRowIds = []; + if (isGroupView) { + if (!window.sfMetadataBody || !window.sfMetadataBody.getGroupMetrics) { + return; + } + const groupMetrics = window.sfMetadataBody.getGroupMetrics(); + const { groupRows } = groupMetrics; + groupRows.forEach(groupRow => { + const { type } = groupRow; + if (type !== GROUP_ROW_TYPE.ROW) { + return; + } + selectedRowIds.push(groupRow.rowId); + }); + } else { + selectedRowIds = recordIds; + } + RecordMetrics.selectRecordsById(selectedRowIds, updatedRecordMetrics); + this.setState({ + recordMetrics: updatedRecordMetrics, + }); + }; + + onDeselectAllRecords = () => { + const { recordMetrics } = this.state; + if (!RecordMetrics.hasSelectedRecords(recordMetrics)) { + return; + } + let updatedRecordMetrics = { ...recordMetrics }; + RecordMetrics.deselectAllRecords(updatedRecordMetrics); + this.setState({ + recordMetrics: updatedRecordMetrics, + lastRowIdxUiSelected: { groupRecordIndex: -1, recordIndex: -1 }, + }); + }; + + hasSelectedCell = ({ groupRecordIndex, recordIndex }, selectedPosition) => { + if (!selectedPosition) return false; + const { isGroupView } = this.props; + const { groupRecordIndex: selectedGroupRowIndex, rowIdx: selectedRecordIndex } = selectedPosition; + if (isGroupView) { + return groupRecordIndex === selectedGroupRowIndex; + } + return recordIndex === selectedRecordIndex; + }; + + hasSelectedRecord = () => { + const { recordMetrics } = this.state; + if (!RecordMetrics.hasSelectedRecords(recordMetrics)) { + return false; + } + const selectedRecordIds = RecordMetrics.getSelectedIds(recordMetrics); + const selectedRecords = selectedRecordIds && selectedRecordIds.map(id => this.props.recordGetterById(id)).filter(Boolean); + return selectedRecords && selectedRecords.length > 0; + }; + + getHorizontalScrollState = ({ gridWidth, columnMetrics, scrollLeft }) => { + const { columns } = columnMetrics; + const columnsLength = columns.length; + const { colVisibleStartIdx, colVisibleEndIdx } = getVisibleBoundaries(columns, scrollLeft, gridWidth); + const colOverScanStartIdx = getColOverScanStartIdx(colVisibleStartIdx); + const colOverScanEndIdx = getColOverScanEndIdx(colVisibleEndIdx, columnsLength); + return { + colOverScanStartIdx, + colOverScanEndIdx, + }; + }; + + updateHorizontalScrollState = ({ columnMetrics, gridWidth, scrollLeft }) => { + const scrollState = this.getHorizontalScrollState({ columnMetrics, gridWidth, scrollLeft }); + this.setState(scrollState); + }; + + cacheDownloadFilesProps = (column, records) => { + // todo + }; + + downloadColumnAllFiles = (column) => { + // todo + }; + + openDownloadFilesDialog = () => { + // todo + }; + + closeDownloadFilesDialog = () => { + // todo + }; + + renderRecordsBody = ({ containerWidth }) => { + const { recordMetrics, columnMetrics, colOverScanStartIdx, colOverScanEndIdx } = this.state; + const { + columns, allColumns, totalWidth, lastFrozenColumnKey, frozenColumnsWidth, + } = columnMetrics; + const commonProps = { + ...this.props, + columns, allColumns, totalWidth, lastFrozenColumnKey, frozenColumnsWidth, + recordMetrics, colOverScanStartIdx, colOverScanEndIdx, + hasSelectedRecord: this.hasSelectedRecord(), + getScrollLeft: this.getScrollLeft, + getScrollTop: this.getScrollTop, + selectNone: this.selectNone, + onCellClick: this.onCellClick, + onCellRangeSelectionUpdated: this.onCellRangeSelectionUpdated, + onSelectRecord: this.onSelectRecord, + setRecordsScrollLeft: this.setScrollLeft, + hasSelectedCell: this.hasSelectedCell, + cacheScrollTop: this.storeScrollTop, + }; + if (this.props.isGroupView) { + return ( + { + this.bodyRef = ref; + }} + {...commonProps} + groups={this.props.groups} + groupbys={this.props.groupbys} + groupOffsetLeft={this.props.groupOffsetLeft} + /> + ); + } + return ( + { + this.bodyRef = ref; + }} + {...commonProps} + recordIds={this.props.recordIds} + /> + ); + }; + + render() { + const { recordIds, recordsCount, table, isGroupView, groupOffsetLeft } = this.props; + const { recordMetrics, columnMetrics, selectedRange, colOverScanStartIdx, colOverScanEndIdx } = this.state; + const { columns, totalWidth, lastFrozenColumnKey } = columnMetrics; + const containerWidth = totalWidth + SEQUENCE_COLUMN_WIDTH + CANVAS_RIGHT_INTERVAL; + const hasSelectedRecord = this.hasSelectedRecord(); + const isSelectedAll = RecordMetrics.isSelectedAll(recordIds, recordMetrics); + + return ( + +
+
+ { + this.headerFrozenRef = ref; + }} + containerWidth={containerWidth} + table={table} + columns={columns} + colOverScanStartIdx={colOverScanStartIdx} + colOverScanEndIdx={colOverScanEndIdx} + hasSelectedRecord={hasSelectedRecord} + isSelectedAll={isSelectedAll} + isGroupView={isGroupView} + groupOffsetLeft={groupOffsetLeft} + lastFrozenColumnKey={lastFrozenColumnKey} + resizeColumnWidth={this.resizeColumnWidth} + selectNoneRecords={this.selectNone} + selectAllRecords={this.selectAllRecords} + downloadColumnAllFiles={this.downloadColumnAllFiles} + /> + {this.renderRecordsBody({ containerWidth })} +
+
+ {this.isWindows && this.isWebkit && ( + + )} + this.recordsFooterRef = ref} + recordsCount={recordsCount} + hasMore={this.props.hasMore} + columns={columns} + groupOffsetLeft={groupOffsetLeft} + recordMetrics={recordMetrics} + selectedRange={selectedRange} + isGroupView={isGroupView} + hasSelectedRecord={hasSelectedRecord} + isLoadingMore={this.props.isLoadingMore} + recordGetterById={this.props.recordGetterById} + recordGetterByIndex={this.props.recordGetterByIndex} + getRecordsSummaries={() => {}} + clickToLoadMore={this.props.clickToLoadMore} + /> +
+ ); + } +} + +Records.propTypes = { + isGroupView: PropTypes.bool, + columns: PropTypes.array, + table: PropTypes.object, + hasMore: PropTypes.bool, + isLoadingMore: PropTypes.bool, + groupOffsetLeft: PropTypes.number, + gridUtils: PropTypes.object, + recordIds: PropTypes.array, + recordsCount: PropTypes.number, + groups: PropTypes.array, + groupbys: PropTypes.array, + searchResult: PropTypes.object, + tableContentWidth: PropTypes.number, + scrollToLoadMore: PropTypes.func, + updateRecord: PropTypes.func, + updateRecords: PropTypes.func, + recordGetterById: PropTypes.func, + recordGetterByIndex: PropTypes.func, + clickToLoadMore: PropTypes.func, +}; + +export default Records; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/load-all-tip.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/load-all-tip.js new file mode 100644 index 0000000000..d6d80ea69d --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/load-all-tip.js @@ -0,0 +1,27 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { toaster } from '@seafile/sf-metadata-ui-component'; +import { gettext } from '../../../../../../utils/constants'; + +class LoadAllTip extends React.Component { + + onClick = () => { + toaster.closeAll(); + this.props.clickToLoadMore(100000); + }; + + render() { + return ( +
+ {gettext('Loaded 50,000 records.')} +
{gettext('Click to load more')}
+
+ ); + } +} + +LoadAllTip.propTypes = { + clickToLoadMore: PropTypes.func +}; + +export default LoadAllTip; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/record-cell.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/record-cell.js new file mode 100644 index 0000000000..821890de82 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/record-cell.js @@ -0,0 +1,204 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { toaster } from '@seafile/sf-metadata-ui-component'; +import { isFunction } from '../../../../_basic'; +import { isNameColumn } from '../../../../utils/column-utils'; +import { TABLE_SUPPORT_EDIT_TYPE_MAP } from '../../../../constants'; +import { isCellValueChanged } from '../../../../utils/cell-comparer'; +import CellFormatter from '../../../cell-formatter'; + +class RecordCell extends React.Component { + + static defaultProps = { + needBindEvents: true + }; + + state = {}; + + shouldComponentUpdate(nextProps, nextState) { + const { + record: oldRecord, column, isCellSelected, isLastCell, highlightClassName, + height, bgColor, + } = this.props; + const { record: newRecord, highlightClassName: newHighlightClassName, height: newHeight, column: newColumn, bgColor: newBgColor } = nextProps; + // the modification of column is not currently supported, only the modification of cell data is considered + const oldValue = oldRecord[column.name] || oldRecord[column.key]; + const newValue = newRecord[column.name] || newRecord[column.key]; + const isShouldUpdated = ( + isCellValueChanged(oldValue, newValue, column.type) || + oldRecord._last_modifier !== newRecord._last_modifier || + isCellSelected !== nextProps.isCellSelected || + isLastCell !== nextProps.isLastCell || + highlightClassName !== newHighlightClassName || + height !== newHeight || + column.left !== newColumn.left || + column.width !== newColumn.width || + bgColor !== newBgColor + ); + return isShouldUpdated; + } + + getCellClass = (hasComment) => { + const { column, highlightClassName, isLastCell, isLastFrozenCell, isCellSelected } = this.props; + const { isFileTipShow } = this.state; + const { type } = column; + let className = `sf-metadata-result-table-cell sf-metadata-result-table-${type}-cell `; + const canEditable = window.sfMetadataContext.canModifyCell(column); + className = `${className}${(canEditable || !TABLE_SUPPORT_EDIT_TYPE_MAP[type]) ? '' : 'table-cell-uneditable '}`; + if (highlightClassName) { + className += `${highlightClassName} `; + } + if (isLastCell) { + className += 'last-cell '; + } + if (isLastFrozenCell) { + className += 'table-last--frozen '; + } + if (isCellSelected) { + className += 'cell-selected '; + } + if (isFileTipShow) { + className += 'draging-file-to-cell '; + } + if (hasComment) { + className += 'row-comment-cell'; + } + return className; + }; + + onCellClick = (e) => { + const { column, groupRecordIndex, recordIndex, cellMetaData } = this.props; + const cell = { idx: column.idx, groupRecordIndex, rowIdx: recordIndex }; + + // select cell + if (isFunction(cellMetaData.onCellClick)) { + cellMetaData.onCellClick(cell, e); + } + }; + + onCellDoubleClick = (e) => { + const { column, groupRecordIndex, recordIndex, cellMetaData } = this.props; + const cell = { idx: column.idx, groupRecordIndex, rowIdx: recordIndex }; + + if (isFunction(cellMetaData.onCellDoubleClick)) { + cellMetaData.onCellDoubleClick(cell, e); + } + }; + + onCellMouseDown = (e) => { + if (e.button === 2) { + return; + } + const { column, groupRecordIndex, recordIndex, cellMetaData } = this.props; + const cell = { idx: column.idx, groupRecordIndex, rowIdx: recordIndex }; + + if (isFunction(cellMetaData.onCellMouseDown)) { + cellMetaData.onCellMouseDown(cell, e); + } + }; + + onCellMouseEnter = (e) => { + const { column, groupRecordIndex, recordIndex, cellMetaData } = this.props; + const cell = { idx: column.idx, groupRecordIndex, rowIdx: recordIndex }; + if (isFunction(cellMetaData.onCellMouseEnter)) { + const mousePosition = { x: e.clientX, y: e.clientY }; + cellMetaData.onCellMouseEnter({ ...cell, mousePosition }, e); + } + }; + + onCellMouseMove = (e) => { + const { column, groupRecordIndex, recordIndex, cellMetaData } = this.props; + const cell = { idx: column.idx, groupRecordIndex, rowIdx: recordIndex }; + if (isFunction(cellMetaData.onCellMouseMove)) { + const mousePosition = { x: e.clientX, y: e.clientY }; + cellMetaData.onCellMouseMove({ ...cell, mousePosition }, e); + } + }; + + onCellMouseLeave = () => { + return; + }; + + getEvents = () => { + return { + onClick: this.onCellClick, + onDoubleClick: this.onCellDoubleClick, + onMouseDown: this.onCellMouseDown, + onMouseEnter: this.onCellMouseEnter, + onMouseMove: this.onCellMouseMove, + onMouseLeave: this.onCellMouseLeave, + onDragOver: this.onDragOver + }; + }; + + onDragOver = (e) => { + e.stopPropagation(); + e.preventDefault(); + }; + + updateParentTips = (isFileTipShow) => { + this.setState({ isFileTipShow: isFileTipShow }); + }; + + onCellTipShow = (message) => { + toaster.warning(message); + }; + + render = () => { + const { frozen, record, column, needBindEvents, height, bgColor } = this.props; + const { key, name, left, width } = column; + const readonly = true; + const commentCount = isNameColumn(column) && this.getCommentCount(); + const hasComment = !!commentCount; + const className = this.getCellClass(hasComment); + const cellStyle = { + width, + height, + }; + if (!frozen) { + cellStyle.left = left; + } + if (bgColor) { + cellStyle['backgroundColor'] = bgColor; + } + + let cellValue = record[name] || record[key]; + const cellEvents = needBindEvents && this.getEvents(); + const props = { + className, + style: cellStyle, + ...cellEvents, + }; + const cellContent = ( + + ); + + return ( +
+ {cellContent} +
+ ); + }; +} + +RecordCell.propTypes = { + frozen: PropTypes.bool, + isCellSelected: PropTypes.bool, + isLastCell: PropTypes.bool, + isLastFrozenCell: PropTypes.bool, + cellMetaData: PropTypes.object, + record: PropTypes.object.isRequired, + groupRecordIndex: PropTypes.number, + recordIndex: PropTypes.number.isRequired, + column: PropTypes.object.isRequired, + height: PropTypes.number, + needBindEvents: PropTypes.bool, + modifyRecord: PropTypes.func, + lockRecordViaButton: PropTypes.func, + modifyRecordViaButton: PropTypes.func, + reloadCurrentRecord: PropTypes.func, + highlightClassName: PropTypes.string, + bgColor: PropTypes.string, +}; + +export default RecordCell; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/record-footer/index.css b/frontend/src/metadata/metadata-view/components/table/table-main/records/record-footer/index.css new file mode 100644 index 0000000000..fba0063219 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/record-footer/index.css @@ -0,0 +1,88 @@ +.sf-metadata-result-footer .rows-record { + width: 80px; + padding-left: 8px; +} + +.load-all-tip { + display: flex; + align-items: center; +} + +.sf-metadata-result-footer .summaries-pane { + position: relative; + display: flex; + flex: 1 1; + overflow: hidden; +} + +.sf-metadata-result-footer .summaries-scroll { + position: relative; + overflow: hidden; +} + +.sf-metadata-result-footer .summaries-scroll > div { + display: inline-flex; +} + +.sf-metadata-result-footer .summary-item, +.canvas-groups-rows .summary-item { + display: flex; + justify-content: flex-end; + text-align: right; + padding: 0 8px; + height: 30px; +} + +.sf-metadata-result-footer .summary-item .summary-value, +.canvas-groups-rows .summary-item .summary-value { + display: flex; + justify-content: flex-end; + max-width: calc(100% - 18px); + overflow: hidden; +} + +.sf-metadata-result-footer .summary-value .summary-value-title, +.canvas-groups-rows .summary-value .summary-value-title { + color: #666666; + flex: none; +} + +.sf-metadata-result-footer .summary-value .summary-value-text, +.canvas-groups-rows .summary-value .summary-value-text { + max-width: 100%; + padding-left: 3px; + flex: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.load-all-tip .load-all, +.sf-metadata-result-footer .load-all { + height: 16px; + line-height: 16px; + color: #666; + cursor: pointer; + border-bottom: 1px solid #666; +} + +.sf-metadata-result-footer .load-all { + margin: auto; +} + +.load-all-tip .load-all:hover, +.sf-metadata-result-footer .load-all:hover { + color: #212529; + border-bottom: 1px solid #212529; +} + +.sf-metadata-result-footer .loading-message { + display: inline-flex; + align-items: center; + color: #666; +} + +.sf-metadata-result-footer .loading-message .loading-icon { + width: 16px; + height: 16px; +} diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/record-footer/index.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/record-footer/index.js new file mode 100644 index 0000000000..80d140077e --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/record-footer/index.js @@ -0,0 +1,150 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Loading, toaster } from '@seafile/sf-metadata-ui-component'; +import { Z_INDEX } from '../../../../../_basic'; +import LoadAllTip from '../load-all-tip'; +import RecordMetrics from '../../../../../utils/record-metrics'; +import { SEQUENCE_COLUMN_WIDTH, CANVAS_RIGHT_INTERVAL } from '../../../../../constants'; +import { getRecordsFromSelectedRange } from '../../../../../utils/selected-cell-utils'; +import { gettext } from '../../../../../../../utils/constants'; + +import './index.css'; + +class RecordsFooter extends React.Component { + + onClick = () => { + if (this.props.isLoadingMore) { + return; + } + const loadNumber = this.props.recordsCount < 50000 ? 50000 : 100000; + this.props.clickToLoadMore(loadNumber, (hasMore) => { + if (hasMore) { + toaster.success(, { duration: 5 }); + } else { + toaster.success(gettext('All records loaded')); + } + }); + }; + + setSummaryScrollLeft = (scrollLeft) => { + this.summaryItemsRef.scrollLeft = scrollLeft; + }; + + getSelectedCellsCount = (selectedRange) => { + const { topLeft, bottomRight } = selectedRange; + + // if no cell selected topLeft.rowIdx is -1 , then return 0 + if (topLeft.rowIdx === -1) { + return 0; + } + + return (bottomRight.idx - topLeft.idx + 1) * (bottomRight.rowIdx - topLeft.rowIdx + 1); + }; + + getSummaries = () => { + const { + isGroupView, hasSelectedRecord, recordMetrics, selectedRange, summaries, + recordGetterByIndex, + } = this.props; + if (hasSelectedRecord) { + const selectedRecordIds = RecordMetrics.getSelectedIds(recordMetrics); + const selectedRecords = selectedRecordIds && selectedRecordIds.map(id => this.props.recordGetterById(id)).filter(Boolean); + return this.props.getRecordsSummaries(selectedRecords); + } + + const selectedCellsCount = this.getSelectedCellsCount(selectedRange); + if (selectedCellsCount > 1) { + const records = getRecordsFromSelectedRange({ selectedRange, isGroupView, recordGetterByIndex }); + return this.props.getRecordsSummaries(records); + } + + return summaries; + }; + + getSummaryItems = () => { + const { columns, hasMore, isLoadingMore } = this.props; + const displayColumns = isLoadingMore || hasMore ? columns.slice(1, columns.length) : columns; + let totalWidth = SEQUENCE_COLUMN_WIDTH; + let summaryItems = Array.isArray(displayColumns) && displayColumns.map((column, columnIndex) => { + let summaryItem; + let { width, key } = column; + totalWidth += width; + summaryItem =
; + return summaryItem; + }); + return { summaryItems, totalWidth }; + }; + + getRecord = () => { + const { hasMore, hasSelectedRecord, recordMetrics, selectedRange, recordsCount } = this.props; + if (hasSelectedRecord) { + const selectedRecordsCount = RecordMetrics.getSelectedIds(recordMetrics).length; + return selectedRecordsCount > 1 ? gettext('xxx_records_selected').replace('xxx', selectedRecordsCount) : gettext('1 record selected'); + } + const selectedCellsCount = this.getSelectedCellsCount(selectedRange); + if (selectedCellsCount > 1) { + return gettext('xxx cells selected').replace('xxx', selectedCellsCount); + } + + let recordsCountText = gettext('No record'); + if (recordsCount > 1) { + recordsCountText = gettext('xxx records').replace('xxx', recordsCount); + } else if (recordsCount === 1) { + recordsCountText = gettext('1 record'); + } + if (hasMore) { + recordsCountText += ' +'; + } + return recordsCountText; + }; + + render() { + const { hasMore, isLoadingMore, columns, groupOffsetLeft } = this.props; + let { summaryItems, totalWidth } = this.getSummaryItems(); + const recordWidth = (isLoadingMore || hasMore ? SEQUENCE_COLUMN_WIDTH + columns[0].width : SEQUENCE_COLUMN_WIDTH) + groupOffsetLeft; + + return ( +
+
+ {this.getRecord()} + {!isLoadingMore && hasMore && + {gettext('Load_all')} + } + {isLoadingMore && + + {gettext('Loading')} + + + } +
+
+
this.summaryItemsRef = ref}> +
+ {summaryItems || ''} +
+
+
+
+ ); + } +} + +RecordsFooter.propTypes = { + hasMore: PropTypes.bool, + isLoadingMore: PropTypes.bool, + isGroupView: PropTypes.bool, + hasSelectedRecord: PropTypes.bool, + recordsCount: PropTypes.number, + summaries: PropTypes.object, + summaryConfigs: PropTypes.object, + columns: PropTypes.array, + groupOffsetLeft: PropTypes.number, + recordMetrics: PropTypes.object, + selectedRange: PropTypes.object, + recordGetterById: PropTypes.func, + recordGetterByIndex: PropTypes.func, + getRecordsSummaries: PropTypes.func, + clickToLoadMore: PropTypes.func, +}; + +export default RecordsFooter; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/record/index.css b/frontend/src/metadata/metadata-view/components/table/table-main/records/record/index.css new file mode 100644 index 0000000000..487dc858a4 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/record/index.css @@ -0,0 +1,72 @@ +.sf-metadata-result-table-row .rdg-row-expand-icon { + opacity: 0; + width: 20px; + height: 20px; + border-radius: 50%; + text-align: center; + line-height: 20px; + flex-shrink: 0; + cursor: pointer; +} + +.sf-metadata-result-table-row:hover .rdg-row-expand-icon { + opacity: 1; +} + +.sf-metadata-result-table-row .sf-metadata-result-column-content.actions-checkbox { + text-align: center; + display: none; +} + +.sf-metadata-result-table-row:hover .sf-metadata-result-column-content.actions-checkbox { + display: block; +} + +.sf-metadata-result-table-row:hover .sf-metadata-result-column-content.row-index { + display: none; +} + +.sf-metadata-result-table-row.row-selected .sf-metadata-result-column-content.actions-checkbox { + display: block !important; +} + +.sf-metadata-result-table-row.row-selected .sf-metadata-result-column-content.row-index { + display: none !important; +} + +.sf-metadata-result-table-row .rdg-row-expand-icon:hover { + background: #c2f5e9; +} + +.sf-metadata-result-table-row .rdg-row-expand-icon .sf-metadata-icon-open { + color: #467fcf !important; + fill: #467fcf !important; + transform: scale(0.8); +} + +.sf-metadata-result-table-row .cell-jump-link { + display: inline-block; + font-size: 14px; + height: 20px; + line-height: 20px; + margin-left: 8px; + border: 1px solid #eee; + padding: 0 2px; + color: #666666; + border-radius: 2px; + background: #fff; + cursor: pointer; + box-shadow: 0 0 1px; +} + +.cell-highlight { + background-color: rgb(239, 199, 151) !important; +} + +.cell-current-highlight { + background-color: #f09f3f !important; +} + +.frozen-columns { + background-color: #fff; +} diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/record/index.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/record/index.js new file mode 100644 index 0000000000..69850e8160 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/record/index.js @@ -0,0 +1,306 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import RecordCell from '../record-cell'; +import ActionsCell from '../actions-cell'; +import { getFrozenColumns } from '../../../../../utils/table-utils'; +import { Z_INDEX } from '../../../../../_basic'; + +import './index.css'; + +class Record extends React.Component { + + componentDidMount() { + this.checkScroll(); + } + + shouldComponentUpdate(nextProps) { + return ( + nextProps.isGroupView !== this.props.isGroupView || + nextProps.hasSelectedCell !== this.props.hasSelectedCell || + (nextProps.hasSelectedCell && this.props.selectedPosition.idx !== nextProps.selectedPosition.idx) || // selected cell in same row but different column + nextProps.isSelected !== this.props.isSelected || + nextProps.groupRecordIndex !== this.props.groupRecordIndex || + nextProps.index !== this.props.index || + nextProps.isLastRecord !== this.props.isLastRecord || + nextProps.lastFrozenColumnKey !== this.props.lastFrozenColumnKey || + nextProps.columns !== this.props.columns || + nextProps.colOverScanStartIdx !== this.props.colOverScanStartIdx || + nextProps.colOverScanEndIdx !== this.props.colOverScanEndIdx || + nextProps.record !== this.props.record || + nextProps.top !== this.props.top || + nextProps.left !== this.props.left || + nextProps.height !== this.props.height || + nextProps.searchResult !== this.props.searchResult || + nextProps.columnColor !== this.props.columnColor + ); + } + + checkScroll = () => { + this.cancelFixFrozenDOMs(this.props.scrollLeft); + }; + + cancelFixFrozenDOMs = (scrollLeft) => { + const { isGroupView } = this.props; + const frozenChildrenCount = this.frozenColumns.childElementCount; + if (!this.frozenColumns || frozenChildrenCount < 1 || (isGroupView && frozenChildrenCount < 2)) { + return; + } + this.frozenColumns.style.position = 'absolute'; + this.frozenColumns.style.marginLeft = scrollLeft + 'px'; + this.frozenColumns.style.marginTop = '0px'; + }; + + onSelectRecord = (e) => { + const { groupRecordIndex, index } = this.props; + this.props.selectNoneCells(); + this.props.onSelectRecord({ groupRecordIndex, recordIndex: index }, e); + }; + + onRowExpand = () => { + const { record } = this.props; + this.props.onRowExpand(record); + }; + + isCellSelected = (columnIdx) => { + const { hasSelectedCell, selectedPosition } = this.props; + if (!selectedPosition) return false; + return hasSelectedCell && selectedPosition.idx === columnIdx; + }; + + isLastCell(columns, columnKey) { + return columns[columns.length - 1].key === columnKey; + } + + reloadCurrentRecord = () => { + this.props.reloadRecords([this.props.record._id]); + }; + + getFrozenCells = () => { + const { + columns, lastFrozenColumnKey, groupRecordIndex, index: recordIndex, record, + cellMetaData, isGroupView, height, columnColor + } = this.props; + const frozenColumns = getFrozenColumns(columns); + if (frozenColumns.length === 0) return null; + const recordId = record._id; + return frozenColumns.map((column, index) => { + const { key } = column; + const isCellHighlight = this.isCellHighlight(key, recordId); + const isCurrentCellHighlight = this.isCurrentCellHighlight(key, recordId); + const highlightClassName = isCurrentCellHighlight ? 'cell-current-highlight' : isCellHighlight ? 'cell-highlight' : null; + const isCellSelected = this.isCellSelected(index); + const isLastCell = this.isLastCell(columns, key); + const isLastFrozenCell = key === lastFrozenColumnKey; + const bgColor = columnColor && columnColor[key]; + return ( + + ); + }); + }; + + isCellHighlight = (columnKey, rowId) => { + const { searchResult } = this.props; + if (searchResult) { + const matchedColumns = searchResult.matchedRows[rowId]; + if (matchedColumns && matchedColumns.includes(columnKey)) { + return true; + } + } + return false; + }; + + isCurrentCellHighlight = (columnKey, rowId) => { + const { searchResult } = this.props; + if (searchResult) { + const { currentSelectIndex } = searchResult; + if (typeof(currentSelectIndex) !== 'number') return false; + const currentSelectCell = searchResult.matchedCells[currentSelectIndex]; + if (!currentSelectCell) return false; + if (currentSelectCell.row === rowId && currentSelectCell.column === columnKey) return true; + } + return false; + }; + + getColumnCells = () => { + const { + columns, colOverScanStartIdx, colOverScanEndIdx, groupRecordIndex, index: recordIndex, + record, cellMetaData, isGroupView, height, columnColor + } = this.props; + const recordId = record._id; + const rendererColumns = columns.slice(colOverScanStartIdx, colOverScanEndIdx); + return rendererColumns.map((column) => { + const { key, frozen } = column; + const needBindEvents = !frozen; + const isCellSelected = this.isCellSelected(columns.findIndex(col => col.key === column.key)); + const isCellHighlight = this.isCellHighlight(key, recordId); + const isCurrentCellHighlight = this.isCurrentCellHighlight(key, recordId); + const highlightClassName = isCurrentCellHighlight ? 'cell-current-highlight' : isCellHighlight ? 'cell-highlight' : null; + const isLastCell = this.isLastCell(columns, key); + const bgColor = columnColor && columnColor[key]; + return ( + + ); + }); + }; + + getRecordStyle = () => { + const { isGroupView, height } = this.props; + let style = { + height: height + 'px', + }; + if (isGroupView) { + const { top, left } = this.props; + style.top = top + 'px'; + style.left = left + 'px'; + } + return style; + }; + + getFrozenColumnsStyle = () => { + const { isGroupView, lastFrozenColumnKey, height } = this.props; + let style = { + zIndex: Z_INDEX.SEQUENCE_COLUMN, + height: height - 1, + }; + if (isGroupView) { + style.height = height; + style.zIndex = Z_INDEX.FROZEN_GROUP_CELL; + if (!lastFrozenColumnKey) { + style.marginLeft = '0px'; + } + } + return style; + }; + + // handle drag copy + handleDragEnter = (e) => { + // Prevent default to allow drop + e.preventDefault(); + const { index, groupRecordIndex, cellMetaData: { onDragEnter } } = this.props; + onDragEnter({ overRecordIdx: index, overGroupRecordIndex: groupRecordIndex }); + }; + + handleDragOver = (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + }; + + handleDrop = (e) => { + // The default in Firefox is to treat data in dataTransfer as a URL and perform navigation on it, even if the data type used is 'text' + // To bypass this, we need to capture and prevent the drop event. + e.preventDefault(); + }; + + render() { + const { + isSelected, isGroupView, index, isLastRecord, lastFrozenColumnKey, height, record + } = this.props; + const isLocked = record._locked ? true : false; + const cellHeight = isGroupView ? height : height - 1; + + const frozenCells = this.getFrozenCells(); + const columnCells = this.getColumnCells(); + + return ( +
+ {/* frozen */} +
this.frozenColumns = ref} + > + + {frozenCells} +
+ {/* scroll */} + {columnCells} +
+ ); + } +} + +Record.propTypes = { + hasSelectedCell: PropTypes.bool, + isGroupView: PropTypes.bool, + isSelected: PropTypes.bool, + groupRecordIndex: PropTypes.number, + index: PropTypes.number.isRequired, + isLastRecord: PropTypes.bool, + lastFrozenColumnKey: PropTypes.string, + cellMetaData: PropTypes.object, + selectedPosition: PropTypes.object, + record: PropTypes.object.isRequired, + columns: PropTypes.array.isRequired, + colOverScanStartIdx: PropTypes.number, + colOverScanEndIdx: PropTypes.number, + scrollLeft: PropTypes.number, + top: PropTypes.number, + left: PropTypes.number, + height: PropTypes.number, + selectNoneCells: PropTypes.func, + onSelectRecord: PropTypes.func, + onRowExpand: PropTypes.func, + modifyRecord: PropTypes.func, + lockRecordViaButton: PropTypes.func, + modifyRecordViaButton: PropTypes.func, + reloadRecords: PropTypes.func, + searchResult: PropTypes.object, + columnColor: PropTypes.object, +}; + +export default Record; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/records-body.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/records-body.js new file mode 100644 index 0000000000..9ef06140a3 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/records-body.js @@ -0,0 +1,646 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { Loading } from '@seafile/sf-metadata-ui-component'; +import { RightScrollbar } from '../../../scrollbar'; +import Record from './record'; +import InteractionMasks from '../../table-masks/interaction-masks'; +import { EVENT_BUS_TYPE, SEQUENCE_COLUMN_WIDTH } from '../../../../constants'; +import { isShiftKeyDown } from '../../../../utils/keyboard-utils'; +import { isColumnSupportDirectEdit, isColumnSupportEdit } from '../../../../utils/column-utils'; +import { isSelectedCellSupportOpenEditor } from '../../../../utils/selected-cell-utils'; +import RecordMetrics from '../../../../utils/record-metrics'; +import { getColumnScrollPosition, getColVisibleStartIdx, getColVisibleEndIdx } from '../../../../utils/records-body-utils'; + +const ROW_HEIGHT = 33; +const RENDER_MORE_NUMBER = 10; +const CONTENT_HEIGHT = window.innerHeight - 174; +const { max, min, ceil, round } = Math; + +class RecordsBody extends Component { + + static defaultProps = { + editorPortalTarget: document.body, + scrollToRowIndex: 0, + }; + + constructor(props) { + super(props); + this.state = { + startRenderIndex: 0, + endRenderIndex: this.getInitEndIndex(props), + isContextMenuShow: false, + activeRecords: [], + menuPosition: null, + selectedPosition: null, + isScrollingRightScrollbar: false, + }; + this.resultContentRef = null; + this.resultRef = null; + this.recordFrozenRefs = []; + this.frozenBtnAddRecordRefs = []; + this.rowVisibleStart = 0; + this.rowVisibleEnd = this.setRecordVisibleEnd(); + this.columnVisibleStart = 0; + this.columnVisibleEnd = this.setColumnVisibleEnd(); + this.timer = null; + } + + componentDidMount() { + this.props.onRef(this); + window.sfMetadataBody = this; + document.addEventListener('contextmenu', this.handleContextMenu); + } + + UNSAFE_componentWillReceiveProps(nextProps) { + const { recordsCount, recordIds } = nextProps; + if (recordsCount !== this.props.recordsCount || recordIds !== this.props.recordIds) { + this.recalculateRenderIndex(recordIds); + } + } + + componentWillUnmount() { + document.removeEventListener('contextmenu', this.handleContextMenu); + + this.clearHorizontalScroll(); + this.clearScrollbarTimer(); + this.setState = (state, callback) => { + return; + }; + } + + getVisibleIndex = () => { + return { rowVisibleStartIdx: this.rowVisibleStart, rowVisibleEndIdx: this.rowVisibleEnd }; + }; + + getShownRecords = () => { + return this.getShownRecordIds().map((id) => this.props.recordGetterById(id)); + }; + + setRecordVisibleEnd = () => { + return max(ceil(CONTENT_HEIGHT / ROW_HEIGHT), 0); + }; + + setColumnVisibleEnd = () => { + const { columns, getScrollLeft, tableContentWidth } = this.props; + let columnVisibleEnd = 0; + const contentScrollLeft = getScrollLeft(); + let endColumnWidth = tableContentWidth + contentScrollLeft; + for (let i = 0; i < columns.length; i ++) { + const { width } = columns[i]; + endColumnWidth = endColumnWidth - width; + if (endColumnWidth < 0) { + return columnVisibleEnd = i; + } + } + return columnVisibleEnd; + }; + + recalculateRenderIndex = (recordIds) => { + const { startRenderIndex, endRenderIndex } = this.state; + const contentScrollTop = this.resultContentRef.scrollTop; + const start = Math.max(0, Math.floor(contentScrollTop / ROW_HEIGHT) - RENDER_MORE_NUMBER); + const end = Math.min(Math.ceil((contentScrollTop + this.resultContentRef.offsetHeight) / ROW_HEIGHT) + RENDER_MORE_NUMBER, recordIds.length); + if (start !== startRenderIndex) { + this.setState({ startRenderIndex: start }); + } + if (end !== endRenderIndex) { + this.setState({ endRenderIndex: end }); + } + }; + + getInitEndIndex = (props) => { + return Math.min(Math.ceil(window.innerHeight / ROW_HEIGHT) + RENDER_MORE_NUMBER, props.recordsCount); + }; + + getShownRecordIds = () => { + const { recordIds } = this.props; + const { startRenderIndex, endRenderIndex } = this.state; + return recordIds.slice(startRenderIndex, endRenderIndex); + }; + + getRowTop = (rowIdx) => { + return ROW_HEIGHT * rowIdx; + }; + + getRowHeight = () => { + return ROW_HEIGHT; + }; + + jumpToRow = (scrollToRowIndex) => { + const { recordsCount } = this.props; + const rowHeight = this.getRowHeight(); + const height = this.resultContentRef.offsetHeight; + const scrollTop = Math.min(scrollToRowIndex * rowHeight, recordsCount * rowHeight - height); + this.setScrollTop(scrollTop); + }; + + scrollToColumn = (idx) => { + const { columns, tableContentWidth } = this.props; + const newScrollLeft = getColumnScrollPosition(columns, idx, tableContentWidth); + if (newScrollLeft !== null) { + this.props.setRecordsScrollLeft(newScrollLeft); + } + this.updateColVisibleIndex(newScrollLeft); + }; + + updateColVisibleIndex = (scrollLeft) => { + const { columns } = this.props; + const columnVisibleStart = getColVisibleStartIdx(columns, scrollLeft); + const columnVisibleEnd = getColVisibleEndIdx(columns, window.innerWidth, scrollLeft); + this.columnVisibleStart = columnVisibleStart; + this.columnVisibleEnd = columnVisibleEnd; + }; + + setScrollTop = (scrollTop) => { + this.resultContentRef.scrollTop = scrollTop; + }; + + setScrollLeft = (scrollLeft) => { + const { interactionMask } = this; + interactionMask && interactionMask.setScrollLeft(scrollLeft); + }; + + cancelSetScrollLeft = () => { + const { interactionMask } = this; + interactionMask && interactionMask.cancelSetScrollLeft(); + }; + + getClientScrollTopOffset = (node) => { + const rowHeight = this.getRowHeight(); + const scrollVariation = node.scrollTop % rowHeight; + return scrollVariation > 0 ? rowHeight - scrollVariation : 0; + }; + + onHitBottomCanvas = () => { + const rowHeight = this.getRowHeight(); + const node = this.resultContentRef; + node.scrollTop += rowHeight + this.getClientScrollTopOffset(node); + }; + + onHitTopCanvas = () => { + const rowHeight = this.getRowHeight(); + const node = this.resultContentRef; + node.scrollTop -= (rowHeight - this.getClientScrollTopOffset(node)); + }; + + getScrollTop = () => { + return this.resultContentRef ? this.resultContentRef.scrollTop : 0; + }; + + getRecordBodyHeight = () => { + return this.resultContentRef ? this.resultContentRef.offsetHeight : 0; + }; + + onScroll = () => { + const { recordsCount } = this.props; + const { startRenderIndex, endRenderIndex } = this.state; + const { offsetHeight, scrollTop: contentScrollTop } = this.resultContentRef; + // Calculate the start rendering row index, and end rendering row index + const start = Math.max(0, Math.floor(contentScrollTop / ROW_HEIGHT) - RENDER_MORE_NUMBER); + const end = Math.min(Math.ceil((contentScrollTop + this.resultContentRef.offsetHeight) / ROW_HEIGHT) + RENDER_MORE_NUMBER, recordsCount); + + this.oldScrollTop = contentScrollTop; + const renderedRecordsCount = ceil(this.resultContentRef.offsetHeight / ROW_HEIGHT); + const newRecordVisibleStart = max(0, round(contentScrollTop / ROW_HEIGHT)); + const newRecordVisibleEnd = min(newRecordVisibleStart + renderedRecordsCount, recordsCount); + this.rowVisibleStart = newRecordVisibleStart; + this.rowVisibleEnd = newRecordVisibleEnd; + + this.props.cacheScrollTop(contentScrollTop); + + if (Math.abs(start - startRenderIndex) > 5 || start < 5) { + this.setState({ startRenderIndex: start }); + } + if (Math.abs(end - endRenderIndex) > 5 || end > recordsCount - 5) { + this.setState({ endRenderIndex: end }); + } + // Scroll to the bottom of the page, load more records + if (offsetHeight + contentScrollTop >= this.resultContentRef.scrollHeight) { + this.props.scrollToLoadMore(); + } + + if (!this.isScrollingRightScrollbar) { + this.setRightScrollbarScrollTop(this.oldScrollTop); + } + + // solve the bug that the scroll bar disappears when scrolling too fast + this.clearScrollbarTimer(); + this.scrollbarTimer = setTimeout(() => { + this.setState({ isScrollingRightScrollbar: false }); + }, 300); + }; + + onRowExpand = (row) => { + this.props.onRowExpand && this.props.onRowExpand(row); + }; + + onScrollbarScroll = (scrollTop) => { + // solve canvas&rightScrollbar circle scroll problem + if (this.oldScrollTop === scrollTop) { + return; + } + this.setState({ isScrollingRightScrollbar: true }, () => { + this.setScrollTop(scrollTop); + }); + }; + + onScrollbarMouseUp = () => { + this.setState({ isScrollingRightScrollbar: false }); + }; + + setRightScrollbarScrollTop = (scrollTop) => { + this.rightScrollbar && this.rightScrollbar.setScrollTop(scrollTop); + }; + + onDeleteRecords = () => { + this.interactionMask && this.interactionMask.selectNone(); + this.props.selectNone(); + this.props.onDeleteRecords(this.state.activeRecords); + }; + + onInsertRecords = ({ insertRecordsNumber }) => { + const activeRecord = this.state.activeRecords[0]; + const upperRecordId = activeRecord && activeRecord._id; + if (!upperRecordId) return; + this.props.insertRecords({ upperRecordId, insertRecordsNumber }); + }; + + addBlankRecord = () => { + const { recordsCount, recordIds } = this.props; + const lastRecordIndex = recordsCount - 1; + const lastRecordId = lastRecordIndex > -1 && recordIds[lastRecordIndex]; + this.props.insertRecords({ upperRecordId: lastRecordId, insertRecordsNumber: 1 }); + }; + + onDuplicateRecord = () => { + this.props.duplicateRecord(this.state.activeRecords[0]); + }; + + onDuplicateRecords = () => { + this.props.duplicateRecords(this.state.activeRecords); + }; + + selectNoneCells = () => { + this.interactionMask && this.interactionMask.selectNone(); + const { selectedPosition } = this.state; + if (!selectedPosition || selectedPosition.idx < 0 || selectedPosition.rowIdx < 0) { + return; + } + this.selectNone(); + }; + + selectNone = () => { + this.setState({ selectedPosition: { idx: -1, rowIdx: -1 } }); + }; + + selectCell = (cell, openEditor) => { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_CELL, cell, openEditor); + }; + + selectStart = (cellPosition) => { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_START, cellPosition); + }; + + selectUpdate = (cellPosition, isFromKeyboard, callback) => { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_UPDATE, cellPosition, isFromKeyboard, callback); + }; + + selectEnd = () => { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_END); + }; + + onCellClick = (cell, e) => { + const { selectedPosition } = this.state; + if (isShiftKeyDown(e)) { + if (!selectedPosition || selectedPosition.idx === -1) { + // need select cell first + this.selectCell(cell, false); + return; + } + const isFromKeyboard = true; + this.selectUpdate(cell, isFromKeyboard); + } else { + const { columns } = this.props; + const supportOpenEditor = isColumnSupportDirectEdit(cell, columns); + const hasOpenPermission = isSelectedCellSupportOpenEditor(cell, columns, false, this.props.recordGetterByIndex); + this.selectCell(cell, supportOpenEditor && hasOpenPermission); + } + this.props.onCellClick(cell); + this.setState({ selectedPosition: cell }); + }; + + onCellDoubleClick = (cell, e) => { + const { columns } = this.props; + const supportOpenEditor = isColumnSupportEdit(cell, columns); + const hasOpenPermission = isSelectedCellSupportOpenEditor(cell, columns, false, this.props.recordGetterByIndex); + this.selectCell(cell, supportOpenEditor && hasOpenPermission); + }; + + // onRangeSelectStart + onCellMouseDown = (cellPosition, event) => { + if (!isShiftKeyDown(event)) { + this.selectCell(cellPosition); + this.selectStart(cellPosition); + window.addEventListener('mouseup', this.onWindowMouseUp); + } + }; + + // onRangeSelectUpdate + onCellMouseEnter = (cellPosition) => { + this.selectUpdate(cellPosition, false, this.updateViewableArea); + }; + + onCellMouseMove = (cellPosition) => { + this.selectUpdate(cellPosition, false, this.updateViewableArea); + }; + + onWindowMouseUp = (event) => { + window.removeEventListener('mouseup', this.onWindowMouseUp); + if (isShiftKeyDown(event)) return; + this.selectEnd(); + this.clearHorizontalScroll(); + }; + + onCellRangeSelectionUpdated = (selectedRange) => { + this.props.onCellRangeSelectionUpdated(selectedRange); + }; + + /** + * When updating the selection by moving the mouse, you need to automatically scroll to expand the visible area + * @param {object} selectedRange + */ + updateViewableArea = (selectedRange) => { + const { mousePosition } = selectedRange.cursorCell; + const { x: mouseX, y: mouseY } = mousePosition; + const tableHeaderHeight = 50 + 48 + 32; + const interval = 100; + const step = 8; + + // cursor is at right boundary + if (mouseX + interval > window.innerWidth) { + this.scrollToRight(); + } else if (mouseX - interval < SEQUENCE_COLUMN_WIDTH + this.props.frozenColumnsWidth) { + // cursor is at left boundary + this.scrollToLeft(); + } else if (mouseY + interval > window.innerHeight - tableHeaderHeight) { + // cursor is at bottom boundary + const scrollTop = this.getScrollTop(); + this.resultContentRef.scrollTop = scrollTop + step; + this.clearHorizontalScroll(); + } else if (mouseY - interval < tableHeaderHeight) { + // cursor is at top boundary + const scrollTop = this.getScrollTop(); + if (scrollTop - 16 >= 0) { + this.resultContentRef.scrollTop = scrollTop - step; + } + this.clearHorizontalScroll(); + } else { + // cursor is at middle area + this.clearHorizontalScroll(); + } + }; + + scrollToRight = () => { + if (this.timer) return; + this.timer = setInterval(() => { + const scrollLeft = this.props.getScrollLeft(); + this.props.setRecordsScrollLeft(scrollLeft + 20); + }, 10); + }; + + scrollToLeft = () => { + if (this.timer) return; + this.timer = setInterval(() => { + const scrollLeft = this.props.getScrollLeft(); + if (scrollLeft <= 0) { + this.clearHorizontalScroll(); + return; + } + this.props.setRecordsScrollLeft(scrollLeft - 20); + }, 10); + }; + + clearHorizontalScroll = () => { + if (!this.timer) return; + clearInterval(this.timer); + this.timer = null; + }; + + clearScrollbarTimer = () => { + if (!this.scrollbarTimer) return; + clearTimeout(this.scrollbarTimer); + this.scrollbarTimer = null; + }; + + getCellMetaData = () => { + if (this.cellMetaData) { + return this.cellMetaData; + } + this.cellMetaData = { + onCellClick: this.onCellClick, + onCellDoubleClick: this.onCellDoubleClick, + onCellMouseDown: this.onCellMouseDown, + onCellMouseEnter: this.onCellMouseEnter, + onCellMouseMove: this.onCellMouseMove, + onDragEnter: this.handleDragEnter, + }; + return this.cellMetaData; + }; + + handleDragEnter = ({ overRecordIdx, overGroupRecordIndex }) => { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.DRAG_ENTER, { overRecordIdx, overGroupRecordIndex }); + }; + + setRightScrollbar = (ref) => { + this.rightScrollbar = ref; + }; + + setInteractionMaskRef = (ref) => { + this.interactionMask = ref; + }; + + setResultRef = (ref) => { + this.resultRef = ref; + }; + + setResultContentRef = (ref) => { + this.resultContentRef = ref; + }; + + renderRecords = () => { + this.recordFrozenRefs = []; + this.frozenBtnAddRecordRefs = []; + const { + recordsCount, columns, colOverScanStartIdx, colOverScanEndIdx, lastFrozenColumnKey, + recordMetrics, showCellColoring, columnColors + } = this.props; + const { startRenderIndex, endRenderIndex, selectedPosition } = this.state; + const cellMetaData = this.getCellMetaData(); + const lastRecordIndex = recordsCount - 1; + const shownRecordIds = this.getShownRecordIds(); + const scrollLeft = this.props.getScrollLeft(); + const rowHeight = this.getRowHeight(); + let shownRecords = shownRecordIds.map((recordId, index) => { + const record = this.props.recordGetterById(recordId); + const isSelected = RecordMetrics.isRecordSelected(recordId, recordMetrics); + const recordIndex = startRenderIndex + index; + const isLastRecord = lastRecordIndex === recordIndex; + const hasSelectedCell = this.props.hasSelectedCell({ recordIndex }, selectedPosition); + const columnColor = showCellColoring ? columnColors[recordId] : {}; + return ( + { + this.recordFrozenRefs.push(ref); + }} + isSelected={isSelected} + index={recordIndex} + isLastRecord={isLastRecord} + record={record} + columns={columns} + colOverScanStartIdx={colOverScanStartIdx} + colOverScanEndIdx={colOverScanEndIdx} + lastFrozenColumnKey={lastFrozenColumnKey} + scrollLeft={scrollLeft} + height={rowHeight} + cellMetaData={cellMetaData} + hasSelectedCell={hasSelectedCell} + selectedPosition={this.state.selectedPosition} + selectNoneCells={this.selectNoneCells} + onSelectRecord={this.props.onSelectRecord} + onRowExpand={this.onRowExpand} + modifyRecord={this.props.modifyRecord} + searchResult={this.props.searchResult} + columnColor={columnColor} + /> + ); + }); + + const upperHeight = startRenderIndex * ROW_HEIGHT; + const belowHeight = (recordsCount - endRenderIndex) * ROW_HEIGHT; + // add top placeholder + if (upperHeight > 0) { + const style = { height: upperHeight, width: '100%' }; + const upperRow =
; + shownRecords.unshift(upperRow); + } + // add bottom placeholder + if (belowHeight > 0) { + const style = { height: belowHeight, width: '100%' }; + const belowRow =
; + shownRecords.push(belowRow); + } + + return shownRecords; + }; + + render() { + // const { isContextMenuShow, menuPosition, activeRecords } = this.state; + return ( + +
+ +
+ {this.renderRecords()} +
+
+ +
+ ); + } +} + +RecordsBody.propTypes = { + onRef: PropTypes.func, + canAddRow: PropTypes.bool, + gridUtils: PropTypes.object, + table: PropTypes.object, + recordIds: PropTypes.array, + recordsCount: PropTypes.number, + columns: PropTypes.array.isRequired, + colOverScanStartIdx: PropTypes.number, + colOverScanEndIdx: PropTypes.number, + lastFrozenColumnKey: PropTypes.string, + hasSelectedRecord: PropTypes.bool, + recordMetrics: PropTypes.object, + totalWidth: PropTypes.number, + getScrollLeft: PropTypes.func, + setRecordsScrollLeft: PropTypes.func, + hasSelectedCell: PropTypes.func, + cacheScrollTop: PropTypes.func, + scrollToLoadMore: PropTypes.func, + getTableContentLeft: PropTypes.func, + getMobileFloatIconStyle: PropTypes.func, + onToggleMobileMoreOperations: PropTypes.func, + onToggleInsertRecordDialog: PropTypes.func, + onDeleteRecords: PropTypes.func, + duplicateRecord: PropTypes.func, + duplicateRecords: PropTypes.func, + lockRecordViaButton: PropTypes.func, + modifyRecordViaButton: PropTypes.func, + editorPortalTarget: PropTypes.instanceOf(Element), + recordGetterByIndex: PropTypes.func, + recordGetterById: PropTypes.func, + modifyRecord: PropTypes.func.isRequired, + selectNone: PropTypes.func, + onCellClick: PropTypes.func, + onCellRangeSelectionUpdated: PropTypes.func, + onSelectRecord: PropTypes.func, + updateRecords: PropTypes.func, + deleteRecordsLinks: PropTypes.func, + paste: PropTypes.func, + searchResult: PropTypes.object, + scrollToRowIndex: PropTypes.number, + tableContentWidth: PropTypes.number, + frozenColumnsWidth: PropTypes.number, + editMobileCell: PropTypes.func, + insertRecords: PropTypes.func, + reloadRecords: PropTypes.func, + appPage: PropTypes.object, + showCellColoring: PropTypes.bool, + columnColors: PropTypes.object, + onFillingDragRows: PropTypes.func, + getCopiedRecordsAndColumnsFromRange: PropTypes.func, + openDownloadFilesDialog: PropTypes.func, + cacheDownloadFilesProps: PropTypes.func, + onRowExpand: PropTypes.func, +}; + +export default RecordsBody; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/records-group-body.jsx b/frontend/src/metadata/metadata-view/components/table/table-main/records/records-group-body.jsx new file mode 100644 index 0000000000..9f6ff01bae --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/records-group-body.jsx @@ -0,0 +1,1040 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { CellType } from '../../../../_basic'; +import GroupContainer from './group-widgets/group-container'; +import InteractionMasks from '../../table-masks/interaction-masks'; +import { RightScrollbar } from '../../../scrollbar'; +import Record from './record'; +import { createGroupMetrics, getGroupRecordByIndex, isNestedGroupRow } from '../../../../utils/group-metrics'; +import RecordMetrics from '../../../../utils/record-metrics'; +import { isColumnSupportDirectEdit, isColumnSupportEdit } from '../../../../utils/column-utils'; +import { isShiftKeyDown } from '../../../../utils/keyboard-utils'; +import { isSelectedCellSupportOpenEditor } from '../../../../utils/selected-cell-utils'; +import { getColumnScrollPosition, getColVisibleEndIdx, getColVisibleStartIdx } from '../../../../utils/records-body-utils'; +import { isFrozen, isNameColumn } from '../../../../utils/column-utils'; +import { GROUP_HEADER_HEIGHT, GROUP_ROW_TYPE, GROUP_VIEW_OFFSET, SEQUENCE_COLUMN_WIDTH, EVENT_BUS_TYPE } from '../../../../constants'; +import { addClassName, removeClassName } from '../../../../utils'; + +const ROW_HEIGHT = 32; +const GROUP_OVERSCAN_ROWS = 10; +const MAX_ANIMATION_ROWS = 50; +const LOCAL_FOLDED_GROUP_KEY = 'path_folded_group'; +const { max, min } = Math; + +class RecordsGroupBody extends Component { + + static defaultProps = { + editorPortalTarget: document.body, + scrollToRowIndex: 0, + }; + + constructor(props) { + super(props); + const { groups, groupbys, allColumns } = props; + const rowHeight = this.getRowHeight(); + const pathFoldedGroupMap = this.getFoldedGroups(); + const groupMetrics = createGroupMetrics(groups, groupbys, pathFoldedGroupMap, allColumns, rowHeight, false); + const { startRenderIndex, endRenderIndex } = this.getGroupVisibleBoundaries(window.innerHeight, 0, groupMetrics, rowHeight); + this.state = { + isContextMenuShow: false, + activeRecords: [], + menuPosition: null, + groupMetrics, + startRenderIndex, + endRenderIndex, + pathFoldedGroupMap, + isScrollingRightScrollbar: false, + selectedPosition: null, + }; + this.groupsNode = {}; + this.recordFrozenRefs = []; + this.frozenBtnAddRecordRefs = []; + this.rowVisibleStart = startRenderIndex; + this.rowVisibleEnd = endRenderIndex; + this.columnVisibleStart = 0; + this.columnVisibleEnd = this.setColumnVisibleEnd(); + this.disabledAnimation = false; + this.nextPathFoldedGroupMap = null; + } + + componentDidMount() { + window.sfMetadataBody = this; + window.addEventListener('resize', this.onResize); + this.props.onRef(this); + this.unSubscribeCollapseAllGroups = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.COLLAPSE_ALL_GROUPS, this.collapseAllGroups); + this.unSubscribeExpandAllGroups = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.EXPAND_ALL_GROUPS, this.expandAllGroups); + } + + componentDidUpdate(prevProps) { + const { groupbys, groups, allColumns, searchResult } = this.props; + const { scrollTop } = this.resultContentRef; + const rowHeight = this.getRowHeight(); + if ( + groupbys !== prevProps.groupbys || + groups !== prevProps.groups || + searchResult !== prevProps.searchResult + ) { + const gridHeight = window.innerHeight; + const { matchedCells } = searchResult || {}; + const pathFoldedGroupMap = Array.isArray(matchedCells) && matchedCells.length > 0 ? {} : this.getFoldedGroups(); + const groupMetrics = createGroupMetrics(groups, groupbys, pathFoldedGroupMap, allColumns, rowHeight, false); + this.updateScroll({ gridHeight, scrollTop, groupMetrics, rowHeight }); + } + if (this.disabledAnimation) { + this.ableRecordsAnimation(); + } + if (this.expandingGroupPathString) { + const groupMetrics = createGroupMetrics(groups, groupbys, this.nextPathFoldedGroupMap, allColumns, rowHeight, false); + this.updateScroll({ scrollTop, groupMetrics, pathFoldedGroupMap: this.nextPathFoldedGroupMap }); + this.expandingGroupPathString = null; + this.nextPathFoldedGroupMap = null; + } + } + + componentWillUnmount() { + window.removeEventListener('resize', this.onResize); + this.unSubscribeCollapseAllGroups(); + this.unSubscribeExpandAllGroups(); + + this.clearHorizontalScroll(); + this.clearScrollbarTimer(); + this.setState = (state, callback) => { + return; + }; + } + + getShownRecords = () => { + const { startRenderIndex, endRenderIndex, groupMetrics } = this.state; + const visibleGroupRows = this.getVisibleGroupRecords(startRenderIndex, endRenderIndex, groupMetrics.groupRows); + return visibleGroupRows.map(groupRow => this.props.recordGetterById(groupRow.rowId)).filter(row => !!row); + }; + + getGroupVisibleBoundaries = (gridHeight, scrollTop, groupMetrics, rowHeight) => { + const { groupRows, groupRowsHeight, maxLevel } = groupMetrics; + if (!Array.isArray(groupRows) || groupRows.length === 0) { + return { startRenderIndex: 0, endRenderIndex: 0 }; + } + let startRenderIndex = 0; + let endRenderIndex = 0; + const GROUP_TOP_OFFSET = GROUP_HEADER_HEIGHT * maxLevel + GROUP_OVERSCAN_ROWS * rowHeight; + const GROUP_BOTTOM_OFFSET = GROUP_HEADER_HEIGHT * maxLevel + GROUP_OVERSCAN_ROWS * rowHeight; + const overScanStartTop = max(0, scrollTop - GROUP_TOP_OFFSET); + const overScanEndTop = min(groupRowsHeight, scrollTop + gridHeight + GROUP_BOTTOM_OFFSET); + const groupRowsLen = groupRows.length; + for (let i = 0; i < groupRowsLen; i++) { + const groupRow = groupRows[i]; + const { top } = groupRow; + if (top <= overScanStartTop) { + startRenderIndex++; + } + if (top <= overScanEndTop) { + endRenderIndex++; + } + } + return { startRenderIndex, endRenderIndex }; + }; + + setGroupNode = (groupPathString) => node => { + this.groupsNode[groupPathString] = node; + }; + + setResultContentRef = (ref) => { + this.resultContentRef = ref; + }; + + setInteractionMaskRef = (ref) => { + this.interactionMask = ref; + }; + + setResultRef = (ref) => { + this.resultRef = ref; + }; + + setScrollTop = (scrollTop) => { + this.resultContentRef.scrollTop = scrollTop; + }; + + setScrollLeft = (scrollLeft) => { + this.interactionMask && this.interactionMask.setScrollLeft(scrollLeft); + }; + + cancelSetScrollLeft = () => { + this.interactionMask && this.interactionMask.cancelSetScrollLeft(); + }; + + setRightScrollbar = (ref) => { + this.rightScrollbar = ref; + }; + + setColumnVisibleEnd = () => { + const { columns, getScrollLeft, tableContentWidth } = this.props; + let columnVisibleEnd = 0; + const contentScrollLeft = getScrollLeft(); + let endColumnWidth = tableContentWidth + contentScrollLeft; + for (let i = 0; i < columns.length; i ++) { + const { width } = columns[i]; + endColumnWidth = endColumnWidth - width; + if (endColumnWidth < 0) { + return columnVisibleEnd = i; + } + } + return columnVisibleEnd; + }; + + getScrollTop = () => { + return this.resultContentRef ? this.resultContentRef.scrollTop : 0; + }; + + getRowHeight = () => { + return ROW_HEIGHT; + }; + + getRowTop = (groupRecordIndex) => { + const { groupMetrics } = this.state; + const groupRow = getGroupRecordByIndex(groupRecordIndex, groupMetrics); + if (!groupRow) { + return 0; + } + return groupRow.top || 0; + }; + + jumpToRow = (scrollToGroupRecordIndex) => { + const { groupMetrics } = this.state; + const height = this.resultContentRef.offsetHeight; + const groupRecordTop = this.getRowTop(scrollToGroupRecordIndex); + const scrollTop = Math.min(groupRecordTop, groupMetrics.groupRowsHeight - height); + this.setScrollTop(scrollTop); + }; + + scrollToColumn = (idx) => { + const { columns, tableContentWidth } = this.props; + const newScrollLeft = getColumnScrollPosition(columns, idx, tableContentWidth); + if (newScrollLeft !== null) { + this.props.setRecordsScrollLeft(newScrollLeft); + } + this.updateColVisibleIndex(newScrollLeft); + }; + + updateColVisibleIndex = (scrollLeft) => { + const { columns } = this.props; + const columnVisibleStart = getColVisibleStartIdx(columns, scrollLeft); + const columnVisibleEnd = getColVisibleEndIdx(columns, window.innerWidth, scrollLeft); + this.columnVisibleStart = columnVisibleStart; + this.columnVisibleEnd = columnVisibleEnd; + }; + + getRecordBodyHeight = () => { + return this.resultContentRef ? this.resultContentRef.offsetHeight : 0; + }; + + /** + * When updating the selection by moving the mouse, you need to automatically scroll to expand the visible area + * @param {object} selectedRange + */ + updateViewableArea = (selectedRange) => { + const { mousePosition } = selectedRange.cursorCell; + const { x: mouseX, y: mouseY } = mousePosition; + const tableHeaderHeight = 50 + 48 + 32; + const interval = 100; + const step = 8; + + // cursor is at right boundary + if (mouseX + interval > window.innerWidth) { + this.scrollToRight(); + } else if (mouseX - interval < SEQUENCE_COLUMN_WIDTH + this.props.frozenColumnsWidth) { + // cursor is at left boundary + this.scrollToLeft(); + } else if (mouseY + interval > window.innerHeight - tableHeaderHeight) { + // cursor is at bottom boundary + const scrollTop = this.getScrollTop(); + this.resultContentRef.scrollTop = scrollTop + step; + this.clearHorizontalScroll(); + } else if (mouseY - interval < tableHeaderHeight) { + // cursor is at top boundary + const scrollTop = this.getScrollTop(); + if (scrollTop - 16 >= 0) { + this.resultContentRef.scrollTop = scrollTop - step; + } + this.clearHorizontalScroll(); + } else { + // cursor is at middle area + this.clearHorizontalScroll(); + } + }; + + scrollToRight = () => { + if (this.scrollTimer) return; + this.scrollTimer = setInterval(() => { + const scrollLeft = this.props.getScrollLeft(); + this.props.setRecordsScrollLeft(scrollLeft + 20); + }, 10); + }; + + scrollToLeft = () => { + if (this.scrollTimer) return; + this.scrollTimer = setInterval(() => { + const scrollLeft = this.props.getScrollLeft(); + if (scrollLeft <= 0) { + this.clearHorizontalScroll(); + return; + } + this.props.setRecordsScrollLeft(scrollLeft - 20); + }, 10); + }; + + clearHorizontalScroll = () => { + if (!this.scrollTimer) return; + clearInterval(this.scrollTimer); + this.scrollTimer = null; + }; + + clearScrollbarTimer = () => { + if (!this.scrollbarTimer) return; + clearTimeout(this.scrollbarTimer); + this.scrollbarTimer = null; + }; + + getCellMetaData = () => { + if (this.cellMetaData) { + return this.cellMetaData; + } + this.cellMetaData = { + onCellClick: this.onCellClick, + onCellDoubleClick: this.onCellDoubleClick, + onCellMouseDown: this.onCellMouseDown, + onCellMouseEnter: this.onCellMouseEnter, + onCellMouseMove: this.onCellMouseMove, + onDragEnter: this.handleDragEnter, + }; + return this.cellMetaData; + }; + + handleDragEnter = ({ overRecordIdx, overGroupRecordIndex }) => { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.DRAG_ENTER, { overRecordIdx, overGroupRecordIndex }); + }; + + getGroupMetrics = () => { + return this.state.groupMetrics; + }; + + getGroupRecordByIndex = (groupRecordIndex) => { + const groupMetrics = this.getGroupMetrics(); + return getGroupRecordByIndex(groupRecordIndex, groupMetrics); + }; + + fixFrozenDoms = (scrollLeft, scrollTop) => { + if (!isFrozen(this.props.columns[0]) && scrollLeft === 0) { + return; + } + Object.keys(this.groupsNode).forEach((groupIdx) => { + const groupNode = this.groupsNode[groupIdx]; + if (!groupNode) { + return; + } + groupNode.fixedFrozenDOMs(scrollLeft, scrollTop); + }); + }; + + cancelFixFrozenDOMs = (scrollLeft) => { + if (!isFrozen(this.props.columns[0]) && scrollLeft === 0) { + return; + } + if (this.groupsNode) { + Object.keys(this.groupsNode).forEach((groupPathString) => { + const groupNode = this.groupsNode[groupPathString]; + if (!groupNode) { + return; + } + groupNode.cancelFixFrozenDOMs(scrollLeft); + }); + } + }; + + onResize = () => { + const gridHeight = window.innerHeight; + if (!gridHeight) { + return; + } + const { scrollTop } = this.resultContentRef; + const rowHeight = this.getRowHeight(); + this.updateScroll({ gridHeight, scrollTop, rowHeight }); + }; + + onScroll = () => { + const { offsetHeight, scrollTop: contentScrollTop } = this.resultContentRef; + this.oldScrollTop = contentScrollTop; + + this.props.cacheScrollTop(contentScrollTop); + + this.updateScroll({ scrollTop: contentScrollTop }); + + // Scroll to the bottom of the page, load more records + if (offsetHeight + contentScrollTop >= this.resultContentRef.scrollHeight) { + this.props.scrollToLoadMore(); + } + + if (!this.isScrollingRightScrollbar) { + this.setRightScrollbarScrollTop(this.oldScrollTop); + } + + // solve the bug that the scroll bar disappears when scrolling too fast + this.clearScrollbarTimer(); + this.scrollbarTimer = setTimeout(() => { + this.setState({ isScrollingRightScrollbar: false }); + }, 300); + }; + + onRowExpand = (record) => { + this.props.expandRow(this.props.table, this.props.columns, record); + }; + + setRightScrollbarScrollTop = (scrollTop) => { + this.rightScrollbar && this.rightScrollbar.setScrollTop(scrollTop); + }; + + onScrollbarScroll = (scrollTop) => { + // solve canvas&rightScrollbar circle scroll problem + if (this.oldScrollTop === scrollTop) { + return; + } + this.setState({ isScrollingRightScrollbar: true }, () => { + this.setScrollTop(scrollTop); + }); + }; + + onScrollbarMouseUp = () => { + this.setState({ isScrollingRightScrollbar: false }); + }; + + onCellClick = (cell, e) => { + const { selectedPosition } = this.state; + if (isShiftKeyDown(e)) { + if (!selectedPosition || selectedPosition.idx === -1) { + this.selectCell(cell, false); + return; + } + const isFromKeyboard = true; + this.selectUpdate(cell, isFromKeyboard); + } else { + const { columns } = this.props; + const supportOpenEditor = isColumnSupportDirectEdit(cell, columns); + const hasOpenPermission = isSelectedCellSupportOpenEditor(cell, columns, true, this.props.recordGetterByIndex); + this.selectCell(cell, supportOpenEditor && hasOpenPermission); + } + this.props.onCellClick(cell); + this.setState({ selectedPosition: cell }); + }; + + onCellDoubleClick = (cell, e) => { + const { columns } = this.props; + const supportOpenEditor = isColumnSupportEdit(cell, columns); + const hasOpenPermission = isSelectedCellSupportOpenEditor(cell, columns, true, this.props.recordGetterByIndex); + this.selectCell(cell, supportOpenEditor && hasOpenPermission); + }; + + onCellMouseDown = (cellPosition, event) => { + if (!isShiftKeyDown(event)) { + this.selectCell(cellPosition); + this.selectStart(cellPosition); + window.addEventListener('mouseup', this.onWindowMouseUp); + } + }; + + // onRangeSelectUpdate + onCellMouseEnter = (cellPosition) => { + this.selectUpdate(cellPosition, false, this.updateViewableArea); + }; + + onCellMouseMove = (cellPosition) => { + this.selectUpdate(cellPosition, false, this.updateViewableArea); + }; + + onWindowMouseUp = (event) => { + window.removeEventListener('mouseup', this.onWindowMouseUp); + if (isShiftKeyDown(event)) return; + this.selectEnd(); + this.clearHorizontalScroll(); + }; + + onCellRangeSelectionUpdated = (selectedRange) => { + this.props.onCellRangeSelectionUpdated(selectedRange); + }; + + selectNoneCells = () => { + this.interactionMask && this.interactionMask.selectNone(); + const { selectedPosition } = this.state; + if (!selectedPosition || selectedPosition.idx < 0 || selectedPosition.rowIdx < 0) { + return; + } + this.selectNone(); + }; + + selectNone = () => { + this.setState({ selectedPosition: { idx: -1, rowIdx: -1, groupRecordIndex: -1 } }); + }; + + selectCell = (cell, openEditor) => { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_CELL, cell, openEditor); + }; + + selectStart = (cellPosition) => { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_START, cellPosition); + }; + + selectUpdate = (cellPosition, isFromKeyboard, callback) => { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_UPDATE, cellPosition, isFromKeyboard, callback); + }; + + selectEnd = () => { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_END); + }; + + onCloseContextMenu = () => { + this.setState({ + isContextMenuShow: false, + menuPosition: null, + activeRecords: [], + }); + }; + + onDeleteRecords = () => { + this.interactionMask && this.interactionMask.selectNone(); + this.props.selectNone(); + this.props.onDeleteRecords(this.state.activeRecords); + }; + + onInsertRecords = ({ insertRecordsNumber }) => { + const activeRecord = this.state.activeRecords[0]; + const upperRecordId = activeRecord && activeRecord._id; + if (!upperRecordId) return; + this.props.insertRecords({ upperRecordId, insertRecordsNumber }); + }; + + addBlankRecord = (groupRecordIndex) => { + const { groupMetrics } = this.state; + const groupRecord = groupRecordIndex > 0 && getGroupRecordByIndex(groupRecordIndex - 1, groupMetrics); + this.props.insertRecords({ + upperRecordId: groupRecord && groupRecord.rowId, + insertRecordsNumber: 1 + }); + }; + + onDuplicateRecord = () => { + this.props.duplicateRecord(this.state.activeRecords[0]); + }; + + onDuplicateRecords = () => { + this.props.duplicateRecords(this.state.activeRecords); + }; + + getNextScrollState = ({ gridHeight, scrollTop, rowHeight, groupMetrics, pathFoldedGroupMap }) => { + const _gridHeight = gridHeight || window.innerHeight; + const _rowHeight = rowHeight || this.getRowHeight(); + const updatedGroupMetrics = groupMetrics || this.state.groupMetrics; + const updatedPathFoldedGroupMap = pathFoldedGroupMap || this.state.pathFoldedGroupMap; + const { startRenderIndex, endRenderIndex } = this.getGroupVisibleBoundaries(_gridHeight, scrollTop, updatedGroupMetrics, _rowHeight); + return { + startRenderIndex, + endRenderIndex, + groupMetrics: updatedGroupMetrics, + pathFoldedGroupMap: updatedPathFoldedGroupMap, + }; + }; + + updateScroll = (scrollParams) => { + const { startRenderIndex, endRenderIndex, ...scrollArgs } = scrollParams; + let nextScrollState = this.getNextScrollState(scrollArgs); + if (startRenderIndex && endRenderIndex) { + nextScrollState.startRenderIndex = startRenderIndex; + nextScrollState.endRenderIndex = endRenderIndex; + } + this.setState(nextScrollState); + return nextScrollState; + }; + + isParentGroupContainer = (currentGroupRow, targetGroupRow) => { + const { groupPath: currentGroupPath, level: currentGroupLevel, type: currentGroupRowType } = currentGroupRow; + const { groupPath: targetGroupPath, level: targetGroupLevel } = targetGroupRow; + return currentGroupRowType === GROUP_ROW_TYPE.GROUP_CONTAINER && + currentGroupLevel > targetGroupLevel && currentGroupPath[0] === targetGroupPath[0]; + }; + + getPrevGroupContainers = (currentGroupRow, groupRows, maxLevel) => { + if (!currentGroupRow) { + return []; + } + const { level, groupRowIndex, type } = currentGroupRow; + if (groupRowIndex === 0 || (level === maxLevel && type === GROUP_ROW_TYPE.GROUP_CONTAINER)) { + return []; + } + let prevGroupContainers = []; + let prevGroupRowIndex = groupRowIndex - 1; + while (prevGroupRowIndex > -1) { + const prevGroupRow = groupRows[prevGroupRowIndex]; + const { type: preGroupRowType, level: prevGroupRowLevel } = prevGroupRow; + if (preGroupRowType === GROUP_ROW_TYPE.GROUP_CONTAINER) { + // first level group. + if (level === maxLevel) { + prevGroupContainers.push(prevGroupRow); + break; + } + + // multiple level group. + if (this.isParentGroupContainer(prevGroupRow, currentGroupRow)) { + prevGroupContainers.unshift(prevGroupRow); + } + + if (prevGroupRowLevel === maxLevel) { + break; + } + } + prevGroupRowIndex--; + } + return prevGroupContainers; + }; + + getVisibleGroupRecords = (startRenderIndex, endRenderIndex, groupRows) => { + const visibleGroupRows = []; + const overScanStartGroupRow = groupRows[startRenderIndex]; + const maxLevel = this.props.groupbys.length; + + // If first visible group is nested in the previous group, then the previous group container also needs to be rendered. + const prevGroupContainers = this.getPrevGroupContainers(overScanStartGroupRow, groupRows, maxLevel); + visibleGroupRows.push(...prevGroupContainers); + let i = startRenderIndex; + let rows = []; + while (i <= endRenderIndex) { + let groupRow = groupRows[i]; + if (groupRow && groupRow.visible) { + visibleGroupRows.push(groupRow); + if (groupRow.type === GROUP_ROW_TYPE.ROW) { + rows.push(groupRow); + } + } + i++; + } + return visibleGroupRows; + }; + + getFoldedGroups = () => { + const localPageConfigs = this.props.getLocalPageConfigs(); + if (!localPageConfigs) { + return {}; + } + return localPageConfigs[LOCAL_FOLDED_GROUP_KEY] || {}; + }; + + getVisibleIndex = () => { + return { rowVisibleStartIdx: this.rowVisibleStart, rowVisibleEndIdx: this.rowVisibleEnd }; + }; + + updateFoldedGroups = (pathFoldedGroupMap) => { + let localPageConfigs = this.props.getLocalPageConfigs(); + localPageConfigs[LOCAL_FOLDED_GROUP_KEY] = pathFoldedGroupMap; + this.props.setLocalPageConfigs(localPageConfigs); + this.selectNoneCells(); + }; + + collapseAllGroups = () => { + const { groupMetrics } = this.state; + const { groupRows } = groupMetrics; + let pathFoldedGroupMap = {}; + groupRows.forEach(groupRow => { + const { type, groupPathString } = groupRow; + if (type !== GROUP_ROW_TYPE.GROUP_CONTAINER) { + return; + } + pathFoldedGroupMap[groupPathString] = true; + }); + this.updateFoldedGroups(pathFoldedGroupMap); + const { groups, groupbys, allColumns } = this.props; + const rowHeight = this.getRowHeight(); + const { scrollTop } = this.resultContentRef; + const nextGroupMetrics = createGroupMetrics(groups, groupbys, pathFoldedGroupMap, allColumns, rowHeight, false); + this.updateScroll({ scrollTop, rowHeight, groupMetrics: nextGroupMetrics }); + }; + + expandAllGroups = () => { + const pathFoldedGroupMap = {}; + this.updateFoldedGroups(pathFoldedGroupMap); + const { groups, groupbys, allColumns } = this.props; + const { scrollTop } = this.resultContentRef; + const rowHeight = this.getRowHeight(); + const groupMetrics = createGroupMetrics(groups, groupbys, pathFoldedGroupMap, allColumns, rowHeight, false); + this.updateScroll({ scrollTop, rowHeight, groupMetrics }); + }; + + onExpandGroupToggle = (groupPathString) => { + const { groupMetrics, pathFoldedGroupMap } = this.state; + const { groupRows, maxLevel } = groupMetrics; + const groupContainerRow = groupRows.find(groupRow => groupRow.groupPathString === groupPathString && groupRow.type === GROUP_ROW_TYPE.GROUP_CONTAINER); + if (!groupContainerRow) return; + const { groupRowIndex: operatedGroupRowIndex, groupPath: operatedGroupPath, height: operatedGroupRowHeight, isExpanded } = groupContainerRow; + let updatedPathFoldedGroupMap = { ...pathFoldedGroupMap }; + if (isExpanded) { + updatedPathFoldedGroupMap[groupPathString] = true; + } else { + delete updatedPathFoldedGroupMap[groupPathString]; + } + + const { groups, groupbys, allColumns } = this.props; + const { scrollTop } = this.resultContentRef; + const rowHeight = this.getRowHeight(); + const recalculatedGroupMetrics = createGroupMetrics(groups, groupbys, updatedPathFoldedGroupMap, allColumns, rowHeight, false); + + // expand/fold group directly if the records exceed the maximum number of records supported. + if (groupContainerRow.count >= MAX_ANIMATION_ROWS) { + this.forbidRecordsAnimation(); + this.updateFoldedGroups(updatedPathFoldedGroupMap); + this.updateScroll({ scrollTop, rowHeight, groupMetrics: recalculatedGroupMetrics, pathFoldedGroupMap: updatedPathFoldedGroupMap }); + return; + } + + const { startRenderIndex, endRenderIndex } = this.getGroupVisibleBoundaries(window.innerHeight, scrollTop, recalculatedGroupMetrics, rowHeight); + let newGroupMetrics; + if (isExpanded) { + newGroupMetrics = groupMetrics; + let newGroupRows = newGroupMetrics.groupRows; + if (maxLevel > 1) { + // update the parent group container. + const increment = -(operatedGroupRowHeight - GROUP_HEADER_HEIGHT); + for (let i = operatedGroupRowIndex - 1; i > -1; i--) { + let updatedGroupRow = newGroupRows[i]; + const updatedGroupPath = updatedGroupRow.groupPath; + if (this.isParentGroupContainer(updatedGroupRow, groupContainerRow)) { + updatedGroupRow.height = updatedGroupRow.height + increment; + } + if (updatedGroupPath[0] !== operatedGroupPath[0]) { + break; + } + } + } + + // update the group container/record which nested in the folding group. + for (let i = operatedGroupRowIndex + 1; i < newGroupRows.length; i++) { + let updatedGroupRow = newGroupRows[i]; + const updatedGroupPath = updatedGroupRow.groupPath; + if (isNestedGroupRow(updatedGroupRow, groupContainerRow)) { + updatedGroupRow.visible = false; + } + if (updatedGroupPath[0] !== operatedGroupPath[0]) { + break; + } + } + newGroupRows[operatedGroupRowIndex] = { ...newGroupRows[operatedGroupRowIndex], isExpanded: false, height: GROUP_HEADER_HEIGHT }; + } else { + newGroupMetrics = recalculatedGroupMetrics; + let newGroupRows = newGroupMetrics.groupRows; + + // update the group container/record which nested in the expanding group. + const newTop = groupContainerRow.top + GROUP_HEADER_HEIGHT; + for (let i = operatedGroupRowIndex + 1; i < newGroupRows.length; i++) { + let updatedGroupRow = newGroupRows[i]; + const updatedGroupPath = updatedGroupRow.groupPath; + if (isNestedGroupRow(updatedGroupRow, groupContainerRow)) { + updatedGroupRow.height = 0; + updatedGroupRow.top = newTop; + } + if (updatedGroupPath[0] !== operatedGroupPath[0]) { + break; + } + } + } + this.expandingGroupPathString = groupPathString; + this.nextPathFoldedGroupMap = updatedPathFoldedGroupMap; + this.setState({ + groupMetrics: newGroupMetrics, + startRenderIndex, + endRenderIndex, + }); + this.updateFoldedGroups(updatedPathFoldedGroupMap); + }; + + forbidRecordsAnimation = () => { + this.disabledAnimation = true; + const originClassName = this.groupRows.className; + const newClassName = removeClassName(originClassName, 'animation'); + if (newClassName !== originClassName) { + this.groupRows.className = newClassName; + } + }; + + ableRecordsAnimation = () => { + this.disabledAnimation = false; + const originClassName = this.groupRows.className; + const newClassName = addClassName(originClassName, 'animation'); + if (newClassName !== originClassName) { + this.groupRows.className = newClassName; + } + }; + + openDownloadFilesDialog = () => { + const { column, activeRecords } = this.state; + this.props.cacheDownloadFilesProps(column, activeRecords); + this.props.openDownloadFilesDialog(); + }; + + checkSupportDownloadFiles = () => { + const { column } = this.state; + const { left, right } = this.interactionMask.getSelectedPosition(); + const isSelectingMultiColumns = right > left; + return !isSelectingMultiColumns && (column.type === CellType.FILE || column.type === CellType.IMAGE); + }; + + renderGroups = () => { + const { + totalWidth: columnsWidth, containerWidth, appPage, + columns, colOverScanStartIdx, colOverScanEndIdx, groupOffsetLeft, + recordMetrics, summaryConfigs, lastFrozenColumnKey, showCellColoring, columnColors, + } = this.props; + this.recordFrozenRefs = []; + this.frozenBtnAddRecordRefs = []; + const totalColumnsWidth = columnsWidth + SEQUENCE_COLUMN_WIDTH; + const { startRenderIndex, endRenderIndex, groupMetrics, selectedPosition } = this.state; + const { groupRows, maxLevel } = groupMetrics; + const scrollLeft = this.props.getScrollLeft(); + const cellMetaData = this.getCellMetaData(); + let visibleGroupRows = this.getVisibleGroupRecords(startRenderIndex, endRenderIndex, groupRows); + const rendererGroups = []; + const columnsLen = columns.length; + const lastColumn = columns[columnsLen - 1]; + let groupRowsHeight = groupMetrics.groupRowsHeight; + visibleGroupRows.forEach(groupRow => { + let { + type, level, key, left, top, isExpanded, height, groupPathString, groupRowIndex: groupRecordIndex, + } = groupRow; + if (type === GROUP_ROW_TYPE.GROUP_CONTAINER) { + const groupWidth = totalColumnsWidth + (level - 1) * 2 * GROUP_VIEW_OFFSET; // columns + group offset + const folding = this.expandingGroupPathString === groupPathString && !isExpanded; + const backdropHeight = height + GROUP_VIEW_OFFSET; + + rendererGroups.push( + + ); + } else if (type === GROUP_ROW_TYPE.ROW) { + const { rowId, rowIdx, isLastRow } = groupRow; + const record = rowId && this.props.recordGetterById(rowId); + const isSelected = RecordMetrics.isRecordSelected(rowId, recordMetrics); + const hasSelectedCell = this.props.hasSelectedCell({ groupRecordIndex }, selectedPosition); + const columnColor = showCellColoring ? columnColors[rowId] : {}; + if (!record) { + return; + } + rendererGroups.push( + { + this.recordFrozenRefs.push(ref); + }} + isSelected={isSelected} + groupRecordIndex={groupRecordIndex} + index={rowIdx} + isLastRecord={isLastRow} + lastFrozenColumnKey={lastFrozenColumnKey} + record={record} + columns={columns} + colOverScanStartIdx={colOverScanStartIdx} + colOverScanEndIdx={colOverScanEndIdx} + left={left} + top={top} + height={height} + scrollLeft={scrollLeft} + cellMetaData={cellMetaData} + searchResult={this.props.searchResult} + hasSelectedCell={hasSelectedCell} + selectedPosition={this.state.selectedPosition} + selectNoneCells={this.selectNoneCells} + onSelectRecord={this.props.onSelectRecord} + onRowExpand={this.onRowExpand} + modifyRecord={this.props.modifyRecord} + lockRecordViaButton={this.props.lockRecordViaButton} + modifyRecordViaButton={this.props.modifyRecordViaButton} + reloadRecords={this.props.reloadRecords} + appPage={appPage} + columnColor={columnColor} + /> + ); + } + }); + + const allColumnsFrozen = lastFrozenColumnKey === lastColumn.key; + const groupRowsClassName = classnames( + 'canvas-groups-rows', 'animation', + { + 'single-column': isNameColumn(lastColumn), + 'disabled-add-record': true, + 'all-columns-frozen': allColumnsFrozen, + 'frozen': allColumnsFrozen || !!lastFrozenColumnKey, + } + ); + const groupRowsStyle = { + height: groupRowsHeight, + width: containerWidth + ((maxLevel - 1) * 2 + 1) * GROUP_VIEW_OFFSET, // columns width + groups offset + }; + return ( +
this.groupRows = ref}> + {rendererGroups} +
+ ); + }; + + render() { + return ( + +
+ +
+ {this.renderGroups()} +
+
+ + {/* {this.state.isContextMenuShow && + + } */} +
+ ); + } + +} + +RecordsGroupBody.propTypes = { + gridUtils: PropTypes.object, + table: PropTypes.object, + allColumns: PropTypes.array, + columns: PropTypes.array, + colOverScanStartIdx: PropTypes.number, + colOverScanEndIdx: PropTypes.number, + tableContentWidth: PropTypes.number, + totalWidth: PropTypes.number, + containerWidth: PropTypes.number, + groups: PropTypes.array, + groupbys: PropTypes.array, + recordsCount: PropTypes.number, + recordMetrics: PropTypes.object, + groupOffsetLeft: PropTypes.number, + frozenColumnsWidth: PropTypes.number, + summaryConfigs: PropTypes.object, + hasSelectedRecord: PropTypes.bool, + lastFrozenColumnKey: PropTypes.string, + searchResult: PropTypes.object, + editorPortalTarget: PropTypes.instanceOf(Element), + onRef: PropTypes.func, + getScrollLeft: PropTypes.func, + setRecordsScrollLeft: PropTypes.func, + hasSelectedCell: PropTypes.func, + cacheScrollTop: PropTypes.func, + scrollToLoadMore: PropTypes.func, + getTableContentLeft: PropTypes.func, + getMobileFloatIconStyle: PropTypes.func, + onToggleMobileMoreOperations: PropTypes.func, + onToggleInsertRecordDialog: PropTypes.func, + onCellClick: PropTypes.func, + onCellRangeSelectionUpdated: PropTypes.func, + modifyRecord: PropTypes.func, + recordGetterByIndex: PropTypes.func, + recordGetterById: PropTypes.func, + updateRecords: PropTypes.func, + deleteRecordsLinks: PropTypes.func, + paste: PropTypes.func, + selectNone: PropTypes.func, + onSelectRecord: PropTypes.func, + expandRow: PropTypes.func, + getLocalPageConfigs: PropTypes.func, + setLocalPageConfigs: PropTypes.func, + duplicateRecord: PropTypes.func, + duplicateRecords: PropTypes.func, + lockRecordViaButton: PropTypes.func, + modifyRecordViaButton: PropTypes.func, + onDeleteRecords: PropTypes.func, + editMobileCell: PropTypes.func, + insertRecords: PropTypes.func, + reloadRecords: PropTypes.func, + appPage: PropTypes.object, + showCellColoring: PropTypes.bool, + columnColors: PropTypes.object, + getCopiedRecordsAndColumnsFromRange: PropTypes.func, + openDownloadFilesDialog: PropTypes.func, + cacheDownloadFilesProps: PropTypes.func, +}; + +export default RecordsGroupBody; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/records-header.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/records-header.js new file mode 100644 index 0000000000..c2faadd8d0 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/records-header.js @@ -0,0 +1,134 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { + HEADER_HEIGHT_TYPE, + isEmptyObject, + Z_INDEX, +} from '../../../../_basic'; +import HeaderCell from './header-cell'; +import HeaderActionsCell from './header-actions-cell'; +import { isMobile } from '../../../../utils'; +import { getFrozenColumns } from '../../../../utils/table-utils'; +import { isFrozen } from '../../../../utils/column-utils'; +import { GRID_HEADER_DEFAULT_HEIGHT, GRID_HEADER_DOUBLE_HEIGHT } from '../../../../constants'; + +class RecordsHeader extends Component { + + static propTypes = { + containerWidth: PropTypes.number, + columns: PropTypes.array.isRequired, + colOverScanStartIdx: PropTypes.number, + colOverScanEndIdx: PropTypes.number, + table: PropTypes.object, + hasSelectedRecord: PropTypes.bool, + isSelectedAll: PropTypes.bool, + isGroupView: PropTypes.bool, + groupOffsetLeft: PropTypes.number, + lastFrozenColumnKey: PropTypes.string, + onRef: PropTypes.func, + resizeColumnWidth: PropTypes.func, + selectNoneRecords: PropTypes.func, + selectAllRecords: PropTypes.func, + downloadColumnAllFiles: PropTypes.func, + }; + + getFrozenCells = (height, isHideTriangle) => { + const { columns, lastFrozenColumnKey } = this.props; + const frozenColumns = getFrozenColumns(columns); + return frozenColumns.map(column => { + const { key } = column; + const style = { backgroundColor: '#f9f9f9' }; + const isLastFrozenCell = key === lastFrozenColumnKey; + return ( + + ); + }); + }; + + getHeaderCells = (height, isHideTriangle) => { + const { columns, groupOffsetLeft, colOverScanStartIdx, colOverScanEndIdx } = this.props; + const rendererColumns = columns.slice(colOverScanStartIdx, colOverScanEndIdx); + return rendererColumns.map(column => { + return ( + + ); + }); + }; + + getFrozenWrapperStyle = (height) => { + const { isGroupView, columns } = this.props; + let style = { + position: (isMobile ? 'absolute' : 'fixed'), + marginLeft: '0px', + height, + zIndex: Z_INDEX.SEQUENCE_COLUMN, + }; + if ((isGroupView && !isFrozen(columns[0])) || isMobile) { + style.position = 'absolute'; + } + return style; + }; + + render() { + const { + containerWidth, hasSelectedRecord, isSelectedAll, lastFrozenColumnKey, groupOffsetLeft, table + } = this.props; + const headerSettings = table.header_settings || {}; + const heightMode = isEmptyObject(headerSettings) ? HEADER_HEIGHT_TYPE.DEFAULT : headerSettings.header_height; + const isHideTriangle = headerSettings && headerSettings.is_hide_triangle; + const height = heightMode === HEADER_HEIGHT_TYPE.DOUBLE ? GRID_HEADER_DOUBLE_HEIGHT : GRID_HEADER_DEFAULT_HEIGHT; + const headerHeight = height + 1; + const frozenCells = this.getFrozenCells(height, isHideTriangle); + const headerCells = this.getHeaderCells(height, isHideTriangle); + const headerStyle = { + width: containerWidth, + minWidth: '100%', + zIndex: Z_INDEX.GRID_HEADER, + height + }; + return ( +
+
+ {/* frozen */} +
{ + !isMobile && this.props.onRef(ref); + }}> + + {frozenCells} +
+ {/* scroll */} + {headerCells} +
+
+ ); + } +} + +export default RecordsHeader; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/resize-column-handle.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/resize-column-handle.js new file mode 100644 index 0000000000..f96a5fc512 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/resize-column-handle.js @@ -0,0 +1,57 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { debounce } from '../../../../_basic'; + +class ResizeColumnHandle extends Component { + + componentWillUnmount() { + this.cleanUp(); + } + + cleanUp = () => { + window.removeEventListener('mouseup', this.onMouseUp); + window.removeEventListener('mousemove', this.onMouseMove); + window.removeEventListener('touchend', this.onMouseUp); + window.removeEventListener('touchmove', this.onMouseMove); + }; + + onMouseDown = (e) => { + if (e.preventDefault) { + e.preventDefault(); + } + + window.addEventListener('mouseup', this.onMouseUp); + window.addEventListener('mousemove', this.onMouseMove); + window.addEventListener('touchend', this.onMouseUp); + window.addEventListener('touchmove', this.onMouseMove); + }; + + onMouseUp = (e) => { + this.cleanUp(); + }; + + onMouseMove = (e) => { + if (e.preventDefault) { + e.preventDefault(); + } + + debounce(this.props.onDrag(e), 100); + }; + + render() { + return ( +
+ ); + } +} + +ResizeColumnHandle.propTypes = { + onDrag: PropTypes.func +}; + +export default ResizeColumnHandle; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/select-all.jsx b/frontend/src/metadata/metadata-view/components/table/table-main/records/select-all.jsx new file mode 100644 index 0000000000..4ada966cd7 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/select-all.jsx @@ -0,0 +1,89 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { gettext } from '../../../../../../utils/constants'; + +class SelectAll extends Component { + + constructor(props) { + super(props); + this.state = { + isSelectedAll: props.isSelectedAll, + }; + } + + componentDidUpdate(prevProps) { + const { isSelectedAll } = this.props; + if (isSelectedAll !== prevProps.isSelectedAll) { + this.setState({ + isSelectedAll, + }); + } + } + + onToggleSelectAll = (e) => { + const { isMobile, hasSelectedRecord } = this.props; + const { isSelectedAll } = this.state; + if (isMobile) { + e.preventDefault(); + } + if (hasSelectedRecord || isSelectedAll) { + this.setState({ isSelectedAll: false }); + this.props.selectNoneRecords(); + return; + } + this.setState({ isSelectedAll: true }); + this.props.selectAllRecords(); + }; + + render() { + const { isMobile, hasSelectedRecord } = this.props; + const { isSelectedAll } = this.state; + const isSelectedParts = hasSelectedRecord && !isSelectedAll; + return ( +
+ {isMobile ? + : + <> + {isSelectedParts ? + : + + } + + } + +
+ ); + } +} + +SelectAll.propTypes = { + isMobile: PropTypes.bool, + hasSelectedRecord: PropTypes.bool, + isSelectedAll: PropTypes.bool, + selectNoneRecords: PropTypes.func, + selectAllRecords: PropTypes.func, +}; + +export default SelectAll; diff --git a/frontend/src/metadata/metadata-view/components/table/table-masks/cell-mask.js b/frontend/src/metadata/metadata-view/components/table/table-masks/cell-mask.js new file mode 100644 index 0000000000..22a90a0328 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-masks/cell-mask.js @@ -0,0 +1,56 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; + +class CellMask extends React.PureComponent { + + componentDidUpdate() { + // Scrolling left and right causes the interface to re-render, + // and the style of CellMask is reset and needs to be fixed + const dom = ReactDOM.findDOMNode(this); + if (dom.style.position === 'fixed') { + dom.style.transform = 'none'; + } + } + + getMaskStyle = () => { + const { width, height, top, left, zIndex } = this.props; + // mask border needs to cover cell border, height and width are increased 1, left and top are decreased 1 + return { + height: height - 1, + width: width, + zIndex, + position: 'absolute', + pointerEvents: 'none', + transform: `translate(${left}px, ${top}px)`, + outline: 0 + }; + }; + + render() { + const { width, height, top, left, zIndex, children, innerRef, ...rest } = this.props; + const style = this.getMaskStyle(); + return ( +
+ {children} +
+ ); + } +} + +CellMask.propTypes = { + width: PropTypes.number.isRequired, + height: PropTypes.number.isRequired, + top: PropTypes.number.isRequired, + left: PropTypes.number.isRequired, + zIndex: PropTypes.number.isRequired, + children: PropTypes.node, + innerRef: PropTypes.func +}; + +export default CellMask; diff --git a/frontend/src/metadata/metadata-view/components/table/table-masks/drag-handler.js b/frontend/src/metadata/metadata-view/components/table/table-masks/drag-handler.js new file mode 100644 index 0000000000..98ae236eda --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-masks/drag-handler.js @@ -0,0 +1,20 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +function DragHandler({ onDragStart, onDragEnd }) { + return ( +
+ ); +} + +DragHandler.propTypes = { + onDragStart: PropTypes.func.isRequired, + onDragEnd: PropTypes.func.isRequired, +}; + +export default DragHandler; diff --git a/frontend/src/metadata/metadata-view/components/table/table-masks/drag-mask.js b/frontend/src/metadata/metadata-view/components/table/table-masks/drag-mask.js new file mode 100644 index 0000000000..85f4778cfd --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-masks/drag-mask.js @@ -0,0 +1,33 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import CellMask from './cell-mask'; + +function DragMask({ draggedRange, getSelectedRangeDimensions, getSelectedDimensions }) { + const { overRecordIdx, bottomRight } = draggedRange; + const { idx: endColumnIdx, rowIdx: endRowIdx, groupRowIndex: endGroupRowIndex } = bottomRight; + if (overRecordIdx !== null && endRowIdx < overRecordIdx) { + const className = 'react-grid-cell-dragged-over-down'; + let dimensions = getSelectedRangeDimensions(draggedRange); + for (let currentRowIdx = endRowIdx + 1; currentRowIdx <= overRecordIdx; currentRowIdx++) { + const { height } = getSelectedDimensions({ idx: endColumnIdx, rowIdx: currentRowIdx, groupRowIndex: endGroupRowIndex }); + dimensions.height += height; + } + return ( + + ); + } + return null; +} + + +DragMask.propTypes = { + draggedRange: PropTypes.object.isRequired, + getSelectedRangeDimensions: PropTypes.func.isRequired, + getSelectedDimensions: PropTypes.func.isRequired +}; + +export default DragMask; diff --git a/frontend/src/metadata/metadata-view/components/table/table-masks/interaction-masks/index.css b/frontend/src/metadata/metadata-view/components/table/table-masks/interaction-masks/index.css new file mode 100644 index 0000000000..49ff89da76 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-masks/interaction-masks/index.css @@ -0,0 +1,54 @@ +.interaction-mask .rdg-selected { + border: 2px solid #66afe9; +} + +.interaction-mask .rdg-selected-range { + border: 1px solid #66afe9; + background-color: rgba(102, 175, 233, 0.18823529411764706); +} + +.rdg-selected .drag-handle, +.rdg-selected-range .drag-handle, +.checkbox-editor-container .drag-handle { + pointer-events: auto; + position: absolute; + bottom: -5px; + right: -4px; + background: #66afe9; + width: 8px; + height: 8px; + border: 1px solid #fff; + border-right: 0px; + border-bottom: 0px; + cursor: crosshair; + cursor: -moz-grab; + cursor: -webkit-grab; + cursor: grab; +} + +.rdg-selected .drag-handle:hover, +.rdg-selected-range .drag-handle:hover, +.checkbox-editor-container .drag-handle:hover { + bottom: -8px; + right: -7px; + background: white; + width: 16px; + height: 16px; + border: 1px solid #66afe9; + z-index: 2; +} + +.rdg-selected:hover .drag-handle .glyphicon-arrow-down { + display: 'block'; +} + +.react-grid-cell-dragged-over-down { + border-top-width: 0; +} + +.react-grid-cell-dragged-over-up, +.react-grid-cell-dragged-over-down { + border: 1px dashed black; + background: rgba(0, 0, 255, 0.2) !important; +} + diff --git a/frontend/src/metadata/metadata-view/components/table/table-masks/interaction-masks/index.js b/frontend/src/metadata/metadata-view/components/table/table-masks/interaction-masks/index.js new file mode 100644 index 0000000000..946f93c2ed --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-masks/interaction-masks/index.js @@ -0,0 +1,1191 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import deepCopy from 'deep-copy'; +import { toaster } from '@seafile/sf-metadata-ui-component'; +import { + CellType, + NOT_SUPPORT_EDIT_COLUMN_TYPE_MAP, + KeyCodes, + isFunction, +} from '../../../../_basic'; +import { EVENT_BUS_TYPE, TABLE_MOBILE_SUPPORT_EDIT_CELL_TYPE_MAP, GROUP_ROW_TYPE, TRANSFER_TYPES } from '../../../../constants'; +import { + getNewSelectedRange, getSelectedDimensions, selectedRangeIsSingleCell, + getSelectedRangeDimensions, getSelectedRow, getSelectedColumn, + isSelectedCellEditable, getRecordsFromSelectedRange, +} from '../../../../utils/selected-cell-utils'; +import { isCtrlKeyHeldDown, isKeyPrintable } from '../../../../utils/keyboard-utils'; +import SelectionRangeMask from '../selection-range-mask'; +import SelectionMask from '../selection-mask'; +import { getFormatRowData } from '../../../../utils/cell-format-utils'; +import RecordMetrics from '../../../../utils/record-metrics'; +import setEventTransfer from '../../../../utils/set-event-transfer'; +import getEventTransfer from '../../../../utils/get-event-transfer'; +import { getGroupRecordByIndex } from '../../../../utils/group-metrics'; +import DragMask from '../drag-mask'; +import DragHandler from '../drag-handler'; +import { gettext } from '../../../../../../utils/constants'; + +import './index.css'; + +const READONLY_PREVIEW_COLUMNS = [ + CellType.LONG_TEXT, CellType.IMAGE, CellType.FILE, CellType.LINK, CellType.DIGITAL_SIGN, CellType.LINK_FORMULA, +]; + +const propTypes = { + table: PropTypes.object, + columns: PropTypes.array, + canAddRow: PropTypes.bool, + isGroupView: PropTypes.bool, + recordsCount: PropTypes.number, + recordMetrics: PropTypes.object, + groups: PropTypes.array, + groupMetrics: PropTypes.object, + rowHeight: PropTypes.number, + groupOffsetLeft: PropTypes.number, + frozenColumnsWidth: PropTypes.number, + enableCellSelect: PropTypes.bool, + getRowTop: PropTypes.func, + scrollTop: PropTypes.number, + getScrollLeft: PropTypes.func, + getTableContentLeft: PropTypes.func, + getMobileFloatIconStyle: PropTypes.func, + onToggleMobileMoreOperations: PropTypes.func, + onToggleInsertRecordDialog: PropTypes.func, + onCellRangeSelectionStarted: PropTypes.func, + onCellRangeSelectionUpdated: PropTypes.func, + onCellRangeSelectionCompleted: PropTypes.func, + selectNone: PropTypes.func, + onCheckCellIsEditable: PropTypes.func, + editorPortalTarget: PropTypes.instanceOf(Element).isRequired, + modifyRecord: PropTypes.func.isRequired, + recordGetterByIndex: PropTypes.func, + recordGetterById: PropTypes.func, + updateRecords: PropTypes.func, + deleteRecordsLinks: PropTypes.func, + paste: PropTypes.func, + editMobileCell: PropTypes.func, + getVisibleIndex: PropTypes.func, + onHitBottomBoundary: PropTypes.func, + onHitTopBoundary: PropTypes.func, + onCellClick: PropTypes.func, + scrollToColumn: PropTypes.func, + setRecordsScrollLeft: PropTypes.func, + getGroupCanvasScrollTop: PropTypes.func, + setGroupCanvasScrollTop: PropTypes.func, + appPage: PropTypes.object, + onFillingDragRows: PropTypes.func, + onCellsDragged: PropTypes.func, + gridUtils: PropTypes.object, + getCopiedRecordsAndColumnsFromRange: PropTypes.func, +}; +class InteractionMasks extends React.Component { + + + static defaultProps = { + enableCellSelect: true, + isGroupView: false, + groupOffsetLeft: 0, + }; + + throttle = null; + + constructor(props) { + super(props); + const initPosition = { idx: -1, rowIdx: -1, groupRecordIndex: -1 }; + this.state = { + selectedPosition: initPosition, + selectedRange: { + topLeft: initPosition, + bottomRight: initPosition, + startCell: null, + cursorCell: null, + isDragging: false, + }, + draggedRange: null, + isEditorEnabled: false, + openEditorMode: '', + }; + this.selectionMask = null; + } + + componentDidMount() { + this.unsubscribeSelectColumn = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.SELECT_COLUMN, this.onColumnSelect); + this.unsubscribeDragEnter = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.DRAG_ENTER, this.handleDragEnter); + this.unsubscribeSelectCell = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.SELECT_CELL, this.onSelectCell); + this.unsubscribeSelectNone = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.SELECT_NONE, this.selectNone); + this.unsubscribeSelectStart = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.SELECT_START, this.onSelectCellRangeStarted); + this.unsubscribeSelectUpdate = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.SELECT_UPDATE, this.onSelectCellRangeUpdated); + this.unsubscribeSelectEnd = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.SELECT_END, this.onSelectCellRangeEnded); + this.unsubscribeOpenEditorEvent = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.OPEN_EDITOR, this.onOpenEditorEvent); + this.unsubscribeCloseEditorEvent = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.CLOSE_EDITOR, this.onCloseEditorEvent); + this.unsubscribeCopy = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.COPY_CELLS, this.onCopy); + this.unsubscribePaste = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.PASTE_CELLS, this.onPaste); + } + + componentDidUpdate(prevProps, prevState) { + const { selectedRange, isEditorEnabled } = this.state; + const { selectedRange: prevSelectedRange, isEditorEnabled: prevIsEditorEnabled } = prevState; + const isEditorClosed = isEditorEnabled !== prevIsEditorEnabled && !isEditorEnabled; + const isSelectedRangeChanged = selectedRange !== prevSelectedRange && (selectedRange.topLeft !== prevSelectedRange.topLeft || selectedRange.bottomRight !== prevSelectedRange.bottomRight); + if (isSelectedRangeChanged || isEditorClosed) { + this.focus(); + } + } + + componentWillUnmount() { + this.unsubscribeSelectColumn(); + this.unsubscribeSelectCell(); + this.unsubscribeSelectStart(); + this.unsubscribeSelectUpdate(); + this.unsubscribeSelectEnd(); + this.unsubscribeOpenEditorEvent(); + this.unsubscribeCloseEditorEvent(); + this.unsubscribeCopy(); + this.unsubscribePaste(); + this.setState = (state, callback) => { + return; + }; + } + + onColumnSelect = (column) => { + let { columns, isGroupView } = this.props; + if (isGroupView) return; + let selectColumnIndex = 0; + for (let i = 0; i < columns.length; i++) { + if (column.key === columns[i].key) { + selectColumnIndex = i; + break; + } + } + const rowsCount = this.props.recordsCount; + this.setState({ + selectedPosition: { ...this.state.selectedPosition, idx: selectColumnIndex, rowIdx: 0 }, + selectedRange: { + startCell: { idx: selectColumnIndex, rowIdx: 0 }, + topLeft: { idx: selectColumnIndex, rowIdx: 0 }, + bottomRight: { idx: selectColumnIndex, rowIdx: rowsCount - 1 }, + isDragging: false, + } + }); + }; + + onOpenEditorEvent = (mode) => { + this.setState({ + openEditorMode: mode + }); + this.openEditor(); + }; + + onCloseEditorEvent = () => { + if (this.state.isEditorEnabled) { + this.closeEditor(); + } + }; + + onSelectCell = (cell, openEditor) => { + const { selectedPosition, isEditorEnabled } = this.state; + const callback = openEditor ? this.openEditor : () => null; + + if (isEditorEnabled) { + this.closeEditor(); + } + + this.setState((prevState) => { + const next = { ...selectedPosition, ...cell }; + if (this.isCellWithinBounds(next)) { + return { + selectedPosition: next, + selectedRange: { + topLeft: next, + bottomRight: next, + startCell: next, + cursorCell: next, + isDragging: false, + } + }; + } + return prevState; + }, callback); + }; + + selectNone = () => { + const initPosition = { idx: -1, rowIdx: -1, groupRecordIndex: -1 }; + this.setState({ + selectedPosition: initPosition, + selectedRange: { + topLeft: initPosition, + bottomRight: initPosition, + startCell: null, + cursorCell: null, + }, + }); + this.props.selectNone(); + }; + + getSelectedPosition = () => { + const { topLeft, bottomRight } = this.state.selectedRange; + return { + top: topLeft.rowIdx, + bottom: bottomRight.rowIdx, + left: topLeft.idx, + right: bottomRight.idx, + }; + }; + + getSelectedRange = () => { + return this.state.selectedRange; + }; + + selectCell = (groupRecordIndex, rowIdx, idx) => { + const selectedPosition = { idx, groupRecordIndex, rowIdx }; + this.setState({ + selectedPosition, + selectedRange: { + topLeft: selectedPosition, + bottomRight: selectedPosition, + startCell: selectedPosition, + cursorCell: selectedPosition, + }, + }); + }; + + // onCellSelect || onKeyDown + openEditor = (event = null) => { + if (this.isSelectedCellIsLongText()) { + event && event.stopPropagation(); + event && event.preventDefault(); + } + const { key } = event || {}; + const { selectedPosition } = this.state; + const { columns } = this.props; + const selectedColumn = getSelectedColumn({ selectedPosition, columns }); + const { type: columnType } = selectedColumn; + + // how to open editors? + // 1. editor is closed + // 2. record-cell is editable or open editor with preview mode + if (((this.isSelectedCellEditable() || READONLY_PREVIEW_COLUMNS.includes(columnType)) && !this.state.isEditorEnabled)) { + this.setState({ + isEditorEnabled: true, + firstEditorKeyDown: key, + editorPosition: this.getEditorPosition() + }); + } + }; + + openMobileEditor = () => { + const { recordGetterByIndex, isGroupView, columns } = this.props; + const { selectedPosition } = this.state; + const recordData = getSelectedRow({ selectedPosition, recordGetterByIndex, isGroupView }); + const column = getSelectedColumn({ selectedPosition, columns }); + if (!recordData || !column || !TABLE_MOBILE_SUPPORT_EDIT_CELL_TYPE_MAP[column.type]) { + return false; + } + const editingCell = { recordData, column }; + this.props.editMobileCell(editingCell); + return true; + }; + + closeEditor = () => { + this.setState({ + isEditorEnabled: false, + firstEditorKeyDown: null, + editorPosition: null, + openEditorMode: '' + }); + }; + + onSelectCellRangeStarted = (selectedPosition) => { + if (!this.isCellWithinBounds(selectedPosition)) return; + + const selectedRange = this.createSingleCellSelectedRange(selectedPosition, true); + this.setState({ selectedRange }, () => { + if (isFunction(this.props.onCellRangeSelectionStarted)) { + this.props.onCellRangeSelectionStarted(this.state.selectedRange); + } + }); + }; + + onSelectCellRangeUpdated = (cellPosition, isFromKeyboard, callback) => { + if (!this.state.selectedRange.isDragging && !isFromKeyboard) { + return; + } + + if (!this.isCellWithinBounds(cellPosition)) { + return; + } + + const startCell = this.state.selectedRange.startCell || this.state.selectedPosition; + const { topLeft, bottomRight } = getNewSelectedRange(startCell, cellPosition); + const selectedRange = { + // default the startCell to the selected cell, in case we've just started via keyboard + startCell: this.state.selectedPosition, + // assign the previous state (which will override the startCell if we already have one) + ...this.state.selectedRange, + // assign the new state - the bounds of the range, and the new cursor cell + topLeft, + bottomRight, + cursorCell: cellPosition + }; + + this.setState({ selectedRange }, () => { + if (isFunction(this.props.onCellRangeSelectionUpdated)) { + this.props.onCellRangeSelectionUpdated(this.state.selectedRange); + } + if (isFunction(callback)) { + callback(this.state.selectedRange); + } + }); + }; + + onSelectCellRangeEnded = () => { + const selectedRange = { ...this.state.selectedRange, isDragging: false }; + this.setState({ selectedRange }, () => { + if (isFunction(this.props.onCellRangeSelectionCompleted)) { + this.props.onCellRangeSelectionCompleted(this.state.selectedRange); + } + }); + }; + + createSingleCellSelectedRange(cellPosition, isDragging) { + return { + topLeft: cellPosition, + bottomRight: cellPosition, + startCell: cellPosition, + cursorCell: cellPosition, + isDragging + }; + } + + focus = () => { + if (this.selectionMask && !this.isFocused()) { + this.selectionMask.focus(); + } + }; + + isFocused = () => { + return document.activeElement === this.selectionMask; + }; + + isCellSelected = () => { + const { selectedPosition } = this.state; + return selectedPosition.idx !== -1 && selectedPosition.rowIdx !== -1; + }; + + isCellWithinBounds = ({ idx, rowIdx }) => { + const { columns, recordsCount } = this.props; + const maxRowIdx = recordsCount; + return rowIdx >= 0 && rowIdx < maxRowIdx && idx >= 0 && idx < columns.length; + }; + + isSelectedCellEditable = () => { + const { enableCellSelect, columns, isGroupView, recordGetterByIndex, onCheckCellIsEditable } = this.props; + const { selectedPosition } = this.state; + const res = isSelectedCellEditable({ enableCellSelect, columns, isGroupView, recordGetterByIndex, selectedPosition, onCheckCellIsEditable }); + return res; + }; + + isSelectedCellIsLongText = () => { + const { columns } = this.props; + const { selectedPosition } = this.state; + const column = getSelectedColumn({ selectedPosition, columns }); + return column && column.type === CellType.LONG_TEXT; + }; + + isGridSelected = () => { + return this.isCellWithinBounds(this.state.selectedPosition); + }; + + getSelectedDimensions = (selectedPosition) => { + const { columns, rowHeight, isGroupView, groupOffsetLeft, getRowTop: getRecordTopFromRecordsBody } = this.props; + const scrollLeft = this.props.getScrollLeft(); + return { ...getSelectedDimensions({ + selectedPosition, columns, scrollLeft, rowHeight, isGroupView, groupOffsetLeft, getRecordTopFromRecordsBody + }) }; + }; + + getSelectedRangeDimensions = (selectedRange) => { + const { columns, rowHeight, isGroupView, groups, groupMetrics, groupOffsetLeft, getRowTop: getRecordTopFromRecordsBody } = this.props; + return { ...getSelectedRangeDimensions({ + selectedRange, columns, rowHeight, isGroupView, groups, groupMetrics, groupOffsetLeft, getRecordTopFromRecordsBody, + }) }; + }; + + setScrollLeft = (scrollLeft) => { + const { selectionMask, state: { selectedPosition } } = this; + this.setMaskScrollLeft(selectionMask, selectedPosition, scrollLeft); + }; + + setMaskScrollLeft = (mask, position, scrollLeft) => { + if (mask) { + const { idx, rowIdx, groupRecordIndex } = position; + if (idx >= 0 && rowIdx >= 0) { + const { columns, getRowTop, isGroupView, groupOffsetLeft } = this.props; + const column = columns[idx]; + const frozen = !!column.frozen; + if (frozen) { + // use fixed + const { top: containerTop } = this.container.getClientRects()[0]; + const tableContentLeft = this.props.getTableContentLeft(); + let top = containerTop + getRowTop(isGroupView ? groupRecordIndex : rowIdx); + let left = tableContentLeft + column.left; + if (isGroupView) { + top += 1; + left += groupOffsetLeft; + } + mask.style.position = 'fixed'; + mask.style.top = top + 'px'; + mask.style.left = left + 'px'; + mask.style.transform = 'none'; + } + } + } + }; + + cancelSetScrollLeft = () => { + if (this.selectionMask) { + this.cancelSetMaskScrollLeft(this.selectionMask, this.state.selectedPosition); + } + }; + + cancelSetMaskScrollLeft = (mask, position) => { + const { left, top } = this.getSelectedDimensions(position); + mask.style.position = 'absolute'; + mask.style.top = 0; + mask.style.left = 0; + mask.style.transform = `translate(${left}px, ${top}px)`; + }; + + getEditorPosition = () => { + if (this.selectionMask) { + const { editorPortalTarget } = this.props; + const { left: selectionMaskLeft, top: selectionMaskTop } = this.selectionMask.getBoundingClientRect(); + if (editorPortalTarget === document.body) { + const { scrollLeft, scrollTop } = document.scrollingElement || document.documentElement; + return { + left: selectionMaskLeft + scrollLeft, + top: selectionMaskTop + scrollTop + }; + } + + const { left: portalTargetLeft, top: portalTargetTop } = editorPortalTarget.getBoundingClientRect(); + const { scrollLeft, scrollTop } = editorPortalTarget; + return { + left: selectionMaskLeft - portalTargetLeft + scrollLeft, + top: selectionMaskTop - portalTargetTop + scrollTop + }; + } + }; + + modifyRecord = (updated, closeEditor = true) => { + this.props.modifyRecord(updated); + if (closeEditor) { + this.closeEditor(); + } + }; + + onCommitCancel = () => { + this.closeEditor(); + }; + + getEditorContainer = () => { + // todo + return null; + }; + + onKeyDown = (e) => { + const keyCode = e.keyCode; + if (isCtrlKeyHeldDown(e)) { + this.onPressKeyWithCtrl(e); + } else if (keyCode === KeyCodes.Escape) { + this.onPressEscape(e); + } else if (keyCode === KeyCodes.Tab) { + this.onPressTab(e); + } else if (this.isKeyboardNavigationEvent(e)) { + this.changeCellFromEvent(e); + } else if (isKeyPrintable(keyCode) || keyCode === KeyCodes.Enter) { + this.openEditor(e); + } else if (keyCode === KeyCodes.Backspace || keyCode === KeyCodes.Delete) { + const name = e.target.className; + if (name === 'rdg-selected') { + e.preventDefault(); + this.handleSelectCellsDelete(); + } + } + }; + + handleSelectCellsDelete = () => { + const { isGroupView, recordGetterByIndex, columns } = this.props; + const { selectedRange } = this.state; + const { topLeft, bottomRight } = selectedRange; + const recordsFromSelectedRange = getRecordsFromSelectedRange({ selectedRange, isGroupView, recordGetterByIndex }); + const editableRecords = recordsFromSelectedRange.filter(record => window.sfMetadataContext.canModifyRow(record)); + if (editableRecords.length === 0) { + return; + } + const { idx: startColumnIdx } = topLeft; + const { idx: endColumnIdx } = bottomRight; + let editableColumns = []; + let linkColumns = []; + + // get editable columns from selected range + for (let j = startColumnIdx; j <= endColumnIdx; j++) { + const column = columns[j]; + if (!column || NOT_SUPPORT_EDIT_COLUMN_TYPE_MAP[column.type] || !window.sfMetadataContext.canModifyCell(column)) { + break; + } + const { type, data } = column; + editableColumns.push(column); + if (type === CellType.LINK && data) { + linkColumns.push(column); + } + } + + if (editableColumns.length === 0) { + return; + } + + let updateRecordIds = []; + let idRecordUpdates = {}; // row's id to modified records data: { [row_id]: { [column.name: null] } } + let idOriginalRecordUpdates = {}; // row's id to modified original records data: { [row_id]: { [column.key: null] } } + let idOldRecordData = {}; // row's id to old records data: { [row_id]: { [column.name: xxx] } } + let idOriginalOldRecordData = {}; // row's id to old original records data: { [row_id]: { [column.key: xxx] } } + let idRowLinkItems = {}; // row's id to modified links: { [row_id]: { [column.key]: null } } + let idOldRowLinkItems = {}; // row's id to old links: { [row_id]: { [column.key]: [{ row_id: xxx, display_value: 'xxx' }] } } + editableRecords.forEach(record => { + const { _id } = record; + let originalUpdate = {}; + let originalOldRecordData = {}; + let linkItem = {}; + let oldLinkItem = {}; + editableColumns.forEach(column => { + const { key, type } = column; + const cellVal = record[key]; + if (type === CellType.LINK) { + if (!Array.isArray(cellVal) || cellVal.length === 0) { + return; + } + linkItem[key] = null; + oldLinkItem[key] = cellVal; + return; + } + if (cellVal || cellVal === 0 || (Array.isArray(cellVal) && cellVal.length > 0)) { + originalOldRecordData[key] = cellVal; + if (type === CellType.FILE) { + originalUpdate[key] = []; + } else { + originalUpdate[key] = null; + } + } + }); + + // links data + if (Object.keys(linkItem).length > 0) { + idRowLinkItems[_id] = linkItem; + idOldRowLinkItems[_id] = oldLinkItem; + } + + if (Object.keys(originalUpdate).length > 0) { + updateRecordIds.push(_id); + const update = getFormatRowData(editableColumns, originalUpdate); + const oldRecordData = getFormatRowData(editableColumns, originalOldRecordData); + idRecordUpdates[_id] = update; + idOriginalRecordUpdates[_id] = originalUpdate; + idOldRecordData[_id] = oldRecordData; + idOriginalOldRecordData[_id] = originalOldRecordData; + } + }); + + if (updateRecordIds.length > 0) { + const isCopyPaste = true; + this.props.updateRecords({ + recordIds: updateRecordIds, idRecordUpdates, idOriginalRecordUpdates, + idOldRecordData, idOriginalOldRecordData, isCopyPaste, + }); + } + }; + + onCopy = (e) => { + e.preventDefault(); + + const { recordMetrics } = this.props; + // select the records to copy + const selectedRecordIds = RecordMetrics.getSelectedIds(recordMetrics); + if (selectedRecordIds.length > 0) { + this.copyRows(e, selectedRecordIds); + return; + } + + // window.getSelection() doesn't work on the content of in FireFox, Edge and IE. + // The selectionStart and selectionEnd properties could be used to work around this. + let selectTxt = window.getSelection().toString(); + if (!selectTxt && e.target.value) { + const { selectionStart, selectionEnd } = e.target; + selectTxt = e.target.value.substring(selectionStart, selectionEnd); + } + if (selectTxt) { + this.copyText(e, selectTxt); + return; + } + + // when activeElement is not cellMask, can't copy cell + if (!this.isCellMaskActive()) { + return; + } + this.onCopyCells(e); + }; + + onPaste = (e) => { + // when activeElement is not cellMask or has no permission, can't paste cell + if (!this.isCellMaskActive() || window.sfMetadataContext.getPermission() === 'r') { + return; + } + const { columns, isGroupView } = this.props; + const { selectedPosition, selectedRange } = this.state; + const { idx, rowIdx } = selectedPosition; + if (idx === -1 || rowIdx === -1) { + return; // prevent paste when no cell selected + } + const cliperData = getEventTransfer(e); + if (!cliperData) { + return; + } + + const cliperDataType = cliperData.type; + const copied = cliperData[TRANSFER_TYPES.DTABLE_FRAGMENT]; + let copiedRecordsCount = 0; + let copiedColumnsCount = 0; + if (cliperDataType === TRANSFER_TYPES.DTABLE_FRAGMENT) { + const { selectedRecordIds, copiedRange } = copied; + if (Array.isArray(selectedRecordIds) && selectedRecordIds.length > 0) { + // copy from selected records + copiedRecordsCount = selectedRecordIds.length; + copiedColumnsCount = columns.length; + } else { + // copy from selected range + const { topLeft: copiedTopLeft, bottomRight: copiedBottomRight } = copiedRange; + const { idx: startCopiedColumnIndex, rowIdx: startCopiedRecordIndex } = copiedTopLeft; + const { idx: endCopiedColumnIndex, rowIdx: endCopiedRecordIndex } = copiedBottomRight; + copiedRecordsCount = endCopiedRecordIndex - startCopiedRecordIndex + 1; + copiedColumnsCount = endCopiedColumnIndex - startCopiedColumnIndex + 1; + } + } else { + const { copiedRecords, copiedColumns } = copied; + copiedRecordsCount = copiedRecords.length; + copiedColumnsCount = copiedColumns.length; + } + const multiplePaste = this.isMultiplePaste(copiedRecordsCount, copiedColumnsCount); + this.props.paste({ + copied, + multiplePaste, + type: cliperDataType, + pasteRange: selectedRange, + isGroupView, + }); + if (!multiplePaste) { + this.setPasteRange(copiedRecordsCount, copiedColumnsCount); + } + }; + + copyText = (event, copiedText) => { + const type = 'text'; + setEventTransfer({ + type, + event, + copiedText, + }); + }; + + copyRows = (event, selectedRecordIds) => { + const { table, columns, recordGetterById, isGroupView, getCopiedRecordsAndColumnsFromRange } = this.props; + const copiedRowsCount = selectedRecordIds.length; + toaster.success( + copiedRowsCount > 1 ? gettext('xxx rows are copied.').replace('xxx', copiedRowsCount) : gettext('1 row is copied.') + ); + const type = TRANSFER_TYPES.DTABLE_FRAGMENT; + const copied = { selectedRecordIds }; + const { copiedRecords, copiedColumns } = getCopiedRecordsAndColumnsFromRange({ type, copied, isGroupView }); + const { _id: copiedTableId } = table; + setEventTransfer({ + type, + event, + selectedRecordIds, + copiedRecords, + copiedColumns, + copiedTableId, + tableData: { + columns, + }, + recordGetterById, + }); + }; + + onCopyCells = (event) => { + const { table, columns, isGroupView, recordGetterByIndex, getCopiedRecordsAndColumnsFromRange } = this.props; + const { selectedPosition, selectedRange } = this.state; + const { _id: copiedTableId } = table; + const { rowIdx, idx } = selectedPosition; + if (rowIdx < 0 || idx < 0) { + return; // can not copy when no cell select + } + const { topLeft, bottomRight } = selectedRange; + const type = TRANSFER_TYPES.DTABLE_FRAGMENT; + const copiedCellsCount = (bottomRight.rowIdx - topLeft.rowIdx + 1) * (bottomRight.idx - topLeft.idx + 1); + toaster.success( + copiedCellsCount > 1 ? gettext('xxx cells copied').replace('xxx', copiedCellsCount) : gettext('1 cell copied') + ); + const copied = { copiedRange: selectedRange }; + const { copiedRecords, copiedColumns } = getCopiedRecordsAndColumnsFromRange({ type, copied, isGroupView }); + setEventTransfer({ + type, + event, + copiedRange: { ...selectedRange }, + copiedRecords, + copiedColumns, + copiedTableId, + tableData: { + columns, + }, + isGroupView, + recordGetterByIndex, + }); + }; + + isMultiplePaste = (copiedRecordsCount, copiedColumnsCount) => { + const { selectedRange } = this.state; + const { topLeft, bottomRight } = selectedRange; + const { idx: startColumnIndex, rowIdx: startRecordIndex } = topLeft; + const { idx: endColumnIndex, rowIdx: endRecordIndex } = bottomRight; + return Number.isInteger((endColumnIndex - startColumnIndex + 1) / copiedColumnsCount) && Number.isInteger((endRecordIndex - startRecordIndex + 1) / copiedRecordsCount); + }; + + setPasteRange = (copiedRecordsCount, copiedColumnsCount) => { + const { recordsCount, columns } = this.props; + const { selectedPosition, selectedRange } = this.state; + const { topLeft } = selectedRange; + const { idx, rowIdx } = topLeft; + const columnsLen = columns.length; + const groupRecordIndex = selectedPosition.groupRecordIndex; + let nextColumnIndex = idx + copiedColumnsCount - 1; + let nextRecordIndex = rowIdx + copiedRecordsCount - 1; + if (nextColumnIndex >= columnsLen) { + nextColumnIndex = columnsLen - 1; + } + if (nextRecordIndex >= recordsCount) { + nextRecordIndex = recordsCount - 1; + } + const nextSelectedRange = { + topLeft, + startCell: selectedPosition, + bottomRight: { + idx: nextColumnIndex, + rowIdx: nextRecordIndex, + groupRecordIndex, + }, + cursorCell: { + idx: selectedPosition.idx, + rowIdx: selectedPosition.rowIdx, + groupRecordIndex, + } + }; + this.setState({ + selectedRange: { + ...selectedRange, + ...nextSelectedRange + } + }, () => { + this.focus(); + }); + return nextSelectedRange; + }; + + onPressKeyWithCtrl = () => { + + }; + + onPressEscape = () => { + + }; + + onPressTab = (e) => { + this.changeCellFromEvent(e); + }; + + getLeftInterval = () => { + const { isGroupView, columns, groupOffsetLeft, frozenColumnsWidth } = this.props; + const firstColumnFrozen = columns[0] ? columns[0].frozen : false; + let leftInterval = 0; + if (firstColumnFrozen) { + leftInterval = groupOffsetLeft + frozenColumnsWidth; + if (isGroupView) { + leftInterval += groupOffsetLeft; + } + } else { + leftInterval = 0; + } + return leftInterval; + }; + + handleVerticalArrowAction = (current, actionType) => { + const { isGroupView, groupMetrics, rowHeight } = this.props; + const step = actionType === 'ArrowDown' ? 1 : -1; + if (isGroupView) { + const groupRows = groupMetrics.groupRows || []; + const groupRowsLen = groupRows.length; + const { groupRecordIndex: currentGroupRowIndex } = current; + let nextGroupRowIndex = currentGroupRowIndex + step; + let nextGroupRow; + while (nextGroupRowIndex > 0 && nextGroupRowIndex < groupRowsLen) { + nextGroupRow = getGroupRecordByIndex(nextGroupRowIndex, groupMetrics); + if (nextGroupRow.type === GROUP_ROW_TYPE.ROW) { + break; + } + nextGroupRowIndex += step; + } + if (!nextGroupRow || nextGroupRow.type !== GROUP_ROW_TYPE.ROW) { + return; + } + + const currentScrollTop = this.props.getGroupCanvasScrollTop() || 0; + const { rowIdx: nextRowIdx, top: nextRowTop } = nextGroupRow; + let newScrollTop; + + // 32: footerHeight; 16: preview of next row. + const HEADER_HEIGHT = 150; + if (nextRowTop <= currentScrollTop + 16) { + newScrollTop = nextRowTop - 16; + } else if (nextRowTop + HEADER_HEIGHT - currentScrollTop >= window.innerHeight - 32 - 16) { + newScrollTop = nextRowTop + HEADER_HEIGHT - window.innerHeight + 32 + rowHeight + 16; + } + if (newScrollTop !== undefined) { + this.props.setGroupCanvasScrollTop(newScrollTop); + } + return { ...current, rowIdx: nextRowIdx, groupRecordIndex: nextGroupRowIndex }; + } else { + return { ...current, rowIdx: current.rowIdx + step }; + } + }; + + handleLeftArrowAction = (current) => { + let cellContainer = this.selectionMask; + if (!cellContainer) return; + const { columns } = this.props; + const rect = cellContainer.getBoundingClientRect(); + const leftInterval = this.getLeftInterval(); + const nextColumnWidth = columns[current.idx - 1] ? columns[current.idx - 1].width : 0; + const appNavWidth = window.app.state.appNavWidth || 0; + const appLeftBarWidth = parseInt(appNavWidth) + 130; + // selectMask is outside the viewport, scroll to next column + if (rect.x < 0 || rect.x > window.innerWidth) { + this.props.scrollToColumn(current.idx - 1); + } else if (nextColumnWidth > rect.x - leftInterval - appLeftBarWidth) { + // selectMask is part of the viewport, newScrollLeft = columnWidth - visibleWidth + const newScrollLeft = nextColumnWidth - (rect.x - leftInterval - appLeftBarWidth); + this.props.setRecordsScrollLeft(this.props.getScrollLeft() - newScrollLeft); + } + return ({ ...current, idx: current.idx === 0 ? 0 : current.idx - 1 }); + }; + + handleRightArrowAction = (current) => { + let cellContainer = this.selectionMask; + if (!cellContainer) return; + const { columns } = this.props; + const rect = cellContainer.getBoundingClientRect(); + const columnIdx = current.idx; + const column = columns[columnIdx]; + if (columnIdx === 1 && column.frozen === true) { + this.props.scrollToColumn(1); + } else { + const nextColumnWidth = columns[columnIdx + 1] ? columns[columnIdx + 1].width : 0; + // selectMask is outside the viewport, scroll to next column + if (rect.x < 0 || rect.x > window.innerWidth) { + this.props.scrollToColumn(columnIdx + 1); + } else if (rect.x + rect.width + nextColumnWidth > window.innerWidth) { + // selectMask is part of the viewport, newScrollLeft = columnWidth - visibleWidth + const newScrollLeft = nextColumnWidth - (window.innerWidth - rect.x - rect.width); + this.props.setRecordsScrollLeft(this.props.getScrollLeft() + newScrollLeft); + } + } + return ({ ...current, idx: current.idx + 1 }); + }; + + isKeyboardNavigationEvent(e) { + return this.getKeyNavActionFromEvent(e) != null; + } + + getKeyNavActionFromEvent = (e) => { + const { getVisibleIndex, onHitBottomBoundary, onHitTopBoundary } = this.props; + + const { rowVisibleStartIdx, rowVisibleEndIdx } = getVisibleIndex(); + const isCellAtBottomBoundary = cell => cell.rowIdx >= rowVisibleEndIdx - 1; + const isCellAtTopBoundary = cell => cell.rowIdx !== 0 && cell.rowIdx <= rowVisibleStartIdx; + const keyNavActions = { + ArrowDown: { + getNext: (current) => { + return this.handleVerticalArrowAction(current, 'ArrowDown'); + }, + isCellAtBoundary: isCellAtBottomBoundary, + onHitBoundary: onHitBottomBoundary + }, + ArrowUp: { + getNext: (current) => { + return this.handleVerticalArrowAction(current, 'ArrowUp'); + }, + isCellAtBoundary: isCellAtTopBoundary, + onHitBoundary: onHitTopBoundary + }, + ArrowRight: { + getNext: (current) => { + return this.handleRightArrowAction(current); + }, + isCellAtBoundary: () => { + return false; + } + }, + ArrowLeft: { + getNext: (current) => { + return this.handleLeftArrowAction(current); + }, + isCellAtBoundary: () => { + return false; + } + } + }; + if (e.keyCode === KeyCodes.Tab) { + return e.shiftKey === true ? keyNavActions.ArrowLeft : keyNavActions.ArrowRight; + } + return keyNavActions[e.key]; + }; + + changeCellFromEvent = (e) => { + e.preventDefault(); + if (e.keyCode === KeyCodes.ChineseInputMethod && this.state.isEditorEnabled) { + return; + } + if (this.throttle) return; + const currentPosition = this.state.selectedPosition; + const keyNavAction = this.getKeyNavActionFromEvent(e); + const next = keyNavAction.getNext(currentPosition); + if (!next) return; + this.checkIsAtGridBoundary(keyNavAction, next); + this.props.onCellClick(next); + this.onSelectCell({ ...next }); + this.throttle = true; + setTimeout(() => { + this.throttle = false; + }, 30); + }; + + checkIsAtGridBoundary(keyNavAction, next) { + const { isCellAtBoundary, onHitBoundary } = keyNavAction; + if (isCellAtBoundary(next)) { + onHitBoundary(next); + } + } + + onFocus = () => { + + }; + + onScroll = (e) => { + e.stopPropagation(); + }; + + setSelectionMaskRef = (ref) => { + this.selectionMask = ref; + }; + + setSelectionRangeMaskRef = (ref) => { + this.selectedRangeMask = ref; + }; + + setContainerRef = (ref) => { + this.container = ref; + }; + + isCellMaskActive = () => { + const activeElement = document.activeElement; + return (activeElement && + (activeElement.getAttribute('data-test') === 'cell-mask' || + activeElement.getAttribute('data-test') === 'active-editor') + ); + }; + + handleDragCopy = (draggedRange) => { + const { columns, groupMetrics, table: { rows, id_row_map }, gridUtils, updateRecords } = this.props; + // compute the new records + const newRecords = gridUtils.getUpdateDraggedRecords(draggedRange, columns, rows, id_row_map, groupMetrics); + updateRecords({ ...newRecords, isCopyPaste: true }); + }; + + handleDragStart = (e) => { + const { selectedRange: { topLeft, bottomRight, startCell, cursorCell } } = this.state; + // To prevent dragging down/up when reordering rows. (TODO: is this required) + const isViewportDragging = e && e.target && e.target.className; + if (topLeft.idx > -1 && isViewportDragging) { + try { + e.dataTransfer.setData('text/plain', ''); + } catch (ex) { + // IE only supports 'text' and 'URL' for the 'type' argument + e.dataTransfer.setData('text', ''); + } + this.setState({ + draggedRange: { topLeft, bottomRight, startCell, cursorCell } + }); + } + }; + + handleDragEnter = ({ overRecordIdx, overGroupRecordIndex }) => { + if (this.state.draggedRange != null) { + this.setState(({ draggedRange }) => ({ + draggedRange: { ...draggedRange, overRecordIdx, overGroupRecordIndex } + })); + } + }; + + handleDragEnd = () => { + const { draggedRange, selectedRange } = this.state; + let newSelectedRange = deepCopy(selectedRange); + if (draggedRange !== null) { + const { overRecordIdx, overGroupRecordIndex, bottomRight } = draggedRange; + if (overRecordIdx !== null && bottomRight.rowIdx < overRecordIdx) { + this.handleDragCopy(draggedRange); + newSelectedRange.bottomRight.rowIdx = overRecordIdx; + newSelectedRange.cursorCell.rowIdx = overRecordIdx; + newSelectedRange.bottomRight.groupRowIndex = overGroupRecordIndex; + newSelectedRange.cursorCell.groupRowIndex = overGroupRecordIndex; + } + this.setState({ draggedRange: null, selectedRange: newSelectedRange }); + } + }; + + + renderSingleCellSelectView = () => { + const { columns } = this.props; + const { + isEditorEnabled, + selectedPosition, + } = this.state; + const isDragEnabled = this.isSelectedCellEditable(); + const canEdit = false; + const showDragHandle = (isDragEnabled && canEdit); + const column = getSelectedColumn({ selectedPosition, columns }); + const { type: columnType } = column || {}; + if (isEditorEnabled && columnType !== CellType.RATE && columnType !== CellType.CHECKBOX) return null; + if (!this.isGridSelected()) return null; + + const props = { + innerRef: this.setSelectionMaskRef, + selectedPosition, + getSelectedDimensions: this.getSelectedDimensions, + }; + return ( + + {showDragHandle ? + + : null} + + ); + }; + + renderCellRangeSelectView = () => { + const { selectedRange } = this.state; + const { columns, rowHeight } = this.props; + + const isDragEnabled = this.isSelectedCellEditable(); + const canEdit = false; + const showDragHandle = (isDragEnabled && canEdit); + return [ + + {showDragHandle ? + + : null} + , + + ]; + }; + + renderMobileOperations = () => { + const { canAddRow, columns } = this.props; + const { selectedPosition } = this.state; + const isSelectCell = !(selectedPosition.idx === -1 && selectedPosition.rowIdx === -1); + const selectedColumn = isSelectCell && getSelectedColumn({ selectedPosition, columns }); + const cellEditable = isSelectCell && selectedColumn && TABLE_MOBILE_SUPPORT_EDIT_CELL_TYPE_MAP[selectedColumn.type] && this.isSelectedCellEditable(); + const style = this.props.getMobileFloatIconStyle(); + let moreIconPositionStyle = { + bottom: canAddRow || cellEditable ? 102 : 42, + }; + let buttons = [ + + + + ]; + + + if (canAddRow && !cellEditable) { + buttons.push( + + + + ); + } + + if (cellEditable) { + buttons.push( + + + + ); + } + return buttons; + }; + + render() { + const { selectedRange, draggedRange } = this.state; + const isSelectedSingleCell = selectedRangeIsSingleCell(selectedRange); + return ( + + ); + } +} + +InteractionMasks.propTypes = propTypes; + +export default InteractionMasks; diff --git a/frontend/src/metadata/metadata-view/components/table/table-masks/selection-mask.js b/frontend/src/metadata/metadata-view/components/table/table-masks/selection-mask.js new file mode 100644 index 0000000000..fb2a38e32e --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-masks/selection-mask.js @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import CellMask from './cell-mask'; + +function SelectionMask({ innerRef, selectedPosition, getSelectedDimensions, children }) { + const dimensions = getSelectedDimensions(selectedPosition); + return ( + + {children} + + ); +} + +SelectionMask.propTypes = { + selectedPosition: PropTypes.object.isRequired, + getSelectedDimensions: PropTypes.func.isRequired, + innerRef: PropTypes.func.isRequired, + children: PropTypes.element, +}; + +export default SelectionMask; diff --git a/frontend/src/metadata/metadata-view/components/table/table-masks/selection-range-mask.js b/frontend/src/metadata/metadata-view/components/table/table-masks/selection-range-mask.js new file mode 100644 index 0000000000..2297a56d7f --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-masks/selection-range-mask.js @@ -0,0 +1,32 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import CellMask from './cell-mask'; + +function SelectionRangeMask({ selectedRange, innerRef, getSelectedRangeDimensions, children }) { + const dimensions = getSelectedRangeDimensions(selectedRange); + return ( + + {children} + + ); +} + +SelectionRangeMask.propTypes = { + selectedRange: PropTypes.shape({ + topLeft: PropTypes.shape({ idx: PropTypes.number.isRequired, rowIdx: PropTypes.number.isRequired }).isRequired, + bottomRight: PropTypes.shape({ idx: PropTypes.number.isRequired, rowIdx: PropTypes.number.isRequired }).isRequired, + startCell: PropTypes.shape({ idx: PropTypes.number.isRequired, rowIdx: PropTypes.number.isRequired }).isRequired, + cursorCell: PropTypes.shape({ idx: PropTypes.number.isRequired, rowIdx: PropTypes.number.isRequired }) + }).isRequired, + columns: PropTypes.array.isRequired, + rowHeight: PropTypes.number.isRequired, + children: PropTypes.element, + innerRef: PropTypes.func.isRequired, + getSelectedRangeDimensions: PropTypes.func +}; + +export default SelectionRangeMask; diff --git a/frontend/src/metadata/metadata-view/components/table/table-tool/index.css b/frontend/src/metadata/metadata-view/components/table/table-tool/index.css new file mode 100644 index 0000000000..c7e93ec16b --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-tool/index.css @@ -0,0 +1,56 @@ +.sf-metadata-tool { + background-color: #fff; + border-bottom: 1px solid #e4e4e4; + border-top: 1px solid #ddd; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + display: flex; + flex-shrink: 0; + flex-wrap: nowrap; + height: 48px; + justify-content: space-between; + padding: 0 20px; + position: relative; + border-left: 1px solid #ddd; + border-right: 1px solid #ddd; +} + +.sf-metadata-tool .sf-metadata-tool-left-operations, +.sf-metadata-tool .sf-metadata-tool-right-operations { + display: flex; + align-items: center; +} + +.sf-metadata-tool .sf-metadata-tool-left-operations .setting-item { + margin-right: 0 !important; + margin-left: .5rem; +} + +.sf-metadata-tool .sf-metadata-tool-left-operations .setting-item:first-child { + margin-left: 0 !important; +} + +.sf-metadata-tool .custom-filter-label { + padding: 0 .5rem; +} + +.sf-metadata-tool .setting-item-btn { + border-radius: 4px; + cursor: pointer; + padding: 3px 4px; + width: 100%; + height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.sf-metadata-tool .setting-item-btn:hover { + background-color: #efefef; +} + +.sf-metadata-tool .custom-tool-label .sf-metadata-icon { + color: #666; + font-size: 14px; + margin-right: 8px; +} diff --git a/frontend/src/metadata/metadata-view/components/table/table-tool/index.js b/frontend/src/metadata/metadata-view/components/table/table-tool/index.js new file mode 100644 index 0000000000..a3fffece2c --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/table/table-tool/index.js @@ -0,0 +1,67 @@ +import React, { useCallback } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { FilterSetter, GroupbySetter, SortSetter, HideColumnSetter } from '../../data-process-setter'; +import { Z_INDEX } from '../../../_basic'; +import { EVENT_BUS_TYPE } from '../../../constants'; + +import './index.css'; + +const TableTool = ({ searcherActive, onFiltersChange, onSortsChange, modifyGroupbys, modifyHiddenColumns }) => { + + const onHeaderClick = useCallback(() => { + window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_NONE); + }, []); + + return ( +
+
+ + + + +
+
+
+ ); +}; + +TableTool.propTypes = { + searcherActive: PropTypes.bool, + onFiltersChange: PropTypes.func, + onSortsChange: PropTypes.func, + modifyGroupbys: PropTypes.func, + modifyHiddenColumns: PropTypes.func, +}; + +export default TableTool; diff --git a/frontend/src/metadata/metadata-view/constants/TransferTypes.js b/frontend/src/metadata/metadata-view/constants/TransferTypes.js new file mode 100644 index 0000000000..5d970e8127 --- /dev/null +++ b/frontend/src/metadata/metadata-view/constants/TransferTypes.js @@ -0,0 +1,15 @@ +const FRAGMENT = 'application/x-sf-metadata-fragment'; +const HTML = 'text/html'; +const TEXT = 'text/plain'; +const FILES = 'files'; +const DTABLE_FRAGMENT = 'sf-metadata-fragment'; + +const transferTypes = { + FRAGMENT, + HTML, + TEXT, + FILES, + DTABLE_FRAGMENT, +}; + +export default transferTypes; diff --git a/frontend/src/metadata/metadata-view/constants/event-bus-type.js b/frontend/src/metadata/metadata-view/constants/event-bus-type.js new file mode 100644 index 0000000000..ede764d7bf --- /dev/null +++ b/frontend/src/metadata/metadata-view/constants/event-bus-type.js @@ -0,0 +1,28 @@ +export const EVENT_BUS_TYPE = { + QUERY_COLLABORATORS: 'query-collaborators', + QUERY_COLLABORATOR: 'query-collaborator', + UPDATE_TABLE_ROWS: 'update-table-rows', + + // table + LOCAL_TABLE_CHANGED: 'local-table-changed', + SERVER_TABLE_CHANGED: 'server-table-changed', + TABLE_ERROR: 'table-error', + OPEN_EDITOR: 'open-editor', + CLOSE_EDITOR: 'close-editor', + SELECT_CELL: 'select_cell', + SELECT_START: 'select_start', + SELECT_UPDATE: 'select_update', + SELECT_END: 'select_end', + SELECT_END_WITH_SHIFT: 'select_end_with_shift', + SELECT_NONE: 'select_none', + COPY_CELLS: 'copy_cells', + PASTE_CELLS: 'paste_cells', + SEARCH_CELLS: 'search-cells', + CLOSE_SEARCH_CELLS: 'close-search-cells', + OPEN_SELECT: 'open-select', + UPDATE_LINKED_RECORDS: 'update_linked_records', + SELECT_COLUMN: 'select_column', + DRAG_ENTER: 'drag_enter', + COLLAPSE_ALL_GROUPS: 'collapse_all_groups', + EXPAND_ALL_GROUPS: 'expand_all_groups', +}; diff --git a/frontend/src/metadata/metadata-view/constants/index.js b/frontend/src/metadata/metadata-view/constants/index.js new file mode 100644 index 0000000000..1ce706c286 --- /dev/null +++ b/frontend/src/metadata/metadata-view/constants/index.js @@ -0,0 +1,102 @@ +import { CellType } from '../_basic'; +import { EVENT_BUS_TYPE } from './event-bus-type'; +import TRANSFER_TYPES from './TransferTypes'; + +export const CELL_NAVIGATION_MODE = { + NONE: 'none', + CHANGE_ROW: 'changeRow', + LOOP_OVER_ROW: 'loopOverRow', +}; + +export const SEQUENCE_COLUMN_WIDTH = 80; + +export const ROW_HEIGHT = 32; + +export const GRID_HEADER_DEFAULT_HEIGHT = 32; + +export const GRID_HEADER_DOUBLE_HEIGHT = 56; + +export const GROUP_VIEW_OFFSET = 16; + +export const GROUP_HEADER_HEIGHT = 48; + +export const TABLE_LEFT_MARGIN = 10; + +export const TABLE_BORDER_WIDTH = 1; + +export const UNABLE_TO_CALCULATE = '--'; + +export const FROZEN_COLUMN_SHADOW = '2px 0 5px -2px hsla(0,0%,53.3%,.3)'; + +export const TABLE_NOT_SUPPORT_EDIT_TYPE_MAP = { + [CellType.CREATOR]: true, + [CellType.LAST_MODIFIER]: true, + [CellType.CTIME]: true, + [CellType.MTIME]: true, +}; + +export const TABLE_SUPPORT_EDIT_TYPE_MAP = { + [CellType.TEXT]: true, +}; + +export const TABLE_MOBILE_SUPPORT_EDIT_CELL_TYPE_MAP = { + [CellType.TEXT]: true, +}; + +export const CANVAS_RIGHT_INTERVAL = 240; + +export const LEFT_NAV = 280; +export const ROW_DETAIL_PADDING = 40 * 2; +export const ROW_DETAIL_MARGIN = 20 * 2; +export const EDITOR_PADDING = 1.5 * 16; // 1.5: 0.75 * 2 + +export const COLUMN_RATE_MAX_NUMBER = [ + { name: 1 }, + { name: 2 }, + { name: 3 }, + { name: 4 }, + { name: 5 }, + { name: 6 }, + { name: 7 }, + { name: 8 }, + { name: 9 }, + { name: 10 }, +]; + +export const GROUP_ROW_TYPE = { + GROUP_CONTAINER: 'group_container', + ROW: 'row', + BTN_INSERT_ROW: 'btn_insert_row', +}; + +export const INSERT_ROW_HEIGHT = 32; + +export const CHANGE_HEADER_WIDTH = 'CHANGE_HEADER_WIDTH'; + +export const NOT_SUPPORT_DRAG_COPY_COLUMN_TYPES = [ +]; + +export const SUPPORT_PREVIEW_COLUMN_TYPES = []; + +export const OVER_SCAN_COLUMNS = 10; + +export const DELETED_OPTION_BACKGROUND_COLOR = '#eaeaea'; + +export const DELETED_OPTION_TIPS = 'deleted_option'; + +export const SUPPORT_BATCH_DOWNLOAD_TYPES = []; + +export const DEFAULT_COLUMNS = [ + { name: 'Name', type: CellType.TEXT, width: 200, editable: false, key: 'name' }, + { name: 'Parent_dir', type: CellType.TEXT, width: 200, editable: false, key: 'parent_dir' }, + { name: 'CTime', type: CellType.CTIME, width: 200, editable: false, key: 'created_time' }, + { name: 'MTime', type: CellType.MTIME, width: 200, editable: false, key: 'modified_time' }, + { name: 'Creator', type: CellType.CREATOR, width: 200, editable: false, key: 'creator' }, + { name: 'Last_modified', type: CellType.LAST_MODIFIER, width: 200, editable: false, key: 'modifier' }, + { name: 'Is_dir', type: CellType.TEXT, width: 200, editable: false, key: 'is_dir' }, +]; + +export { + EVENT_BUS_TYPE, + TRANSFER_TYPES, +}; diff --git a/frontend/src/metadata/metadata-view/context.js b/frontend/src/metadata/metadata-view/context.js new file mode 100644 index 0000000000..c1a7fca2bb --- /dev/null +++ b/frontend/src/metadata/metadata-view/context.js @@ -0,0 +1,79 @@ +import metadataAPI from '../api'; +import { UserService, LocalStorage } from './_basic'; +import EventBus from './utils/event-bus'; + +class Context { + + constructor() { + this.settings = {}; + this.metadataAPI = null; + this.localStorage = null; + this.userService = null; + this.eventBus = null; + this.hasInit = false; + } + + async init({ otherSettings }) { + if (this.hasInit) return; + + // init settings + this.settings = otherSettings || {}; + + // init metadataAPI + const { mediaUrl } = this.settings; + this.metadataAPI = metadataAPI; + + // init localStorage + const { repoID } = this.settings; + this.localStorage = new LocalStorage(`sf-metadata-${repoID}`); + + // init userService + this.userService = new UserService({ mediaUrl, api: this.metadataAPI.listUserInfo }); + + const eventBus = new EventBus(); + this.eventBus = eventBus; + + this.hasInit = true; + } + + destroy = () => { + this.settings = {}; + this.metadataAPI = null; + this.localStorage = null; + this.userService = null; + this.eventBus = null; + this.hasInit = false; + }; + + getSetting = (key) => { + if (this.settings[key] === false) return this.settings[key]; + return this.settings[key] || ''; + }; + + setSetting = (key, value) => { + this.settings[key] = value; + }; + + // metadata + getMetadata = (repoID) => { + return this.metadataAPI.getMetadata(repoID); + }; + + canModifyCell = (column) => { + return false; + }; + + canModifyRow = (row) => { + return false; + }; + + getPermission = () => { + return 'rw'; + }; + + getCollaboratorsFromCache = () => { + // + }; +} + +export default Context; diff --git a/frontend/src/metadata/metadata-view/hooks/collaborators.js b/frontend/src/metadata/metadata-view/hooks/collaborators.js new file mode 100644 index 0000000000..7ce92d7844 --- /dev/null +++ b/frontend/src/metadata/metadata-view/hooks/collaborators.js @@ -0,0 +1,36 @@ +/* eslint-disable react/prop-types */ +import React, { useContext, useState, useRef, useCallback } from 'react'; + +const CollaboratorsContext = React.createContext(null); + +export const CollaboratorsProvider = ({ + collaborators, + collaboratorsCache: propsCollaboratorsCache, + updateCollaboratorsCache: propsUpdateCollaboratorsCache, + children, +}) => { + const collaboratorsCacheRef = useRef(propsCollaboratorsCache || {}); + const [collaboratorsCache, setCollaboratorsCache] = useState(propsCollaboratorsCache || {}); + + const updateCollaboratorsCache = useCallback((user) => { + const newCollaboratorsCache = { ...collaboratorsCacheRef.current, [user.email]: user }; + collaboratorsCacheRef.current = newCollaboratorsCache; + setCollaboratorsCache(newCollaboratorsCache); + propsUpdateCollaboratorsCache && propsUpdateCollaboratorsCache(user); + }, [propsUpdateCollaboratorsCache]); + + return ( + + {children} + + ); +}; + +export const useCollaborators = () => { + const context = useContext(CollaboratorsContext); + if (!context) { + throw new Error('\'CollaboratorsContext\' is null'); + } + const { collaborators, collaboratorsCache, updateCollaboratorsCache } = context; + return { collaborators, collaboratorsCache, updateCollaboratorsCache }; +}; diff --git a/frontend/src/metadata/metadata-view/hooks/index.js b/frontend/src/metadata/metadata-view/hooks/index.js new file mode 100644 index 0000000000..650e76b7f4 --- /dev/null +++ b/frontend/src/metadata/metadata-view/hooks/index.js @@ -0,0 +1,9 @@ +import { CollaboratorsProvider, useCollaborators } from './collaborators'; +import { MetadataProvider, useMetadata } from './metadata'; +import { RecordDetailsProvider, useRecordDetails } from './record-details'; + +export { + CollaboratorsProvider, useCollaborators, + MetadataProvider, useMetadata, + RecordDetailsProvider, useRecordDetails, +}; diff --git a/frontend/src/metadata/metadata-view/hooks/metadata.js b/frontend/src/metadata/metadata-view/hooks/metadata.js new file mode 100644 index 0000000000..6eb9023d55 --- /dev/null +++ b/frontend/src/metadata/metadata-view/hooks/metadata.js @@ -0,0 +1,136 @@ +/* eslint-disable react/prop-types */ +import React, { useCallback, useContext, useEffect, useState } from 'react'; +import { toaster } from '@seafile/sf-metadata-ui-component'; +import { Metadata } from '../model'; +import { gettext } from '../../../utils/constants'; +import { getErrorMsg, CellType } from '../_basic'; +import Context from '../context'; + +const MetadataContext = React.createContext(null); + +export const MetadataProvider = ({ + children, + ...params +}) => { + const [isLoading, setLoading] = useState(true); + const [metadata, setMetadata] = useState({ records: [], columns: [] }); + + const getColumnName = useCallback((key, name) => { + switch (key) { + case '_ctime': + return gettext('Created time'); + case '_mtime': + return gettext('Last modified time'); + case '_creator': + return gettext('Creator'); + case '_last_modifier': + return gettext('Last modifier'); + case '_file_creator': + return gettext('File creator'); + case '_file_modifier': + return gettext('File modifier'); + case '_file_ctime': + return gettext('File created time'); + case '_file_mtime': + return gettext('File last modified time'); + case '_is_dir': + return gettext('Is dir'); + case '_parent_dir': + return gettext('Parent dir'); + case '_name': + return gettext('File name'); + default: + return name; + } + }, []); + + const getColumnType = useCallback((key, type) => { + switch (key) { + case '_ctime': + case '_file_ctime': + return CellType.CTIME; + case '_mtime': + case '_file_mtime': + return CellType.MTIME; + case '_creator': + case '_file_creator': + return CellType.CREATOR; + case '_last_modifier': + case '_file_modifier': + return CellType.LAST_MODIFIER; + default: + return type; + } + }, []); + + const getColumns = useCallback((columns) => { + if (!Array.isArray(columns) || columns.length === 0) return []; + return columns.map((column) => { + const { type, key, name, ...params } = column; + return { + key, + type: getColumnType(key, type), + name: getColumnName(key, name), + ...params, + width: 200, + }; + }).filter(column => !['_id', '_ctime', '_mtime', '_creator', '_last_modifier'].includes(column.key)); + }, [getColumnType, getColumnName]); + + // init + useEffect(() => { + const init = async () => { + + // init context + const context = new Context(); + window.sfMetadataContext = context; + await window.sfMetadataContext.init({ otherSettings: params }); + + const repoID = window.sfMetadataContext.getSetting('repoID'); + window.sfMetadataContext.getMetadata(repoID).then(res => { + setMetadata(new Metadata({ rows: res?.data?.results || [], columns: getColumns(res?.data?.metadata) })); + setLoading(false); + }).catch(error => { + const errorMsg = getErrorMsg(error); + toaster.danger(gettext(errorMsg)); + }); + }; + + init(); + + return () => { + window.sfMetadataContext.destroy(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const extendMetadataRows = useCallback((callback) => { + const repoID = window.sfMetadataContext.getSetting('repoID'); + window.sfMetadataContext.getMetadata(repoID).then(res => { + const rows = res?.data?.results || []; + metadata.extendRows(rows); + setMetadata(metadata); + callback && callback(true); + }).catch(error => { + const errorMsg = getErrorMsg(error); + toaster.danger(gettext(errorMsg)); + callback && callback(false); + }); + + }, [metadata]); + + return ( + + {children} + + ); +}; + +export const useMetadata = () => { + const context = useContext(MetadataContext); + if (!context) { + throw new Error('\'MetadataContext\' is null'); + } + const { isLoading, metadata, extendMetadataRows } = context; + return { isLoading, metadata, extendMetadataRows }; +}; diff --git a/frontend/src/metadata/metadata-view/hooks/record-details.js b/frontend/src/metadata/metadata-view/hooks/record-details.js new file mode 100644 index 0000000000..639d4fcf22 --- /dev/null +++ b/frontend/src/metadata/metadata-view/hooks/record-details.js @@ -0,0 +1,34 @@ +/* eslint-disable react/prop-types */ +import React, { useContext, useState, useCallback } from 'react'; + +const RecordDetailsContext = React.createContext(null); + +export const RecordDetailsProvider = ({ children }) => { + const [isShowRecordDetails, setIsShowRecordDetails] = useState(false); + const [recordDetails, setRecordDetails] = useState({}); + + const openRecordDetails = useCallback((recordDetails) => { + setRecordDetails(recordDetails); + setIsShowRecordDetails(true); + }, []); + + const closeRecordDetails = useCallback(() => { + setRecordDetails({}); + setIsShowRecordDetails(false); + }, []); + + return ( + + {children} + + ); +}; + +export const useRecordDetails = () => { + const context = useContext(RecordDetailsContext); + if (!context) { + throw new Error('\'RecordDetailsContext\' is null'); + } + const { isShowRecordDetails, recordDetails, openRecordDetails, closeRecordDetails } = context; + return { isShowRecordDetails, recordDetails, openRecordDetails, closeRecordDetails }; +}; diff --git a/frontend/src/metadata/metadata-view/index.js b/frontend/src/metadata/metadata-view/index.js new file mode 100644 index 0000000000..5fc62cf5a5 --- /dev/null +++ b/frontend/src/metadata/metadata-view/index.js @@ -0,0 +1,30 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { MetadataProvider, CollaboratorsProvider, RecordDetailsProvider } from './hooks/index'; +import { Table } from './components/index'; + +const SeafileMetadata = ({ collaborators, collaboratorsCache, updateCollaboratorsCache, ...params }) => { + const collaboratorsProviderProps = { + collaborators, + collaboratorsCache, + updateCollaboratorsCache, + }; + + return ( + + + + + + + + ); +}; + +SeafileMetadata.propTypes = { + collaborators: PropTypes.array, + collaboratorsCache: PropTypes.object, + updateCollaboratorsCache: PropTypes.func, +}; + +export default SeafileMetadata; diff --git a/frontend/src/metadata/metadata-view/model/index.js b/frontend/src/metadata/metadata-view/model/index.js new file mode 100644 index 0000000000..ede00b259e --- /dev/null +++ b/frontend/src/metadata/metadata-view/model/index.js @@ -0,0 +1,7 @@ +import Metadata from './metadata'; +import User from './user'; + +export { + Metadata, + User, +}; diff --git a/frontend/src/metadata/metadata-view/model/metadata/index.js b/frontend/src/metadata/metadata-view/model/metadata/index.js new file mode 100644 index 0000000000..36cd21496f --- /dev/null +++ b/frontend/src/metadata/metadata-view/model/metadata/index.js @@ -0,0 +1,33 @@ +class Metadata { + constructor(object) { + this.columns = object.columns || []; + this.rows = object.rows || []; + this.id_row_map = {}; + this.row_ids = []; + this.rows.forEach(record => { + this.row_ids.push(record._id); + this.id_row_map[record._id] = record; + }); + + this.hasMore = object.hasMore || false; + this.recordsCount = object.recordsCount || this.row_ids.length; + this.page = 1; + this.perPageCount = 1000; + } + + extendRows = (rows) => { + if (!Array.isArray(rows) || rows.length === 0) { + this.hasMore = false; + return; + } + + this.rows.push(...rows); + rows.forEach(record => { + this.row_ids.push(record._id); + this.id_row_map[record._id] = record; + }); + }; + +} + +export default Metadata; diff --git a/frontend/src/metadata/metadata-view/model/user.js b/frontend/src/metadata/metadata-view/model/user.js new file mode 100644 index 0000000000..f41a8888e2 --- /dev/null +++ b/frontend/src/metadata/metadata-view/model/user.js @@ -0,0 +1,12 @@ +class User { + constructor(object) { + this.avatar_url = object.avatar_url || ''; + this.contact_email = object.contact_email || ''; + this.email = object.email || ''; + this.name = object.name || ''; + this.name_pinyin = object.name_pinyin || ''; + this.id = object.id_in_org || ''; + } +} + +export default User; diff --git a/frontend/src/metadata/metadata-view/utils/cell-comparer.js b/frontend/src/metadata/metadata-view/utils/cell-comparer.js new file mode 100644 index 0000000000..1a6b633c73 --- /dev/null +++ b/frontend/src/metadata/metadata-view/utils/cell-comparer.js @@ -0,0 +1,28 @@ +import { CellType, isEmptyObject } from '../_basic'; +import ObjectUtils from './object-utils'; + +export const isCellValueChanged = (oldVal, newVal, columnType) => { + if (oldVal === newVal) { + return false; + } + if (oldVal === undefined || oldVal === null) { + if (columnType === CellType.GEOLOCATION && isEmptyObject(newVal)) { + return false; + } + if ((columnType === CellType.DATE || columnType === CellType.NUMBER || columnType === CellType.AUTO_NUMBER) && newVal === null) { + return false; + } + if (Array.isArray(newVal)) { + return newVal.length !== 0; + } + return newVal !== false && newVal !== ''; + } + if (Array.isArray(oldVal) && Array.isArray(newVal)) { + // [{}].toString(): [object Object] + return JSON.stringify(oldVal) !== JSON.stringify(newVal); + } + if (typeof oldVal === 'object' && typeof newVal === 'object' && newVal !== null) { + return !ObjectUtils.isSameObject(oldVal, newVal); + } + return oldVal !== newVal; +}; diff --git a/frontend/src/metadata/metadata-view/utils/cell-format-utils.js b/frontend/src/metadata/metadata-view/utils/cell-format-utils.js new file mode 100644 index 0000000000..a4fda9cafe --- /dev/null +++ b/frontend/src/metadata/metadata-view/utils/cell-format-utils.js @@ -0,0 +1,79 @@ +import dayjs from 'dayjs'; +import { + CellType, +} from '../_basic'; + +const getAutoTimeDisplayString = (autoTime) => { + if (!autoTime) { + return null; + } + const date = dayjs(autoTime); + if (!date.isValid()) return autoTime; + return date.format('YYYY-MM-DD HH:mm:ss'); +}; + +export const getClientCellValueDisplayString = (row, type, key, { data, collaborators = [] } = {}) => { + const cellValue = row[key]; + if (type === CellType.CTIME || type === CellType.MTIME) { + return getAutoTimeDisplayString(cellValue); + } + return row[key]; +}; + +export const getFormatRowData = (columns, rowData) => { + let keyColumnMap = {}; + columns.forEach(column => { + keyColumnMap[column.key] = column; + }); + return convertedToRecordData(rowData, keyColumnMap); +}; + +export const getFormattedRowsData = (rowsData, columns, excludesColumnTypes) => { + let keyColumnMap = {}; + columns.forEach(column => { + keyColumnMap[column.key] = column; + }); + return rowsData.map(rowData => { + let formattedRowsData = convertedToRecordData(rowData, keyColumnMap, excludesColumnTypes); + if (rowData._id) { + formattedRowsData._id = rowData._id; + } + if (Object.prototype.hasOwnProperty.call(rowData, '_archived')) { + formattedRowsData._archived = rowData._archived ? 'true' : 'false'; + } + return formattedRowsData; + }); +}; + +// { [column.key]: cellValue } -> { [column.name]: cellValue } +// { [option-column.key]: option.id } -> { [option-column.name]: option.name } +function convertedToRecordData(rowData, keyColumnMap, excludesColumnTypes = []) { + if (!rowData || !keyColumnMap) { + return {}; + } + let recordData = {}; + Object.keys(rowData).forEach(key => { + const column = keyColumnMap[key]; + if (!column) { + return; + } + + const { name: colName, type } = column; + if (excludesColumnTypes && excludesColumnTypes.includes(type)) { + return; + } + + let cellValue = rowData[key]; + recordData[colName] = cellValue; + switch (type) { + case CellType.TEXT: { + recordData[colName] = typeof cellValue === 'string' ? cellValue.trim() : ''; + break; + } + default: { + break; + } + } + }); + return recordData; +} diff --git a/frontend/src/metadata/metadata-view/utils/cell-value-utils.js b/frontend/src/metadata/metadata-view/utils/cell-value-utils.js new file mode 100644 index 0000000000..afe163f8db --- /dev/null +++ b/frontend/src/metadata/metadata-view/utils/cell-value-utils.js @@ -0,0 +1,14 @@ +class CellValueUtils { + + isValidCellValue = (value) => { + if (value === undefined) return false; + if (value === null) return false; + if (value === '') return false; + if (JSON.stringify(value) === '{}') return false; + if (JSON.stringify(value) === '[]') return false; + return true; + }; + +} + +export default CellValueUtils; diff --git a/frontend/src/metadata/metadata-view/utils/column-utils.js b/frontend/src/metadata/metadata-view/utils/column-utils.js new file mode 100644 index 0000000000..09ac05b798 --- /dev/null +++ b/frontend/src/metadata/metadata-view/utils/column-utils.js @@ -0,0 +1,176 @@ +import { + CellType, + DEFAULT_DATE_FORMAT, +} from '../_basic'; +import { + SEQUENCE_COLUMN_WIDTH +} from '../constants'; + +export function getSelectColumnOptions(column) { + if (!column || !column.data || !Array.isArray(column.data.options)) { + return []; + } + return column.data.options; +} + +export function getDateColumnFormat(column) { + const format = (column && column.data && column.data.format) ? column.data.format : DEFAULT_DATE_FORMAT; + // Old Europe format is D/M/YYYY new format is DD/MM/YYYY + return format; +} + +export function isCheckboxColumn(column) { + let { type } = column; + return type === CellType.CHECKBOX; +} + +export function getColumnByKey(columnKey, columns) { + if (!columnKey || !Array.isArray(columns)) { + return null; + } + return columns.find(column => column.key === columnKey); +} + +export function getColumnByName(columnName, columns) { + if (!columnName || !Array.isArray(columns)) { + return null; + } + return columns.find(column => column.name === columnName); +} + +export function getColumnByType(columnType, columns) { + if (!columnType || !Array.isArray(columns)) { + return null; + } + return columns.find(column => column.type === columnType); +} + +export function getColumnByIndex(index, columns) { + if (Array.isArray(columns)) { + return columns[index]; + } + if (typeof Immutable !== 'undefined') { + return columns.get(index); + } + return null; +} + +export const getColumnWidth = (column) => { + let { type } = column; + switch (type) { + case CellType.CTIME: + case CellType.MTIME: { + return 160; + } + default: { + return 100; + } + } +}; + +export const isNameColumn = (column) => { + return column.key === '0000'; +}; + +export const handleCascadeColumn = (optionValue, columnKey, columns, row, updated = {}, processedColumns = new Set()) => { + // This column has already been processed, avoid circular dependency. + if (!Array.isArray(columns) || processedColumns.has(columnKey)) { + return updated; + } + processedColumns.add(columnKey); + const singleSelectColumns = columns.filter(column => column.type === CellType.SINGLE_SELECT); + for (let i = 0; i < singleSelectColumns.length; i++) { + const singleSelectColumn = singleSelectColumns[i]; + const { data: { cascade_column_key, cascade_settings } } = singleSelectColumn; + if (cascade_column_key === columnKey) { + const { key: childColumnKey } = singleSelectColumn; + const childColumnOptions = cascade_settings[optionValue]; + const childColumnCellValue = row[childColumnKey]; + const cellValueInOptions = childColumnOptions && childColumnOptions.includes(childColumnCellValue); + if (!cellValueInOptions) { + updated[childColumnKey] = ''; + handleCascadeColumn('', childColumnKey, columns, row, updated, processedColumns); + } + } + } + return updated; +}; + +export const isFrozen = (column) => { + if (!column) return false; + return column.frozen === true; +}; + +export const findLastFrozenColumnIndex = (columns) => { + for (let i = 0; i < columns.length; i++) { + if (isFrozen(columns[i])) { + return i; + } + } + return -1; +}; + +export const setColumnOffsets = (columns) => { + let nextColumns = []; + let left = 0; + columns.forEach((column) => { + nextColumns.push({ ...column, left }); + left += column.width; + }); + return nextColumns; +}; + +export function isColumnSupportEdit(cell, columns) { + const column = columns[cell.idx]; + if (column?.type === CellType.LINK_FORMULA && [CellType.IMAGE, CellType.FILE].includes(column?.data?.array_type)) { + return true; + } + return false; +} + +export function isColumnSupportDirectEdit(cell, columns) { + const column = columns[cell.idx]; + return [].includes(column?.type); +} + +const _getCustomColumnsWidth = () => { + // todo + return {}; +}; + +export const recalculate = (columns, allColumns, tableId) => { + const displayColumns = columns; + const displayAllColumns = allColumns; + const pageColumnsWidth = _getCustomColumnsWidth(); // get columns width from local storage + const totalWidth = displayColumns.reduce((total, column) => { + const key = `${tableId}-${column.key}`; + const width = pageColumnsWidth[key] || column.width; + total += width; + return total; + }, 0); + let left = SEQUENCE_COLUMN_WIDTH; + const frozenColumns = displayColumns.filter(c => isFrozen(c)); + const frozenColumnsWidth = frozenColumns.reduce((w, column) => { + const key = `${tableId}-${column.key}`; + const width = pageColumnsWidth[key] || column.width; + return w + width; + }, 0); + const lastFrozenColumnKey = frozenColumnsWidth > 0 ? frozenColumns[frozenColumns.length - 1].key : null; + const newColumns = displayColumns.map((column, index) => { + const key = `${tableId}-${column.key}`; + const width = pageColumnsWidth[key] || column.width; + column.idx = index; // set column idx + column.left = left; // set column offset + column.width = width; + left += width; + return column; + }); + + return { + totalWidth, + lastFrozenColumnKey, + frozenColumnsWidth, + columns: newColumns, + allColumns: displayAllColumns, + }; +}; diff --git a/frontend/src/metadata/metadata-view/utils/date-translate.js b/frontend/src/metadata/metadata-view/utils/date-translate.js new file mode 100644 index 0000000000..01e30f6dbe --- /dev/null +++ b/frontend/src/metadata/metadata-view/utils/date-translate.js @@ -0,0 +1,70 @@ +import { gettext, lang } from '../../../utils/constants'; + +const zhCN = require('@seafile/seafile-calendar/lib/locale/zh_CN'); +const zhTW = require('@seafile/seafile-calendar/lib/locale/zh_TW'); +const enUS = require('@seafile/seafile-calendar/lib/locale/en_US'); +const frFR = require('@seafile/seafile-calendar/lib/locale/fr_FR'); +const deDE = require('@seafile/seafile-calendar/lib/locale/de_DE'); +const esES = require('@seafile/seafile-calendar/lib/locale/es_ES'); +const plPL = require('@seafile/seafile-calendar/lib/locale/pl_PL'); +const csCZ = require('@seafile/seafile-calendar/lib/locale/cs_CZ'); +const ruRU = require('@seafile/seafile-calendar/lib/locale/ru_RU'); + +function translateCalendar() { + const locale = lang ? lang : 'en'; + let language; + switch (locale) { + case 'zh-cn': + language = zhCN; + break; + case 'zh-tw': + language = zhTW; + break; + case 'en': + language = enUS; + break; + case 'fr': + language = frFR; + break; + case 'de': + language = deDE; + break; + case 'es': + language = esES; + break; + case 'es-ar': + language = esES; + break; + case 'es-mx': + language = esES; + break; + case 'pl': + language = plPL; + break; + case 'cs': + language = csCZ; + break; + case 'ru': + language = ruRU; + break; + default: + language = enUS; + } + return language; +} + +function getMobileDatePickerLocale() { + return { + DatePickerLocale: { + year: gettext('Year'), + month: gettext('Month'), + day: gettext('Day'), + hour: gettext('Hour'), + minute: gettext('Minute'), + }, + okText: gettext('Done'), + dismissText: gettext('Cancel') + }; +} + +export { translateCalendar, getMobileDatePickerLocale }; diff --git a/frontend/src/metadata/metadata-view/utils/dayjs.js b/frontend/src/metadata/metadata-view/utils/dayjs.js new file mode 100644 index 0000000000..aa040de5ab --- /dev/null +++ b/frontend/src/metadata/metadata-view/utils/dayjs.js @@ -0,0 +1,6 @@ +import dayjs from 'dayjs'; +import customParseFormat from 'dayjs/plugin/customParseFormat'; + +dayjs.extend(customParseFormat); + +export default dayjs; diff --git a/frontend/src/metadata/metadata-view/utils/event-bus.js b/frontend/src/metadata/metadata-view/utils/event-bus.js new file mode 100644 index 0000000000..ceb58b75b5 --- /dev/null +++ b/frontend/src/metadata/metadata-view/utils/event-bus.js @@ -0,0 +1,28 @@ +class EventBus { + subscribers = {}; + + subscribe(type, handler) { + if (!this.subscribers[type]) { + this.subscribers[type] = []; + } + + const handlers = this.subscribers[type]; + handlers.push(handler); + + return () => { + const index = handlers.indexOf(handler); + if (index > -1) { + handlers.splice(index, 1); + } + }; + } + + dispatch(type, ...data) { + const handlers = this.subscribers[type]; + if (Array.isArray(handlers)) { + handlers.forEach(handler => handler(...data)); + } + } +} + +export default EventBus; diff --git a/frontend/src/metadata/metadata-view/utils/filters-utils.js b/frontend/src/metadata/metadata-view/utils/filters-utils.js new file mode 100644 index 0000000000..e6a8e03acd --- /dev/null +++ b/frontend/src/metadata/metadata-view/utils/filters-utils.js @@ -0,0 +1,251 @@ +import { + CellType, + FILTER_PREDICATE_TYPE, + FILTER_COLUMN_OPTIONS, + FILTER_TERM_MODIFIER_TYPE, + filterTermModifierNotWithin, + filterTermModifierIsWithin, + isDateColumn, + FILTER_ERR_MSG, +} from '../_basic'; + +export const SPECIAL_TERM_TYPE = { + CREATOR: 'creator', + SINGLE_SELECT: 'single_select', + MULTIPLE_SELECT: 'multiple_select', + COLLABORATOR: 'collaborator', + RATE: 'rate' +}; + +export const SIMPLE_TEXT_INPUT_COLUMNS_MAP = { + [CellType.TEXT]: true, + [CellType.URL]: true, +}; + +export const DATE_LABEL_MAP = { + [FILTER_TERM_MODIFIER_TYPE.EXACT_DATE]: true, + [FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_AGO]: true, + [FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_FROM_NOW]: true, + [FILTER_TERM_MODIFIER_TYPE.THE_NEXT_NUMBERS_OF_DAYS]: true, + [FILTER_TERM_MODIFIER_TYPE.THE_PAST_NUMBERS_OF_DAYS]: true, +}; + +export const ARRAY_PREDICATE = { + [FILTER_PREDICATE_TYPE.IS_ANY_OF]: true, + [FILTER_PREDICATE_TYPE.IS_NONE_OF]: true, + [FILTER_PREDICATE_TYPE.HAS_ANY_OF]: true, + [FILTER_PREDICATE_TYPE.HAS_ALL_OF]: true, + [FILTER_PREDICATE_TYPE.HAS_NONE_OF]: true, + [FILTER_PREDICATE_TYPE.IS_EXACTLY]: true, +}; + +const STRING_PREDICATE = { + [FILTER_PREDICATE_TYPE.IS]: true, + [FILTER_PREDICATE_TYPE.IS_NOT]: true +}; + +export const DATE_EMPTY_LABEL_MAP = { + [FILTER_PREDICATE_TYPE.EMPTY]: true, + [FILTER_PREDICATE_TYPE.NOT_EMPTY]: true, +}; + +export const FILTER_ERR_MSG_LIST = [ + FILTER_ERR_MSG.INVALID_FILTER, + FILTER_ERR_MSG.INCOMPLETE_FILTER, + FILTER_ERR_MSG.COLUMN_MISSING, + FILTER_ERR_MSG.COLUMN_NOT_SUPPORTED, + FILTER_ERR_MSG.UNMATCHED_PREDICATE, + FILTER_ERR_MSG.UNMATCHED_MODIFIER, + FILTER_ERR_MSG.INVALID_TERM, +]; + +const MULTIPLE_SELECTOR_COLUMNS = [CellType.CREATOR, CellType.LAST_MODIFIER]; + +export const isFilterTermArray = (column, filterPredicate) => { + const { type } = column; + if (MULTIPLE_SELECTOR_COLUMNS.includes(type)) { + return true; + } + return false; +}; + +export const getUpdatedFilterByCreator = (filter, collaborator) => { + const multipleSelectType = [FILTER_PREDICATE_TYPE.CONTAINS, FILTER_PREDICATE_TYPE.NOT_CONTAIN]; + let { filter_predicate, filter_term: filterTerm } = filter; + if (multipleSelectType.includes(filter_predicate)) { + filterTerm = filterTerm ? filter.filter_term.slice(0) : []; + let selectedEmail = collaborator.email; + let collaborator_index = filterTerm.indexOf(selectedEmail); + if (collaborator_index > -1) { + filterTerm.splice(collaborator_index, 1); + } else { + filterTerm.push(selectedEmail); + } + } else { + if (filterTerm[0] === collaborator.email) { + return; + } + filterTerm = [collaborator.email]; + } + return Object.assign({}, filter, { filter_term: filterTerm }); +}; + +export const getUpdatedFilterBySelectSingle = (filter, columnOption) => { + let new_filter_term; + // if predicate is any of / is none of, filter_term is array; else filter_term is string + if (ARRAY_PREDICATE[filter.filter_predicate]) { + new_filter_term = Array.isArray(filter.filter_term) ? [...filter.filter_term] : []; + const index = new_filter_term.indexOf(columnOption.id); + if (index === -1) { + new_filter_term.push(columnOption.id); + } else { + new_filter_term.splice(index, 1); + } + } else { + new_filter_term = columnOption.id; + } + return Object.assign({}, filter, { filter_term: new_filter_term }); +}; + +export const getUpdatedFilterBySelectMultiple = (filter, columnOption) => { + let filterTerm = filter.filter_term ? filter.filter_term : []; + let index = filterTerm.indexOf(columnOption.id); + if (index > -1) { + filterTerm.splice(index, 1); + } else { + filterTerm.push(columnOption.id); + } + return Object.assign({}, filter, { filter_term: filterTerm }); +}; + +export const getUpdatedFilterByCollaborator = (filter, collaborator) => { + let filterTerm = filter.filter_term ? filter.filter_term.slice(0) : []; + let selectedEmail = collaborator.email; + let collaborator_index = filterTerm.indexOf(selectedEmail); + if (collaborator_index > -1) { + filterTerm.splice(collaborator_index, 1); + } else { + filterTerm.push(selectedEmail); + } + return Object.assign({}, filter, { filter_term: filterTerm }); +}; + +export const getUpdatedFilterByRate = (filter, value) => { + if (filter.filter_term === value) { + return Object.assign({}, filter, { filter_term: 0 }); + } + return Object.assign({}, filter, { filter_term: value }); +}; + +export const getColumnOptions = (column) => { + const { type } = column; + return FILTER_COLUMN_OPTIONS[type] || {}; +}; + +export const getFilterByColumn = (column, filter = {}) => { + let { filterPredicateList } = getColumnOptions(column); + if (!filterPredicateList) return; + let filterPredicate = filterPredicateList[0]; + + let updatedFilter = Object.assign({}, filter, { column_key: column.key, filter_predicate: filterPredicate }); + + // text | number | long-text | url | email + // auto-number | geolocation | duration + updatedFilter.filter_term = ''; + + // single-select | multiple-select | collaborators | creator | last-modifier + if (isFilterTermArray(column, filterPredicate)) { + updatedFilter.filter_term = []; + return updatedFilter; + } + // date | ctime | mtime + if (isDateColumn(column)) { + let filterTermModifier = filterPredicate === FILTER_PREDICATE_TYPE.IS_WITHIN ? filterTermModifierIsWithin[0] : filterTermModifierNotWithin[0]; + updatedFilter.filter_term_modifier = filterTermModifier; + updatedFilter.filter_term = ''; + return updatedFilter; + } + + return updatedFilter; +}; + +// file, image : not support +// text, long-text, number, single-select, date, ctime, mtime, formula, link, geolocation : string +// checkbox : boolean +// multiple-select, collaborator, creator, last modifier : array + +export const getUpdatedFilterByColumn = (filters, filterIndex, column) => { + const filter = filters[filterIndex]; + if (filter.column_key === column.key) { + return; + } + return getFilterByColumn(column, filter); +}; + +export const getUpdatedFilterByPredicate = (filter, column, filterPredicate) => { + let updatedFilter = Object.assign({}, filter, { filter_predicate: filterPredicate }); + let { type: columnType } = column; + if ([CellType.CREATOR, CellType.LAST_MODIFIER].includes(columnType)) { + if (STRING_PREDICATE[filter.filter_predicate] !== STRING_PREDICATE[filterPredicate] + || filterPredicate === FILTER_PREDICATE_TYPE.INCLUDE_ME + ) { + updatedFilter.filter_term = []; + } + } + if (isFilterTermArray(column, filterPredicate)) { + if (DATE_EMPTY_LABEL_MAP[filterPredicate] || filterPredicate === FILTER_PREDICATE_TYPE.INCLUDE_ME) { + updatedFilter.filter_term = []; + } + return updatedFilter; + } + if (isDateColumn(column)) { + let filterTermModifier = filterPredicate === FILTER_PREDICATE_TYPE.IS_WITHIN ? filterTermModifierIsWithin[0] : filterTermModifierNotWithin[0]; + updatedFilter.filter_term_modifier = filterTermModifier; + return updatedFilter; + } + + return updatedFilter; +}; + +export const getUpdatedFilterByTermModifier = (filter, filterTermModifier) => { + if (filter.filter_term_modifier === filterTermModifier) { + return; + } + return Object.assign({}, filter, { filter_term_modifier: filterTermModifier }); +}; + +export const getUpdatedFilterByNormalTerm = (filter, column, filterIndex, event) => { + let filterTerm; + if (column.type === CellType.CHECKBOX) { + filterTerm = event.target.checked; + } else { + filterTerm = event.target.value; + } + if (filter.filter_term === filterTerm) { + return filter; + } + return Object.assign({}, filter, { filter_term: filterTerm }); +}; + +export const getUpdatedFilterBySpecialTerm = (filter, type, value) => { + switch (type) { + case SPECIAL_TERM_TYPE.CREATOR: { + return getUpdatedFilterByCreator(filter, value); + } + case SPECIAL_TERM_TYPE.SINGLE_SELECT: { + return getUpdatedFilterBySelectSingle(filter, value); + } + case SPECIAL_TERM_TYPE.MULTIPLE_SELECT: { + return getUpdatedFilterBySelectMultiple(filter, value); + } + case SPECIAL_TERM_TYPE.COLLABORATOR: { + return getUpdatedFilterByCollaborator(filter, value); + } + case SPECIAL_TERM_TYPE.RATE: { + return getUpdatedFilterByRate(filter, value); + } + default: { + return filter; + } + } +}; diff --git a/frontend/src/metadata/metadata-view/utils/get-event-transfer.js b/frontend/src/metadata/metadata-view/utils/get-event-transfer.js new file mode 100644 index 0000000000..f6a4e5997c --- /dev/null +++ b/frontend/src/metadata/metadata-view/utils/get-event-transfer.js @@ -0,0 +1,107 @@ +import { TRANSFER_TYPES } from '../constants'; + +const { FRAGMENT, HTML, TEXT } = TRANSFER_TYPES; + +function getEventTransfer(event) { + const transfer = event.dataTransfer || event.clipboardData; + let dtableFragment = getType(transfer, FRAGMENT); + let html = getType(transfer, HTML); + let text = getType(transfer, TEXT); + let files = getFiles(transfer); + + // paste sf-metadata + if (dtableFragment) { + return { [TRANSFER_TYPES.DTABLE_FRAGMENT]: JSON.parse(dtableFragment), type: TRANSFER_TYPES.DTABLE_FRAGMENT }; + } + + // paste html + if (html) { + let copiedTableNode = (new DOMParser()).parseFromString(html, HTML).querySelector('table'); + if (copiedTableNode) { + return { [TRANSFER_TYPES.DTABLE_FRAGMENT]: html2TableFragment(copiedTableNode), html, text, type: 'html' }; + } + return { [TRANSFER_TYPES.DTABLE_FRAGMENT]: text2TableFragment(text), html, text, type: 'html' }; + } + + // paste local picture or other files here + if (files && files.length) { + return { [TRANSFER_TYPES.DTABLE_FRAGMENT]: text2TableFragment(text), 'files': files, type: 'files' }; + } + + // paste text + if (text) { + return { [TRANSFER_TYPES.DTABLE_FRAGMENT]: text2TableFragment(text), text, type: 'text' }; + } +} + +function getType(transfer, type) { + if (!transfer.types || !transfer.types.length) { + // COMPAT: In IE 11, there is no `types` field but `getData('Text')` + // is supported`. (2017/06/23) + return type === TEXT ? transfer.getData('Text') || null : null; + } + + return transfer.getData(type); +} + +function text2TableFragment(data) { + let formattedData = data ? data.replace(/\r/g, '') : ''; + let dataSplitted = formattedData.split('\n'); + let rowSplitted = dataSplitted[0].split('\t'); + let copiedColumns = rowSplitted.map((value, j) => ({ key: `col${j}`, type: 'text' })); + let copiedRecords = []; + dataSplitted.forEach((row) => { + let obj = {}; + if (row) { + row = row.split('\t'); + row.forEach((col, j) => { + obj[`col${j}`] = col; + }); + } + copiedRecords.push(obj); + }); + + return { copiedRecords, copiedColumns }; +} + +function html2TableFragment(tableNode) { + let trs = tableNode.querySelectorAll('tr'); + let tds = trs[0].querySelectorAll('td'); + let copiedColumns = []; + let copiedRecords = []; + tds.forEach((td, i) => { + copiedColumns.push({ key: `col${i}`, type: 'text' }); + }); + trs.forEach((tr) => { + let row = {}; + let cells = tr.querySelectorAll('td'); + cells.forEach((cell, i) => { + row[`col${i}`] = cell.innerText; + }); + copiedRecords.push(row); + }); + return { copiedRecords, copiedColumns }; +} + +function getFiles(transfer) { + let files; + try { + // Get and normalize files if they exist. + if (transfer.items && transfer.items.length) { + files = Array.from(transfer.items) + .map(item => (item.kind === 'file' ? item.getAsFile() : null)) + .filter(exists => exists); + } else if (transfer.files && transfer.files.length) { + files = Array.from(transfer.files); + } + } catch (err) { + if (transfer.files && transfer.files.length) { + files = Array.from(transfer.files); + } + } + return files; +} + +export { text2TableFragment }; + +export default getEventTransfer; diff --git a/frontend/src/metadata/metadata-view/utils/grid-utils.js b/frontend/src/metadata/metadata-view/utils/grid-utils.js new file mode 100644 index 0000000000..d434d545fd --- /dev/null +++ b/frontend/src/metadata/metadata-view/utils/grid-utils.js @@ -0,0 +1,404 @@ +import dayjs from 'dayjs'; +import { + CellType, + NOT_SUPPORT_EDIT_COLUMN_TYPE_MAP, +} from '../_basic'; +import { getColumnByIndex } from './column-utils'; +import { NOT_SUPPORT_DRAG_COPY_COLUMN_TYPES, TRANSFER_TYPES } from '../constants'; +import { getGroupRecordByIndex } from './group-metrics'; + +const NORMAL_RULE = ({ value }) => { + return value; +}; + +const isCopyPaste = true; + +class GridUtils { + + constructor(metadata, api) { + this.metadata = metadata; + this.api = api; + } + + getCopiedContent({ type, copied, isGroupView }) { + // copy from internal grid + if (type === TRANSFER_TYPES.DTABLE_FRAGMENT) { + const { shownColumns: columns } = this.tablePage.state; + const { selectedRecordIds, copiedRange } = copied; + + // copy from selected rows + if (Array.isArray(selectedRecordIds) && selectedRecordIds.length > 0) { + return { + copiedRecords: selectedRecordIds.map(recordId => this.tablePage.recordGetterById(recordId)), + copiedColumns: [...columns], + }; + } + + // copy from selected range + let copiedRecords = []; + let copiedColumns = []; + const { topLeft, bottomRight } = copiedRange; + const { rowIdx: minRecordIndex, idx: minColumnIndex, groupRecordIndex: minGroupRecordIndex } = topLeft; + const { rowIdx: maxRecordIndex, idx: maxColumnIndex } = bottomRight; + let currentGroupIndex = minGroupRecordIndex; + for (let i = minRecordIndex; i <= maxRecordIndex; i++) { + copiedRecords.push(this.tablePage.recordGetterByIndex({ isGroupView, groupRecordIndex: currentGroupIndex, recordIndex: i })); + if (isGroupView) { + currentGroupIndex++; + } + } + for (let i = minColumnIndex; i <= maxColumnIndex; i++) { + copiedColumns.push(getColumnByIndex(i, columns)); + } + return { copiedRecords, copiedColumns }; + } + + // copy from other external apps as default + const { copiedRecords, copiedColumns } = copied; + return { copiedRecords, copiedColumns }; + } + + async paste({ copied, multiplePaste, pasteRange, isGroupView }) { + const { row_ids: renderRecordIds, columns } = this.metadata; + const { topLeft, bottomRight = {} } = pasteRange; + const { rowIdx: startRecordIndex, idx: startColumnIndex, groupRecordIndex } = topLeft; + const { rowIdx: endRecordIndex, idx: endColumnIndex } = bottomRight; + const { copiedRecords, copiedColumns } = copied; + const copiedRecordsLen = copiedRecords.length; + const copiedColumnsLen = copiedColumns.length; + const pasteRecordsLen = multiplePaste ? endRecordIndex - startRecordIndex + 1 : copiedRecordsLen; + const pasteColumnsLen = multiplePaste ? endColumnIndex - startColumnIndex + 1 : copiedColumnsLen; + const renderRecordsCount = renderRecordIds.length; + + // need expand records + const startExpandRecordIndex = renderRecordsCount - startRecordIndex; + if ((copiedRecordsLen > startExpandRecordIndex)) return; + + let updateRecordIds = []; + let idRecordUpdates = {}; + let idOriginalRecordUpdates = {}; + let idOldRecordData = {}; + let idOriginalOldRecordData = {}; + let currentGroupRecordIndex = groupRecordIndex; + for (let i = 0; i < pasteRecordsLen; i++) { + const pasteRecord = this.tablePage.recordGetterByIndex({ isGroupView, groupRecordIndex: currentGroupRecordIndex, recordIndex: startRecordIndex + i }); + if (isGroupView) { + currentGroupRecordIndex++; + } + if (!pasteRecord) { + continue; + } + const updateRecordId = pasteRecord._id; + const copiedRecordIndex = i % copiedRecordsLen; + const copiedRecord = copiedRecords[copiedRecordIndex]; + let originalUpdate = {}; + let originalOldRecordData = {}; + for (let j = 0; j < pasteColumnsLen; j++) { + const pasteColumn = getColumnByIndex(j + startColumnIndex, columns); + if (!pasteColumn || NOT_SUPPORT_EDIT_COLUMN_TYPE_MAP[pasteColumn.type]) { + continue; + } + const copiedColumnIndex = j % copiedColumnsLen; + const copiedColumn = getColumnByIndex(copiedColumnIndex, copiedColumns); + const { key: pasteColumnKey } = pasteColumn; + const { key: copiedColumnKey } = copiedColumn; + const pasteCellValue = Object.prototype.hasOwnProperty.call(pasteRecord, pasteColumnKey) ? pasteRecord[pasteColumnKey] : null; + const copiedCellValue = Object.prototype.hasOwnProperty.call(copiedRecord, copiedColumnKey) ? copiedRecord[copiedColumnKey] : null; + const update = this.convertCellValue(copiedCellValue, pasteCellValue, pasteColumn, copiedColumn); + if (update === pasteCellValue) { + continue; + } + originalUpdate[pasteColumnKey] = update; + originalOldRecordData[pasteColumnKey] = pasteCellValue; + } + + if (Object.keys(originalUpdate).length > 0) { + updateRecordIds.push(updateRecordId); + const update = originalUpdate; + const oldRecordData = originalOldRecordData; + idRecordUpdates[updateRecordId] = update; + idOriginalRecordUpdates[updateRecordId] = originalUpdate; + idOldRecordData[updateRecordId] = oldRecordData; + idOriginalOldRecordData[updateRecordId] = originalOldRecordData; + } + } + + if (updateRecordIds.length === 0) return; + this.modifyRecords(updateRecordIds, idRecordUpdates, idOriginalRecordUpdates, idOldRecordData, idOriginalOldRecordData, isCopyPaste); + } + + getLinkedRowsIdsByNameColumn(linkedTableRows, linkColumnKey, cellValue, linkItem) { + if (!Array.isArray(linkedTableRows) || linkedTableRows.length === 0) { + return []; + } + const cellValueStr = String(cellValue); + + // 1、If all string match the corresponding row, return this row + const linkedRow = linkedTableRows.find(row => row['0000']?.trim() === cellValueStr.trim()) || null; + if (linkedRow) { + linkItem[linkColumnKey] = [{ display_value: cellValueStr, row_id: linkedRow._id }]; + return [linkedRow._id]; + } + + // 2、If the string contains a comma, split into multiple substrings to match the corresponding rows + let linkedRowsIds = []; + if (cellValueStr.includes(',') || cellValueStr.includes(',')) { + const copiedNames = cellValueStr.split(/[,,]/).map(item => item.trim()).filter((value, index, self) => self.indexOf(value) === index); + if (!Array.isArray(copiedNames) || copiedNames.length === 0) { + return []; + } + linkItem[linkColumnKey] = []; + copiedNames.forEach((copiedName) => { + const linkedRow = linkedTableRows.find(row => row['0000']?.trim() === copiedName) || null; + if (linkedRow) { + linkItem[linkColumnKey].push({ display_value: copiedName, row_id: linkedRow._id }); + linkedRowsIds.push(linkedRow._id); + } + }); + } + return linkedRowsIds; + } + + getUpdateDraggedRecords(draggedRange, shownColumns, rows, idRowMap, groupMetrics) { + let rowIds = []; + let updatedOriginalRows = {}; + let oldOriginalRows = {}; + const updatedRows = {}; + const oldRows = {}; + const { overRecordIdx, topLeft, bottomRight } = draggedRange; + let { idx: startColumnIdx } = topLeft; + let { idx: endColumnIdx, rowIdx: endRecordIdx, groupRecordIndex } = bottomRight; + + let draggedRangeMatrix = this.getdraggedRangeMatrix(shownColumns, draggedRange, rows, groupMetrics, idRowMap); + + let rules = this.getDraggedRangeRules(draggedRangeMatrix, shownColumns, startColumnIdx); + + const selectedRowLength = draggedRangeMatrix[0].length; + let fillingIndex = draggedRangeMatrix[0].length; + + // if group view then use index of gropRows which is different from the normal rows(they represent DOMs) + let currentGroupRowIndex = groupRecordIndex + 1; + for (let i = endRecordIdx + 1; i <= overRecordIdx; i++) { + let dragRow; + // find the row that need to be updated (it's draged) + if (currentGroupRowIndex) { + const groupRow = getGroupRecordByIndex(currentGroupRowIndex, groupMetrics); + dragRow = idRowMap[groupRow.rowId]; + } else { + dragRow = rows[i]; + } + let { _id: dragRowId, _locked } = dragRow; + fillingIndex++; + if (_locked) continue; + rowIds.push(dragRowId); + + let idx = (i - endRecordIdx - 1) % selectedRowLength; + + for (let j = startColumnIdx; j <= endColumnIdx; j++) { + let column = shownColumns[j]; + let { key: cellKey, type, editable } = column; + if (editable && !NOT_SUPPORT_EDIT_COLUMN_TYPE_MAP[type] && !NOT_SUPPORT_DRAG_COPY_COLUMN_TYPES.includes(type)) { + let value = draggedRangeMatrix[j - startColumnIdx][idx]; + let rule = rules[cellKey]; + let fillingValue = rule({ n: fillingIndex - 1, value }); + updatedOriginalRows[dragRowId] = Object.assign({}, updatedOriginalRows[dragRowId], { [cellKey]: fillingValue }); + oldOriginalRows[dragRowId] = Object.assign({}, oldOriginalRows[dragRowId], { [cellKey]: dragRow[cellKey] }); + // update: {[name]: value} + // originalUpdate: {[key]: id} + const update = updatedOriginalRows[dragRowId]; + const oldUpdate = oldOriginalRows[dragRowId]; + + updatedRows[dragRowId] = Object.assign({}, updatedRows[dragRowId], update); + oldRows[dragRowId] = Object.assign({}, oldRows[dragRowId], oldUpdate); + } + } + currentGroupRowIndex++; + } + + return { recordIds: rowIds, idOriginalRecordUpdates: updatedOriginalRows, idRecordUpdates: updatedRows, idOriginalOldRecordData: oldOriginalRows, idOldRecordData: oldRows }; + } + + getdraggedRangeMatrix(columns, draggedRange, rows, groupMetrics, idRowMap) { + let draggedRangeMatrix = []; + let { topLeft, bottomRight } = draggedRange; + let { idx: startColumnIdx, rowIdx: startRowIdx, groupRecordIndex } = topLeft; + let { idx: endColumnIdx, rowIdx: endRowIdx } = bottomRight; + for (let i = startColumnIdx; i <= endColumnIdx; i++) { + let currentGroupRecordIndex = groupRecordIndex; + draggedRangeMatrix[i - startColumnIdx] = []; + let column = columns[i]; + let { key } = column; + for (let j = startRowIdx; j <= endRowIdx; j++) { + let selectedRecord; + if (currentGroupRecordIndex) { + const groupRecord = getGroupRecordByIndex(currentGroupRecordIndex, groupMetrics); + selectedRecord = idRowMap[groupRecord.rowId]; + } else { + selectedRecord = rows[j]; + } + draggedRangeMatrix[i - startColumnIdx][j - startRowIdx] = selectedRecord[key]; + currentGroupRecordIndex++; + } + } + return draggedRangeMatrix; + } + + getDraggedRangeRules(draggedRangeMatrix, columns, startColumnIdx) { + let draggedRangeRuleMatrix = {}; + draggedRangeMatrix.forEach((valueList, i) => { + let column = columns[i + startColumnIdx]; + let { type, data, key } = column; + let ruleMatrixItem = NORMAL_RULE; + if (valueList.length > 1) { + switch (type) { + case CellType.DATE: { + let format = data && data.format && data.format.indexOf('HH:mm') > -1 ? 'YYYY-MM-DD HH:mm' : 'YYYY-MM-DD'; + let value0 = valueList[0]; + let yearTolerance = this.getYearTolerance(valueList); + if (yearTolerance) { + ruleMatrixItem = ({ n }) => { + return dayjs(value0).add(n * yearTolerance, 'years').format(format); + }; + break; + } + let monthTolerance = this.getMonthTolerance(valueList); + if (monthTolerance) { + ruleMatrixItem = ({ n }) => { + return dayjs(value0).add(n * monthTolerance, 'months').format(format); + }; + break; + } + let dayTolerance = this.getDayTolerance(valueList); + if (dayTolerance) { + ruleMatrixItem = ({ n }) => { + let time = n * dayTolerance + this.getDateStringValue(value0); + return dayjs(time).format(format); + }; + break; + } + break; + } + case CellType.NUMBER: { + ruleMatrixItem = this.getLeastSquares(valueList); + break; + } + case CellType.TEXT: { + ruleMatrixItem = this._getTextRule(valueList); + break; + } + case CellType.RATE: { + ruleMatrixItem = this.getRatingLeastSquares(valueList, data); + break; + } + default: { + ruleMatrixItem = NORMAL_RULE; + break; + } + } + } + draggedRangeRuleMatrix[key] = ruleMatrixItem; + }); + return draggedRangeRuleMatrix; + } + + getDateStringValue(date) { + let dateObject = dayjs(date); + return dateObject.isValid() ? dateObject.valueOf() : 0; + } + + getYearTolerance(dateList) { + let date0 = dayjs(dateList[0]); + let date1 = dayjs(dateList[1]); + if (!date0.isValid() || !date1.isValid()) { + return 0; + } + if (date0.month() !== date1.month() || date0.date() !== date1.date() + || date0.hour() !== date1.hour() || date0.minute() !== date1.minute()) { + return 0; + } + let date0Year = date0.year(); + let tolerance = date1.year() - date0Year; + let isYearArithmeticSequence = dateList.every((date, n) => { + let dateObject = dayjs(date); + if (!dateObject.isValid()) { + return false; + } + return dateObject.year() === n * tolerance + date0Year; + }); + return isYearArithmeticSequence ? tolerance : 0; + } + + getMonthTolerance(dateList) { + let date0 = dayjs(dateList[0]); + let date1 = dayjs(dateList[1]); + if (!date0.isValid() || !date1.isValid()) { + return 0; + } + if (date0.date() !== date1.date() || date0.hour() !== date1.hour() || date0.minute() !== date1.minute()) { + return 0; + } + let tolerance = (date1.month() - date0.month()) + (date1.year() - date0.year()) * 12; + let isMonthArithmeticSequence = dateList.every((date, i) => { + let month = i * tolerance; + let dateObject = dayjs(date); + if (!dateObject.isValid()) { + return false; + } + return dateObject.isSame(dayjs(dateList[0]).add(month, 'month'), 'minute'); + }); + return isMonthArithmeticSequence ? tolerance : 0; + } + + getDayTolerance(dateList) { + let date0 = this.getDateStringValue(dateList[0]); + let tolerance = this.getDateStringValue(dateList[1]) - date0; + let isDayArithmeticSequence = dateList.every((date, i) => { + if (!dayjs(date).isValid()) { + return false; + } + return this.getDateStringValue(date) === i * tolerance + date0; + }); + return isDayArithmeticSequence ? tolerance : 0; + } + + getLeastSquares(numberList) { + let slope; + let intercept; + let xAverage; + let yAverage; + let xSum = 0; + let ySum = 0; + let xSquareSum = 0; + let xySum = 0; + let validCellsLen = 0; + let emptyCellPositions = []; + numberList.forEach((v, i) => { + if (v !== undefined && v !== null && v !== '') { + validCellsLen++; + xSum += i; + ySum += v; + xySum += (v * i); + xSquareSum += Math.pow(i, 2); + } else { + emptyCellPositions.push(i); + } + }); + if (validCellsLen < 2) { + return NORMAL_RULE; + } + xAverage = xSum / validCellsLen; + yAverage = ySum / validCellsLen; + slope = (xySum - validCellsLen * xAverage * yAverage) / (xSquareSum - validCellsLen * Math.pow(xAverage, 2)); + intercept = yAverage - slope * xAverage; + return ({ n }) => { + if (emptyCellPositions.length && emptyCellPositions.includes(n % numberList.length)) { + return ''; + } + let y = n * slope + intercept; + return Number(parseFloat(y).toFixed(8)); + }; + } + +} + +export default GridUtils; diff --git a/frontend/src/metadata/metadata-view/utils/grid.js b/frontend/src/metadata/metadata-view/utils/grid.js new file mode 100644 index 0000000000..3a7d1eae68 --- /dev/null +++ b/frontend/src/metadata/metadata-view/utils/grid.js @@ -0,0 +1,9 @@ +import { OVER_SCAN_COLUMNS } from '../constants'; + +export const getColOverScanStartIdx = (colVisibleStartIdx) => { + return Math.max(0, Math.floor(colVisibleStartIdx / 10) * 10 - OVER_SCAN_COLUMNS); +}; + +export const getColOverScanEndIdx = (colVisibleEndIdx, totalNumberColumns) => { + return Math.min(Math.ceil(colVisibleEndIdx / 10) * 10 + OVER_SCAN_COLUMNS, totalNumberColumns); +}; diff --git a/frontend/src/metadata/metadata-view/utils/group-metrics.js b/frontend/src/metadata/metadata-view/utils/group-metrics.js new file mode 100644 index 0000000000..d85073ce05 --- /dev/null +++ b/frontend/src/metadata/metadata-view/utils/group-metrics.js @@ -0,0 +1,183 @@ +import { getColumnByKey } from './column-utils'; +import { GROUP_HEADER_HEIGHT, GROUP_ROW_TYPE, GROUP_VIEW_OFFSET, INSERT_ROW_HEIGHT } from '../constants'; + +export const createGroupMetrics = (groups, groupbys, pathFoldedGroupMap, columns, rowHeight, includeInsertRow) => { + let groupbyColumnsMap = {}; + groupbys.forEach(groupby => { + const columnKey = groupby.column_key; + const column = getColumnByKey(columnKey, columns); + groupbyColumnsMap[columnKey] = column; + }); + const maxLevel = groupbys.length; + const groupRows = getGroupsRows( + groups, groupbyColumnsMap, pathFoldedGroupMap, includeInsertRow, rowHeight, maxLevel, + { parentGroupPath: [], currentLevel: maxLevel, isParentGroupVisible: true } + ); + const { computedGroupRows, groupRowsHeight, idGroupRowMap } = setupGroupsRows(groupRows, maxLevel); + return { + groupRows: computedGroupRows, + idGroupRowMap, + groupRowsHeight, + maxLevel, + }; +}; + +export const getGroupsRows = ( + groups, groupbyColumnsMap, pathFoldedGroupMap, includeInsertRow, rowHeight, maxLevel, { + parentGroupPath, parentGroupKey, currentLevel, isParentGroupVisible, + } +) => { + let groupRows = []; + groups.forEach((group, groupIndex) => { + let groupPath = []; + if (parentGroupPath.length > 0) { + groupPath.push(...parentGroupPath); + } + groupPath.push(groupIndex); + const { cell_value, subgroups, row_ids, column_key, summaries, original_cell_value } = group; + const groupPathString = groupPath.join('-'); + const isExpanded = isExpandedGroup(groupPathString, pathFoldedGroupMap); + const left = (maxLevel - currentLevel + 1) * GROUP_VIEW_OFFSET; + const groupKey = `${parentGroupKey ? parentGroupKey : column_key}_${cell_value}`; + let groupContainer = { + type: GROUP_ROW_TYPE.GROUP_CONTAINER, + level: currentLevel, + left, + key: groupKey, + cell_value, + column_key, + isExpanded, + summaries, + groupPath, + groupPathString, + column: groupbyColumnsMap[column_key], + visible: isParentGroupVisible, + original_cell_value + }; + if (Array.isArray(subgroups) && subgroups.length > 0) { + const flattenSubgroups = getGroupsRows( + subgroups, groupbyColumnsMap, pathFoldedGroupMap, includeInsertRow, rowHeight, maxLevel, + { parentGroupPath: groupPath, parentGroupKey: groupKey, currentLevel: currentLevel - 1, isParentGroupVisible: isParentGroupVisible && isExpanded } + ); + let groupCount = 0; + let subgroupsHeight = 0; + let first_row_id; + flattenSubgroups.forEach((subgroupContainer) => { + if (subgroupContainer.type === GROUP_ROW_TYPE.GROUP_CONTAINER && subgroupContainer.level + 1 === currentLevel) { + groupCount += subgroupContainer.count || 0; + subgroupsHeight += (subgroupContainer.height || 0) + GROUP_VIEW_OFFSET; + if (!first_row_id) { + first_row_id = subgroupContainer.first_row_id; + } + } + }); + groupContainer.first_row_id = first_row_id; + groupContainer.count = groupCount; + groupContainer.height = (isExpanded ? subgroupsHeight : 0) + GROUP_HEADER_HEIGHT; + groupRows.push(groupContainer); + groupRows.push(...flattenSubgroups); + } else if (Array.isArray(row_ids) && row_ids.length > 0) { + const rowsLength = row_ids.length; + const lastRowIndex = rowsLength - 1; + const isRowVisible = isParentGroupVisible && isExpanded; + const isBtnInsertRowVisible = isRowVisible && includeInsertRow; + const rowsHeight = isRowVisible ? rowsLength * rowHeight : 0; + const btnInsertRowHeight = isBtnInsertRowVisible ? INSERT_ROW_HEIGHT : 0; + let rows = row_ids.map((rowId, index) => { + return { + type: GROUP_ROW_TYPE.ROW, + key: `row-${rowId}`, + rowIdx: index, + isLastRow: index === lastRowIndex, + visible: isRowVisible, + height: rowHeight, + level: currentLevel, + rowsLength, + left, + rowId, + groupPath, + groupPathString, + }; + }); + rows.push({ + type: GROUP_ROW_TYPE.BTN_INSERT_ROW, + key: `btn-insert-row_${groupKey}`, + visible: isBtnInsertRowVisible, + height: INSERT_ROW_HEIGHT, + level: currentLevel, + lastRowIndex, + left, + groupPath, + groupPathString, + }); + groupContainer.first_row_id = rows[0].rowId; + groupContainer.count = rowsLength; + groupContainer.height = rowsHeight + btnInsertRowHeight + GROUP_HEADER_HEIGHT; + groupRows.push(groupContainer); + groupRows.push(...rows); + } + }); + return groupRows; +}; + +export const setupGroupsRows = (groupRows, maxLevel) => { + let groupRowsHeight = 0; + let top = GROUP_VIEW_OFFSET; + let idGroupRowMap = {}; + let pervVisibleGroupLevel; + const computedGroupRows = groupRows.map((flattenGroup, index) => { + const { type, level, height, visible } = flattenGroup; + let newGroupRow = { + ...flattenGroup, + top, + groupRowIndex: index, + }; + if (type === GROUP_ROW_TYPE.GROUP_CONTAINER) { + if (visible) { + if (level === maxLevel) { + groupRowsHeight += height + GROUP_VIEW_OFFSET; + } + top += GROUP_HEADER_HEIGHT; + pervVisibleGroupLevel = level; + } + } else if (type === GROUP_ROW_TYPE.ROW) { + const { rowId } = flattenGroup; + idGroupRowMap[rowId] = newGroupRow; + if (visible) { + top += height; + } + } else if (type === GROUP_ROW_TYPE.BTN_INSERT_ROW) { + if (visible) { + top += height; + } + } + const nextFlattenGroup = groupRows[index + 1]; + if (nextFlattenGroup && nextFlattenGroup.visible && nextFlattenGroup.type === GROUP_ROW_TYPE.GROUP_CONTAINER) { + const { groupPath: nextGroupPath, level: nextGroupLevel } = nextFlattenGroup; + if (nextGroupPath[nextGroupPath.length - 1] > 0) { + top += GROUP_VIEW_OFFSET; + } + if (nextGroupLevel > pervVisibleGroupLevel) { + top += (nextGroupLevel - pervVisibleGroupLevel) * GROUP_VIEW_OFFSET; + } + } + return newGroupRow; + }); + return { computedGroupRows, groupRowsHeight, idGroupRowMap }; +}; + +export const isExpandedGroup = (groupPathString, pathFoldedGroupMap) => { + return !pathFoldedGroupMap || !pathFoldedGroupMap[groupPathString]; +}; + +export const isNestedGroupRow = (currentGroupRow, targetGroupRow) => { + const { groupPath: currentGroupPath, groupPathString: currentGroupPathString, level: currentGroupLevel, type: currentGroupRowType } = currentGroupRow; + const { groupPath: targetGroupPath, groupPathString: targetGroupPathString, level: targetGroupLevel } = targetGroupRow; + return (currentGroupPathString === targetGroupPathString && currentGroupRowType !== GROUP_ROW_TYPE.GROUP_CONTAINER) || + (currentGroupLevel < targetGroupLevel && currentGroupPath[0] === targetGroupPath[0]); +}; + +export const getGroupRecordByIndex = (index, groupMetrics) => { + const groupRows = groupMetrics.groupRows || []; + return groupRows[index] || {}; +}; diff --git a/frontend/src/metadata/metadata-view/utils/groupby-utils.js b/frontend/src/metadata/metadata-view/utils/groupby-utils.js new file mode 100644 index 0000000000..f9d568699a --- /dev/null +++ b/frontend/src/metadata/metadata-view/utils/groupby-utils.js @@ -0,0 +1,80 @@ +import { + CellType, + DISPLAY_GROUP_DATE_GRANULARITY, + GROUP_DATE_GRANULARITY, + FORMULA_COLUMN_TYPES_MAP, + FORMULA_RESULT_TYPE, + SORT_TYPE, + SUPPORT_GROUP_COLUMN_TYPES, + isDateColumn, + GROUPBY_DATE_GRANULARITY_LIST, +} from '../_basic'; + +const NOT_SUPPORT_GROUPBY_ARRAY_TYPE = [CellType.LONG_TEXT, CellType.IMAGE, CellType.FILE]; + +export const getDefaultCountType = (column) => { + if (isDateColumn(column)) { + return GROUP_DATE_GRANULARITY.MONTH; + } + return null; +}; + +export const getGroupbyColumns = (columns, groupbys = []) => { + let groupbyColumnKeyMap = {}; + groupbys.forEach(groupby => { + const { column_key } = groupby; + if (column_key) { + groupbyColumnKeyMap[column_key] = true; + } + }); + return columns.filter(column => { + const { key, type, data } = column; + if (!SUPPORT_GROUP_COLUMN_TYPES.includes(type)) { + return false; + } + if (groupbyColumnKeyMap[key]) return false; // group by has already exist + if (FORMULA_COLUMN_TYPES_MAP[type]) { + const { result_type, array_type } = data || {}; + if (result_type === FORMULA_RESULT_TYPE.ARRAY && NOT_SUPPORT_GROUPBY_ARRAY_TYPE.includes(array_type)) { + return false; + } + } + return true; + }); +}; + +export const getSelectedCountType = (column, countType) => { + const type = countType || getDefaultCountType(column); + if (!type) { + return null; + } + if (isDateColumn(column)) { + return DISPLAY_GROUP_DATE_GRANULARITY[type]; + } + return null; +}; + +export const isShowGroupCountType = (column) => { + if (isDateColumn(column)) return true; + return false; +}; + +export const getGroupbyGranularityByColumn = (column) => { + let granularityList = []; + let displayGranularity = {}; + if (isDateColumn(column)) { + granularityList = GROUPBY_DATE_GRANULARITY_LIST; + displayGranularity = DISPLAY_GROUP_DATE_GRANULARITY; + } + return { granularityList, displayGranularity }; +}; + +export const generateDefaultGroupby = (columns) => { + const dateColumn = columns.find(column => column.type === CellType.DATE) || columns.find(column => isDateColumn(column)); + let groupby = { column_key: null, sort_type: SORT_TYPE.UP }; + if (dateColumn) { + groupby.column_key = dateColumn.key; + groupby.count_type = getDefaultCountType(dateColumn); + } + return groupby; +}; diff --git a/frontend/src/metadata/metadata-view/utils/index.js b/frontend/src/metadata/metadata-view/utils/index.js new file mode 100644 index 0000000000..fdb3a232d1 --- /dev/null +++ b/frontend/src/metadata/metadata-view/utils/index.js @@ -0,0 +1,63 @@ +import getEventTransfer from './get-event-transfer'; +import CellValueUtils from './cell-value-utils'; +import { gettext } from '../../../utils/constants'; + +export const getEventClassName = (e) => { + // svg mouseEvent event.target.className is an object + if (!e || !e.target) return ''; + return e.target.getAttribute('class') || ''; +}; + +export const initScrollBar = () => { + const isWin = (navigator.platform === 'Win32') || (navigator.platform === 'Windows'); + if (isWin) { + const style = document.createElement('style'); + document.head.appendChild(style); + const sheet = style.sheet; + sheet.addRule('div::-webkit-scrollbar', 'width: 8px;height: 8px;'); + sheet.addRule('div::-webkit-scrollbar-button', 'display: none;'); + sheet.addRule('div::-webkit-scrollbar-thumb', 'background-color: rgb(206, 206, 212);border-radius: 10px;'); + } +}; + +export const isMobile = (typeof (window) !== 'undefined') && (window.innerWidth < 768 || + navigator.userAgent.toLowerCase().match(/(ipod|ipad|iphone|android|coolpad|mmp|smartphone|midp|wap|xoom|symbian|j2me|blackberry|wince)/i) != null); + +export const addClassName = (originClassName, targetClassName) => { + const originClassNames = originClassName.split(' '); + if (originClassNames.indexOf(targetClassName) > -1) return originClassName; + return originClassName + ' ' + targetClassName; +}; + +export const removeClassName = (originClassName, targetClassName) => { + let originClassNames = originClassName.split(' '); + const targetClassNameIndex = originClassNames.indexOf(targetClassName); + if (targetClassNameIndex < 0) return originClassName; + originClassNames.splice(targetClassNameIndex, 1); + return originClassNames.join(' '); +}; + +/* is weiXin built-in browser */ +export const isWeiXinBuiltInBrowser = () => { + const agent = navigator.userAgent.toLowerCase(); + if (agent.match(/MicroMessenger/i) === 'micromessenger' || + (typeof window.WeixinJSBridge !== 'undefined')) { + return true; + } + return false; +}; + +export const isWindowsBrowser = () => { + return /windows|win32/i.test(navigator.userAgent); +}; + +export const isWebkitBrowser = () => { + let agent = navigator.userAgent.toLowerCase(); + return agent.includes('webkit'); +}; + +export { + gettext, + getEventTransfer, + CellValueUtils, +}; diff --git a/frontend/src/metadata/metadata-view/utils/keyboard-utils.js b/frontend/src/metadata/metadata-view/utils/keyboard-utils.js new file mode 100644 index 0000000000..35b39a9c6e --- /dev/null +++ b/frontend/src/metadata/metadata-view/utils/keyboard-utils.js @@ -0,0 +1,25 @@ +function isKeyPrintable(keycode) { + const valid = + (keycode > 47 && keycode < 58) || // number keys + keycode === 32 || keycode === 13 || // spacebar & return key(s) (if you want to allow carriage returns) + (keycode > 64 && keycode < 91) || // letter keys + (keycode > 95 && keycode < 112) || // numpad keys + (keycode > 185 && keycode < 193) || // ;=,-./` (in order) + (keycode > 218 && keycode < 223); // [\]' (in order) + + return valid; +} + +function isCtrlKeyHeldDown(e) { + return (e.ctrlKey === true || e.metaKey === true) && e.key !== 'Control'; +} + +function isShiftKeyDown(e) { + return e && e.shiftKey; +} + +export { + isKeyPrintable, + isCtrlKeyHeldDown, + isShiftKeyDown, +}; diff --git a/frontend/src/metadata/metadata-view/utils/object-utils.js b/frontend/src/metadata/metadata-view/utils/object-utils.js new file mode 100644 index 0000000000..fac3e1fd98 --- /dev/null +++ b/frontend/src/metadata/metadata-view/utils/object-utils.js @@ -0,0 +1,47 @@ +class ObjectUtils { + + static getDataType(data){ + let type = typeof data; + if (type !== 'object') { + return type; + } + return Object.prototype.toString.call(data).replace(/^\[object (\S+)\]$/, '$1'); + } + + static iterable(data){ + return ['Object', 'Array'].includes(this.getDataType(data)); + } + + static isObjectChanged(source, comparison) { + if (!this.iterable(source)) { + throw new Error(`source should be a Object or Array , but got ${this.getDataType(source)}`); + } + if (this.getDataType(source) !== this.getDataType(comparison)) { + return true; + } + const sourceKeys = Object.keys(source); + const comparisonKeys = Object.keys({ ...source, ...comparison }); + if (sourceKeys.length !== comparisonKeys.length) { + return true; + } + return comparisonKeys.some(key => { + if (this.iterable(source[key])) { + return this.isObjectChanged(source[key], comparison[key]); + } else { + return source[key] !== comparison[key]; + } + }); + } + + static isSameObject(source, comparison) { + if (!source || !comparison) return false; + return !this.isObjectChanged(source, comparison); + } +} + +export const hasOwnProperty = (obj, propertyKey) => { + if (!obj || !propertyKey) return false; + return Object.prototype.hasOwnProperty.call(obj, propertyKey); +}; + +export default ObjectUtils; diff --git a/frontend/src/metadata/metadata-view/utils/record-metrics.js b/frontend/src/metadata/metadata-view/utils/record-metrics.js new file mode 100644 index 0000000000..7ffa0c187e --- /dev/null +++ b/frontend/src/metadata/metadata-view/utils/record-metrics.js @@ -0,0 +1,56 @@ +function selectRecord(recordId, recordMetrics) { + if (isRecordSelected(recordId, recordMetrics)) { + return; + } + recordMetrics.idSelectedRecordMap[recordId] = true; +} + +function selectRecordsById(recordIds, recordMetrics) { + recordIds.forEach(recordId => { + selectRecord(recordId, recordMetrics); + }); +} + +function deselectRecord(recordId, recordMetrics) { + if (!isRecordSelected(recordId, recordMetrics)) { + return; + } + delete recordMetrics.idSelectedRecordMap[recordId]; +} + +function deselectAllRecords(recordMetrics) { + recordMetrics.idSelectedRecordMap = {}; +} + +function isRecordSelected(recordId, recordMetrics) { + return recordMetrics.idSelectedRecordMap[recordId]; +} + +function getSelectedIds(recordMetrics) { + return Object.keys(recordMetrics.idSelectedRecordMap); +} + +function hasSelectedRecords(recordMetrics) { + return getSelectedIds(recordMetrics).length > 0; +} + +function isSelectedAll(recordIds, recordMetrics) { + const selectedRecordsLen = getSelectedIds(recordMetrics).length; + if (selectedRecordsLen === 0) { + return false; + } + return recordIds.every(recordId => isRecordSelected(recordId, recordMetrics)); +} + +const recordMetrics = { + selectRecord, + selectRecordsById, + deselectRecord, + deselectAllRecords, + isRecordSelected, + getSelectedIds, + hasSelectedRecords, + isSelectedAll, +}; + +export default recordMetrics; diff --git a/frontend/src/metadata/metadata-view/utils/records-body-utils.js b/frontend/src/metadata/metadata-view/utils/records-body-utils.js new file mode 100644 index 0000000000..b655859d1e --- /dev/null +++ b/frontend/src/metadata/metadata-view/utils/records-body-utils.js @@ -0,0 +1,53 @@ +import { isMobile } from '.'; +import { isFrozen } from './column-utils'; + +export const getColumnScrollPosition = (columns, idx, tableContentWidth) => { + let left = 0; + let frozen = 0; + const selectedColumn = getColumn(columns, idx); + if (!selectedColumn) return null; + + for (let i = 0; i < idx; i++) { + const column = getColumn(columns, i); + if (column) { + if (column.width) { + left += column.width; + } + if (isFrozen(column)) { + frozen += column.width; + } + } + } + return isMobile ? left - (tableContentWidth - selectedColumn.width) / 2 : left - frozen; +}; + +export const getColumn = (columns, idx) => { + if (Array.isArray(columns)) { + return columns[idx]; + } else if (typeof Immutable !== 'undefined') { + return columns.get(idx); + } +}; + +export const getColVisibleStartIdx = (columns, scrollLeft) => { + let remainingScroll = scrollLeft; + for (let i = 0; i < columns.length; i++) { + let { width } = columns[i]; + remainingScroll -= width; + if (remainingScroll < 0) { + return i; + } + } +}; + +export const getColVisibleEndIdx = (columns, recordBodyWidth, scrollLeft) => { + let usefulWidth = recordBodyWidth + scrollLeft; + for (let i = 0; i < columns.length; i++) { + let { width } = columns[i]; + usefulWidth -= width; + if (usefulWidth < 0) { + return i - 1 - 1; + } + } + return columns.length - 1; +}; diff --git a/frontend/src/metadata/metadata-view/utils/row-utils.js b/frontend/src/metadata/metadata-view/utils/row-utils.js new file mode 100644 index 0000000000..944ebe4523 --- /dev/null +++ b/frontend/src/metadata/metadata-view/utils/row-utils.js @@ -0,0 +1,28 @@ +const RowUtils = { + + get: function (row, property) { + if (typeof row.get === 'function') { + return row.get(property); + } + + return row[property]; + }, + + isRowSelected(keys, indexes, isSelectedKey, rowData, rowIdx) { + if (indexes && Object.prototype.toString.call(indexes) === '[object Array]') { + return indexes.indexOf(rowIdx) > -1; + } else if (keys && keys.rowKey && keys.values && Object.prototype.toString.call(keys.values) === '[object Array]') { + return keys.values.indexOf(rowData[keys.rowKey]) > -1; + } else if (isSelectedKey && rowData && typeof isSelectedKey === 'string') { + return rowData[isSelectedKey]; + } + return false; + }, + + getRecordById(recordId, value) { + return recordId && value.id_row_map[recordId]; + } + +}; + +export default RowUtils; diff --git a/frontend/src/metadata/metadata-view/utils/selected-cell-utils.js b/frontend/src/metadata/metadata-view/utils/selected-cell-utils.js new file mode 100644 index 0000000000..6c0d15a7fc --- /dev/null +++ b/frontend/src/metadata/metadata-view/utils/selected-cell-utils.js @@ -0,0 +1,188 @@ +import { Z_INDEX, getGroupByPath, isFunction } from '../_basic'; +import { getColumnByIndex } from './column-utils'; +import { SUPPORT_PREVIEW_COLUMN_TYPES } from '../constants'; +import { getGroupRecordByIndex } from './group-metrics'; +import RowUtils from './row-utils'; + +const SELECT_DIRECTION = { + UP: 'upwards', + DOWN: 'downwards', +}; + +export const getRowTop = (rowIdx, rowHeight) => rowIdx * rowHeight; + +export const getSelectedRow = ({ selectedPosition, isGroupView, recordGetterByIndex }) => { + const { groupRecordIndex, rowIdx } = selectedPosition; + return recordGetterByIndex({ isGroupView, groupRecordIndex, recordIndex: rowIdx }); +}; + +export const getSelectedColumn = ({ selectedPosition, columns }) => { + const { idx } = selectedPosition; + return getColumnByIndex(idx, columns); +}; + +export const getSelectedCellValue = ({ selectedPosition, columns, isGroupView, recordGetterByIndex }) => { + const column = getSelectedColumn({ selectedPosition, columns }); + const row = getSelectedRow({ selectedPosition, isGroupView, recordGetterByIndex }); + + return row && column ? RowUtils.get(row, column.key) : null; +}; + +export const isSelectedCellSupportOpenEditor = (cell, columns, isGroupView, recordGetterByIndex) => { + const { idx, groupRecordIndex, rowIdx } = cell; + const column = columns[idx]; + if (!column) return false; + if (SUPPORT_PREVIEW_COLUMN_TYPES.includes(column.type)) { + return true; + } + + const record = recordGetterByIndex({ isGroupView, groupRecordIndex, recordIndex: rowIdx }); + if (!record) return false; + return true; +}; + +export const isSelectedCellEditable = ({ enableCellSelect, selectedPosition, columns, isGroupView, recordGetterByIndex, onCheckCellIsEditable }) => { + const column = getSelectedColumn({ selectedPosition, columns }); + const row = getSelectedRow({ selectedPosition, isGroupView, recordGetterByIndex }); + if (!window.sfMetadataContext.canModifyRow(row)) { + return false; + } + const isCellEditable = isFunction(onCheckCellIsEditable) ? onCheckCellIsEditable({ row, column, ...selectedPosition }) : true; + return isCellEditable; +}; + +export function selectedRangeIsSingleCell(selectedRange) { + const { topLeft, bottomRight } = selectedRange; + if ( + topLeft.idx !== bottomRight.idx || + topLeft.rowIdx !== bottomRight.rowIdx + ) { + return false; + } + return true; +} + +export const getSelectedDimensions = ({ + selectedPosition, columns, rowHeight, scrollLeft, isGroupView, groupOffsetLeft, + getRecordTopFromRecordsBody, +}) => { + const { idx, rowIdx, groupRecordIndex } = selectedPosition; + const defaultDimensions = { width: 0, left: 0, top: 0, height: rowHeight, zIndex: 1 }; + if (idx >= 0) { + const column = columns && columns[idx]; + if (!column) { + return defaultDimensions; + } + const { frozen, width } = column; + let left = frozen ? scrollLeft + column.left : column.left; + let top; + if (isGroupView) { + left += groupOffsetLeft; + // group view uses border-top, No group view uses border-bottom (for group animation) so selected top should be increased 1 + top = getRecordTopFromRecordsBody(groupRecordIndex) + 1; + } else { + top = getRecordTopFromRecordsBody(rowIdx); + } + const zIndex = frozen ? Z_INDEX.FROZEN_CELL_MASK : Z_INDEX.CELL_MASK; + return { width, left, top, height: rowHeight, zIndex }; + } + return defaultDimensions; +}; + +export function getNewSelectedRange(startCell, nextCellPosition) { + const { idx: currentIdx, rowIdx: currentRowIdx, groupRecordIndex: currentGroupRecordIndex } = startCell; + const { idx: newIdx, rowIdx: newRowIdx, groupRecordIndex: newGroupRecordIndex } = nextCellPosition; + const colIndexes = [currentIdx, newIdx].sort((a, b) => a - b); + const rowIndexes = [currentRowIdx, newRowIdx].sort((a, b) => a - b); + const groupRecordIndexes = [currentGroupRecordIndex, newGroupRecordIndex].sort((a, b) => a - b); + const topLeft = { idx: colIndexes[0], rowIdx: rowIndexes[0], groupRecordIndex: groupRecordIndexes[0] }; + const bottomRight = { idx: colIndexes[1], rowIdx: rowIndexes[1], groupRecordIndex: groupRecordIndexes[1] }; + return { topLeft, bottomRight }; +} + +const getColumnRangeProperties = (from, to, columns) => { + let totalWidth = 0; + let anyColFrozen = false; + for (let i = from; i <= to; i++) { + const column = columns[i]; + if (column) { + totalWidth += column.width; + anyColFrozen = anyColFrozen || column.frozen; + } + } + return { totalWidth, anyColFrozen, left: columns[from].left }; +}; + +export const getSelectedRangeDimensions = ({ + selectedRange, columns, rowHeight, isGroupView, groups, groupMetrics, + groupOffsetLeft, getRecordTopFromRecordsBody, +}) => { + const { topLeft, bottomRight, startCell, cursorCell } = selectedRange; + if (topLeft.idx < 0) { + return { width: 0, left: 0, top: 0, height: rowHeight, zIndex: Z_INDEX.CELL_MASK }; + } + + let { totalWidth, anyColFrozen, left } = getColumnRangeProperties(topLeft.idx, bottomRight.idx, columns); + let height; + let top; + if (isGroupView) { + let { groupRecordIndex: startGroupRecordIndex } = startCell; + let { groupRecordIndex: endGroupRecordIndex } = cursorCell; + const startGroupRow = getGroupRecordByIndex(startGroupRecordIndex, groupMetrics); + const endGroupRow = getGroupRecordByIndex(endGroupRecordIndex, groupMetrics); + const startGroupPathString = startGroupRow.groupPathString; + const endGroupPathString = endGroupRow.groupPathString; + let topGroupRowIndex; + let selectDirection; + if (startGroupRecordIndex < endGroupRecordIndex) { + topGroupRowIndex = startGroupRecordIndex; + selectDirection = SELECT_DIRECTION.DOWN; + } else { + topGroupRowIndex = endGroupRecordIndex; + selectDirection = SELECT_DIRECTION.UP; + } + + if (startGroupPathString === endGroupPathString) { + // within the same group. + height = (Math.abs(endGroupRecordIndex - startGroupRecordIndex) + 1) * rowHeight; + } else if (selectDirection === SELECT_DIRECTION.DOWN) { + // within different group: select cells from top to bottom. + const groupPath = startGroupRow.groupPath; + const group = getGroupByPath(groupPath, groups); + const groupRowIds = group.row_ids || []; + height = (groupRowIds.length - startGroupRow.rowIdx || 0) * rowHeight; + } else if (selectDirection === SELECT_DIRECTION.UP) { + // within different group: select cells from bottom to top. + const startGroupRowIdx = startGroupRow.rowIdx || 0; + topGroupRowIndex = startGroupRecordIndex - startGroupRowIdx; + height = (startGroupRowIdx + 1) * rowHeight; + } + height += 1; // record height: 32 + left += groupOffsetLeft; + top = getRecordTopFromRecordsBody(topGroupRowIndex); + } else { + height = (bottomRight.rowIdx - topLeft.rowIdx + 1) * rowHeight; + top = getRecordTopFromRecordsBody(topLeft.rowIdx); + } + + const zIndex = anyColFrozen ? Z_INDEX.FROZEN_CELL_MASK : Z_INDEX.CELL_MASK; + return { width: totalWidth, left, top, height, zIndex }; +}; + +export const getRecordsFromSelectedRange = ({ selectedRange, isGroupView, recordGetterByIndex }) => { + const { topLeft, bottomRight } = selectedRange; + const { rowIdx: startRecordIdx, groupRecordIndex } = topLeft; + const { rowIdx: endRecordIdx } = bottomRight; + let currentGroupRowIndex = groupRecordIndex; + let records = []; + for (let recordIndex = startRecordIdx, endIdx = endRecordIdx + 1; recordIndex < endIdx; recordIndex++) { + const record = recordGetterByIndex({ isGroupView, groupRecordIndex: currentGroupRowIndex, recordIndex }); + if (isGroupView) { + currentGroupRowIndex++; + } + if (record) { + records.push(record); + } + } + return records; +}; diff --git a/frontend/src/metadata/metadata-view/utils/set-event-transfer.js b/frontend/src/metadata/metadata-view/utils/set-event-transfer.js new file mode 100644 index 0000000000..bc57c5c5ce --- /dev/null +++ b/frontend/src/metadata/metadata-view/utils/set-event-transfer.js @@ -0,0 +1,129 @@ +import { CellType } from '../_basic'; +import { toggleSelection } from './toggle-selection'; +import { TRANSFER_TYPES } from '../constants'; +import { getClientCellValueDisplayString } from './cell-format-utils'; +import { getColumnByIndex } from './column-utils'; + +const { TEXT, FRAGMENT } = TRANSFER_TYPES; + +function setEventTransfer({ + type, selectedRecordIds, copiedRange, copiedColumns, copiedRecords, copiedTableId, tableData, copiedText, + recordGetterById, isGroupView, recordGetterByIndex, event = {}, +}) { + const transfer = event.dataTransfer || event.clipboardData; + if (type === TRANSFER_TYPES.DTABLE_FRAGMENT) { + const copiedText = Array.isArray(selectedRecordIds) && selectedRecordIds.length > 0 ? + getCopiedTextFormSelectedRecordIds(selectedRecordIds, tableData, recordGetterById) : + getCopiedTextFromSelectedCells(copiedRange, tableData, isGroupView, recordGetterByIndex); + const copiedGrid = { + selectedRecordIds, + copiedRange, + copiedColumns, + copiedRecords, + copiedTableId, + }; + const serializeCopiedGrid = JSON.stringify(copiedGrid); + if (transfer) { + transfer.setData(TEXT, copiedText); + transfer.setData(FRAGMENT, serializeCopiedGrid); + } else { + execCopyWithNoEvents(copiedText, serializeCopiedGrid); + } + } else { + let format = TRANSFER_TYPES[type.toUpperCase()]; + if (transfer) { + transfer.setData(format, copiedText); + } else { + execCopyWithNoEvents(copiedText, { format }); + } + } +} + +function getCopiedTextFormSelectedRecordIds(selectedRecordIds, tableData, recordGetterById) { + const records = selectedRecordIds.map(recordId => recordGetterById(recordId)); + return getCopiedText(records, tableData.columns); +} + +function getCopiedTextFromSelectedCells(copiedRange, tableData, isGroupView, recordGetterByIndex) { + const { topLeft, bottomRight } = copiedRange; + const { rowIdx: minRecordIndex, idx: minColumnIndex, groupRecordIndex } = topLeft; + const { rowIdx: maxRecordIndex, idx: maxColumnIndex } = bottomRight; + const { columns } = tableData; + let currentGroupRecordIndex = groupRecordIndex; + let operateRecords = []; + let operateColumns = []; + for (let i = minRecordIndex; i <= maxRecordIndex; i++) { + operateRecords.push(recordGetterByIndex({ isGroupView, groupRecordIndex: currentGroupRecordIndex, recordIndex: i })); + if (isGroupView) { + currentGroupRecordIndex++; + } + } + for (let i = minColumnIndex; i <= maxColumnIndex; i++) { + operateColumns.push(getColumnByIndex(i, columns)); + } + return getCopiedText(operateRecords, operateColumns); +} + +function getCopiedText(records, columns) { + const collaborators = window.sfMetadataContext.getCollaboratorsFromCache(); + const lastRecordIndex = records.length - 1; + const lastColumnIndex = columns.length - 1; + let copiedText = ''; + records.forEach((record, recordIndex) => { + columns.forEach((column, columnIndex) => { + const { key, type, data } = column || {}; + if (type === CellType.LONG_TEXT) { + const cellValue = record[key]; + copiedText += cellValue || ''; + } else { + copiedText += (record && getClientCellValueDisplayString(record, type, key, { data, collaborators })) || ''; + } + if (columnIndex < lastColumnIndex) { + copiedText += '\t'; + } + }); + if (recordIndex < lastRecordIndex) { + copiedText += '\n'; + } + }); + return copiedText; +} + +export function execCopyWithNoEvents(text, serializeContent) { + let reselectPrevious; + let range; + let selection; + let mark; + let success = false; + try { + reselectPrevious = toggleSelection(); + range = document.createRange(); + selection = document.getSelection(); + mark = document.createElement('span'); + mark.textContent = text; + mark.addEventListener('copy', function (e) { + e.stopPropagation(); + e.preventDefault(); + let transfer = e.dataTransfer || e.clipboardData; + transfer.clearData(); + transfer.setData(TEXT, text); + transfer.setData(FRAGMENT, serializeContent); + }); + document.body.appendChild(mark); + range.selectNodeContents(mark); + selection.addRange(range); + success = document.execCommand('copy'); + if (!success) { + return false; + } + } catch { + return false; + } finally { + if (mark) { + document.body.removeChild(mark); + } + reselectPrevious(); + } +} + +export default setEventTransfer; diff --git a/frontend/src/metadata/metadata-view/utils/table-utils.js b/frontend/src/metadata/metadata-view/utils/table-utils.js new file mode 100644 index 0000000000..1ba4e00519 --- /dev/null +++ b/frontend/src/metadata/metadata-view/utils/table-utils.js @@ -0,0 +1,13 @@ +export const getFrozenColumns = (columns) => { + return columns.filter(column => column.frozen); +}; + +export const getFrozenColumnsWidth = (columns) => { + let width = 0; + columns.forEach(column => { + if (column.frozen) { + width += column.width; + } + }); + return width; +}; diff --git a/frontend/src/metadata/metadata-view/utils/toggle-selection.js b/frontend/src/metadata/metadata-view/utils/toggle-selection.js new file mode 100644 index 0000000000..b32af312c6 --- /dev/null +++ b/frontend/src/metadata/metadata-view/utils/toggle-selection.js @@ -0,0 +1,34 @@ +export function toggleSelection() { + let selection = document.getSelection(); + if (!selection.rangeCount) { + return function () {}; + } + let active = document.activeElement; + let ranges = []; + for (let i = 0; i < selection.rangeCount; i++) { + ranges.push(selection.getRangeAt(i)); + } + + switch (active.tagName.toUpperCase()) { // .toUpperCase handles XHTML + case 'INPUT': + case 'TEXTAREA': + active.blur(); + break; + default: + active = null; + break; + } + + selection.removeAllRanges(); + return function () { + selection.type === 'Caret' && + selection.removeAllRanges(); + if (!selection.rangeCount) { + ranges.forEach(function (range) { + selection.addRange(range); + }); + } + active && + active.focus(); + }; +} diff --git a/frontend/src/metadata/metadata-view/utils/viewport.js b/frontend/src/metadata/metadata-view/utils/viewport.js new file mode 100644 index 0000000000..2a129baa6c --- /dev/null +++ b/frontend/src/metadata/metadata-view/utils/viewport.js @@ -0,0 +1,29 @@ +export const getColVisibleStartIdx = (columns, scrollLeft) => { + let remainingScroll = scrollLeft; + const nonFrozenColumns = columns.slice(0); + for (let i = 0; i < nonFrozenColumns.length; i++) { + let { width } = columns[i]; + remainingScroll -= width; + if (remainingScroll < 0) { + return i; + } + } +}; + +export const getColVisibleEndIdx = (columns, gridWidth, scrollLeft) => { + let remainingWidth = gridWidth + scrollLeft; + for (let i = 0; i < columns.length; i++) { + let { width } = columns[i]; + remainingWidth -= width; + if (remainingWidth < 0) { + return i - 1; + } + } + return columns.length - 1; +}; + +export const getVisibleBoundaries = (columns, scrollLeft, gridWidth) => { + const colVisibleStartIdx = getColVisibleStartIdx(columns, scrollLeft); + const colVisibleEndIdx = getColVisibleEndIdx(columns, gridWidth, scrollLeft); + return { colVisibleStartIdx, colVisibleEndIdx }; +}; diff --git a/frontend/src/pages/lib-content-view/lib-content-view.js b/frontend/src/pages/lib-content-view/lib-content-view.js index 62ca08cab4..2ab7290b14 100644 --- a/frontend/src/pages/lib-content-view/lib-content-view.js +++ b/frontend/src/pages/lib-content-view/lib-content-view.js @@ -461,6 +461,14 @@ class LibContentView extends React.Component { window.history.pushState({url: url, path: filePath}, filePath, url); }; + showFileMetadata = (filePath) => { + const repoID = this.props.repoID; + this.setState({ path: filePath, isViewFile: true, isFileLoading: false, isFileLoadedErr: false, content: '__sf-metadata' }); + const repoInfo = this.state.currentRepoInfo; + const url = siteRoot + 'library/' + repoID + '/' + encodeURIComponent(repoInfo.repo_name); + window.history.pushState({url: url, path: ''}, '', url); + }; + loadDirentList = (path) => { let repoID = this.props.repoID; seafileAPI.listDir(repoID, path, {'with_thumbnail': true}).then(res => { @@ -1649,6 +1657,7 @@ class LibContentView extends React.Component { onTreeNodeClick = (node) => { this.resetSelected(); let repoID = this.props.repoID; + if (!this.state.pathExist) { this.setState({pathExist: true}); } @@ -1680,7 +1689,7 @@ class LibContentView extends React.Component { } } - if (node.path === this.state.path ) { + if (node.path === this.state.path) { return; } @@ -1691,6 +1700,10 @@ class LibContentView extends React.Component { if (node.path !== this.state.path) { this.showColumnMarkdownFile(node.path); } + } else if (Utils.isFileMetadata(node?.object?.type)) { + if (node.path !== this.state.path) { + this.showFileMetadata(node.path); + } } else { let url = siteRoot + 'lib/' + repoID + '/file' + Utils.encodePath(node.path); let dirent = node.object; diff --git a/frontend/src/utils/utils.js b/frontend/src/utils/utils.js index ef2ae025d2..0a75a14515 100644 --- a/frontend/src/utils/utils.js +++ b/frontend/src/utils/utils.js @@ -4,6 +4,7 @@ import React from 'react'; import toaster from '../components/toast'; import PermissionDeniedTip from '../components/permission-denied-tip'; import { compareTwoString } from './compare-two-string'; +import { PRIVATE_FILE_TYPE } from '../constants'; export const Utils = { @@ -872,6 +873,10 @@ export const Utils = { } }, + isFileMetadata: function(type) { + return type === PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES; + }, + isInternalFileLink: function(url, repoID) { var re = new RegExp(serviceURL + '/lib/' + repoID + '/file.*'); return re.test(url); diff --git a/seahub/api2/endpoints/metadata_manage.py b/seahub/api2/endpoints/metadata_manage.py index fa1e6828c3..6eb05af553 100644 --- a/seahub/api2/endpoints/metadata_manage.py +++ b/seahub/api2/endpoints/metadata_manage.py @@ -153,8 +153,8 @@ class MetadataRecords(APIView): #args check parent_dir = request.GET.get('parent_dir') name = request.GET.get('name') - page = request.GET.get('page', '1') - per_page = request.GET.get('per_page', '1000') + page = request.GET.get('page', 1) + per_page = request.GET.get('per_page', 1000) is_dir = request.GET.get('is_dir') order_by = request.GET.get('order_by') @@ -186,7 +186,7 @@ class MetadataRecords(APIView): error_msg = f'The metadata module is disabled for repo {repo_id}.' return api_error(status.HTTP_404_NOT_FOUND, error_msg) - # recource check + # resource check repo = seafile_api.get_repo(repo_id) if not repo: error_msg = 'Library %s not found.' % repo_id @@ -209,4 +209,4 @@ class MetadataRecords(APIView): error_msg = 'Internal Server Error' return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) - return Response({'results': results}) + return Response(results) diff --git a/seahub/api2/endpoints/user_list.py b/seahub/api2/endpoints/user_list.py new file mode 100644 index 0000000000..cb70da4780 --- /dev/null +++ b/seahub/api2/endpoints/user_list.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +import logging + +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from rest_framework.authentication import SessionAuthentication + +from seahub.api2.utils import api_error, get_user_common_info +from seahub.api2.throttling import UserRateThrottle +from seahub.api2.authentication import TokenAuthentication + +logger = logging.getLogger(__name__) + + +class UserListView(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated, ) + throttle_classes = (UserRateThrottle, ) + + def post(self, request): + """return user_list by user_id_list + """ + # argument check + user_id_list = request.data.get('user_id_list') + if not isinstance(user_id_list, list): + error_msg = 'user_id_list invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + # main + user_list = list() + for user_id in user_id_list: + if not isinstance(user_id, str): + continue + user_info = get_user_common_info(user_id) + user_list.append(user_info) + + return Response({'user_list': user_list}) diff --git a/seahub/repo_metadata/metadata_server_api.py b/seahub/repo_metadata/metadata_server_api.py index 3dc1a66f21..303baff578 100644 --- a/seahub/repo_metadata/metadata_server_api.py +++ b/seahub/repo_metadata/metadata_server_api.py @@ -50,7 +50,7 @@ def list_metadata_records(repo_id, user, parent_dir=None, name=None, is_dir=None sql += ';' metadata_server_api = MetadataServerAPI(repo_id, user) - response_results = metadata_server_api.query_rows(sql, parameters)['results'] + response_results = metadata_server_api.query_rows(sql, parameters) return response_results diff --git a/seahub/repo_metadata/views.py b/seahub/repo_metadata/views.py deleted file mode 100644 index 9de8734c8e..0000000000 --- a/seahub/repo_metadata/views.py +++ /dev/null @@ -1,56 +0,0 @@ -from seahub.views import check_folder_permission -from seaserv import seafile_api -from seahub.auth.decorators import login_required -from seahub.base.decorators import repo_passwd_set_required -from django.http import Http404, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseServerError -from seahub.api2.endpoints.metadata_manage import list_metadata_records -from seahub.repo_metadata.models import RepoMetadata -from django.shortcuts import render - - -@login_required -@repo_passwd_set_required -def view_metadata(request, repo_id): - template = 'metadata_table.html' - - # metadata enable check - record = RepoMetadata.objects.filter(repo_id=repo_id).first() - if not record or not record.enabled: - return HttpResponseBadRequest(f'The metadata module is not enable for repo {repo_id}.') - - # recource check - repo = seafile_api.get_repo(repo_id) - if not repo: - raise Http404 - - # permission check - permission = check_folder_permission(request, repo_id, '/') - if not permission: - return HttpResponseForbidden('Permission denied.') - - try: - results = list_metadata_records(repo_id, request.user.username) - except Exception as err: - return HttpResponseServerError(repr(err)) - - return_results = [] - - for result in results: - - result_info = { - 'id': result['_id'], - 'creator': result['_file_creator'], - 'file_ctime': result['_file_ctime'], - 'modifier': result['_file_modifier'], - 'file_mtime': result['_file_mtime'], - 'parent_dir': result['_parent_dir'], - 'name': result['_name'], - 'is_dir': result['_is_dir'], - } - return_results.append(result_info) - - return_dict = { - 'metadata_records': return_results - } - - return render(request, template, return_dict) diff --git a/seahub/templates/metadata_table.html b/seahub/templates/metadata_table.html deleted file mode 100644 index a2771f75ee..0000000000 --- a/seahub/templates/metadata_table.html +++ /dev/null @@ -1,53 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} - -{% block main_panel %} -
-
- - - - - - - - - - - - - - {% for record in metadata_records %} - - - - - - - - - - - {% endfor %} - -
idCreatorCreated TimeModifierModified TimeParent FolderNameIs Folder
{{ record.id }}{{ record.creator }}{{ record.file_ctime }}{{ record.modifier }}{{ record.file_mtime }}{{ record.parent_dir }}{{ record.name }}{{ record.is_dir|yesno:"Yes,No" }}
-
-{% endblock %} - -{% block extra_script %} - -{% endblock %} \ No newline at end of file diff --git a/seahub/urls.py b/seahub/urls.py index a94cb9cade..23243fa46e 100644 --- a/seahub/urls.py +++ b/seahub/urls.py @@ -204,10 +204,10 @@ from seahub.ocm.settings import OCM_ENDPOINT from seahub.ai.apis import LibrarySdocIndexes, Search, LibrarySdocIndex, TaskStatus, \ LibraryIndexState, QuestionAnsweringSearchInLibrary, FileDownloadToken from seahub.wiki2.views import wiki_view -from seahub.repo_metadata.views import view_metadata from seahub.api2.endpoints.wiki2 import Wikis2View, Wiki2View, Wiki2ConfigView, Wiki2PagesView, Wiki2PageView from seahub.api2.endpoints.subscription import SubscriptionView, SubscriptionPlansView, SubscriptionLogsView from seahub.api2.endpoints.metadata_manage import MetadataRecords, MetadataManage +from seahub.api2.endpoints.user_list import UserListView urlpatterns = [ @@ -320,6 +320,9 @@ urlpatterns = [ # user:convert to team account re_path(r'^api/v2.1/user/convert-to-team/$', UserConvertToTeamView.as_view(), name="api-v2.1-user-convert-to-team"), + # user list + re_path(r'^api/v2.1/user-list/$', UserListView.as_view(), name='api-v2.1-user-list'), + ## obtain auth token by login session re_path(r'^api/v2.1/auth-token-by-session/$', AuthTokenBySession.as_view(), name="api-v2.1-auth-token-by-session"), @@ -1032,5 +1035,4 @@ if settings.ENABLE_METADATA_MANAGEMENT: urlpatterns += [ re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/$', MetadataManage.as_view(), name='api-v2.1-metadata'), re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/records/$', MetadataRecords.as_view(), name='api-v2.1-metadata-records'), - re_path(r'^repos/(?P[-0-9a-f]{36})/metadata/table-view/$', view_metadata, name='view_metadata'), ]