Compare commits
19 Commits
Lib-query-
...
nav-split-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16bbf677a5 | ||
|
|
e1d1d31a1a | ||
|
|
7b91252075 | ||
|
|
8dfb8c5b67 | ||
|
|
401167cc00 | ||
|
|
2947b47c23 | ||
|
|
abb710334f | ||
|
|
fed0dbfd58 | ||
|
|
edd44ef851 | ||
|
|
1cd4d62061 | ||
|
|
426c9e6406 | ||
|
|
feb7aa645c | ||
|
|
5349fb75d7 | ||
|
|
5073c20eb1 | ||
|
|
9d6d4c656e | ||
|
|
2f02a29249 | ||
|
|
3b9a1df5ac | ||
|
|
7e9ffc83ce | ||
|
|
35bf6ca092 |
92
bun.lock
92
bun.lock
@@ -20,6 +20,8 @@
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "^6.39.11",
|
||||
"@dagrejs/dagre": "^1.1.5",
|
||||
"@dicebear/core": "^10.7.0",
|
||||
"@dicebear/styles": "^10.6.0",
|
||||
"@dnd-kit/core": "^6.0.8",
|
||||
"@dnd-kit/modifiers": "^6.0.1",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
@@ -809,6 +811,10 @@
|
||||
|
||||
"@dependents/detective-less": ["@dependents/detective-less@5.0.1", "", { "dependencies": { "gonzales-pe": "^4.3.0", "node-source-walk": "^7.0.1" } }, "sha512-Y6+WUMsTFWE5jb20IFP4YGa5IrGY/+a/FbOSjDF/wz9gepU2hwCYSXRHP/vPwBvwcY3SVMASt4yXxbXNXigmZQ=="],
|
||||
|
||||
"@dicebear/core": ["@dicebear/core@10.7.0", "", {}, "sha512-JUOzKOd8RUByJJo8ZL+C7YE/pvEsWewfpbWUmR8OIQZWv/PvUQHnohdevySC3icyyxe6EIU/8B/rQgA42us1QA=="],
|
||||
|
||||
"@dicebear/styles": ["@dicebear/styles@10.6.0", "", {}, "sha512-KhxbEVI+z8TaenX58+ZhIRFBt7adggu/F+8g6ZYLu43H2/lgLuWA3Kg8vHBrCVWdfTTTt1p2wUhQomIkkypTuQ=="],
|
||||
|
||||
"@discoveryjs/json-ext": ["@discoveryjs/json-ext@0.5.7", "", {}, "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw=="],
|
||||
|
||||
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.0", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ=="],
|
||||
@@ -1845,8 +1851,6 @@
|
||||
|
||||
"@types/node": ["@types/node@22.1.0", "", { "dependencies": { "undici-types": "~6.13.0" } }, "sha512-AOmuRF0R2/5j1knA3c6G3HOk523Ga+l+ZXltX8SF1+5oqcXijjfTd8fY3XRZqSihEu9XhtQnKYLmkFaoxgsJHw=="],
|
||||
|
||||
"@types/node-forge": ["@types/node-forge@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ=="],
|
||||
|
||||
"@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
|
||||
|
||||
"@types/parse-json": ["@types/parse-json@4.0.0", "", {}, "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA=="],
|
||||
@@ -1925,7 +1929,7 @@
|
||||
|
||||
"@types/yoga-layout": ["@types/yoga-layout@1.9.2", "", {}, "sha512-S9q47ByT2pPvD65IvrWp7qppVMpk9WGMbVq9wbWZOHg6tnXSD4vyhao6nOSBwwfDdV2p3Kx9evA9vI+XWTfDvw=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.54.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/type-utils": "8.54.0", "@typescript-eslint/utils": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.54.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ=="],
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.60.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/type-utils": "8.60.0", "@typescript-eslint/utils": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.60.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.60.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg=="],
|
||||
|
||||
@@ -1935,7 +1939,7 @@
|
||||
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.60.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ=="],
|
||||
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/utils": "8.54.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA=="],
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0", "@typescript-eslint/utils": "8.60.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg=="],
|
||||
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.39.0", "", {}, "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg=="],
|
||||
|
||||
@@ -1943,7 +1947,7 @@
|
||||
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.54.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="],
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg=="],
|
||||
|
||||
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
|
||||
|
||||
@@ -4023,8 +4027,6 @@
|
||||
|
||||
"node-fetch-h2": ["node-fetch-h2@2.3.0", "", { "dependencies": { "http2-client": "^1.2.5" } }, "sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg=="],
|
||||
|
||||
"node-forge": ["node-forge@1.3.0", "", {}, "sha512-08ARB91bUi6zNKzVmaj3QO7cr397uiDT2nJ63cHjyNtCTWIgvS47j3eT0WfzUwS9+6Z5YshRaoasFkXCKrIYbA=="],
|
||||
|
||||
"node-int64": ["node-int64@0.4.0", "", {}, "sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs="],
|
||||
|
||||
"node-notifier": ["node-notifier@9.0.1", "", { "dependencies": { "growly": "^1.3.0", "is-wsl": "^2.2.0", "semver": "^7.3.2", "shellwords": "^0.1.1", "uuid": "^8.3.0", "which": "^2.0.2" } }, "sha512-fPNFIp2hF/Dq7qLDzSg4vZ0J4e9v60gJR+Qx7RbjbWqzPDdEqeVpEx5CFeDAELIl+A/woaaNn1fQ5nEVerMxJg=="],
|
||||
@@ -4713,8 +4715,6 @@
|
||||
|
||||
"secure-keys": ["secure-keys@1.0.0", "", {}, "sha1-8MgtmKOxOah3aogIBQuCRDEIf8o="],
|
||||
|
||||
"selfsigned": ["selfsigned@2.4.1", "", { "dependencies": { "@types/node-forge": "^1.3.0", "node-forge": "^1" } }, "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q=="],
|
||||
|
||||
"semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
||||
|
||||
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
@@ -5063,7 +5063,7 @@
|
||||
|
||||
"ts-algebra": ["ts-algebra@1.2.2", "", {}, "sha512-kloPhf1hq3JbCPOTYoOWDKxebWjNb2o/LKnNfkWhxVVisFFmMJPPdJeGoGmM+iRLyoXAR61e08Pb+vUXINg8aA=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
|
||||
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||
|
||||
"ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="],
|
||||
|
||||
@@ -5681,38 +5681,32 @@
|
||||
|
||||
"@types/leaflet/@types/geojson": ["@types/geojson@7946.0.8", "", {}, "sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0" } }, "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils": ["@typescript-eslint/utils@8.60.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA=="],
|
||||
|
||||
"@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0" } }, "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw=="],
|
||||
|
||||
"@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="],
|
||||
|
||||
"@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg=="],
|
||||
|
||||
"@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="],
|
||||
|
||||
"@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.54.0", "", {}, "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA=="],
|
||||
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.54.0", "", {}, "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA=="],
|
||||
"@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="],
|
||||
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.54.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.54.0", "@typescript-eslint/tsconfig-utils": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA=="],
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/utils": ["@typescript-eslint/utils@8.60.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.54.0", "", {}, "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.54.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.54.0", "@typescript-eslint/tsconfig-utils": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.54.0", "", {}, "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
|
||||
"@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
||||
|
||||
"@visx/axis/classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
|
||||
@@ -6801,8 +6795,6 @@
|
||||
|
||||
"typedoc/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.60.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/type-utils": "8.60.0", "@typescript-eslint/utils": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.60.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.60.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA=="],
|
||||
|
||||
"unbox-primitive/which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
||||
@@ -7099,28 +7091,32 @@
|
||||
|
||||
"@types/jest/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="],
|
||||
|
||||
"@typescript-eslint/parser/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="],
|
||||
|
||||
"@typescript-eslint/parser/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.39.0", "", {}, "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg=="],
|
||||
"@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.54.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.54.0", "@typescript-eslint/types": "^8.54.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g=="],
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0" } }, "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw=="],
|
||||
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.54.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw=="],
|
||||
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.39.0", "", {}, "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg=="],
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.54.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.54.0", "@typescript-eslint/types": "^8.54.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.54.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
|
||||
|
||||
"@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],
|
||||
|
||||
"@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
|
||||
@@ -7299,8 +7295,6 @@
|
||||
|
||||
"detective-typescript/@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"detective-typescript/@typescript-eslint/typescript-estree/ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||
|
||||
"es-get-iterator/get-intrinsic/has-proto": ["has-proto@1.0.1", "", {}, "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg=="],
|
||||
|
||||
"es-get-iterator/get-intrinsic/hasown": ["hasown@2.0.1", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA=="],
|
||||
@@ -7807,14 +7801,6 @@
|
||||
|
||||
"typed-array-length/is-typed-array/which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0" } }, "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0", "@typescript-eslint/utils": "8.60.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/eslint-plugin/ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0" } }, "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="],
|
||||
@@ -7991,11 +7977,17 @@
|
||||
|
||||
"@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.54.0", "", {}, "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
|
||||
"@typescript-eslint/parser/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.54.0", "", {}, "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA=="],
|
||||
|
||||
"@typescript-eslint/parser/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
|
||||
|
||||
@@ -8393,9 +8385,7 @@
|
||||
|
||||
"test-exclude/glob/path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="],
|
||||
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="],
|
||||
|
||||
"webpack-dev-middleware/schema-utils/ajv-formats/ajv": ["ajv@8.10.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-bzqAEZOjkrUMl2afH8dknrq5KEk2SrwdBROR+vH1EKVQTqaUbJVPdc/gEdggTMM0Se+s+Ja4ju4TlNcStKl2Hw=="],
|
||||
|
||||
@@ -8493,7 +8483,9 @@
|
||||
|
||||
"@storybook/builder-webpack5/terser-webpack-plugin/schema-utils/ajv-formats/ajv": ["ajv@8.10.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-bzqAEZOjkrUMl2afH8dknrq5KEk2SrwdBROR+vH1EKVQTqaUbJVPdc/gEdggTMM0Se+s+Ja4ju4TlNcStKl2Hw=="],
|
||||
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.54.0", "", {}, "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA=="],
|
||||
|
||||
"@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
|
||||
"@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
@@ -8675,9 +8667,9 @@
|
||||
|
||||
"test-exclude/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.2", "", { "dependencies": { "jackspeak": "^4.2.3" } }, "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.54.0", "", {}, "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA=="],
|
||||
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.54.0", "", {}, "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA=="],
|
||||
|
||||
"typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
"typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
|
||||
"webpack-dev-middleware/schema-utils/ajv-formats/ajv/uri-js": ["uri-js@4.4.0", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-B0yRTzYdUCCn9n+F4+Gh4yIDtMQcaJsmYBDsTSG8g/OejKBodLQ2IHfN3bM7jUsRXndopT7OIXWdYqc1fjmV6g=="],
|
||||
|
||||
|
||||
@@ -164,9 +164,6 @@ export const wrapSyncedCollection = (alias = "syncedCollection", n = 0) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const getSyncStatusIndicators = () =>
|
||||
navigationSidebar().findAllByTestId("remote-sync-status");
|
||||
|
||||
export const updateRemoteQuestion = (
|
||||
updateFn: (val: Record<string, any>) => Record<string, any>,
|
||||
assertionsFn?: (val: Record<string, any>) => void,
|
||||
@@ -211,8 +208,6 @@ export const moveCollectionItemToSyncedCollection = (
|
||||
cy.button("Move").click();
|
||||
});
|
||||
|
||||
getSyncStatusIndicators().should("have.length", 1);
|
||||
|
||||
navigationSidebar()
|
||||
.findByRole("treeitem", { name: new RegExp(targetCollection) })
|
||||
.click();
|
||||
|
||||
@@ -70,9 +70,6 @@ describe("Remote Sync", () => {
|
||||
});
|
||||
|
||||
cy.visit("/");
|
||||
|
||||
// Ensure that status icon is present
|
||||
H.getSyncStatusIndicators().should("have.length.greaterThan", 0);
|
||||
H.navigationSidebar()
|
||||
.findByRole("link", { name: /Test Synced Collection/ })
|
||||
.click();
|
||||
@@ -91,11 +88,6 @@ describe("Remote Sync", () => {
|
||||
triggered_from: "app-bar",
|
||||
});
|
||||
|
||||
H.navigationSidebar()
|
||||
.findByRole("link", { name: /Test Synced Collection/ })
|
||||
.findByTestId("remote-sync-status")
|
||||
.should("not.exist");
|
||||
|
||||
H.updateRemoteQuestion(
|
||||
(doc) => {
|
||||
doc.name = UPDATED_REMOTE_QUESTION_NAME;
|
||||
@@ -129,8 +121,6 @@ describe("Remote Sync", () => {
|
||||
|
||||
cy.visit("/collection/root");
|
||||
|
||||
H.getSyncStatusIndicators().should("have.length", 0);
|
||||
|
||||
const expectedError = "Uses content that is not remote synced.";
|
||||
|
||||
cy.log("Test moving via 'Move' modal");
|
||||
@@ -167,8 +157,6 @@ describe("Remote Sync", () => {
|
||||
H.entityPickerModalItem(1, "Test Synced Collection").click();
|
||||
cy.button("Move").click();
|
||||
});
|
||||
|
||||
H.getSyncStatusIndicators().should("have.length", 1);
|
||||
});
|
||||
|
||||
it("should show a warning modal when you try to push but are out of date", () => {
|
||||
@@ -1056,57 +1044,12 @@ describe("Remote Sync", () => {
|
||||
|
||||
cy.visit("/");
|
||||
|
||||
// Verify sync status indicator appears
|
||||
H.getSyncStatusIndicators().should("have.length.greaterThan", 0);
|
||||
|
||||
// Push changes
|
||||
H.clickPushOption();
|
||||
H.modal()
|
||||
.button(/Push changes/)
|
||||
.click();
|
||||
H.waitForTask({ taskName: "export" });
|
||||
|
||||
// Verify changes were pushed (status indicator should clear)
|
||||
H.navigationSidebar()
|
||||
.findByRole("link", { name: /Syncable Tenant Collection/ })
|
||||
.findByTestId("remote-sync-status")
|
||||
.should("not.exist");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("shows sync status badge on synced tenant collections in sidebar", () => {
|
||||
H.configureGitAndPullChanges("read-write");
|
||||
|
||||
// Create a tenant collection
|
||||
H.createSharedTenantCollection("Badge Test Collection").then(
|
||||
(response) => {
|
||||
const collectionId = response.body.id;
|
||||
|
||||
// Enable sync
|
||||
cy.visit("/admin/settings/remote-sync");
|
||||
// Mantine Switch has a hidden input (0x0 pixels), so we need force: true
|
||||
cy.findByTestId("admin-layout-content")
|
||||
.findByRole("switch", { name: "Sync Badge Test Collection" })
|
||||
.click({ force: true });
|
||||
cy.findByTestId("admin-layout-content")
|
||||
.button("Save changes")
|
||||
.click();
|
||||
|
||||
// Create content to trigger dirty state
|
||||
H.createQuestion({
|
||||
name: "Status Badge Test Question",
|
||||
query: { "source-table": PRODUCTS_ID },
|
||||
collection_id: collectionId,
|
||||
});
|
||||
|
||||
cy.visit("/");
|
||||
|
||||
// Verify the sync status badge appears on the tenant collection
|
||||
H.navigationSidebar()
|
||||
.findByRole("treeitem", { name: /Badge Test Collection/ })
|
||||
.findByTestId("remote-sync-status")
|
||||
.should("exist");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -170,7 +170,7 @@ describe("scenarios > data studio > library", () => {
|
||||
});
|
||||
|
||||
describe("Library collection management", () => {
|
||||
it("should create a new library collection from the New button", () => {
|
||||
it("should create a top-level library folder from the New button", () => {
|
||||
H.createLibrary();
|
||||
H.createCollection({ name: "Outside Library" });
|
||||
H.DataStudio.Library.visit();
|
||||
@@ -178,11 +178,15 @@ describe("scenarios > data studio > library", () => {
|
||||
cy.intercept("POST", "/api/collection").as("createCollection");
|
||||
|
||||
H.DataStudio.Library.newButton().click();
|
||||
H.popover().findByText("Collection").click();
|
||||
H.popover().findByText("Folder").click();
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByLabelText("Name").type("New Library Collection");
|
||||
cy.findByTestId("collection-picker-button").should("contain", "Data");
|
||||
cy.findByLabelText("Name").type("New Library Folder");
|
||||
cy.log("a new folder goes to the top level of the Library by default");
|
||||
cy.findByTestId("collection-picker-button").should(
|
||||
"contain",
|
||||
"Library",
|
||||
);
|
||||
cy.findByTestId("collection-picker-button").click();
|
||||
});
|
||||
|
||||
@@ -197,13 +201,46 @@ describe("scenarios > data studio > library", () => {
|
||||
H.modal().button("Create").click();
|
||||
cy.wait("@createCollection").then(({ response }) => {
|
||||
expect(response?.statusCode).to.equal(200);
|
||||
expect(response?.body.type).to.equal("library");
|
||||
});
|
||||
|
||||
H.DataStudio.Library.collectionItem("New Library Collection").should(
|
||||
H.DataStudio.Library.collectionItem("New Library Folder").should(
|
||||
"be.visible",
|
||||
);
|
||||
});
|
||||
|
||||
it("should create a subfolder from a library section's row menu and give it a custom icon", () => {
|
||||
H.createLibrary();
|
||||
H.DataStudio.Library.visit();
|
||||
|
||||
cy.intercept("POST", "/api/collection").as("createCollection");
|
||||
|
||||
cy.log(
|
||||
"the seeded Data section offers New folder but not rename/archive",
|
||||
);
|
||||
H.DataStudio.Library.collectionItem("Data")
|
||||
.findByLabelText("Collection options")
|
||||
.click();
|
||||
H.popover().findByText("Edit collection details").should("not.exist");
|
||||
H.popover().findByText("Archive").should("not.exist");
|
||||
H.popover().findByText("New folder").click();
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByLabelText("Name").type("Warehouse");
|
||||
cy.findByLabelText("Icon").click();
|
||||
});
|
||||
H.popover().findByLabelText("gem").click();
|
||||
H.modal().button("Create").click();
|
||||
|
||||
cy.wait("@createCollection").then(({ request, response }) => {
|
||||
expect(request.body.icon).to.equal("gem");
|
||||
expect(response?.statusCode).to.equal(200);
|
||||
expect(response?.body.type).to.equal("library-data");
|
||||
});
|
||||
|
||||
H.DataStudio.Library.collectionItem("Warehouse").should("be.visible");
|
||||
});
|
||||
|
||||
it("should edit a library collection name and description", () => {
|
||||
H.createLibrary();
|
||||
getLibraryRootCollections().then(({ dataCollection }) => {
|
||||
|
||||
@@ -258,7 +258,7 @@ describe("scenarios > data studio > snippets", () => {
|
||||
H.DataStudio.Library.visit();
|
||||
|
||||
H.DataStudio.Library.newButton().click();
|
||||
H.popover().findByText("Collection").click();
|
||||
H.popover().findByText("Folder").click();
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByLabelText("Name").type("Test Folder");
|
||||
|
||||
@@ -133,67 +133,4 @@ describe("scenarios > sidecar > tenant users", () => {
|
||||
|
||||
H.entityPickerModal().findByText("Our data").should("be.visible");
|
||||
});
|
||||
|
||||
it("tenant users should not see the synced collection icons", () => {
|
||||
cy.signInAsAdmin();
|
||||
|
||||
// Setup git sync
|
||||
H.setupGitSync();
|
||||
H.copySyncedCollectionFixture();
|
||||
H.commitToRepo();
|
||||
|
||||
// Make shared tenant collections synced
|
||||
cy.get<number>("@sharedCollection1Id").then((id1) => {
|
||||
cy.get<number>("@sharedCollection2Id").then((id2) => {
|
||||
const collectionsConfig = {
|
||||
[id1]: true,
|
||||
[id2]: true,
|
||||
};
|
||||
|
||||
cy.request("PUT", "/api/ee/remote-sync/settings", {
|
||||
collections: collectionsConfig,
|
||||
"remote-sync-branch": "main",
|
||||
"remote-sync-type": "read-write",
|
||||
"remote-sync-url": H.LOCAL_GIT_PATH + "/.git",
|
||||
"remote-sync-enabled": true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
cy.signOut();
|
||||
|
||||
loginWithJWT(GIZMO_USER);
|
||||
|
||||
// Check sidebar
|
||||
H.navigationSidebar().within(() => {
|
||||
// Synced collections should show a regular folder icon for tenant users
|
||||
cy.findByText("Shared tenant collection 1")
|
||||
.closest("li")
|
||||
.icon("folder")
|
||||
.should("be.visible");
|
||||
|
||||
cy.findByText("Shared tenant collection 1")
|
||||
.closest("li")
|
||||
.icon("synced_collection")
|
||||
.should("not.exist");
|
||||
});
|
||||
|
||||
// Check picker
|
||||
H.newButton().click();
|
||||
H.popover().findByText("Question").click();
|
||||
H.popover().findByText("Orders").click();
|
||||
H.main().findByText("Save").click();
|
||||
H.modal()
|
||||
.findByLabelText(/Where do you want to save this/)
|
||||
.click();
|
||||
|
||||
H.entityPickerModal().within(() => {
|
||||
cy.findByText("Shared collections").click();
|
||||
|
||||
cy.findByText("Shared tenant collection 1")
|
||||
.closest("a")
|
||||
.icon("folder")
|
||||
.should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,8 +87,7 @@ describe("scenarios > metrics > metric page", () => {
|
||||
H.expectNoBadSnowplowEvents();
|
||||
});
|
||||
|
||||
it("should display scalar metric, edit name and description, explore link, and more menu actions", () => {
|
||||
cy.intercept("PUT", "/api/card/*").as("updateCard");
|
||||
it("should display scalar metric with read-only name and description, explore link, and more menu actions", () => {
|
||||
cy.intercept("POST", "/api/card").as("createCard");
|
||||
|
||||
H.createQuestion(ORDERS_SCALAR_METRIC).then(({ body: metric }) => {
|
||||
@@ -121,34 +120,25 @@ describe("scenarios > metrics > metric page", () => {
|
||||
.should("have.attr", "href")
|
||||
.and("include", "/explore");
|
||||
|
||||
cy.log("edit description");
|
||||
H.MetricPage.aboutPageDescriptionSidebar().within(() => {
|
||||
cy.findByText("Total number of orders").click();
|
||||
cy.log("name and description are read-only in the main app");
|
||||
H.MetricPage.aboutPage().within(() => {
|
||||
cy.findByTestId("metric-header")
|
||||
.findByText("Orders count")
|
||||
.should("be.visible");
|
||||
cy.findByDisplayValue("Orders count").should("not.exist");
|
||||
});
|
||||
cy.focused().clear().type("Updated description").blur();
|
||||
cy.wait("@updateCard");
|
||||
H.MetricPage.aboutPageDescriptionSidebar()
|
||||
.findByText("Updated description")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("edit name inline");
|
||||
H.MetricPage.aboutPage()
|
||||
.findByDisplayValue("Orders count")
|
||||
.clear()
|
||||
.type("Renamed metric{enter}");
|
||||
cy.wait("@updateCard");
|
||||
H.MetricPage.aboutPage()
|
||||
.findByDisplayValue("Renamed metric")
|
||||
.should("be.visible");
|
||||
.findByTestId("editable-text")
|
||||
.should("not.exist");
|
||||
|
||||
cy.log("duplicate via more menu");
|
||||
H.MetricPage.moreMenu().click();
|
||||
H.popover().findByText("Duplicate").click();
|
||||
H.modal().within(() => {
|
||||
cy.findByLabelText("Name")
|
||||
.should("have.value", "Renamed metric - Duplicate")
|
||||
.should("have.value", "Orders count - Duplicate")
|
||||
.clear()
|
||||
.type("Renamed metric copy");
|
||||
.type("Orders count copy");
|
||||
cy.button("Duplicate").click();
|
||||
});
|
||||
cy.wait("@createCard");
|
||||
|
||||
@@ -171,8 +171,8 @@
|
||||
body :- ::publish-table-selectors]
|
||||
(api/check-data-analyst)
|
||||
(let [target-collection (api/check-404 (data-studio.db/collection (:collection_id body)))
|
||||
_ (api/check-400 (= (:type target-collection) collection/library-data-collection-type)
|
||||
(tru "Tables can only be published to Library/Data collections."))
|
||||
_ (api/check-400 (collection/can-contain-published-tables? target-collection)
|
||||
(tru "Tables can only be published to Library/Data collections or Library folders."))
|
||||
selectors (body->table-selectors body)
|
||||
upstream-ids (all-upstream-table-ids selectors)
|
||||
;; Don't move already-published upstream tables; only publish unpublished ones. Get table IDs before update
|
||||
|
||||
@@ -34,10 +34,11 @@
|
||||
:archive-operation-id nil})]
|
||||
:order-by [[:%lower.name :asc]]}))
|
||||
|
||||
(mu/defn collection-type
|
||||
"The type of the Collection with `collection-id`."
|
||||
(mu/defn collection-type-and-entity-id
|
||||
"The `:type` and `:entity_id` of the Collection with `collection-id` — enough to tell a library folder from one of
|
||||
the seeded Library roots via [[metabase.collections.models.collection/library-root-collection?]]."
|
||||
[collection-id :- ::lib.schema.id/collection]
|
||||
(t2/select-one-fn :type [:model/Collection :type] :id collection-id))
|
||||
(t2/select-one [:model/Collection :type :entity_id] :id collection-id))
|
||||
|
||||
(mu/defn unarchived-card-collection-types-reducible
|
||||
"Reducible distinct Collection ID and Card type pairs of the unarchived Cards."
|
||||
|
||||
@@ -6,20 +6,31 @@
|
||||
[metabase.premium-features.core :refer [defenterprise]]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(def ^:private library-collection-content-specs
|
||||
"A map from the `:type` of the parent collection to a spec for what contents it permits."
|
||||
{collection/library-collection-type
|
||||
{:allowed-content-types #{collection/library-data-collection-type
|
||||
collection/library-metrics-collection-type}
|
||||
:error-message "Cannot add anything to the Library collection"}
|
||||
(defn- library-content-spec
|
||||
"A spec for what contents `parent` permits, or `nil` if `parent` is not a library collection at all.
|
||||
|
||||
collection/library-data-collection-type
|
||||
{:allowed-content-types #{:table collection/library-data-collection-type}
|
||||
:error-message "Can only add tables to the 'Data' collection"}
|
||||
The Library root only holds folders; a generic (user-created) library folder holds published tables, metrics, and
|
||||
more folders; the seeded 'Data' and 'Metrics' trees stay single-purpose."
|
||||
[{parent-type :type :as parent}]
|
||||
(condp = parent-type
|
||||
collection/library-collection-type
|
||||
(if (collection/library-root-collection? parent)
|
||||
{:allowed-content-types #{collection/library-collection-type
|
||||
collection/library-data-collection-type
|
||||
collection/library-metrics-collection-type}
|
||||
:error-message "Only folders can be added to the Library"}
|
||||
{:allowed-content-types #{:table :metric collection/library-collection-type}
|
||||
:error-message "Can only add tables, metrics, and folders to a Library folder"})
|
||||
|
||||
collection/library-metrics-collection-type
|
||||
{:allowed-content-types #{:metric collection/library-metrics-collection-type}
|
||||
:error-message "Can only add metrics to the 'Metrics' collection"}})
|
||||
collection/library-data-collection-type
|
||||
{:allowed-content-types #{:table collection/library-data-collection-type}
|
||||
:error-message "Can only add tables to the 'Data' collection"}
|
||||
|
||||
collection/library-metrics-collection-type
|
||||
{:allowed-content-types #{:metric collection/library-metrics-collection-type}
|
||||
:error-message "Can only add metrics to the 'Metrics' collection"}
|
||||
|
||||
nil))
|
||||
|
||||
(defenterprise check-allowed-content
|
||||
"Check if the collection's content matches the allowed content.
|
||||
@@ -27,13 +38,13 @@
|
||||
:feature :library
|
||||
[content-type collection-id]
|
||||
(when collection-id
|
||||
(let [collection-type (library.db/collection-type collection-id)]
|
||||
(when-let [{:keys [allowed-content-types error-message]} (some-> collection-type
|
||||
library-collection-content-specs)]
|
||||
(let [parent (library.db/collection-type-and-entity-id collection-id)]
|
||||
(when-let [{:keys [allowed-content-types error-message]} (library-content-spec parent)]
|
||||
(when-not (allowed-content-types content-type)
|
||||
(throw (ex-info error-message {:status-code 400}))))
|
||||
(when (and (= content-type :table) (not= collection-type collection/library-data-collection-type))
|
||||
(throw (ex-info "Tables can only be added to 'Data' collections" {:status-code 400})))))
|
||||
(when (and (= content-type :table)
|
||||
(not (collection/can-contain-published-tables? parent)))
|
||||
(throw (ex-info "Tables can only be added to Library folders" {:status-code 400})))))
|
||||
true)
|
||||
|
||||
(defenterprise check-library-update
|
||||
@@ -49,6 +60,6 @@
|
||||
(when (and (collection/is-library-collection? (:id collection))
|
||||
(contains? change-keys :location)
|
||||
(when-let [parent-id (collection/location-path->parent-id (:location collection))]
|
||||
(not= (:type collection) (library.db/collection-type parent-id))))
|
||||
(not= (:type collection) (:type (library.db/collection-type-and-entity-id parent-id)))))
|
||||
(throw (ex-info "Cannot move a Library collection outside the Library" {}))))
|
||||
true)
|
||||
|
||||
@@ -101,9 +101,9 @@
|
||||
(testing "returns 400 when collection_id is missing"
|
||||
(mt/user-http-request :crowberto :post 400 "ee/data-studio/table/publish-tables"
|
||||
{:table_ids [(mt/id :users)]}))
|
||||
(testing "returns 400 when target collection is not a library-data collection"
|
||||
(testing "returns 400 when target collection cannot hold published tables"
|
||||
(mt/with-temp [:model/Collection {collection-id :id} {}]
|
||||
(is (= "Tables can only be published to Library/Data collections."
|
||||
(is (= "Tables can only be published to Library/Data collections or Library folders."
|
||||
(mt/user-http-request :crowberto :post 400 "ee/data-studio/table/publish-tables"
|
||||
{:table_ids [(mt/id :users)]
|
||||
:collection_id collection-id})))))
|
||||
|
||||
@@ -182,7 +182,8 @@
|
||||
(mt/with-premium-features #{:library}
|
||||
(let [search-term (random-name)]
|
||||
(mt/with-temp
|
||||
[:model/Collection {lib-id :id} {:name "Library" :type collection/library-collection-type :location "/"}
|
||||
[:model/Collection {lib-id :id} {:name "Library" :type collection/library-collection-type :location "/"
|
||||
:entity_id collection/library-entity-id}
|
||||
:model/Collection {data-coll :id} {:name "Data"
|
||||
:type collection/library-data-collection-type
|
||||
:location (collection/location-path lib-id)}
|
||||
|
||||
@@ -14,18 +14,65 @@
|
||||
(is (some? (t2/insert! :model/Card (merge (mt/with-temp-defaults :model/Card) {:type :metric, :collection_id (:id regular-collection)}))))
|
||||
(is (some? (t2/insert! :model/Dashboard (merge (mt/with-temp-defaults :model/Dashboard) {:collection_id (:id regular-collection)}))))))))
|
||||
|
||||
(deftest library-completely-locked-down
|
||||
(defn- library-root-defaults
|
||||
"Temp-collection properties that make a collection look like the seeded Library root: the hard-coded
|
||||
`entity_id` is what [[collection/library-root-collection?]] keys off."
|
||||
[]
|
||||
{:name "Test Library"
|
||||
:type collection/library-collection-type
|
||||
:entity_id collection/library-entity-id})
|
||||
|
||||
(defn- collection-under
|
||||
"Fresh `:model/Collection` insert properties for a child of `parent`, with `:type` set to `folder-type`."
|
||||
[parent folder-type]
|
||||
(merge (mt/with-temp-defaults :model/Collection)
|
||||
{:location (str (:location parent) (:id parent) "/")
|
||||
:type folder-type}))
|
||||
|
||||
(deftest library-root-holds-only-folders
|
||||
(mt/with-premium-features #{:library}
|
||||
(mt/with-temp [:model/Collection no-allowed-content {:name "Test No Content" :type collection/library-collection-type}]
|
||||
(testing "Cannot add anything to library collections"
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Cannot add anything to the Library collection"
|
||||
(t2/insert! :model/Collection (merge (mt/with-temp-defaults :model/Collection) {:location (str "/" (:id no-allowed-content) "/")}))))
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Cannot add anything to the Library collection"
|
||||
(t2/insert! :model/Card (merge (mt/with-temp-defaults :model/Card) {:type :model, :collection_id (:id no-allowed-content)}))))
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Cannot add anything to the Library collection"
|
||||
(t2/insert! :model/Card (merge (mt/with-temp-defaults :model/Card) {:type :metric :collection_id (:id no-allowed-content)}))))
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Cannot add anything to the Library collection"
|
||||
(t2/insert! :model/Dashboard (merge (mt/with-temp-defaults :model/Dashboard) {:collection_id (:id no-allowed-content)}))))))))
|
||||
(mt/with-temp [:model/Collection library (library-root-defaults)]
|
||||
(testing "folders of every library type can be created at the top level of the Library"
|
||||
(doseq [folder-type [collection/library-collection-type
|
||||
collection/library-data-collection-type
|
||||
collection/library-metrics-collection-type]]
|
||||
(testing (str "type " folder-type)
|
||||
(is (= 1 (t2/insert! :model/Collection (collection-under library folder-type)))))))
|
||||
(testing "the Library root rejects leaf content and untyped collections"
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Only folders can be added to the Library"
|
||||
(t2/insert! :model/Table (merge (mt/with-temp-defaults :model/Table)
|
||||
{:collection_id (:id library)
|
||||
:is_published true}))))
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Only folders can be added to the Library"
|
||||
(t2/insert! :model/Card (merge (mt/with-temp-defaults :model/Card) {:type :metric :collection_id (:id library)}))))
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Only folders can be added to the Library"
|
||||
(t2/insert! :model/Dashboard (merge (mt/with-temp-defaults :model/Dashboard) {:collection_id (:id library)}))))
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Only folders can be added to the Library"
|
||||
(t2/insert! :model/Collection (collection-under library nil))))))))
|
||||
|
||||
(deftest library-folder-holds-tables-metrics-and-folders
|
||||
(mt/with-premium-features #{:library}
|
||||
(mt/with-temp [:model/Collection library (library-root-defaults)
|
||||
:model/Collection folder {:name "My Folder"
|
||||
:type collection/library-collection-type
|
||||
:location (str "/" (:id library) "/")}]
|
||||
(testing "a Library folder holds published tables"
|
||||
(mt/with-temp [:model/Table table {:collection_id (:id folder)
|
||||
:is_published true}]
|
||||
(is (some? table))))
|
||||
(testing "a Library folder holds metrics"
|
||||
(mt/with-temp [:model/Card metric {:collection_id (:id folder)
|
||||
:type :metric}]
|
||||
(is (some? metric))))
|
||||
(testing "a Library folder holds nested Library folders"
|
||||
(is (= 1 (t2/insert! :model/Collection (collection-under folder collection/library-collection-type)))))
|
||||
(testing "a Library folder rejects everything else"
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Can only add tables, metrics, and folders to a Library folder"
|
||||
(t2/insert! :model/Card (merge (mt/with-temp-defaults :model/Card) {:type :model :collection_id (:id folder)}))))
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Can only add tables, metrics, and folders to a Library folder"
|
||||
(t2/insert! :model/Dashboard (merge (mt/with-temp-defaults :model/Dashboard) {:collection_id (:id folder)}))))
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Can only add tables, metrics, and folders to a Library folder"
|
||||
(t2/insert! :model/Collection (collection-under folder nil))))))))
|
||||
|
||||
(deftest check-allowed-content-table
|
||||
(mt/with-premium-features #{:library}
|
||||
@@ -185,7 +232,7 @@
|
||||
|
||||
(deftest cannot-move-library-collections-to-vanilla-collection-test
|
||||
(mt/with-premium-features #{:library}
|
||||
(mt/with-temp [:model/Collection library-root {:name "Library" :type collection/library-collection-type}
|
||||
(mt/with-temp [:model/Collection library-root (library-root-defaults)
|
||||
:model/Collection data-root {:name "Data" :type collection/library-data-collection-type
|
||||
:location (str "/" (:id library-root) "/")}
|
||||
:model/Collection metrics-root {:name "Metrics" :type collection/library-metrics-collection-type
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { Collection, CollectionId } from "metabase-types/api";
|
||||
import type { CollectionId } from "metabase-types/api";
|
||||
|
||||
export const findCollectionById = (
|
||||
collections: Collection[],
|
||||
export const findCollectionById = <
|
||||
T extends { id: CollectionId; children?: T[] },
|
||||
>(
|
||||
collections: T[],
|
||||
collectionId: CollectionId,
|
||||
): Collection | null => {
|
||||
): T | null => {
|
||||
if (!collections || collections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -14,11 +14,6 @@ export const REGULAR_COLLECTION: CollectionAuthorityLevelConfig = {
|
||||
icon: "folder",
|
||||
};
|
||||
|
||||
export const REMOTE_SYNC_COLLECTION: CollectionInstanceAnaltyicsConfig = {
|
||||
type: null,
|
||||
icon: "synced_collection",
|
||||
};
|
||||
|
||||
export const OFFICIAL_COLLECTION: CollectionAuthorityLevelConfig = {
|
||||
type: "official",
|
||||
get name() {
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
filterOutItemsFromInstanceAnalytics,
|
||||
getCollectionType,
|
||||
isRegularCollection,
|
||||
isSyncedCollection,
|
||||
useGetIcon,
|
||||
} from "./utils";
|
||||
|
||||
@@ -34,10 +33,6 @@ import {
|
||||
* Initialize collections plugin features that depend on hasPremiumFeature.
|
||||
*/
|
||||
export function initializePlugin() {
|
||||
if (hasPremiumFeature("remote_sync")) {
|
||||
PLUGIN_COLLECTIONS.isSyncedCollection = isSyncedCollection;
|
||||
}
|
||||
|
||||
if (hasPremiumFeature("official_collections")) {
|
||||
PLUGIN_COLLECTIONS.isRegularCollection = isRegularCollection;
|
||||
PLUGIN_COLLECTIONS.REGULAR_COLLECTION = REGULAR_COLLECTION;
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
INSTANCE_ANALYTICS_COLLECTION,
|
||||
OFFICIAL_COLLECTION,
|
||||
REGULAR_COLLECTION,
|
||||
REMOTE_SYNC_COLLECTION,
|
||||
} from "./constants";
|
||||
|
||||
export function isRegularCollection({
|
||||
@@ -52,20 +51,11 @@ export function isInstanceAnalyticsCollection(
|
||||
);
|
||||
}
|
||||
|
||||
export function isSyncedCollection(
|
||||
collection: Pick<Collection, "is_remote_synced">,
|
||||
): boolean {
|
||||
return collection.is_remote_synced === true;
|
||||
}
|
||||
|
||||
export const useGetIcon = () => {
|
||||
const getIconBase = useGetIconBase();
|
||||
|
||||
return useCallback(
|
||||
(
|
||||
item: ObjectWithModel,
|
||||
{ isTenantUser = false }: { isTenantUser?: boolean } = {},
|
||||
): IconData => {
|
||||
(item: ObjectWithModel): IconData => {
|
||||
const collectionType = getCollectionType({
|
||||
// Unjustified type cast. FIXME
|
||||
type: (item.type as CollectionType) || item.collection_type,
|
||||
@@ -77,18 +67,11 @@ export const useGetIcon = () => {
|
||||
}
|
||||
|
||||
if (item.model === "collection") {
|
||||
// Library collections keep their special icon regardless of sync status
|
||||
// Library collections keep their special icon regardless of authority level
|
||||
if (PLUGIN_LIBRARY.isLibraryCollectionType(item.type)) {
|
||||
return getIconBase(item);
|
||||
}
|
||||
|
||||
// tenant users see the normal icon, they don't know what a synced collection is
|
||||
if (item.is_remote_synced && !isTenantUser) {
|
||||
return {
|
||||
name: REMOTE_SYNC_COLLECTION.icon,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
item.authority_level === "official" ||
|
||||
item.collection_authority_level === "official"
|
||||
|
||||
@@ -109,20 +109,6 @@ describe("Collections plugin utils", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should return the correct icon for a remote synced collection", () => {
|
||||
const getIcon = createGetIcon();
|
||||
expect(
|
||||
getIcon({ model: "collection", is_remote_synced: true }),
|
||||
).toEqual({ name: "synced_collection" });
|
||||
});
|
||||
|
||||
it("should return the correct icon for a remote synced entity", () => {
|
||||
const getIcon = createGetIcon();
|
||||
expect(getIcon({ model: "dashboard", is_remote_synced: true })).toEqual(
|
||||
{ name: "dashboard" },
|
||||
);
|
||||
});
|
||||
|
||||
it("official collection in search", () => {
|
||||
const collection = {
|
||||
id: 101,
|
||||
|
||||
@@ -62,8 +62,7 @@ function LibraryPageContent() {
|
||||
isChildrenLoading,
|
||||
isLoading,
|
||||
emptyMessage,
|
||||
refreshTableCollections,
|
||||
refreshMetricCollections,
|
||||
refreshCollections,
|
||||
} = useLibraryTreeTableInstance({
|
||||
collections,
|
||||
isLoadingCollections,
|
||||
@@ -102,16 +101,13 @@ function LibraryPageContent() {
|
||||
.otherwise(() => undefined);
|
||||
|
||||
const handleActionComplete = useCallback(
|
||||
(section: LibrarySection, affectedCollectionIds: CollectionId[]) => {
|
||||
if (section === "data") {
|
||||
refreshTableCollections(affectedCollectionIds);
|
||||
} else if (section === "metrics") {
|
||||
refreshMetricCollections(affectedCollectionIds);
|
||||
}
|
||||
// Snippet sections refetch via RTK tag invalidation.
|
||||
(_section: LibrarySection, affectedCollectionIds: CollectionId[]) => {
|
||||
// Snippet sections refetch via RTK tag invalidation; library rows are refreshed explicitly
|
||||
// because their children are held in a local lazy-load cache.
|
||||
refreshCollections(affectedCollectionIds);
|
||||
clearSelection();
|
||||
},
|
||||
[refreshTableCollections, refreshMetricCollections, clearSelection],
|
||||
[refreshCollections, clearSelection],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -145,6 +141,8 @@ function LibraryPageContent() {
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<CreateMenu
|
||||
libraryCollectionId={libraryCollection?.id}
|
||||
canWriteToLibrary={!!libraryCollection?.can_write}
|
||||
metricCollectionId={writableMetricCollection?.id}
|
||||
canWriteToMetricCollection={!!writableMetricCollection}
|
||||
dataCollectionId={tableCollection?.id}
|
||||
|
||||
@@ -14,12 +14,11 @@ import { RootSnippetsCollectionMenu } from "./RootSnippetsCollectionMenu";
|
||||
|
||||
type ActionCellProps = {
|
||||
treeItem: TreeItem;
|
||||
refreshTableCollections: (collectionIds: CollectionId[]) => void;
|
||||
refreshMetricCollections: (collectionIds: CollectionId[]) => void;
|
||||
refreshCollections: (collectionIds: CollectionId[]) => void;
|
||||
};
|
||||
|
||||
export function ActionCell(props: ActionCellProps) {
|
||||
const { treeItem, refreshTableCollections, refreshMetricCollections } = props;
|
||||
const { treeItem, refreshCollections } = props;
|
||||
const { data, children } = treeItem;
|
||||
|
||||
if (isEmptyStateData(data)) {
|
||||
@@ -27,7 +26,7 @@ export function ActionCell(props: ActionCellProps) {
|
||||
}
|
||||
|
||||
if (isTableData(data)) {
|
||||
return <TableMoreMenu table={data} onMoved={refreshTableCollections} />;
|
||||
return <TableMoreMenu table={data} onMoved={refreshCollections} />;
|
||||
}
|
||||
|
||||
if (!isCollectionData(data) || data.model !== "collection") {
|
||||
@@ -44,17 +43,14 @@ export function ActionCell(props: ActionCellProps) {
|
||||
return <CollectionRowMenu collection={data} />;
|
||||
}
|
||||
|
||||
const isLibraryCollection =
|
||||
PLUGIN_LIBRARY.isLibrarySubCollectionType(data.type) &&
|
||||
!data.is_library_root;
|
||||
|
||||
if (isLibraryCollection) {
|
||||
// Includes the seeded Data/Metrics roots: they can't be renamed or archived, but they can still
|
||||
// take a new subfolder, a custom icon and permission changes.
|
||||
if (PLUGIN_LIBRARY.isLibraryCollectionType(data.type)) {
|
||||
return (
|
||||
<LibraryCollectionRowMenu
|
||||
childCount={children?.length ?? 0}
|
||||
collection={data}
|
||||
refreshMetricCollections={refreshMetricCollections}
|
||||
refreshTableCollections={refreshTableCollections}
|
||||
refreshCollections={refreshCollections}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,11 +19,15 @@ import type { CollectionId, CollectionNamespace } from "metabase-types/api";
|
||||
import { PublishTableModal } from "./PublishTableModal";
|
||||
|
||||
export const CreateMenu = ({
|
||||
libraryCollectionId,
|
||||
canWriteToLibrary,
|
||||
metricCollectionId,
|
||||
canWriteToMetricCollection,
|
||||
dataCollectionId,
|
||||
canWriteToDataCollection,
|
||||
}: {
|
||||
libraryCollectionId?: CollectionId;
|
||||
canWriteToLibrary?: boolean;
|
||||
metricCollectionId?: CollectionId;
|
||||
canWriteToMetricCollection?: boolean;
|
||||
dataCollectionId?: CollectionId;
|
||||
@@ -47,7 +51,10 @@ export const CreateMenu = ({
|
||||
const canCreateMetric =
|
||||
hasDataAccess && metricCollectionId && canWriteToMetricCollection;
|
||||
|
||||
const canCreateLibraryFolder = !!(libraryCollectionId && canWriteToLibrary);
|
||||
|
||||
const canCreateCollection =
|
||||
canCreateLibraryFolder ||
|
||||
(dataCollectionId && canWriteToDataCollection) ||
|
||||
(metricCollectionId && canWriteToMetricCollection) ||
|
||||
(hasNativeWrite && PLUGIN_SNIPPET_FOLDERS.isEnabled);
|
||||
@@ -55,6 +62,7 @@ export const CreateMenu = ({
|
||||
const collectionNamespaces: CollectionNamespace[] = [];
|
||||
|
||||
if (
|
||||
canCreateLibraryFolder ||
|
||||
(dataCollectionId && canWriteToDataCollection) ||
|
||||
(metricCollectionId && canWriteToMetricCollection)
|
||||
) {
|
||||
@@ -65,7 +73,9 @@ export const CreateMenu = ({
|
||||
collectionNamespaces.push("snippets");
|
||||
}
|
||||
|
||||
// Default to a new top-level folder in the Library; the picker lets you nest it instead.
|
||||
const initialCollectionId =
|
||||
(canCreateLibraryFolder && libraryCollectionId) ||
|
||||
(dataCollectionId && canWriteToDataCollection && dataCollectionId) ||
|
||||
(metricCollectionId && canWriteToMetricCollection && metricCollectionId) ||
|
||||
null;
|
||||
@@ -105,23 +115,24 @@ export const CreateMenu = ({
|
||||
canCreateCollection && (
|
||||
<Menu.Item
|
||||
key="collection"
|
||||
leftSection={<FixedSizeIcon name="folder" />}
|
||||
leftSection={<FixedSizeIcon name="new_folder" />}
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
setOpenModalWithProps({
|
||||
id: "collection",
|
||||
props: {
|
||||
title: t`New folder`,
|
||||
initialCollectionId,
|
||||
namespaces: collectionNamespaces,
|
||||
pickerOptions: LIBRARY_COLLECTION_PICKER_OPTIONS,
|
||||
showAuthorityLevelPicker: false,
|
||||
inDataStudio: true,
|
||||
showIconPicker: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t`Collection`}
|
||||
{t`Folder`}
|
||||
</Menu.Item>
|
||||
),
|
||||
].filter(Boolean);
|
||||
|
||||
@@ -15,6 +15,8 @@ import { CreateMenu } from "./CreateMenu";
|
||||
|
||||
interface SetupOptions {
|
||||
user?: Partial<User>;
|
||||
libraryCollectionId?: number;
|
||||
canWriteToLibrary?: boolean;
|
||||
dataCollectionId?: number;
|
||||
canWriteToDataCollection?: boolean;
|
||||
canWriteToMetricCollection?: boolean;
|
||||
@@ -31,6 +33,8 @@ const fullPermissionsUser: Partial<User> = {
|
||||
|
||||
const setup = ({
|
||||
user,
|
||||
libraryCollectionId = 7,
|
||||
canWriteToLibrary = true,
|
||||
dataCollectionId = 2,
|
||||
canWriteToDataCollection = true,
|
||||
canWriteToMetricCollection = true,
|
||||
@@ -50,6 +54,8 @@ const setup = ({
|
||||
setupEnterprisePlugins();
|
||||
const utils = renderWithProviders(
|
||||
<CreateMenu
|
||||
libraryCollectionId={libraryCollectionId}
|
||||
canWriteToLibrary={canWriteToLibrary}
|
||||
metricCollectionId={1}
|
||||
dataCollectionId={dataCollectionId}
|
||||
canWriteToDataCollection={canWriteToDataCollection}
|
||||
@@ -71,17 +77,17 @@ describe("CreateMenu", () => {
|
||||
|
||||
expect(
|
||||
screen.getAllByRole("menuitem").map((item) => item.textContent),
|
||||
).toEqual(["Published table", "Metric", "Snippet", "Collection"]);
|
||||
).toEqual(["Published table", "Metric", "Snippet", "Folder"]);
|
||||
});
|
||||
|
||||
it("renders publish and collection options for data analysts", async () => {
|
||||
it("renders publish and folder options for data analysts", async () => {
|
||||
setup({ user: { is_data_analyst: true } });
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /New/ }));
|
||||
|
||||
expect(
|
||||
screen.getAllByRole("menuitem").map((item) => item.textContent),
|
||||
).toEqual(["Published table", "Collection"]);
|
||||
).toEqual(["Published table", "Folder"]);
|
||||
});
|
||||
|
||||
it("renders publish and metric options if user only has query builder access", async () => {
|
||||
@@ -91,7 +97,7 @@ describe("CreateMenu", () => {
|
||||
|
||||
expect(
|
||||
screen.getAllByRole("menuitem").map((item) => item.textContent),
|
||||
).toEqual(["Published table", "Metric", "Collection"]);
|
||||
).toEqual(["Published table", "Metric", "Folder"]);
|
||||
});
|
||||
|
||||
it("does not render Metric option when canWriteToMetricCollection is false", async () => {
|
||||
@@ -110,10 +116,10 @@ describe("CreateMenu", () => {
|
||||
|
||||
expect(
|
||||
screen.getAllByRole("menuitem").map((item) => item.textContent),
|
||||
).toEqual(["Published table", "Snippet", "Collection"]);
|
||||
).toEqual(["Published table", "Snippet", "Folder"]);
|
||||
});
|
||||
|
||||
it("renders Collection option when only Data collection is writable", async () => {
|
||||
it("renders Folder option when only Data collection is writable", async () => {
|
||||
setup({
|
||||
user: { is_data_analyst: true },
|
||||
canWriteToDataCollection: true,
|
||||
@@ -124,12 +130,13 @@ describe("CreateMenu", () => {
|
||||
|
||||
expect(
|
||||
screen.getAllByRole("menuitem").map((item) => item.textContent),
|
||||
).toEqual(["Published table", "Collection"]);
|
||||
).toEqual(["Published table", "Folder"]);
|
||||
});
|
||||
|
||||
it("does not render Collection option without writable Library collections or native write", async () => {
|
||||
it("does not render Folder option without writable Library collections or native write", async () => {
|
||||
setup({
|
||||
user: {},
|
||||
canWriteToLibrary: false,
|
||||
canWriteToDataCollection: false,
|
||||
canWriteToMetricCollection: false,
|
||||
});
|
||||
@@ -141,20 +148,21 @@ describe("CreateMenu", () => {
|
||||
).toEqual(["Published table"]);
|
||||
});
|
||||
|
||||
it("opens the collection modal with Library and snippets picker options", async () => {
|
||||
it("defaults a new folder to the top level of the Library", async () => {
|
||||
const { store } = setup({
|
||||
user: fullPermissionsUser,
|
||||
libraryCollectionId: 7,
|
||||
dataCollectionId: 42,
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /New/ }));
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: /Collection/ }));
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: /Folder/ }));
|
||||
|
||||
expect(store.getState().modal).toEqual({
|
||||
id: "collection",
|
||||
props: {
|
||||
inDataStudio: true,
|
||||
initialCollectionId: 42,
|
||||
title: "New folder",
|
||||
initialCollectionId: 7,
|
||||
namespaces: [null, "snippets"],
|
||||
pickerOptions: {
|
||||
hasLibrary: true,
|
||||
@@ -166,19 +174,36 @@ describe("CreateMenu", () => {
|
||||
canCreateCollections: false,
|
||||
},
|
||||
showAuthorityLevelPicker: false,
|
||||
showIconPicker: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the Data collection when the Library root is not writable", async () => {
|
||||
const { store } = setup({
|
||||
user: fullPermissionsUser,
|
||||
canWriteToLibrary: false,
|
||||
dataCollectionId: 42,
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /New/ }));
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: /Folder/ }));
|
||||
|
||||
expect(store.getState().modal.props).toMatchObject({
|
||||
initialCollectionId: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the collection modal scoped to snippets when only native write is available", async () => {
|
||||
const { store } = setup({
|
||||
user: { permissions: { can_create_native_queries: true } },
|
||||
canWriteToLibrary: false,
|
||||
canWriteToDataCollection: false,
|
||||
canWriteToMetricCollection: false,
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /New/ }));
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: /Collection/ }));
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: /Folder/ }));
|
||||
|
||||
expect(store.getState().modal.props).toMatchObject({
|
||||
initialCollectionId: null,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useCallback } from "react";
|
||||
import { t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
import { CollectionRowMenu } from "metabase/common/collections/components/CollectionRowMenu";
|
||||
import CreateCollectionModal from "metabase/common/collections/containers/CreateCollectionModal";
|
||||
import type { Collection, CollectionId } from "metabase-types/api";
|
||||
|
||||
import { getArchiveLibraryCollectionsMessage } from "../utils";
|
||||
@@ -9,31 +12,16 @@ import { getArchiveLibraryCollectionsMessage } from "../utils";
|
||||
type LibraryCollectionRowMenuProps = {
|
||||
childCount: number;
|
||||
collection: Collection;
|
||||
refreshMetricCollections: (collectionIds: CollectionId[]) => void;
|
||||
refreshTableCollections: (collectionIds: CollectionId[]) => void;
|
||||
refreshCollections: (collectionIds: CollectionId[]) => void;
|
||||
};
|
||||
|
||||
export function LibraryCollectionRowMenu(props: LibraryCollectionRowMenuProps) {
|
||||
const {
|
||||
childCount,
|
||||
collection,
|
||||
refreshMetricCollections,
|
||||
refreshTableCollections,
|
||||
} = props;
|
||||
const { childCount, collection, refreshCollections } = props;
|
||||
const [isNewFolderModalOpen, { open: openNewFolder, close: closeNewFolder }] =
|
||||
useDisclosure(false);
|
||||
|
||||
const isLibraryDataCollection =
|
||||
collection.type === "library-data" && !collection.is_library_root;
|
||||
const refreshCollections = useCallback(
|
||||
(collectionIds: CollectionId[]) => {
|
||||
if (collection.type === "library-metrics") {
|
||||
refreshMetricCollections(collectionIds);
|
||||
}
|
||||
|
||||
if (collection.type === "library-data") {
|
||||
refreshTableCollections(collectionIds);
|
||||
}
|
||||
},
|
||||
[refreshMetricCollections, refreshTableCollections, collection.type],
|
||||
);
|
||||
|
||||
const onArchiveSuccess = useCallback(() => {
|
||||
const parentId = getParentCollectionId(collection);
|
||||
@@ -45,19 +33,38 @@ export function LibraryCollectionRowMenu(props: LibraryCollectionRowMenuProps) {
|
||||
refreshCollections([parentId]);
|
||||
}, [collection, refreshCollections]);
|
||||
|
||||
const onFolderCreated = useCallback(() => {
|
||||
closeNewFolder();
|
||||
refreshCollections([collection.id]);
|
||||
}, [closeNewFolder, collection.id, refreshCollections]);
|
||||
|
||||
return (
|
||||
<CollectionRowMenu
|
||||
collection={collection}
|
||||
onSave={(details) => {
|
||||
refreshCollections(getAffectedCollectionIds(details));
|
||||
}}
|
||||
customArchiveMessage={
|
||||
isLibraryDataCollection && childCount > 0
|
||||
? getArchiveLibraryCollectionsMessage(1)
|
||||
: undefined
|
||||
}
|
||||
onArchiveSuccess={onArchiveSuccess}
|
||||
/>
|
||||
<>
|
||||
<CollectionRowMenu
|
||||
collection={collection}
|
||||
onNewFolder={openNewFolder}
|
||||
onSave={(details) => {
|
||||
refreshCollections(getAffectedCollectionIds(details));
|
||||
}}
|
||||
customArchiveMessage={
|
||||
isLibraryDataCollection && childCount > 0
|
||||
? getArchiveLibraryCollectionsMessage(1)
|
||||
: undefined
|
||||
}
|
||||
onArchiveSuccess={onArchiveSuccess}
|
||||
/>
|
||||
{isNewFolderModalOpen && (
|
||||
<CreateCollectionModal
|
||||
title={t`New folder`}
|
||||
initialCollectionId={collection.id}
|
||||
showCollectionPicker={false}
|
||||
showAuthorityLevelPicker={false}
|
||||
showIconPicker
|
||||
onCreate={onFolderCreated}
|
||||
onClose={closeNewFolder}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import { setupEnterpriseOnlyPlugin } from "__support__/enterprise";
|
||||
import {
|
||||
setupCollectionByIdEndpoint,
|
||||
setupCreateCollectionEndpoint,
|
||||
setupUpdateCollectionEndpoint,
|
||||
} from "__support__/server-mocks";
|
||||
import { mockSettings } from "__support__/settings";
|
||||
@@ -25,8 +27,7 @@ function setup({
|
||||
}),
|
||||
childCount = 0,
|
||||
}: Partial<Parameters<typeof LibraryCollectionRowMenu>[0]> = {}) {
|
||||
const refreshMetricCollections = jest.fn();
|
||||
const refreshTableCollections = jest.fn();
|
||||
const refreshCollections = jest.fn();
|
||||
const parentCollection = createMockCollection({
|
||||
id: 22,
|
||||
name: "Data",
|
||||
@@ -36,16 +37,19 @@ function setup({
|
||||
setupEnterpriseOnlyPlugin("library");
|
||||
setupEnterpriseOnlyPlugin("remote_sync");
|
||||
setupUpdateCollectionEndpoint(collection);
|
||||
setupCollectionByIdEndpoint({ collections: [parentCollection] });
|
||||
setupCreateCollectionEndpoint(
|
||||
createMockCollection({ id: 99, name: "Finance", type: "library-data" }),
|
||||
);
|
||||
setupCollectionByIdEndpoint({ collections: [parentCollection, collection] });
|
||||
|
||||
renderWithProviders(
|
||||
<LibraryCollectionRowMenu
|
||||
childCount={childCount}
|
||||
collection={collection}
|
||||
refreshMetricCollections={refreshMetricCollections}
|
||||
refreshTableCollections={refreshTableCollections}
|
||||
refreshCollections={refreshCollections}
|
||||
/>,
|
||||
{
|
||||
withRouter: true,
|
||||
storeInitialState: createMockState({
|
||||
currentUser: createMockUser({ is_superuser: true }),
|
||||
settings: mockSettings({
|
||||
@@ -58,16 +62,20 @@ function setup({
|
||||
},
|
||||
);
|
||||
|
||||
return { refreshMetricCollections, refreshTableCollections };
|
||||
return { refreshCollections };
|
||||
}
|
||||
|
||||
async function openMenu() {
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Collection options" }),
|
||||
);
|
||||
}
|
||||
|
||||
describe("LibraryCollectionRowMenu", () => {
|
||||
it("refreshes table collections after saving a Library Data collection", async () => {
|
||||
const { refreshMetricCollections, refreshTableCollections } = setup();
|
||||
it("refreshes the parent collection after saving", async () => {
|
||||
const { refreshCollections } = setup();
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Collection options" }),
|
||||
);
|
||||
await openMenu();
|
||||
await userEvent.click(
|
||||
screen.getByRole("menuitem", { name: /Edit collection details/ }),
|
||||
);
|
||||
@@ -75,42 +83,36 @@ describe("LibraryCollectionRowMenu", () => {
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refreshTableCollections).toHaveBeenCalledWith([22]);
|
||||
expect(refreshCollections).toHaveBeenCalledWith([22]);
|
||||
});
|
||||
expect(refreshMetricCollections).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes metric collections after saving a Library Metrics collection", async () => {
|
||||
const { refreshMetricCollections, refreshTableCollections } = setup({
|
||||
collection: createMockCollection({
|
||||
id: 2,
|
||||
name: "Library Metrics Collection",
|
||||
type: "library-metrics",
|
||||
parent_id: 22,
|
||||
}),
|
||||
});
|
||||
it("creates a subfolder in the clicked collection and refreshes it", async () => {
|
||||
const { refreshCollections } = setup();
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Collection options" }),
|
||||
);
|
||||
await userEvent.click(
|
||||
screen.getByRole("menuitem", { name: /Edit collection details/ }),
|
||||
);
|
||||
await userEvent.type(screen.getByLabelText("Name"), " Updated");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
await openMenu();
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: /New folder/ }));
|
||||
await userEvent.type(screen.getByLabelText("Name"), "Finance");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refreshMetricCollections).toHaveBeenCalledWith([22]);
|
||||
expect(fetchMock.callHistory.called("create-collection")).toBe(true);
|
||||
});
|
||||
const call = fetchMock.callHistory.calls("create-collection").at(-1);
|
||||
expect(JSON.parse(String(call?.options?.body))).toMatchObject({
|
||||
name: "Finance",
|
||||
parent_id: 1,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refreshCollections).toHaveBeenCalledWith([1]);
|
||||
});
|
||||
expect(refreshTableCollections).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a table unpublish warning when archiving a non-empty Library Data collection", async () => {
|
||||
setup({ childCount: 1 });
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Collection options" }),
|
||||
);
|
||||
await openMenu();
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: /Archive/ }));
|
||||
|
||||
expect(
|
||||
@@ -119,4 +121,27 @@ describe("LibraryCollectionRowMenu", () => {
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not offer rename or archive on a seeded Library section, but still offers a new folder", async () => {
|
||||
setup({
|
||||
collection: createMockCollection({
|
||||
id: 22,
|
||||
name: "Data",
|
||||
type: "library-data",
|
||||
is_library_root: true,
|
||||
}),
|
||||
});
|
||||
|
||||
await openMenu();
|
||||
|
||||
expect(
|
||||
screen.getByRole("menuitem", { name: /New folder/ }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("menuitem", { name: /Edit collection details/ }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("menuitem", { name: /Archive/ }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
skipToken,
|
||||
useListCollectionItemsQuery,
|
||||
} from "metabase/api";
|
||||
import type { IconData, ObjectWithModel } from "metabase/common/utils/icon";
|
||||
import type {
|
||||
LibrarySectionType,
|
||||
TreeItem,
|
||||
@@ -23,24 +24,29 @@ import type {
|
||||
CollectionItem,
|
||||
} from "metabase-types/api";
|
||||
|
||||
const LIBRARY_ITEM_MODELS = ["metric", "table", "collection"] as const;
|
||||
|
||||
type GetIcon = (item: ObjectWithModel) => IconData;
|
||||
|
||||
/** Builds the whole Library subtree from the Library root collection. The root itself is not
|
||||
* rendered: its children — the seeded Data and Metrics folders plus any folders the user created —
|
||||
* become the top-level rows. Everything below them is loaded lazily when a row is expanded. */
|
||||
export function useLibraryCollectionTree(
|
||||
collection: Collection | undefined,
|
||||
sectionType: LibrarySectionType,
|
||||
metricCollectionId?: CollectionId,
|
||||
libraryCollection: Collection | undefined,
|
||||
) {
|
||||
const dispatch = useDispatch();
|
||||
const getIcon = useGetIcon();
|
||||
|
||||
// 1. Fetch top-level items
|
||||
// 1. Fetch the Library root's children
|
||||
const {
|
||||
data: topLevelItems,
|
||||
isLoading,
|
||||
error,
|
||||
} = useListCollectionItemsQuery(
|
||||
collection
|
||||
libraryCollection
|
||||
? {
|
||||
id: collection.id,
|
||||
models: ["metric", "table", "collection"],
|
||||
id: libraryCollection.id,
|
||||
models: [...LIBRARY_ITEM_MODELS],
|
||||
archived: false,
|
||||
}
|
||||
: skipToken,
|
||||
@@ -57,7 +63,7 @@ export function useLibraryCollectionTree(
|
||||
useEffect(() => {
|
||||
setLoadedCollections(new Map());
|
||||
loadingIds.current = new Set();
|
||||
}, [collection]);
|
||||
}, [libraryCollection]);
|
||||
|
||||
const loadCollectionItems = useCallback(
|
||||
async (collectionId: CollectionId) => {
|
||||
@@ -71,7 +77,7 @@ export function useLibraryCollectionTree(
|
||||
collectionApi.endpoints.listCollectionItems.initiate(
|
||||
{
|
||||
id: collectionId,
|
||||
models: ["metric", "table", "collection"],
|
||||
models: [...LIBRARY_ITEM_MODELS],
|
||||
archived: false,
|
||||
},
|
||||
{ forceRefetch: true },
|
||||
@@ -86,8 +92,7 @@ export function useLibraryCollectionTree(
|
||||
const refreshCollections = useCallback(
|
||||
async (collectionIds: CollectionId[]) => {
|
||||
for (const id of collectionIds) {
|
||||
const key = String(id);
|
||||
loadingIds.current.delete(key);
|
||||
loadingIds.current.delete(String(id));
|
||||
}
|
||||
await Promise.all(collectionIds.map(loadCollectionItems));
|
||||
},
|
||||
@@ -96,43 +101,24 @@ export function useLibraryCollectionTree(
|
||||
|
||||
// 3. Build tree
|
||||
const tree = useMemo((): TreeItem[] => {
|
||||
if (isLoading || !topLevelItems || !collection) {
|
||||
if (isLoading || !topLevelItems || !libraryCollection) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const children = buildChildren(
|
||||
topLevelItems.data,
|
||||
loadedCollections,
|
||||
getIcon,
|
||||
return sortTopLevelRows(
|
||||
buildChildren(
|
||||
topLevelItems.data,
|
||||
loadedCollections,
|
||||
getIcon,
|
||||
isRemoteSyncReadOnly,
|
||||
),
|
||||
);
|
||||
const hasItems = children.length > 0;
|
||||
|
||||
return [
|
||||
{
|
||||
name: collection.name,
|
||||
id: `collection:${collection.id}`,
|
||||
icon: getIcon({ ...collection, model: "collection" }).name,
|
||||
data: { ...collection, model: "collection" as const },
|
||||
model: "collection",
|
||||
children: hasItems
|
||||
? children
|
||||
: [
|
||||
createEmptyStateItem(
|
||||
sectionType,
|
||||
metricCollectionId,
|
||||
isRemoteSyncReadOnly,
|
||||
),
|
||||
],
|
||||
},
|
||||
];
|
||||
}, [
|
||||
isLoading,
|
||||
topLevelItems,
|
||||
collection,
|
||||
libraryCollection,
|
||||
loadedCollections,
|
||||
getIcon,
|
||||
sectionType,
|
||||
metricCollectionId,
|
||||
isRemoteSyncReadOnly,
|
||||
]);
|
||||
|
||||
@@ -183,7 +169,8 @@ export function useLibraryCollectionTree(
|
||||
function buildChildren(
|
||||
items: CollectionItem[],
|
||||
loadedCollections: Map<CollectionId, CollectionItem[]>,
|
||||
getIcon: ReturnType<typeof useGetIcon>,
|
||||
getIcon: GetIcon,
|
||||
isRemoteSyncReadOnly: boolean,
|
||||
): TreeItem[] {
|
||||
const visibleItems = items.filter((i) => !i.archived);
|
||||
const collections = visibleItems.filter((i) => i.model === "collection");
|
||||
@@ -195,16 +182,31 @@ function buildChildren(
|
||||
let children: TreeItem[] | undefined;
|
||||
|
||||
if (childItems !== undefined) {
|
||||
const built = buildChildren(childItems, loadedCollections, getIcon);
|
||||
const built = buildChildren(
|
||||
childItems,
|
||||
loadedCollections,
|
||||
getIcon,
|
||||
isRemoteSyncReadOnly,
|
||||
);
|
||||
children = built.length > 0 ? built : undefined;
|
||||
} else if (hasContent(col)) {
|
||||
children = [];
|
||||
}
|
||||
|
||||
// `children === undefined` means "resolved and empty" — either the fetch came back empty or
|
||||
// `here`/`below` told us there is nothing to fetch. The seeded Data/Metrics sections show a
|
||||
// pitch + call to action in that state instead of collapsing to a bare row.
|
||||
const sectionType = getSeededSectionType(col);
|
||||
if (children === undefined && sectionType != null) {
|
||||
children = [
|
||||
createEmptyStateItem(sectionType, col.id, isRemoteSyncReadOnly),
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
name: col.name,
|
||||
id: `collection:${col.id}`,
|
||||
icon: "folder",
|
||||
icon: getIcon({ ...col, model: "collection" }).name,
|
||||
data: col,
|
||||
model: "collection",
|
||||
children,
|
||||
@@ -215,10 +217,7 @@ function buildChildren(
|
||||
];
|
||||
}
|
||||
|
||||
function buildItemNode(
|
||||
item: CollectionItem,
|
||||
getIcon: ReturnType<typeof useGetIcon>,
|
||||
): TreeItem {
|
||||
function buildItemNode(item: CollectionItem, getIcon: GetIcon): TreeItem {
|
||||
return {
|
||||
name: item.name,
|
||||
updatedAt: item["last-edit-info"]?.timestamp,
|
||||
@@ -235,3 +234,40 @@ function hasContent(item: CollectionItem): boolean {
|
||||
(item.below != null && item.below.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
/** The `LibrarySectionType` of a seeded Library section, or null for user-created folders. */
|
||||
function getSeededSectionType(item: CollectionItem): LibrarySectionType | null {
|
||||
if (!item.is_library_root) {
|
||||
return null;
|
||||
}
|
||||
if (item.type === "library-data") {
|
||||
return "data";
|
||||
}
|
||||
if (item.type === "library-metrics") {
|
||||
return "metrics";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Sort rank of the seeded sections; everything the user created sorts after them. */
|
||||
const SEEDED_SECTION_RANK: Record<string, number> = {
|
||||
"library-data": 0,
|
||||
"library-metrics": 1,
|
||||
};
|
||||
const UNRANKED = 2;
|
||||
|
||||
/** Data and Metrics always lead; user-created top-level folders follow, alphabetically. */
|
||||
function sortTopLevelRows(rows: TreeItem[]): TreeItem[] {
|
||||
const rankOf = ({ data }: TreeItem) => {
|
||||
if (isEmptyStateData(data) || !("is_library_root" in data)) {
|
||||
return UNRANKED;
|
||||
}
|
||||
return data.is_library_root
|
||||
? (SEEDED_SECTION_RANK[String(data.type)] ?? UNRANKED)
|
||||
: UNRANKED;
|
||||
};
|
||||
|
||||
return [...rows].sort(
|
||||
(a, b) => rankOf(a) - rankOf(b) || a.name.localeCompare(b.name),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,25 +62,13 @@ export function useLibraryTreeTableInstance({
|
||||
useLibraryCollections(collections);
|
||||
|
||||
const {
|
||||
tree: tablesTree,
|
||||
isLoading: loadingTables,
|
||||
error: tablesError,
|
||||
watchRows: watchTableRows,
|
||||
isChildrenLoading: isTableChildrenLoading,
|
||||
refreshCollections: refreshTableCollections,
|
||||
} = useLibraryCollectionTree(tableCollection, "data");
|
||||
const {
|
||||
tree: metricsTree,
|
||||
isLoading: loadingMetrics,
|
||||
error: metricsError,
|
||||
watchRows: watchMetricRows,
|
||||
isChildrenLoading: isMetricChildrenLoading,
|
||||
refreshCollections: refreshMetricCollections,
|
||||
} = useLibraryCollectionTree(
|
||||
metricCollection,
|
||||
"metrics",
|
||||
metricCollection?.id,
|
||||
);
|
||||
tree: libraryTree,
|
||||
isLoading: loadingLibrary,
|
||||
error: libraryError,
|
||||
watchRows: watchLibraryRows,
|
||||
isChildrenLoading: isLibraryChildrenLoading,
|
||||
refreshCollections,
|
||||
} = useLibraryCollectionTree(libraryCollection);
|
||||
const {
|
||||
tree: snippetTree,
|
||||
isLoading: loadingSnippets,
|
||||
@@ -95,20 +83,16 @@ export function useLibraryTreeTableInstance({
|
||||
} = useLibrarySearch(searchQuery, libraryCollection?.id, snippetTree);
|
||||
|
||||
const combinedTree = useMemo(
|
||||
() =>
|
||||
isSearchActive
|
||||
? searchTree
|
||||
: [...tablesTree, ...metricsTree, ...snippetTree],
|
||||
[isSearchActive, searchTree, tablesTree, metricsTree, snippetTree],
|
||||
() => (isSearchActive ? searchTree : [...libraryTree, ...snippetTree]),
|
||||
[isSearchActive, searchTree, libraryTree, snippetTree],
|
||||
);
|
||||
|
||||
const isLoading =
|
||||
isLoadingCollections ||
|
||||
loadingTables ||
|
||||
loadingMetrics ||
|
||||
loadingLibrary ||
|
||||
loadingSnippets ||
|
||||
isSearchLoading;
|
||||
useErrorHandling(tablesError || metricsError || snippetsError);
|
||||
useErrorHandling(libraryError || snippetsError);
|
||||
|
||||
const libraryHasContent = useMemo(
|
||||
() =>
|
||||
@@ -213,18 +197,12 @@ export function useLibraryTreeTableInstance({
|
||||
cell: ({ row }) => (
|
||||
<ActionCell
|
||||
treeItem={row.original}
|
||||
refreshMetricCollections={refreshMetricCollections}
|
||||
refreshTableCollections={refreshTableCollections}
|
||||
refreshCollections={refreshCollections}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
isRemoteSyncReadOnly,
|
||||
onPublishTableClick,
|
||||
refreshMetricCollections,
|
||||
refreshTableCollections,
|
||||
],
|
||||
[isRemoteSyncReadOnly, onPublishTableClick, refreshCollections],
|
||||
);
|
||||
|
||||
const snippetRootId = snippetTree[0]?.id;
|
||||
@@ -315,15 +293,8 @@ export function useLibraryTreeTableInstance({
|
||||
|
||||
// Lazy-load subcollection items when expanded
|
||||
useEffect(() => {
|
||||
watchTableRows(treeTableInstance.rows);
|
||||
watchMetricRows(treeTableInstance.rows);
|
||||
}, [treeTableInstance.rows, watchTableRows, watchMetricRows]);
|
||||
|
||||
const isChildrenLoading = useCallback(
|
||||
(row: Parameters<typeof isTableChildrenLoading>[0]) =>
|
||||
isTableChildrenLoading(row) || isMetricChildrenLoading(row),
|
||||
[isTableChildrenLoading, isMetricChildrenLoading],
|
||||
);
|
||||
watchLibraryRows(treeTableInstance.rows);
|
||||
}, [treeTableInstance.rows, watchLibraryRows]);
|
||||
|
||||
let emptyMessage = null;
|
||||
if (!libraryHasContent) {
|
||||
@@ -337,10 +308,9 @@ export function useLibraryTreeTableInstance({
|
||||
return {
|
||||
treeTableInstance,
|
||||
allRows,
|
||||
isChildrenLoading,
|
||||
isChildrenLoading: isLibraryChildrenLoading,
|
||||
isLoading,
|
||||
emptyMessage,
|
||||
refreshTableCollections,
|
||||
refreshMetricCollections,
|
||||
refreshCollections,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ export function DataStudioMetricAboutPage() {
|
||||
urls={dataStudioMetricUrls}
|
||||
showAppSwitcher
|
||||
showDataStudioLink={false}
|
||||
isInlineEditable
|
||||
showManagementPanels
|
||||
titleSize="h3"
|
||||
renderBreadcrumbs={(card) => <DataStudioMetricBreadcrumbs card={card} />}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ export function DataStudioMetricDependenciesPage() {
|
||||
urls={dataStudioMetricUrls}
|
||||
showAppSwitcher
|
||||
showDataStudioLink={false}
|
||||
isInlineEditable
|
||||
renderBreadcrumbs={(card) => <DataStudioMetricBreadcrumbs card={card} />}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ export function DataStudioMetricDimensionsPage() {
|
||||
urls={dataStudioMetricUrls}
|
||||
showAppSwitcher
|
||||
showDataStudioLink={false}
|
||||
isInlineEditable
|
||||
renderBreadcrumbs={(card) => <DataStudioMetricBreadcrumbs card={card} />}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ export function DataStudioMetricHistoryPage() {
|
||||
urls={dataStudioMetricUrls}
|
||||
showAppSwitcher
|
||||
showDataStudioLink={false}
|
||||
isInlineEditable
|
||||
renderBreadcrumbs={(card) => <DataStudioMetricBreadcrumbs card={card} />}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ export function DataStudioMetricOverviewPage() {
|
||||
urls={dataStudioMetricUrls}
|
||||
showAppSwitcher
|
||||
showDataStudioLink={false}
|
||||
isInlineEditable
|
||||
renderBreadcrumbs={(card) => <DataStudioMetricBreadcrumbs card={card} />}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ export function DataStudioMetricQueryPage() {
|
||||
urls={dataStudioMetricUrls}
|
||||
showAppSwitcher
|
||||
showDataStudioLink={false}
|
||||
isInlineEditable
|
||||
renderBreadcrumbs={(card) => <DataStudioMetricBreadcrumbs card={card} />}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import { allCollectionModels } from "metabase/common/components/Pickers/utils";
|
||||
import type {
|
||||
GetEntityPickerSyntheticLibraryItemFunction,
|
||||
LibraryCollectionType,
|
||||
LibrarySubCollectionType,
|
||||
} from "metabase/plugins/oss/library";
|
||||
import { useGetLibraryCollectionQuery } from "metabase-enterprise/api";
|
||||
@@ -128,7 +129,11 @@ export function getCollectionPickerItems({
|
||||
parentItem: OmniPickerItem;
|
||||
items: CollectionItem[];
|
||||
}): OmniPickerItem[] | undefined {
|
||||
if (parentItem.model !== "collection" || parentItem.type !== "library") {
|
||||
if (
|
||||
parentItem.model !== "collection" ||
|
||||
parentItem.type !== "library" ||
|
||||
!parentItem.is_library_root
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -137,17 +142,17 @@ export function getCollectionPickerItems({
|
||||
"library-metrics",
|
||||
];
|
||||
|
||||
return librarySubCollectionType.flatMap((type) => {
|
||||
const sectionItems = items.filter((item) =>
|
||||
const sectionItems = librarySubCollectionType.flatMap((type) => {
|
||||
const typedItems = items.filter((item) =>
|
||||
isLibrarySectionCollectionItem(item, type),
|
||||
);
|
||||
|
||||
const realRoot = sectionItems.find((item) => item.is_library_root);
|
||||
const realRoot = typedItems.find((item) => item.is_library_root);
|
||||
if (realRoot) {
|
||||
return [realRoot];
|
||||
}
|
||||
|
||||
if (sectionItems.length > 0) {
|
||||
if (typedItems.length > 0) {
|
||||
const syntheticItem = getEntityPickerSyntheticLibraryItem({
|
||||
collectionId: parentItem.id,
|
||||
type,
|
||||
@@ -158,6 +163,14 @@ export function getCollectionPickerItems({
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
// Folders the user created at the top level of the Library are plain `library` collections and
|
||||
// pass through untouched; the two seeded sections above are the only rewritten rows.
|
||||
const userFolders = items.filter(
|
||||
(item) => item.model === "collection" && item.type === "library",
|
||||
);
|
||||
|
||||
return [...sectionItems, ...userFolders];
|
||||
}
|
||||
|
||||
function isLibrarySectionCollectionItem(
|
||||
@@ -213,6 +226,6 @@ export const isLibraryDataCollectionType = (
|
||||
|
||||
export const isLibraryCollectionType = (
|
||||
type?: string | null,
|
||||
): type is LibrarySubCollectionType => {
|
||||
): type is LibraryCollectionType => {
|
||||
return isLibrarySubCollectionType(type) || type === "library";
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ const libraryParentItem: OmniPickerItem = {
|
||||
name: "Library",
|
||||
model: "collection",
|
||||
type: "library",
|
||||
is_library_root: true,
|
||||
};
|
||||
|
||||
describe("getCollectionPickerItems", () => {
|
||||
@@ -76,6 +77,52 @@ describe("getCollectionPickerItems", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("should pass through folders the user created at the top level", () => {
|
||||
const dataRoot = createMockCollectionItem({
|
||||
id: 2,
|
||||
name: "Data",
|
||||
model: "collection",
|
||||
type: "library-data",
|
||||
is_library_root: true,
|
||||
});
|
||||
const userFolder = createMockCollectionItem({
|
||||
id: 8,
|
||||
name: "Finance",
|
||||
model: "collection",
|
||||
type: "library",
|
||||
});
|
||||
|
||||
expect(
|
||||
getCollectionPickerItems({
|
||||
parentItem: libraryParentItem,
|
||||
items: [dataRoot, userFolder],
|
||||
}),
|
||||
).toEqual([dataRoot, userFolder]);
|
||||
});
|
||||
|
||||
it("should return undefined for a user-created Library folder, so its children are listed as-is", () => {
|
||||
const userFolderParent: OmniPickerItem = {
|
||||
id: 8,
|
||||
name: "Finance",
|
||||
model: "collection",
|
||||
type: "library",
|
||||
};
|
||||
|
||||
expect(
|
||||
getCollectionPickerItems({
|
||||
parentItem: userFolderParent,
|
||||
items: [
|
||||
createMockCollectionItem({
|
||||
id: 9,
|
||||
name: "Nested",
|
||||
model: "collection",
|
||||
type: "library",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined for non-Library parent items", () => {
|
||||
const regularParentItem: OmniPickerItem = {
|
||||
id: 6,
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { userToColor } from "metabase/admin/people/colors";
|
||||
import { useListUsersQuery } from "metabase/api";
|
||||
import { UserAvatar } from "metabase/common/components/UserAvatar";
|
||||
import {
|
||||
@@ -203,7 +202,7 @@ export const AddDataAppUsers = ({
|
||||
p="0.5rem 1rem"
|
||||
onClick={() => addUser(user)}
|
||||
>
|
||||
<UserAvatar bg={userToColor(user)} user={user} />
|
||||
<UserAvatar user={user} />
|
||||
<Text fw="bold" size="lg">
|
||||
{user.common_name}
|
||||
</Text>
|
||||
|
||||
@@ -23,7 +23,7 @@ export const CollectionSyncRow = ({
|
||||
const icon = getIcon({
|
||||
model: "collection",
|
||||
type: collection.type,
|
||||
is_remote_synced: isChecked,
|
||||
icon: collection.icon,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import { useListCollectionsQuery } from "metabase/api";
|
||||
import type { CollectionTreeItem } from "metabase/common/collections/utils";
|
||||
import { Tree } from "metabase/common/components/tree";
|
||||
import type { ITreeNodeItem } from "metabase/common/components/tree/types";
|
||||
import { SidebarCollectionLink } from "metabase/nav/containers/MainNavbar/SidebarItems";
|
||||
|
||||
import { useRemoteSyncDirtyState } from "../../hooks/use-remote-sync-dirty-state";
|
||||
import { CollectionSyncStatusBadge } from "../SyncedCollectionsSidebarSection/CollectionSyncStatusBadge";
|
||||
|
||||
interface CollectionsNavTreeProps {
|
||||
collections: CollectionTreeItem[];
|
||||
selectedId?: number | string;
|
||||
onSelect?: (item: ITreeNodeItem) => void;
|
||||
}
|
||||
|
||||
export const CollectionsNavTree = ({
|
||||
collections,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: CollectionsNavTreeProps) => {
|
||||
// Fetch flat list to check for remote-synced collections
|
||||
const { data: collectionsList = [] } = useListCollectionsQuery({
|
||||
archived: false,
|
||||
});
|
||||
|
||||
const hasRemoteSyncedCollections = useMemo(
|
||||
() => collectionsList.some((c) => c.is_remote_synced),
|
||||
[collectionsList],
|
||||
);
|
||||
|
||||
const { isCollectionDirty } = useRemoteSyncDirtyState();
|
||||
|
||||
const showChangesBadge = useCallback(
|
||||
(itemId?: number | string) => {
|
||||
if (!hasRemoteSyncedCollections) {
|
||||
return false;
|
||||
}
|
||||
return isCollectionDirty(itemId);
|
||||
},
|
||||
[hasRemoteSyncedCollections, isCollectionDirty],
|
||||
);
|
||||
|
||||
return (
|
||||
<Tree
|
||||
data={collections}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
TreeNode={SidebarCollectionLink}
|
||||
role="tree"
|
||||
aria-label="collection-tree"
|
||||
rightSection={(item) =>
|
||||
showChangesBadge(item?.id) && <CollectionSyncStatusBadge />
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,214 +0,0 @@
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import { mockSettings } from "__support__/settings";
|
||||
import { createMockState } from "__support__/state";
|
||||
import { renderWithProviders, screen, waitFor } from "__support__/ui";
|
||||
import { Route } from "metabase/router";
|
||||
import type { Collection } from "metabase-types/api";
|
||||
import { createMockCollection } from "metabase-types/api/mocks";
|
||||
|
||||
// Mock useRemoteSyncDirtyState
|
||||
const mockIsCollectionDirty = jest.fn(
|
||||
(_id: number | string | undefined) => false,
|
||||
);
|
||||
jest.mock("../../hooks/use-remote-sync-dirty-state", () => ({
|
||||
useRemoteSyncDirtyState: () => ({
|
||||
isCollectionDirty: mockIsCollectionDirty,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { CollectionsNavTree } from "./CollectionsNavTree";
|
||||
|
||||
const createMockCollectionTreeItem = (
|
||||
overrides: Partial<Collection> = {},
|
||||
): Collection => ({
|
||||
...createMockCollection({
|
||||
id: 1,
|
||||
name: "Test Collection",
|
||||
is_remote_synced: false,
|
||||
...overrides,
|
||||
}),
|
||||
});
|
||||
|
||||
const setupEndpoints = ({
|
||||
collections = [createMockCollectionTreeItem()],
|
||||
}: {
|
||||
collections?: Collection[];
|
||||
} = {}) => {
|
||||
fetchMock.get("path:/api/collection", collections);
|
||||
};
|
||||
|
||||
const setup = ({
|
||||
collections = [createMockCollectionTreeItem()],
|
||||
collectionsList = [createMockCollectionTreeItem()],
|
||||
// Unjustified type cast. FIXME
|
||||
dirtyCollectionIds = [] as number[],
|
||||
}: {
|
||||
collections?: Collection[];
|
||||
collectionsList?: Collection[];
|
||||
dirtyCollectionIds?: number[];
|
||||
} = {}) => {
|
||||
setupEndpoints({ collections: collectionsList });
|
||||
|
||||
// Configure mock to return true for dirty collection IDs
|
||||
mockIsCollectionDirty.mockImplementation(
|
||||
(id: number | string | undefined) =>
|
||||
typeof id === "number" && dirtyCollectionIds.includes(id),
|
||||
);
|
||||
|
||||
return renderWithProviders(
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<CollectionsNavTree
|
||||
// Unjustified type cast. FIXME
|
||||
collections={collections as any}
|
||||
selectedId={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>
|
||||
}
|
||||
/>,
|
||||
{
|
||||
storeInitialState: createMockState({
|
||||
settings: mockSettings({}),
|
||||
}),
|
||||
withDND: true,
|
||||
withRouter: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
describe("CollectionsNavTree", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
fetchMock.removeRoutes();
|
||||
fetchMock.clearHistory();
|
||||
mockIsCollectionDirty.mockReturnValue(false);
|
||||
});
|
||||
|
||||
describe("rendering", () => {
|
||||
it("should render collections tree", async () => {
|
||||
const collection = createMockCollectionTreeItem({
|
||||
name: "My Collection",
|
||||
});
|
||||
setup({ collections: [collection] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Collection")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render multiple collections", async () => {
|
||||
const collection1 = createMockCollectionTreeItem({
|
||||
id: 1,
|
||||
name: "Collection 1",
|
||||
});
|
||||
const collection2 = createMockCollectionTreeItem({
|
||||
id: 2,
|
||||
name: "Collection 2",
|
||||
});
|
||||
setup({ collections: [collection1, collection2] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Collection 1")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Collection 2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dirty state badges", () => {
|
||||
it("should not show badge when no collections are remote-synced", async () => {
|
||||
const collection = createMockCollectionTreeItem({
|
||||
id: 1,
|
||||
name: "Regular Collection",
|
||||
is_remote_synced: false,
|
||||
});
|
||||
setup({
|
||||
collections: [collection],
|
||||
collectionsList: [collection],
|
||||
dirtyCollectionIds: [1],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Regular Collection")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Badge should not be present since collection is not remote-synced
|
||||
expect(
|
||||
screen.queryByTestId("remote-sync-status"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show badge when collection has dirty changes and is remote-synced", async () => {
|
||||
const collection = createMockCollectionTreeItem({
|
||||
id: 1,
|
||||
name: "Synced Collection",
|
||||
is_remote_synced: true,
|
||||
});
|
||||
setup({
|
||||
collections: [collection],
|
||||
collectionsList: [collection],
|
||||
dirtyCollectionIds: [1],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Synced Collection")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("remote-sync-status")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not show badge for collection without dirty changes", async () => {
|
||||
const syncedCollection = createMockCollectionTreeItem({
|
||||
id: 1,
|
||||
name: "Clean Synced Collection",
|
||||
is_remote_synced: true,
|
||||
});
|
||||
setup({
|
||||
collections: [syncedCollection],
|
||||
collectionsList: [syncedCollection],
|
||||
dirtyCollectionIds: [],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Clean Synced Collection")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Badge should not be present since there are no dirty changes
|
||||
expect(
|
||||
screen.queryByTestId("remote-sync-status"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show badge only for collections with changes", async () => {
|
||||
const dirtyCollection = createMockCollectionTreeItem({
|
||||
id: 1,
|
||||
name: "Dirty Collection",
|
||||
is_remote_synced: true,
|
||||
});
|
||||
const cleanCollection = createMockCollectionTreeItem({
|
||||
id: 2,
|
||||
name: "Clean Collection",
|
||||
is_remote_synced: true,
|
||||
});
|
||||
setup({
|
||||
collections: [dirtyCollection, cleanCollection],
|
||||
collectionsList: [dirtyCollection, cleanCollection],
|
||||
dirtyCollectionIds: [1],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Dirty Collection")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Clean Collection")).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
// Only one badge should be present
|
||||
const badges = screen.getAllByTestId("remote-sync-status");
|
||||
expect(badges).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { CollectionsNavTree } from "./CollectionsNavTree";
|
||||
@@ -134,7 +134,6 @@ const RequiredSyncItem = ({
|
||||
type: row.type,
|
||||
is_personal: row.personal,
|
||||
location: "/",
|
||||
is_remote_synced: isSynced,
|
||||
is_library_root: row.type === "library",
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import ErrorBoundary from "metabase/common/components/ErrorBoundary";
|
||||
@@ -12,9 +11,6 @@ import type { SyncedCollectionsSidebarSectionProps } from "metabase/plugins/type
|
||||
import { Box, Flex, Group, Text } from "metabase/ui";
|
||||
|
||||
import { useGitSyncVisible } from "../../hooks/use-git-sync-visible";
|
||||
import { useRemoteSyncDirtyState } from "../../hooks/use-remote-sync-dirty-state";
|
||||
|
||||
import { CollectionSyncStatusBadge } from "./CollectionSyncStatusBadge";
|
||||
|
||||
export const SyncedCollectionsSidebarSection = ({
|
||||
onItemSelect,
|
||||
@@ -24,30 +20,6 @@ export const SyncedCollectionsSidebarSection = ({
|
||||
const hasSyncedCollections = syncedCollections.length > 0;
|
||||
|
||||
const { isVisible: isGitSyncVisible } = useGitSyncVisible();
|
||||
const { dirty, isCollectionDirty } = useRemoteSyncDirtyState();
|
||||
|
||||
const hasEntityRemoved = useMemo(
|
||||
() => dirty.some((entity) => entity.sync_status === "removed"),
|
||||
[dirty],
|
||||
);
|
||||
|
||||
const showChangesBadge = (itemId?: number | string) => {
|
||||
if (typeof itemId !== "number") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collectionIsUpdated = isCollectionDirty(itemId);
|
||||
const hasSingleRootCollection = syncedCollections.length === 1;
|
||||
|
||||
return (
|
||||
// Collection was updated
|
||||
collectionIsUpdated ||
|
||||
// An item was removed and this is the root synced collection (TODO: this is a workaround before we do UXW-2181)
|
||||
(hasEntityRemoved &&
|
||||
hasSingleRootCollection &&
|
||||
syncedCollections[0].id === itemId)
|
||||
);
|
||||
};
|
||||
|
||||
if (!isGitSyncVisible) {
|
||||
return null;
|
||||
@@ -77,9 +49,6 @@ export const SyncedCollectionsSidebarSection = ({
|
||||
TreeNode={SidebarCollectionLink}
|
||||
role="tree"
|
||||
aria-label="collection-tree"
|
||||
rightSection={(item) =>
|
||||
showChangesBadge(item?.id) && <CollectionSyncStatusBadge />
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -29,16 +29,6 @@ export function useRemoteSyncDirtyState(): RemoteSyncDirtyState {
|
||||
[dirty],
|
||||
);
|
||||
|
||||
const isCollectionDirty = useCallback(
|
||||
(collectionId: number | string | undefined) => {
|
||||
if (typeof collectionId !== "number") {
|
||||
return false;
|
||||
}
|
||||
return !!changedCollections[collectionId];
|
||||
},
|
||||
[changedCollections],
|
||||
);
|
||||
|
||||
const hasAnyCollectionDirty = useCallback(
|
||||
(collectionIds: Set<number> | number[]) => {
|
||||
const ids =
|
||||
@@ -78,7 +68,6 @@ export function useRemoteSyncDirtyState(): RemoteSyncDirtyState {
|
||||
isDirty,
|
||||
hasRemovedItems,
|
||||
isLoading,
|
||||
isCollectionDirty,
|
||||
hasAnyCollectionDirty,
|
||||
hasDirtyInCollectionTree,
|
||||
refetch,
|
||||
|
||||
@@ -92,45 +92,6 @@ describe("useRemoteSyncDirtyState", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCollectionDirty", () => {
|
||||
it("returns false for collection without dirty items", async () => {
|
||||
const { result } = setup({
|
||||
dirty: [createMockDirtyEntity({ collection_id: 1 })],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.dirty.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(result.current.isCollectionDirty(2)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for collection with dirty items", async () => {
|
||||
const { result } = setup({
|
||||
dirty: [createMockDirtyEntity({ collection_id: 1 })],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.dirty.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(result.current.isCollectionDirty(1)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-numeric collection id", async () => {
|
||||
const { result } = setup({
|
||||
dirty: [createMockDirtyEntity({ collection_id: 1 })],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.dirty.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(result.current.isCollectionDirty("root")).toBe(false);
|
||||
expect(result.current.isCollectionDirty(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasAnyCollectionDirty", () => {
|
||||
it("returns false when no collections in set are dirty", async () => {
|
||||
const { result } = setup({
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
import { hasPremiumFeature } from "metabase-enterprise/settings";
|
||||
|
||||
import { LibraryNav } from "./LibraryNav";
|
||||
import { CollectionsNavTree } from "./components/CollectionsNavTree";
|
||||
import { GitSettingsModal } from "./components/GitSettingsModal";
|
||||
import { GitSyncControls } from "./components/GitSyncControls";
|
||||
import { GitSyncSetupMenuItem } from "./components/GitSyncSetupMenuItem";
|
||||
@@ -38,7 +37,6 @@ export function initializePlugin() {
|
||||
SyncedCollectionsSidebarSection;
|
||||
PLUGIN_REMOTE_SYNC.GitSyncAppBarControls = GitSyncControls;
|
||||
PLUGIN_REMOTE_SYNC.GitSettingsModal = GitSettingsModal;
|
||||
PLUGIN_REMOTE_SYNC.CollectionsNavTree = CollectionsNavTree;
|
||||
PLUGIN_REMOTE_SYNC.CollectionSyncStatusBadge = CollectionSyncStatusBadge;
|
||||
PLUGIN_REMOTE_SYNC.REMOTE_SYNC_INVALIDATION_TAGS =
|
||||
REMOTE_SYNC_INVALIDATION_TAGS;
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
BulkActionButton,
|
||||
} from "metabase/common/components/BulkActionBar";
|
||||
import { GenericError } from "metabase/common/components/ErrorPages";
|
||||
import { useCloseNavbarOnMount } from "metabase/common/hooks/use-close-navbar-on-mount";
|
||||
import { getUserIsAdmin } from "metabase/current-user";
|
||||
import { useSelector } from "metabase/redux";
|
||||
import { useLocation, useParams } from "metabase/router";
|
||||
@@ -49,8 +48,6 @@ export const EditTableDataContainer = () => {
|
||||
const { dbId: dbIdParam, tableId: tableIdParam } =
|
||||
useParams<EditTableDataContainerParams>();
|
||||
|
||||
useCloseNavbarOnMount();
|
||||
|
||||
const databaseId = parseInt(dbIdParam ?? "", 10);
|
||||
const tableId = parseInt(tableIdParam ?? "", 10);
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
useCreateCollectionMutation,
|
||||
useListCollectionsQuery,
|
||||
} from "metabase/api";
|
||||
import { useCreateCollectionMutation } from "metabase/api";
|
||||
import { CreateCollectionForm } from "metabase/common/collections/components/CreateCollectionForm";
|
||||
import type { CreateCollectionProperties } from "metabase/common/collections/components/CreateCollectionForm/CreateCollectionForm";
|
||||
import { buildCollectionTree } from "metabase/common/collections/utils";
|
||||
@@ -23,8 +20,6 @@ import {
|
||||
tenantSpecificCollections,
|
||||
tenantUsersPersonalCollections,
|
||||
} from "metabase/urls";
|
||||
import { useGetRemoteSyncChangesQuery } from "metabase-enterprise/api";
|
||||
import { CollectionSyncStatusBadge } from "metabase-enterprise/remote_sync/components/SyncedCollectionsSidebarSection/CollectionSyncStatusBadge";
|
||||
import type { Collection } from "metabase-types/api";
|
||||
|
||||
export const MainNavSharedCollections = ({
|
||||
@@ -47,23 +42,6 @@ export const MainNavSharedCollections = ({
|
||||
const isTenantsEnabled = useSetting("use-tenants");
|
||||
const isAdmin = useSelector(getUserIsAdmin);
|
||||
|
||||
// Fetch flat list of tenant collections to check if any are remote-synced
|
||||
const { data: tenantCollectionsList = [] } = useListCollectionsQuery(
|
||||
{ namespace: "shared-tenant-collection" },
|
||||
{ skip: !isTenantsEnabled },
|
||||
);
|
||||
|
||||
// Check if any tenant collections have is_remote_synced=true
|
||||
const hasRemoteSyncedTenantCollections = useMemo(
|
||||
() => tenantCollectionsList.some((c) => c.is_remote_synced),
|
||||
[tenantCollectionsList],
|
||||
);
|
||||
|
||||
const { data: dirtyData } = useGetRemoteSyncChangesQuery(undefined, {
|
||||
skip: !hasRemoteSyncedTenantCollections,
|
||||
refetchOnFocus: true,
|
||||
});
|
||||
|
||||
const [createCollection] = useCreateCollectionMutation();
|
||||
|
||||
const sharedTenantCollectionTree = useMemo(
|
||||
@@ -71,26 +49,6 @@ export const MainNavSharedCollections = ({
|
||||
[sharedTenantCollections],
|
||||
);
|
||||
|
||||
const changedCollections = useMemo(
|
||||
() => dirtyData?.changedCollections ?? {},
|
||||
[dirtyData?.changedCollections],
|
||||
);
|
||||
|
||||
const showChangesBadge = useCallback(
|
||||
(itemId?: number | string) => {
|
||||
if (
|
||||
!hasRemoteSyncedTenantCollections ||
|
||||
!changedCollections ||
|
||||
typeof itemId !== "number"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!changedCollections[itemId];
|
||||
},
|
||||
[hasRemoteSyncedTenantCollections, changedCollections],
|
||||
);
|
||||
|
||||
const handleCreateTenantCollection = useCallback(
|
||||
async (values: CreateCollectionProperties) => {
|
||||
await createCollection({
|
||||
@@ -142,9 +100,6 @@ export const MainNavSharedCollections = ({
|
||||
TreeNode={SidebarCollectionLink}
|
||||
role="tree"
|
||||
aria-label="tenant-collection-tree"
|
||||
rightSection={(item) =>
|
||||
showChangesBadge(item?.id) && <CollectionSyncStatusBadge />
|
||||
}
|
||||
/>
|
||||
{canAccessTenantSpecificCollections && (
|
||||
<PaddedSidebarLink icon="group" url={tenantSpecificCollections()}>
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from "metabase/ui";
|
||||
import { tenantIdToColor } from "metabase-enterprise/tenants/utils/colors";
|
||||
import * as Urls from "metabase-enterprise/urls";
|
||||
import type { Tenant } from "metabase-types/api";
|
||||
|
||||
@@ -99,10 +98,7 @@ export const TenantsListing = ({
|
||||
className={CS.link}
|
||||
gap="lg"
|
||||
>
|
||||
<UserAvatar
|
||||
user={{ name: tenant.name }}
|
||||
bg={tenantIdToColor(tenant.id)}
|
||||
/>
|
||||
<UserAvatar user={{ name: tenant.name }} />
|
||||
<Box component="span" fw={700} c="core-brand">
|
||||
{tenant.name}
|
||||
</Box>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { color } from "metabase/ui/colors";
|
||||
import type { Tenant } from "metabase-types/api";
|
||||
|
||||
const groupColorPalette = [
|
||||
color("feedback-negative"),
|
||||
color("accent2"),
|
||||
color("core-brand"),
|
||||
color("accent4"),
|
||||
color("accent1"),
|
||||
];
|
||||
|
||||
export const tenantIdToColor = (tenantId: Tenant["id"]) => {
|
||||
return groupColorPalette[tenantId % groupColorPalette.length];
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
|
||||
import type { CardId, CardType } from "./card";
|
||||
import type { DatabaseId } from "./database";
|
||||
import type { IconName } from "./icon";
|
||||
import type { SortDirection } from "./sorting";
|
||||
import type { TableId } from "./table";
|
||||
import type { UserId, UserInfo } from "./user";
|
||||
@@ -67,6 +68,9 @@ export interface Collection {
|
||||
children?: Collection[];
|
||||
authority_level?: CollectionAuthorityLevel;
|
||||
type?: CollectionType;
|
||||
// User-chosen icon override for the icon that would otherwise be derived
|
||||
// from the collection's type/identity. Only surfaced for library folders.
|
||||
icon?: IconName | null;
|
||||
is_remote_synced?: boolean;
|
||||
namespace: CollectionNamespace | null;
|
||||
|
||||
@@ -135,6 +139,7 @@ export interface CollectionItem {
|
||||
can_restore?: boolean;
|
||||
can_delete?: boolean;
|
||||
is_library_root?: boolean;
|
||||
icon?: IconName | null;
|
||||
"last-edit-info"?: LastEditInfo;
|
||||
location?: string | null;
|
||||
effective_location?: string;
|
||||
@@ -214,6 +219,7 @@ export interface UpdateCollectionRequest {
|
||||
authority_level?: CollectionAuthorityLevel;
|
||||
type?: CollectionType;
|
||||
is_remote_synced?: boolean;
|
||||
icon?: IconName | null;
|
||||
}
|
||||
|
||||
export interface CreateCollectionRequest {
|
||||
@@ -223,6 +229,7 @@ export interface CreateCollectionRequest {
|
||||
namespace?: CollectionNamespace;
|
||||
authority_level?: CollectionAuthorityLevel;
|
||||
is_shared_tenant_collection?: boolean;
|
||||
icon?: IconName | null;
|
||||
}
|
||||
|
||||
export type ListCollectionsRequest = {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import styled from "@emotion/styled";
|
||||
|
||||
import { APP_BAR_HEIGHT } from "metabase/nav/constants";
|
||||
|
||||
export const AppContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -27,11 +25,25 @@ export const AppContentContainer = styled.div<{
|
||||
}
|
||||
`;
|
||||
|
||||
export const AppMain = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
@media print {
|
||||
height: 100%;
|
||||
overflow: visible !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export const AppContent = styled.main`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
scroll-margin-top: ${APP_BAR_HEIGHT};
|
||||
|
||||
@media print {
|
||||
overflow: visible !important;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { AppBarContainer } from "metabase/app/nav/AppBar";
|
||||
import { AppPageHeader } from "metabase/app/nav/AppPageHeader";
|
||||
import { Navbar } from "metabase/app/nav/Navbar";
|
||||
import {
|
||||
getIsAdminApp,
|
||||
getIsAppBarVisible,
|
||||
getIsDataApp,
|
||||
getIsDataStudioApp,
|
||||
getIsMonitorApp,
|
||||
getIsNavBarEnabled,
|
||||
getIsPageHeaderVisible,
|
||||
} from "metabase/app/selectors";
|
||||
import { AppBanner } from "metabase/common/components/AppBanner";
|
||||
import ErrorBoundary from "metabase/common/components/ErrorBoundary";
|
||||
@@ -34,7 +34,12 @@ import { useGetSettingsQuery, useTokenRefresh } from "metabase/settings";
|
||||
import { StatusListing } from "metabase/status/components/StatusListing";
|
||||
import { initializeIframeResizer } from "metabase/utils/dom";
|
||||
|
||||
import { AppContainer, AppContent, AppContentContainer } from "./App.styled";
|
||||
import {
|
||||
AppContainer,
|
||||
AppContent,
|
||||
AppContentContainer,
|
||||
AppMain,
|
||||
} from "./App.styled";
|
||||
import { AppKBarProvider } from "./AppKBarProvider";
|
||||
import ScrollToTop from "./ScrollToTop";
|
||||
import { trackPageView } from "./analytics";
|
||||
@@ -76,8 +81,8 @@ export function App() {
|
||||
getIsMonitorApp(state, routerProps),
|
||||
);
|
||||
const isDataApp = useSelector((state) => getIsDataApp(state, routerProps));
|
||||
const isAppBarVisible = useSelector((state) =>
|
||||
getIsAppBarVisible(state, routerProps),
|
||||
const isPageHeaderVisible = useSelector((state) =>
|
||||
getIsPageHeaderVisible(state, routerProps),
|
||||
);
|
||||
const isNavBarEnabled = useSelector((state) =>
|
||||
getIsNavBarEnabled(state, routerProps),
|
||||
@@ -108,16 +113,18 @@ export function App() {
|
||||
<KeyboardTriggeredErrorModal />
|
||||
<AppContainer className={CS.spread}>
|
||||
<AppBanner />
|
||||
{isAppBarVisible && <AppBarContainer />}
|
||||
<AppContentContainer isAdminApp={isAdminApp}>
|
||||
{isNavBarEnabled && <Navbar />}
|
||||
<AppContent ref={setViewportElement}>
|
||||
<ContentViewportContext.Provider
|
||||
value={viewportElement ?? null}
|
||||
>
|
||||
{errorPage ? getErrorComponent(errorPage) : <Outlet />}
|
||||
</ContentViewportContext.Provider>
|
||||
</AppContent>
|
||||
<AppMain>
|
||||
{isPageHeaderVisible && <AppPageHeader />}
|
||||
<AppContent ref={setViewportElement}>
|
||||
<ContentViewportContext.Provider
|
||||
value={viewportElement ?? null}
|
||||
>
|
||||
{errorPage ? getErrorComponent(errorPage) : <Outlet />}
|
||||
</ContentViewportContext.Provider>
|
||||
</AppContent>
|
||||
</AppMain>
|
||||
<UndoListing />
|
||||
<StatusListing />
|
||||
<NewModals />
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import cx from "classnames";
|
||||
import { useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { Breadcrumbs } from "metabase/common/components/Breadcrumbs";
|
||||
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { Revision } from "metabase/segments";
|
||||
import { assignUserColors } from "metabase/ui/colors/formatting-colors";
|
||||
import * as Urls from "metabase/urls";
|
||||
import type {
|
||||
Revision as RevisionType,
|
||||
@@ -21,17 +19,6 @@ interface Props {
|
||||
}
|
||||
|
||||
export function RevisionHistory({ revisions, segment, user }: Props) {
|
||||
const userColorAssignments = useMemo(() => {
|
||||
if (!revisions) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return assignUserColors(
|
||||
revisions.map((revision) => String(revision.user.id)),
|
||||
String(user.id),
|
||||
);
|
||||
}, [revisions, user]);
|
||||
|
||||
return (
|
||||
<LoadingAndErrorWrapper
|
||||
loading={!segment || !revisions}
|
||||
@@ -70,7 +57,6 @@ export function RevisionHistory({ revisions, segment, user }: Props) {
|
||||
objectName={segment.name}
|
||||
revision={revision}
|
||||
tableId={segment.table_id}
|
||||
userColor={userColorAssignments[revision.user.id]}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { color } from "metabase/ui/colors";
|
||||
import type { GroupId, User } from "metabase-types/api";
|
||||
|
||||
export const userToColor = (user: User) => {
|
||||
return user.is_superuser ? color("accent2") : color("core-brand");
|
||||
};
|
||||
|
||||
const groupColorPalette = [
|
||||
color("feedback-negative"),
|
||||
color("accent2"),
|
||||
color("core-brand"),
|
||||
color("accent4"),
|
||||
color("accent1"),
|
||||
];
|
||||
|
||||
export const groupIdToColor = (groupId: GroupId) => {
|
||||
return groupColorPalette[groupId % groupColorPalette.length];
|
||||
};
|
||||
@@ -7,8 +7,6 @@ import { PLUGIN_TENANTS } from "metabase/plugins";
|
||||
import { Flex, Pill, Popover, Text, UnstyledButton } from "metabase/ui";
|
||||
import type { Group, Member, User } from "metabase-types/api";
|
||||
|
||||
import { userToColor } from "../colors";
|
||||
|
||||
import { AddRow } from "./AddRow";
|
||||
|
||||
interface AddMemberRowProps {
|
||||
@@ -116,7 +114,7 @@ export function AddMemberRow({
|
||||
p="0.5rem 1rem"
|
||||
onClick={() => handleAddUser(user)}
|
||||
>
|
||||
<UserAvatar bg={userToColor(user)} user={user} />
|
||||
<UserAvatar user={user} />
|
||||
<Text fw="bold" size="lg">
|
||||
{user.common_name}
|
||||
</Text>
|
||||
|
||||
@@ -30,7 +30,6 @@ import { KEYCODE_ENTER } from "metabase/utils/keyboard";
|
||||
import type { ApiKey, GroupInfo } from "metabase-types/api";
|
||||
|
||||
import { useListApiKeysQuery } from "../../settings/api/api-key";
|
||||
import { groupIdToColor } from "../colors";
|
||||
|
||||
import { AddRow } from "./AddRow";
|
||||
import { SearchFilter } from "./SearchFilter";
|
||||
@@ -233,7 +232,7 @@ function GroupNameCell({ group }: { group: GroupInfo }) {
|
||||
className={CS.link}
|
||||
gap="md"
|
||||
>
|
||||
<UserAvatar user={{ name }} bg={groupIdToColor(group.id)} />
|
||||
<UserAvatar user={{ name }} />
|
||||
<Box component="span" fw={700} c="core-brand">
|
||||
{name}
|
||||
</Box>
|
||||
|
||||
@@ -26,8 +26,6 @@ import type {
|
||||
User,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { userToColor } from "../colors";
|
||||
|
||||
import { MembershipSelect } from "./MembershipSelect";
|
||||
import { ReactivateUserButton } from "./ReactivateUserButton";
|
||||
|
||||
@@ -75,7 +73,7 @@ export const PeopleListRow = ({
|
||||
return (
|
||||
<tr key={user.id}>
|
||||
<Flex component="td" align="center" gap="lg" c="text-primary-inverse">
|
||||
<UserAvatar bg={userToColor(user)} user={user} />
|
||||
<UserAvatar user={user} />
|
||||
<Text fw="700">{getFullName(user) ?? "-"}</Text>
|
||||
</Flex>
|
||||
<td>
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import { skipToken, useGetDashboardQuery } from "metabase/api";
|
||||
import { QuestionLineage } from "metabase/app/nav/QuestionLineage";
|
||||
import {
|
||||
getCollectionId,
|
||||
getIsAppSwitcherVisible,
|
||||
getIsCollectionPathVisible,
|
||||
getIsLogoVisible,
|
||||
getIsMetricsViewer,
|
||||
getIsNavBarEnabled,
|
||||
getIsNewButtonVisible,
|
||||
getIsQuestionLineageVisible,
|
||||
getIsSearchVisible,
|
||||
} from "metabase/app/selectors";
|
||||
import { useInitialCollectionId } from "metabase/common/collections/hooks";
|
||||
import { getUser } from "metabase/current-user";
|
||||
import { getIsSidebarOpen } from "metabase/documents/selectors";
|
||||
import { getMetabotVisible } from "metabase/metabot/state";
|
||||
import { AppBar as AppBarView } from "metabase/nav/components/AppBar";
|
||||
import { CollectionBreadcrumbs } from "metabase/nav/containers/CollectionBreadcrumbs";
|
||||
import { isQuestionPath } from "metabase/nav/containers/MainNavbar/getSelectedItems";
|
||||
import { getOriginalQuestion, getQuestion } from "metabase/query_builder";
|
||||
import { useDispatch, useSelector } from "metabase/redux";
|
||||
import { closeNavbar, toggleNavbar } from "metabase/redux/app";
|
||||
import { useLocation, useNavigate, useParams } from "metabase/router";
|
||||
import { getDetailViewState, getIsNavbarOpen } from "metabase/selectors/app";
|
||||
import { modelToUrl } from "metabase/urls";
|
||||
import { isWithinIframe } from "metabase/utils/iframe";
|
||||
import type { SearchResult } from "metabase-types/api";
|
||||
|
||||
type SearchResultSelection =
|
||||
| { type: "zoom"; objectId: SearchResult["id"] }
|
||||
| { type: "navigate"; url: string };
|
||||
|
||||
/**
|
||||
* Decides what should happen when a search result is selected from the app bar.
|
||||
* When the result is an indexed-entity row that belongs to the model we're
|
||||
* already viewing, we zoom into that row in place instead of navigating away.
|
||||
*/
|
||||
export function getSearchResultSelection(
|
||||
result: SearchResult,
|
||||
currentCardId: number | undefined,
|
||||
): SearchResultSelection {
|
||||
if (result.model === "indexed-entity" && result.model_id === currentCardId) {
|
||||
return { type: "zoom", objectId: result.id };
|
||||
}
|
||||
return { type: "navigate", url: modelToUrl(result) };
|
||||
}
|
||||
|
||||
export function AppBarContainer() {
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// These selectors derive app-bar visibility from the URL, so they take the
|
||||
// router props rather than reading them from the store.
|
||||
const location = useLocation();
|
||||
const params = useParams();
|
||||
const routerProps = { location };
|
||||
const collectionId =
|
||||
useInitialCollectionId({ location, params }) ?? undefined;
|
||||
|
||||
const currentUser = useSelector(getUser);
|
||||
const isNavBarOpen = useSelector(getIsNavbarOpen);
|
||||
const isNavBarEnabled = useSelector((state) =>
|
||||
getIsNavBarEnabled(state, routerProps),
|
||||
);
|
||||
const isMetabotVisible = useSelector((state) =>
|
||||
getMetabotVisible(state, "omnibot"),
|
||||
);
|
||||
const isDocumentSidebarOpen = useSelector(getIsSidebarOpen);
|
||||
const isLogoVisible = useSelector(getIsLogoVisible);
|
||||
const isSearchVisible = useSelector(getIsSearchVisible);
|
||||
const isEmbeddingIframe = isWithinIframe();
|
||||
const isNewButtonVisible = useSelector(getIsNewButtonVisible);
|
||||
const isAppSwitcherVisible = useSelector(getIsAppSwitcherVisible);
|
||||
const isCollectionPathVisible = useSelector((state) =>
|
||||
getIsCollectionPathVisible(state, routerProps),
|
||||
);
|
||||
const isQuestionLineageVisible = useSelector((state) =>
|
||||
getIsQuestionLineageVisible(state, routerProps),
|
||||
);
|
||||
const detailView = useSelector(getDetailViewState);
|
||||
const isMetricsViewer = useSelector((state) =>
|
||||
getIsMetricsViewer(state, routerProps),
|
||||
);
|
||||
|
||||
const question = useSelector(getQuestion);
|
||||
const originalQuestion = useSelector(getOriginalQuestion);
|
||||
// The breadcrumbs' current collection is derived from the active
|
||||
// dashboard/question/document state. CollectionBreadcrumbs used to read this
|
||||
// itself, but getCollectionId orchestrates feature state and now lives in the
|
||||
// app tier, so the app-tier AppBar resolves it and passes it down.
|
||||
const breadcrumbCollectionId = useSelector(getCollectionId);
|
||||
|
||||
const { pathname } = location;
|
||||
const isOnQuestionPage = pathname && isQuestionPath(pathname);
|
||||
const dashboardId = isOnQuestionPage ? question?.dashboard()?.id : undefined;
|
||||
const { data: dashboard } = useGetDashboardQuery(
|
||||
dashboardId != null ? { id: dashboardId } : skipToken,
|
||||
);
|
||||
|
||||
// Unjustified type cast. FIXME
|
||||
const locationState = location.state as { cardId?: number } | undefined;
|
||||
|
||||
const onSearchItemSelect = async (result: SearchResult) => {
|
||||
const selection = getSearchResultSelection(result, locationState?.cardId);
|
||||
if (selection.type === "zoom") {
|
||||
// Reached through an import so the app bar, which every page mounts, does
|
||||
// not put the query builder's actions in the initial bundle. Only a search
|
||||
// selection on an object-detail page gets here, and the query builder it
|
||||
// zooms within is already loaded by then.
|
||||
const { zoomInRow } = await import("metabase/query_builder");
|
||||
dispatch(zoomInRow({ objectId: selection.objectId }));
|
||||
} else {
|
||||
navigate(selection.url);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppBarView
|
||||
currentUser={currentUser}
|
||||
isNavBarOpen={isNavBarOpen}
|
||||
isNavBarEnabled={isNavBarEnabled}
|
||||
isMetabotVisible={isMetabotVisible}
|
||||
isDocumentSidebarOpen={isDocumentSidebarOpen}
|
||||
isLogoVisible={isLogoVisible}
|
||||
isSearchVisible={isSearchVisible}
|
||||
isEmbeddingIframe={isEmbeddingIframe}
|
||||
isNewButtonVisible={isNewButtonVisible}
|
||||
isAppSwitcherVisible={isAppSwitcherVisible}
|
||||
isCollectionPathVisible={isCollectionPathVisible}
|
||||
isQuestionLineageVisible={isQuestionLineageVisible}
|
||||
detailView={detailView}
|
||||
isMetricsViewer={isMetricsViewer}
|
||||
onToggleNavbar={() => dispatch(toggleNavbar())}
|
||||
onCloseNavbar={() => dispatch(closeNavbar())}
|
||||
collectionId={collectionId}
|
||||
collectionBreadcrumbs={
|
||||
<CollectionBreadcrumbs
|
||||
dashboard={dashboardId != null ? dashboard : undefined}
|
||||
collectionId={breadcrumbCollectionId ?? undefined}
|
||||
/>
|
||||
}
|
||||
questionLineage={
|
||||
<QuestionLineage
|
||||
question={question}
|
||||
originalQuestion={originalQuestion}
|
||||
/>
|
||||
}
|
||||
onSearchItemSelect={onSearchItemSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,427 +0,0 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import {
|
||||
setupCardEndpoints,
|
||||
setupCollectionByIdEndpoint,
|
||||
setupCollectionsEndpoints,
|
||||
setupDashboardEndpoints,
|
||||
setupUserMetabotPermissionsEndpoint,
|
||||
} from "__support__/server-mocks";
|
||||
import {
|
||||
createMockAppState,
|
||||
createMockDashboardState,
|
||||
createMockEmbedOptions,
|
||||
createMockEmbedState,
|
||||
createMockQueryBuilderState,
|
||||
createMockStoreDashboard,
|
||||
} from "__support__/state";
|
||||
import { renderWithProviders } from "__support__/ui";
|
||||
import { DEFAULT_INTERACTIVE_EMBEDDING_OPTIONS } from "metabase/redux/embed";
|
||||
import type {
|
||||
DashboardState,
|
||||
InteractiveEmbeddingOptions,
|
||||
} from "metabase/redux/store";
|
||||
import { Route } from "metabase/router";
|
||||
import { modelToUrl } from "metabase/urls";
|
||||
import type { Card } from "metabase-types/api";
|
||||
import {
|
||||
createMockCard,
|
||||
createMockCollection,
|
||||
createMockDashboard,
|
||||
createMockSearchResult,
|
||||
} from "metabase-types/api/mocks";
|
||||
|
||||
import { AppBarContainer, getSearchResultSelection } from "./AppBar";
|
||||
|
||||
const FOO_COLLECTION = createMockCollection({
|
||||
id: 3,
|
||||
name: "Foo Collection",
|
||||
});
|
||||
|
||||
const BAR_DASHBOARD = createMockDashboard({
|
||||
id: 4,
|
||||
name: "Bar Dashboard",
|
||||
collection_id: 3,
|
||||
});
|
||||
|
||||
const CARD_IN_COLLECTION = createMockCard({ id: 2, collection_id: 3 });
|
||||
const CARD_IN_DASHBOARD = createMockCard({
|
||||
id: 3,
|
||||
collection_id: 3,
|
||||
dashboard_id: 4,
|
||||
dashboard: BAR_DASHBOARD,
|
||||
});
|
||||
const CARD_IN_RENAMED_DASHBOARD = createMockCard({
|
||||
id: 4,
|
||||
collection_id: 3,
|
||||
dashboard_id: 4,
|
||||
dashboard: createMockDashboard({
|
||||
id: 4,
|
||||
name: "Stale Dashboard Name",
|
||||
collection_id: 3,
|
||||
}),
|
||||
});
|
||||
const CARD_WITH_DASHBOARD_ID = createMockCard({
|
||||
id: 5,
|
||||
collection_id: 3,
|
||||
dashboard_id: 4,
|
||||
dashboard: undefined,
|
||||
});
|
||||
|
||||
describe("AppBar", () => {
|
||||
const matchMediaSpy = jest.spyOn(window, "matchMedia");
|
||||
|
||||
describe("full-app embedding", () => {
|
||||
beforeEach(async () => {
|
||||
mockEmbedding();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
restoreMockEmbedding();
|
||||
});
|
||||
|
||||
describe("large screens", () => {
|
||||
beforeEach(() => {
|
||||
matchMediaSpy.mockReturnValue(getMediaQuery({ matches: false }));
|
||||
});
|
||||
|
||||
it("should be able to toggle side nav", async () => {
|
||||
setup({
|
||||
embedOptions: {
|
||||
side_nav: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/Our analytics/)).toBeVisible();
|
||||
expect(screen.getByTestId("main-logo")).toBeVisible();
|
||||
|
||||
await userEvent.click(screen.getByTestId("sidebar-toggle"));
|
||||
expect(screen.getByText(/Our analytics/)).not.toBeVisible();
|
||||
expect(screen.getByTestId("main-logo")).toBeVisible();
|
||||
});
|
||||
|
||||
it("should hide side nav toggle icon", async () => {
|
||||
setup({
|
||||
embedOptions: {
|
||||
side_nav: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/Our analytics/)).toBeVisible();
|
||||
expect(screen.getByTestId("main-logo")).toBeVisible();
|
||||
expect(screen.queryByTestId("sidebar-toggle")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should always show side nav toggle icon when logo is hidden", async () => {
|
||||
setup({
|
||||
embedOptions: {
|
||||
side_nav: true,
|
||||
logo: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/Our analytics/)).toBeVisible();
|
||||
expect(screen.queryByTestId("main-logo")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("sidebar-toggle")).toBeVisible();
|
||||
|
||||
await userEvent.click(screen.getByTestId("sidebar-toggle"));
|
||||
expect(screen.getByText(/Our analytics/)).not.toBeVisible();
|
||||
expect(screen.queryByTestId("main-logo")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("sidebar-toggle")).toBeVisible();
|
||||
});
|
||||
|
||||
it("should not show either logo or side nav toggle button at all", async () => {
|
||||
setup({
|
||||
embedOptions: {
|
||||
side_nav: false,
|
||||
logo: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/Our analytics/)).toBeVisible();
|
||||
expect(screen.queryByTestId("main-logo")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("sidebar-toggle")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should take you home when clicking the logo", async () => {
|
||||
const { router } = setup({});
|
||||
|
||||
if (!router) {
|
||||
throw new Error("history should be available from test setup");
|
||||
}
|
||||
|
||||
expect(router.location.pathname).toBe("/question/1");
|
||||
await userEvent.click(screen.getByTestId("main-logo"));
|
||||
expect(router.location.pathname).toBe("/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("small screens", () => {
|
||||
beforeEach(() => {
|
||||
matchMediaSpy.mockReturnValue(getMediaQuery({ matches: true }));
|
||||
});
|
||||
|
||||
it("should be able to toggle side nav", async () => {
|
||||
setup({
|
||||
embedOptions: {
|
||||
side_nav: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/Our analytics/)).toBeVisible();
|
||||
|
||||
await userEvent.click(screen.getByTestId("sidebar-toggle"));
|
||||
expect(screen.queryByText(/Our analytics/)).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("main-logo")).toBeVisible();
|
||||
});
|
||||
|
||||
it("should hide side nav toggle icon", async () => {
|
||||
setup({
|
||||
embedOptions: {
|
||||
side_nav: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/Our analytics/)).toBeVisible();
|
||||
expect(screen.getByTestId("main-logo")).toBeVisible();
|
||||
expect(screen.queryByTestId("sidebar-toggle")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should always show side nav toggle icon when logo is hidden", async () => {
|
||||
setup({
|
||||
embedOptions: {
|
||||
side_nav: true,
|
||||
logo: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/Our analytics/)).toBeVisible();
|
||||
expect(screen.queryByTestId("main-logo")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("sidebar-toggle")).toBeVisible();
|
||||
|
||||
await userEvent.click(screen.getByTestId("sidebar-toggle"));
|
||||
expect(screen.queryByText(/Our analytics/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("main-logo")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("sidebar-toggle")).toBeVisible();
|
||||
});
|
||||
|
||||
it("should not show either logo or side nav toggle button at all", async () => {
|
||||
setup({
|
||||
embedOptions: {
|
||||
side_nav: false,
|
||||
logo: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/Our analytics/)).toBeVisible();
|
||||
expect(screen.queryByTestId("main-logo")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("sidebar-toggle")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should take you home when clicking the logo", async () => {
|
||||
const { router } = setup({});
|
||||
|
||||
if (!router) {
|
||||
throw new Error("history should be available from test setup");
|
||||
}
|
||||
|
||||
expect(router.location.pathname).toBe("/question/1");
|
||||
await userEvent.click(screen.getByTestId("main-logo"));
|
||||
expect(router.location.pathname).toBe("/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("breadcrumbs", () => {
|
||||
it("should work for questions in collections", async () => {
|
||||
setup({
|
||||
embedOptions: {
|
||||
breadcrumbs: true,
|
||||
},
|
||||
card: CARD_IN_COLLECTION,
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Foo Collection")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should work for questions in dashboards", async () => {
|
||||
setup({
|
||||
embedOptions: {
|
||||
breadcrumbs: true,
|
||||
},
|
||||
card: CARD_IN_DASHBOARD,
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Foo Collection")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Bar Dashboard")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should use the latest dashboard name for question breadcrumbs (#75184)", async () => {
|
||||
setup({
|
||||
embedOptions: {
|
||||
breadcrumbs: true,
|
||||
},
|
||||
card: CARD_IN_RENAMED_DASHBOARD,
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Bar Dashboard")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Stale Dashboard Name"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show dashboard breadcrumbs without dashboard context", async () => {
|
||||
setup({
|
||||
embedOptions: {
|
||||
breadcrumbs: true,
|
||||
},
|
||||
card: CARD_WITH_DASHBOARD_ID,
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Foo Collection")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Bar Dashboard")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should work for dashboards", async () => {
|
||||
setup({
|
||||
initialRoute: `/dashboard/${BAR_DASHBOARD.id}`,
|
||||
embedOptions: {
|
||||
breadcrumbs: true,
|
||||
},
|
||||
dashboardState: {
|
||||
dashboard: createMockDashboardState({
|
||||
dashboardId: BAR_DASHBOARD.id,
|
||||
dashboards: {
|
||||
[BAR_DASHBOARD.id]: createMockStoreDashboard({
|
||||
id: BAR_DASHBOARD.id,
|
||||
collection_id: FOO_COLLECTION.id,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Foo Collection")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should work for collection pages (UXW-249)", async () => {
|
||||
setup({
|
||||
initialRoute: `/collection/${FOO_COLLECTION.id}-foo-collection`,
|
||||
embedOptions: {
|
||||
breadcrumbs: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Foo Collection")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSearchResultSelection", () => {
|
||||
const indexedEntity = createMockSearchResult({
|
||||
id: 42,
|
||||
model: "indexed-entity",
|
||||
model_id: 7,
|
||||
model_name: "People",
|
||||
});
|
||||
|
||||
it("zooms into the row for an indexed-entity result on the current model", () => {
|
||||
expect(getSearchResultSelection(indexedEntity, 7)).toEqual({
|
||||
type: "zoom",
|
||||
objectId: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it("navigates for an indexed-entity result on a different model", () => {
|
||||
expect(getSearchResultSelection(indexedEntity, 9)).toEqual({
|
||||
type: "navigate",
|
||||
url: modelToUrl(indexedEntity),
|
||||
});
|
||||
});
|
||||
|
||||
it("navigates when there is no current card", () => {
|
||||
expect(getSearchResultSelection(indexedEntity, undefined)).toEqual({
|
||||
type: "navigate",
|
||||
url: modelToUrl(indexedEntity),
|
||||
});
|
||||
});
|
||||
|
||||
it("navigates for a non indexed-entity result even when ids match", () => {
|
||||
const card = createMockSearchResult({ id: 7, model: "card", model_id: 7 });
|
||||
|
||||
expect(getSearchResultSelection(card, 7)).toEqual({
|
||||
type: "navigate",
|
||||
url: modelToUrl(card),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function setup({
|
||||
embedOptions,
|
||||
card = createMockCard(),
|
||||
dashboardState,
|
||||
initialRoute = "/question/1",
|
||||
}: {
|
||||
embedOptions?: Partial<InteractiveEmbeddingOptions>;
|
||||
card?: Card;
|
||||
dashboardState?: { dashboard: DashboardState };
|
||||
initialRoute?: string;
|
||||
}) {
|
||||
// Need to set the location because CollectionBreadcrumbs uses the useLocation()
|
||||
window.history.pushState({}, "", initialRoute);
|
||||
|
||||
setupUserMetabotPermissionsEndpoint();
|
||||
setupCollectionsEndpoints({
|
||||
collections: [FOO_COLLECTION],
|
||||
});
|
||||
setupCollectionByIdEndpoint({ collections: [FOO_COLLECTION] });
|
||||
setupCardEndpoints(card);
|
||||
setupDashboardEndpoints(BAR_DASHBOARD);
|
||||
|
||||
return renderWithProviders(<Route path="*" element={<AppBarContainer />} />, {
|
||||
withRouter: true,
|
||||
initialRoute,
|
||||
storeInitialState: {
|
||||
app: createMockAppState({ isNavbarOpen: false }),
|
||||
embed: createMockEmbedState({
|
||||
options: createMockEmbedOptions({
|
||||
...DEFAULT_INTERACTIVE_EMBEDDING_OPTIONS,
|
||||
...embedOptions,
|
||||
}),
|
||||
}),
|
||||
qb: createMockQueryBuilderState({
|
||||
card,
|
||||
}),
|
||||
...dashboardState,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getMediaQuery(opts?: Partial<MediaQueryList>): MediaQueryList {
|
||||
return {
|
||||
media: "",
|
||||
matches: false,
|
||||
onchange: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
addListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
const windowSelf = window.self;
|
||||
function mockEmbedding() {
|
||||
Object.defineProperty(window, "self", {
|
||||
value: {},
|
||||
});
|
||||
}
|
||||
|
||||
function restoreMockEmbedding() {
|
||||
Object.defineProperty(window, "self", {
|
||||
value: windowSelf,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { skipToken, useGetDashboardQuery } from "metabase/api";
|
||||
import {
|
||||
getCollectionId,
|
||||
getIsCollectionPathVisible,
|
||||
} from "metabase/app/selectors";
|
||||
import { MetabotAppBarButton } from "metabase/metabot/components/MetabotAppBarButton";
|
||||
import { AppSwitcher } from "metabase/nav/components/AppSwitcher";
|
||||
import { SearchButton } from "metabase/nav/components/search/SearchButton/SearchButton";
|
||||
import { CollectionBreadcrumbs } from "metabase/nav/containers/CollectionBreadcrumbs";
|
||||
import { isQuestionPath } from "metabase/nav/containers/MainNavbar/getSelectedItems";
|
||||
import { getQuestion } from "metabase/query_builder";
|
||||
import { useSelector } from "metabase/redux";
|
||||
import { useLocation } from "metabase/router";
|
||||
import { getPageFrame } from "metabase/selectors/app";
|
||||
import { Box, Flex, Group, rem } from "metabase/ui";
|
||||
import { isWithinIframe } from "metabase/utils/iframe";
|
||||
|
||||
/**
|
||||
* The page's own header, in place of the old app bar: where you are on the left, and the
|
||||
* app-wide controls — search, Metabot and the account switcher — on the right. It sits above the
|
||||
* scrolling content rather than spanning the window, so the rail owns the full height of the app.
|
||||
*/
|
||||
export function AppPageHeader() {
|
||||
const location = useLocation();
|
||||
const isCollectionPathVisible = useSelector((state) =>
|
||||
getIsCollectionPathVisible(state, { location }),
|
||||
);
|
||||
const breadcrumbCollectionId = useSelector(getCollectionId);
|
||||
const pageFrame = useSelector(getPageFrame);
|
||||
|
||||
const question = useSelector(getQuestion);
|
||||
const dashboardId = isQuestionPath(location.pathname)
|
||||
? question?.dashboard()?.id
|
||||
: undefined;
|
||||
const { data: dashboard } = useGetDashboardQuery(
|
||||
dashboardId != null ? { id: dashboardId } : skipToken,
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
component="header"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
gap="lg"
|
||||
px={pageFrame?.gutter ?? "xl"}
|
||||
// Centers the row on the nav rail's Library/Playground switcher text: the
|
||||
// rail's 8px top padding + the switcher's 8px padding + half its 38px height
|
||||
// puts that text at 35px, and this row's 32px controls need 19px above them.
|
||||
pt={rem(19)}
|
||||
pb="md"
|
||||
wrap="nowrap"
|
||||
bg={`background_page-${pageFrame?.background ?? "primary"}`}
|
||||
data-testid="app-page-header"
|
||||
>
|
||||
<Box miw={0} style={{ overflow: "hidden" }}>
|
||||
{isCollectionPathVisible && (
|
||||
<CollectionBreadcrumbs
|
||||
dashboard={dashboardId != null ? dashboard : undefined}
|
||||
collectionId={breadcrumbCollectionId ?? undefined}
|
||||
showIcons={false}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Group gap="md" wrap="nowrap" c="text-primary">
|
||||
<SearchButton />
|
||||
{!isWithinIframe() && <MetabotAppBarButton />}
|
||||
<AppSwitcher />
|
||||
</Group>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
1
frontend/src/metabase/app/nav/AppPageHeader/index.ts
Normal file
1
frontend/src/metabase/app/nav/AppPageHeader/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AppPageHeader } from "./AppPageHeader";
|
||||
@@ -9,18 +9,15 @@ import { connect } from "metabase/redux";
|
||||
import type { AdminPath, State, StoreDashboard } from "metabase/redux/store";
|
||||
import { useLocation, useParams } from "metabase/router";
|
||||
import { getAdminPaths } from "metabase/selectors/admin";
|
||||
import { getIsNavbarOpen } from "metabase/selectors/app";
|
||||
import type { User } from "metabase-types/api";
|
||||
|
||||
type NavbarProps = {
|
||||
isOpen: boolean;
|
||||
user: User | null;
|
||||
adminPaths: AdminPath[];
|
||||
dashboard?: StoreDashboard;
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: State) => ({
|
||||
isOpen: getIsNavbarOpen(state),
|
||||
user: getUser(state),
|
||||
adminPaths: getAdminPaths(state),
|
||||
// Can't use the dashboard entity loader instead.
|
||||
@@ -29,7 +26,7 @@ const mapStateToProps = (state: State) => ({
|
||||
dashboard: getDashboard(state),
|
||||
});
|
||||
|
||||
function NavbarInner({ isOpen, user, adminPaths, dashboard }: NavbarProps) {
|
||||
function NavbarInner({ user, adminPaths, dashboard }: NavbarProps) {
|
||||
const location = useLocation();
|
||||
const params = useParams();
|
||||
useListDatabasesQuery();
|
||||
@@ -45,12 +42,7 @@ function NavbarInner({ isOpen, user, adminPaths, dashboard }: NavbarProps) {
|
||||
return isAdminApp ? (
|
||||
<AdminNavbar path={location.pathname} adminPaths={adminPaths} />
|
||||
) : (
|
||||
<MainNavbar
|
||||
isOpen={isOpen}
|
||||
location={location}
|
||||
params={params}
|
||||
dashboard={dashboard}
|
||||
/>
|
||||
<MainNavbar location={location} params={params} dashboard={dashboard} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
import type { Store } from "@reduxjs/toolkit";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import {
|
||||
setupCollectionItemsEndpoint,
|
||||
setupCollectionsEndpoints,
|
||||
setupDatabasesEndpoints,
|
||||
setupGdriveGetFolderEndpoint,
|
||||
setupSearchEndpoints,
|
||||
setupSettingEndpoint,
|
||||
} from "__support__/server-mocks";
|
||||
import {
|
||||
createMockAppState,
|
||||
createMockEmbedOptions,
|
||||
createMockEmbedState,
|
||||
createMockState,
|
||||
} from "__support__/state";
|
||||
import {
|
||||
act,
|
||||
renderWithProviders,
|
||||
screen,
|
||||
waitForLoaderToBeRemoved,
|
||||
} from "__support__/ui";
|
||||
import { ROOT_COLLECTION } from "metabase/common/collections/constants";
|
||||
import {
|
||||
CLOSE_NAVBAR,
|
||||
OPEN_NAVBAR,
|
||||
isNavbarOpenForPathname,
|
||||
} from "metabase/redux/app";
|
||||
import type { State } from "metabase/redux/store";
|
||||
import { Route } from "metabase/router";
|
||||
import * as iframeUtils from "metabase/utils/iframe";
|
||||
import type { User } from "metabase-types/api";
|
||||
import {
|
||||
createMockCollection,
|
||||
createMockDatabase,
|
||||
createMockUser,
|
||||
} from "metabase-types/api/mocks";
|
||||
|
||||
import { Navbar } from "./Navbar";
|
||||
|
||||
type SetupOpts = {
|
||||
isOpen?: boolean;
|
||||
route?: string;
|
||||
pathname?: string;
|
||||
user?: User | null;
|
||||
embedOptions?: Record<string, string | boolean>;
|
||||
};
|
||||
|
||||
async function setup({
|
||||
isOpen,
|
||||
pathname = "/",
|
||||
route = pathname,
|
||||
user = createMockUser(),
|
||||
embedOptions = {},
|
||||
}: SetupOpts = {}) {
|
||||
setupCollectionsEndpoints({ collections: [] });
|
||||
setupDatabasesEndpoints([createMockDatabase()]);
|
||||
setupSearchEndpoints([]);
|
||||
setupCollectionItemsEndpoint({
|
||||
collection: createMockCollection(ROOT_COLLECTION),
|
||||
collectionItems: [],
|
||||
});
|
||||
setupGdriveGetFolderEndpoint({ status: "active" });
|
||||
fetchMock.get("path:/api/bookmark", []);
|
||||
|
||||
setupSettingEndpoint({
|
||||
settingKey: "version-info",
|
||||
settingValue: {},
|
||||
});
|
||||
|
||||
const storeInitialState = createMockState({
|
||||
app: createMockAppState({
|
||||
isNavbarOpen: isOpen ?? isNavbarOpenForPathname(pathname, true),
|
||||
}),
|
||||
embed: createMockEmbedState({
|
||||
options: createMockEmbedOptions(embedOptions),
|
||||
}),
|
||||
currentUser: user,
|
||||
});
|
||||
|
||||
const { store } = renderWithProviders(
|
||||
<Route path={route} element={<Navbar />} />,
|
||||
{
|
||||
storeInitialState,
|
||||
initialRoute: pathname,
|
||||
withRouter: true,
|
||||
withDND: true,
|
||||
},
|
||||
);
|
||||
|
||||
// manually dispatch the location event that the router history sync would otherwise dispatch for us
|
||||
dispatchLocationChange({ store, initialRoute: true, pathname });
|
||||
|
||||
await waitForLoaderToBeRemoved();
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
describe("nav > containers > Navbar > Core App", () => {
|
||||
it("should be open when isOpen is true", async () => {
|
||||
await setup({ isOpen: true });
|
||||
await expectNavbarOpen();
|
||||
});
|
||||
|
||||
it("should be hidden when isOpen is false", async () => {
|
||||
await setup({ isOpen: false });
|
||||
await expectNavbarClosed();
|
||||
});
|
||||
|
||||
it("should not render when signed out", async () => {
|
||||
await setup({ user: null });
|
||||
expect(screen.queryByTestId("main-navbar-root")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render when in the admin app", async () => {
|
||||
await setup({ pathname: "/admin/" });
|
||||
expect(screen.queryByTestId("main-navbar-root")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
["question/1", "model/1", "dashboard/1"].forEach((pathname) => {
|
||||
it(`should be hidden on initial load for a ${pathname}`, async () => {
|
||||
await setup({ pathname: `/${pathname}` });
|
||||
await expectNavbarClosed();
|
||||
});
|
||||
});
|
||||
|
||||
it("should hide when visiting a question", async () => {
|
||||
const store = await setup({ pathname: "/" });
|
||||
await expectNavbarOpen();
|
||||
dispatchLocationChange({ store, pathname: "/question/1" });
|
||||
await expectNavbarClosed();
|
||||
});
|
||||
|
||||
it("should stay open when navigating to the database reference questions page (metabase#72001)", async () => {
|
||||
const store = await setup({ pathname: "/reference/databases/1" });
|
||||
await expectNavbarOpen();
|
||||
dispatchLocationChange({
|
||||
store,
|
||||
pathname: "/reference/databases/1/tables/2/questions",
|
||||
});
|
||||
await expectNavbarOpen();
|
||||
});
|
||||
|
||||
it("should hide when visiting a question and stay hidden when returning to collection", async () => {
|
||||
const store = await setup({ pathname: "/collection/1" });
|
||||
await expectNavbarOpen();
|
||||
dispatchLocationChange({ store, pathname: "/question/1" });
|
||||
await expectNavbarClosed();
|
||||
dispatchLocationChange({ store, pathname: "/collection/1" });
|
||||
await expectNavbarClosed();
|
||||
});
|
||||
|
||||
it("should not close navbar when preserveNavbarState is set", async () => {
|
||||
const store = await setup({ isOpen: true });
|
||||
await expectNavbarOpen();
|
||||
// act(...) flushes the connected Navbar re-render caused by the dispatch
|
||||
act(() => {
|
||||
store.dispatch({
|
||||
type: "@@router/LOCATION_CHANGE",
|
||||
payload: {
|
||||
pathname: "/question/1",
|
||||
state: { preserveNavbarState: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
await expectNavbarOpen();
|
||||
});
|
||||
|
||||
it("should preserve state when navigating collections", async () => {
|
||||
const store = await setup({ pathname: "/collection/1" });
|
||||
await expectNavbarOpen();
|
||||
dispatchLocationChange({ store, pathname: "/collection/2" });
|
||||
await expectNavbarOpen();
|
||||
dispatchLocationChange({ store, pathname: "/question/1" });
|
||||
await expectNavbarClosed();
|
||||
dispatchLocationChange({ store, pathname: "/collection/3" });
|
||||
await expectNavbarClosed();
|
||||
dispatchLocationChange({ store, pathname: "/collection/4" });
|
||||
await expectNavbarClosed();
|
||||
// act(...) flushes the connected Navbar re-render caused by the dispatch
|
||||
act(() => {
|
||||
store.dispatch({ type: OPEN_NAVBAR });
|
||||
});
|
||||
await expectNavbarOpen();
|
||||
dispatchLocationChange({ store, pathname: "/collection/5" });
|
||||
await expectNavbarOpen();
|
||||
act(() => {
|
||||
store.dispatch({ type: CLOSE_NAVBAR });
|
||||
});
|
||||
dispatchLocationChange({ store, pathname: "/collection/6" });
|
||||
await expectNavbarClosed();
|
||||
});
|
||||
|
||||
describe("embedded", () => {
|
||||
let isWithinIframeSpy: jest.SpyInstance;
|
||||
|
||||
beforeAll(() => {
|
||||
isWithinIframeSpy = jest.spyOn(iframeUtils, "isWithinIframe");
|
||||
isWithinIframeSpy.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
isWithinIframeSpy.mockRestore();
|
||||
});
|
||||
|
||||
const normallyHiddenRoutes = ["/model/1", "/dashboard/1", "/question/1"];
|
||||
const normallyVisibleRoutes = ["/"];
|
||||
const allRoutes = [...normallyHiddenRoutes, ...normallyVisibleRoutes];
|
||||
|
||||
allRoutes.forEach((route) => {
|
||||
it(`should be visible when embedded and on ${route} top_nav=false&side_nav=true`, async () => {
|
||||
await setup({
|
||||
pathname: route,
|
||||
isOpen: false, // this should be ignored and overridden by the embedding params
|
||||
embedOptions: { top_nav: false, side_nav: true },
|
||||
});
|
||||
await expectNavbarOpen();
|
||||
});
|
||||
});
|
||||
|
||||
normallyVisibleRoutes.forEach((route) => {
|
||||
it(`should be visible when embedded and on ${route} top_nav=true&side_nav=true`, async () => {
|
||||
await setup({
|
||||
pathname: route,
|
||||
isOpen: true,
|
||||
embedOptions: { top_nav: true, side_nav: true },
|
||||
});
|
||||
await expectNavbarOpen();
|
||||
});
|
||||
});
|
||||
|
||||
normallyHiddenRoutes.forEach((route) => {
|
||||
it(`should not be visible when embedded and on ${route} top_nav=true&side_nav=true`, async () => {
|
||||
await setup({
|
||||
pathname: route,
|
||||
isOpen: false,
|
||||
embedOptions: { top_nav: true, side_nav: true },
|
||||
});
|
||||
await expectNavbarClosed();
|
||||
});
|
||||
});
|
||||
|
||||
normallyHiddenRoutes.forEach((route) => {
|
||||
it(`should not be visible when embedded and on ${route} top_nav=true&side_nav=true`, async () => {
|
||||
await setup({
|
||||
pathname: route,
|
||||
isOpen: false,
|
||||
embedOptions: { top_nav: true, side_nav: true },
|
||||
});
|
||||
await expectNavbarClosed();
|
||||
});
|
||||
});
|
||||
|
||||
// the current state of AppComponent.tsx is such that this should never even happen because we don't even render the parent component
|
||||
// but this test will cover any future changes in the component tree
|
||||
allRoutes.forEach((route) => {
|
||||
it(`should not be visible when embedded and on ${route} with side_nav=false`, async () => {
|
||||
await setup({
|
||||
pathname: route,
|
||||
isOpen: false,
|
||||
embedOptions: { side_nav: false },
|
||||
});
|
||||
await expectNavbarClosed();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function expectNavbarOpen() {
|
||||
const navbar = await screen.findByTestId("main-navbar-root");
|
||||
expect(navbar).toBeVisible();
|
||||
expect(navbar).toHaveAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
async function expectNavbarClosed() {
|
||||
const navbar = await screen.findByTestId("main-navbar-root");
|
||||
expect(navbar).not.toBeVisible();
|
||||
expect(navbar).toHaveAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
interface DispatchLocationChangeParams {
|
||||
store: Store<State>;
|
||||
initialRoute?: boolean;
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
function dispatchLocationChange({
|
||||
store,
|
||||
initialRoute = false,
|
||||
pathname,
|
||||
}: DispatchLocationChangeParams) {
|
||||
// The dispatch synchronously re-renders the connected Navbar; wrap it in
|
||||
// act(...) so React's state updates are flushed within the test instead
|
||||
// of leaking past it.
|
||||
act(() => {
|
||||
store.dispatch({
|
||||
type: "@@router/LOCATION_CHANGE",
|
||||
payload: {
|
||||
pathname,
|
||||
action: initialRoute ? "POP" : "PUSH",
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { Link } from "metabase/common/components/Link/Link";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { Ellipsified } from "metabase/ui";
|
||||
import * as Urls from "metabase/urls";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
|
||||
export interface QuestionLineageProps {
|
||||
question?: Question;
|
||||
originalQuestion?: Question;
|
||||
}
|
||||
|
||||
export const QuestionLineage = ({
|
||||
question,
|
||||
originalQuestion,
|
||||
}: QuestionLineageProps): JSX.Element | null => {
|
||||
if (!question || !originalQuestion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Ellipsified
|
||||
c="text-secondary"
|
||||
fw="bold"
|
||||
lh="normal"
|
||||
miw={0}
|
||||
fz="0.875em"
|
||||
showTooltip={false}
|
||||
>
|
||||
{t`Started from`}{" "}
|
||||
<Link className={CS.link} to={Urls.question(originalQuestion)}>
|
||||
{originalQuestion.displayName()}
|
||||
</Link>
|
||||
</Ellipsified>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./QuestionLineage";
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
import { getCurrentDocument } from "metabase/documents/selectors";
|
||||
import { getEmbedOptions } from "metabase/embedding/interactive-embedding";
|
||||
import { getCurrentExploration } from "metabase/explorations/selectors";
|
||||
import { getIsSavedQuestionChanged, getQuestion } from "metabase/query_builder";
|
||||
import { getQuestion } from "metabase/query_builder";
|
||||
import type { State } from "metabase/redux/store";
|
||||
import { type RouterProps, getDetailViewState } from "metabase/selectors/app";
|
||||
import { type RouterProps, getPageCollection } from "metabase/selectors/app";
|
||||
import * as Urls from "metabase/urls";
|
||||
import { selectIsWithinIframe } from "metabase/utils/iframe";
|
||||
|
||||
@@ -41,36 +41,6 @@ export const getIsDataApp = createSelector([getRouterPath], (path) => {
|
||||
return path.startsWith(`${Urls.DATA_APP_ROOT_URL}/`);
|
||||
});
|
||||
|
||||
export const getIsMetricsViewer = createSelector([getRouterPath], (path) => {
|
||||
return path.startsWith("/explore");
|
||||
});
|
||||
|
||||
export const getIsLogoVisible = createSelector(
|
||||
[selectIsWithinIframe, getEmbedOptions],
|
||||
(isEmbeddingIframe, embedOptions) => {
|
||||
return !isEmbeddingIframe || embedOptions.logo;
|
||||
},
|
||||
);
|
||||
|
||||
export const getIsSearchVisible = createSelector(
|
||||
[selectIsWithinIframe, getEmbedOptions],
|
||||
(isEmbeddingIframe, embedOptions) => {
|
||||
return !isEmbeddingIframe || embedOptions.search;
|
||||
},
|
||||
);
|
||||
|
||||
export const getIsNewButtonVisible = createSelector(
|
||||
[selectIsWithinIframe, getEmbedOptions],
|
||||
(isEmbeddingIframe, embedOptions) => {
|
||||
return !isEmbeddingIframe || embedOptions.new_button;
|
||||
},
|
||||
);
|
||||
|
||||
export const getIsAppSwitcherVisible = createSelector(
|
||||
[selectIsWithinIframe],
|
||||
(isEmbeddingIframe) => !isEmbeddingIframe,
|
||||
);
|
||||
|
||||
const PATHS_WITHOUT_NAVBAR = [
|
||||
/^\/setup/,
|
||||
/^\/auth/,
|
||||
@@ -99,13 +69,13 @@ const PATHS_WITH_COLLECTION_BREADCRUMBS = [
|
||||
// Paths where collection identity comes from the URL itself, so breadcrumbs
|
||||
// can render without needing a question/dashboard/document in redux state.
|
||||
const STANDALONE_COLLECTION_BREADCRUMB_PATHS = [/\/collection\//];
|
||||
const PATHS_WITH_QUESTION_LINEAGE = [/\/question/, /\/model/];
|
||||
|
||||
export const getIsCollectionPathVisible = createSelector(
|
||||
[
|
||||
getQuestion,
|
||||
getDashboard,
|
||||
getCurrentDocument,
|
||||
getPageCollection,
|
||||
getRouterPath,
|
||||
selectIsWithinIframe,
|
||||
getEmbedOptions,
|
||||
@@ -115,6 +85,7 @@ export const getIsCollectionPathVisible = createSelector(
|
||||
question,
|
||||
dashboard,
|
||||
document,
|
||||
pageCollection,
|
||||
path,
|
||||
isEmbedded,
|
||||
embedOptions,
|
||||
@@ -124,8 +95,8 @@ export const getIsCollectionPathVisible = createSelector(
|
||||
return false;
|
||||
}
|
||||
|
||||
const isModelDetail = /\/model\/.*\/detail\/.*/.test(path);
|
||||
if (isModelDetail) {
|
||||
// A page that names its own collection gets breadcrumbs wherever it lives.
|
||||
if (pageCollection) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -147,13 +118,6 @@ export const getIsCollectionPathVisible = createSelector(
|
||||
},
|
||||
);
|
||||
|
||||
export const getIsQuestionLineageVisible = createSelector(
|
||||
[getIsSavedQuestionChanged, getRouterPath],
|
||||
(isSavedQuestionChanged, path) =>
|
||||
isSavedQuestionChanged &&
|
||||
PATHS_WITH_QUESTION_LINEAGE.some((pattern) => pattern.test(path)),
|
||||
);
|
||||
|
||||
export const getIsNavBarEnabled = createSelector(
|
||||
[
|
||||
getUser,
|
||||
@@ -174,31 +138,16 @@ export const getIsNavBarEnabled = createSelector(
|
||||
},
|
||||
);
|
||||
|
||||
const getIsEmbeddedAppBarVisible = createSelector(
|
||||
[
|
||||
getEmbedOptions,
|
||||
getIsQuestionLineageVisible,
|
||||
getIsCollectionPathVisible,
|
||||
getIsNavBarEnabled,
|
||||
],
|
||||
(
|
||||
embedOptions,
|
||||
isQuestionLineageVisible,
|
||||
isCollectionPathVisible,
|
||||
isNavBarEnabled,
|
||||
) => {
|
||||
const anyEmbeddedAppBarElementVisible =
|
||||
isNavBarEnabled ||
|
||||
embedOptions.search ||
|
||||
embedOptions.new_button ||
|
||||
embedOptions.logo ||
|
||||
isQuestionLineageVisible ||
|
||||
isCollectionPathVisible;
|
||||
return embedOptions.top_nav && anyEmbeddedAppBarElementVisible;
|
||||
const getIsEmbeddedPageHeaderVisible = createSelector(
|
||||
[getEmbedOptions, getIsCollectionPathVisible, getIsNavBarEnabled],
|
||||
(embedOptions, isCollectionPathVisible, isNavBarEnabled) => {
|
||||
const anyEmbeddedHeaderElementVisible =
|
||||
isNavBarEnabled || embedOptions.search || isCollectionPathVisible;
|
||||
return embedOptions.top_nav && anyEmbeddedHeaderElementVisible;
|
||||
},
|
||||
);
|
||||
|
||||
export const getIsAppBarVisible = createSelector(
|
||||
export const getIsPageHeaderVisible = createSelector(
|
||||
[
|
||||
getUser,
|
||||
getRouterPath,
|
||||
@@ -208,7 +157,7 @@ export const getIsAppBarVisible = createSelector(
|
||||
getIsMonitorApp,
|
||||
getIsEditingDashboard,
|
||||
selectIsWithinIframe,
|
||||
getIsEmbeddedAppBarVisible,
|
||||
getIsEmbeddedPageHeaderVisible,
|
||||
],
|
||||
(
|
||||
currentUser,
|
||||
@@ -219,13 +168,13 @@ export const getIsAppBarVisible = createSelector(
|
||||
isMonitorApp,
|
||||
isEditingDashboard,
|
||||
isEmbedded,
|
||||
isEmbeddedAppBarVisible,
|
||||
isEmbeddedPageHeaderVisible,
|
||||
) => {
|
||||
const isFullscreen = hash.includes("fullscreen");
|
||||
|
||||
if (
|
||||
!currentUser ||
|
||||
(isEmbedded && !isEmbeddedAppBarVisible) ||
|
||||
(isEmbedded && !isEmbeddedPageHeaderVisible) ||
|
||||
isAdminApp ||
|
||||
isDataStudioApp ||
|
||||
isMonitorApp ||
|
||||
@@ -244,7 +193,7 @@ export const getCollectionId = createSelector(
|
||||
getDashboard,
|
||||
getDashboardId,
|
||||
getCurrentDocument,
|
||||
getDetailViewState,
|
||||
getPageCollection,
|
||||
getRouterPath,
|
||||
getCurrentExploration,
|
||||
],
|
||||
@@ -253,12 +202,12 @@ export const getCollectionId = createSelector(
|
||||
dashboard,
|
||||
dashboardId,
|
||||
document,
|
||||
detailView,
|
||||
pageCollection,
|
||||
path,
|
||||
exploration,
|
||||
) => {
|
||||
if (detailView) {
|
||||
return detailView.collectionId;
|
||||
if (pageCollection) {
|
||||
return pageCollection.collectionId;
|
||||
}
|
||||
|
||||
if (document) {
|
||||
|
||||
@@ -4,9 +4,9 @@ import type { RouterProps } from "metabase/selectors/app";
|
||||
import { createMockUser } from "metabase-types/api/mocks";
|
||||
|
||||
import {
|
||||
getIsAppBarVisible,
|
||||
getIsCollectionPathVisible,
|
||||
getIsNavBarEnabled,
|
||||
getIsPageHeaderVisible,
|
||||
} from "./selectors";
|
||||
|
||||
const createLocation = (pathname: string): Location =>
|
||||
@@ -48,11 +48,11 @@ describe("getIsCollectionPathVisible", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("NavBar / AppBar visibility", () => {
|
||||
describe("NavBar / page header visibility", () => {
|
||||
const stateWithUser = () =>
|
||||
createMockState({ currentUser: createMockUser() });
|
||||
|
||||
it("hides the navbar and app bar within Monitor, Data Studio", () => {
|
||||
it("hides the navbar and page header within Monitor, Data Studio", () => {
|
||||
const state = stateWithUser();
|
||||
|
||||
expect(getIsNavBarEnabled(state, createRouterProps("/monitor"))).toBe(
|
||||
@@ -62,22 +62,22 @@ describe("NavBar / AppBar visibility", () => {
|
||||
false,
|
||||
);
|
||||
|
||||
expect(getIsAppBarVisible(state, createRouterProps("/monitor"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(getIsAppBarVisible(state, createRouterProps("/data-studio"))).toBe(
|
||||
expect(getIsPageHeaderVisible(state, createRouterProps("/monitor"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
getIsPageHeaderVisible(state, createRouterProps("/data-studio")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the navbar and app bar on a regular page", () => {
|
||||
it("keeps the navbar and page header on a regular page", () => {
|
||||
const state = stateWithUser();
|
||||
|
||||
expect(
|
||||
getIsNavBarEnabled(state, createRouterProps("/browse/databases")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
getIsAppBarVisible(state, createRouterProps("/browse/databases")),
|
||||
getIsPageHeaderVisible(state, createRouterProps("/browse/databases")),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/* Applied after `.browseHeader`, whose `3rem` of bottom padding is too much now that the header
|
||||
sits directly above a bordered panel. `/browse/databases` still uses the original. */
|
||||
.header {
|
||||
padding-bottom: var(--mantine-spacing-lg);
|
||||
}
|
||||
89
frontend/src/metabase/browse/components/BrowsePageLayout.tsx
Normal file
89
frontend/src/metabase/browse/components/BrowsePageLayout.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import cx from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Flex, Group, Icon, Stack, Text, Title } from "metabase/ui";
|
||||
import type { IconName } from "metabase-types/api";
|
||||
|
||||
import S from "./BrowseContainer.module.css";
|
||||
import Layout from "./BrowsePageLayout.module.css";
|
||||
|
||||
export type BrowsePageLayoutProps = {
|
||||
icon: IconName;
|
||||
title: string;
|
||||
titleId: string;
|
||||
/** Dot-separated facts under the title. Falsy entries are dropped. */
|
||||
meta?: (string | false | null | undefined)[];
|
||||
/** One explanatory paragraph under the meta row. */
|
||||
description?: string;
|
||||
/** Right-aligned header controls. */
|
||||
actions?: ReactNode;
|
||||
testId: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function BrowsePageLayout({
|
||||
icon,
|
||||
title,
|
||||
titleId,
|
||||
meta,
|
||||
description,
|
||||
actions,
|
||||
testId,
|
||||
children,
|
||||
}: BrowsePageLayoutProps) {
|
||||
const facts = meta?.filter((fact): fact is string => Boolean(fact)) ?? [];
|
||||
|
||||
return (
|
||||
<Flex
|
||||
className={S.browseContainer}
|
||||
flex={1}
|
||||
direction="column"
|
||||
wrap="nowrap"
|
||||
pt="lg"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<Flex
|
||||
className={cx(S.browseHeader, Layout.header)}
|
||||
direction="column"
|
||||
role="heading"
|
||||
data-testid={testId}
|
||||
>
|
||||
<Stack maw="64rem" mx="auto" w="100%" gap="sm">
|
||||
<Flex w="100%" mih="2.25rem" justify="space-between" align="center">
|
||||
<Title order={1} c="text-primary" id={titleId}>
|
||||
<Group gap="sm">
|
||||
<Icon size={24} c="icon-brand" name={icon} />
|
||||
{title}
|
||||
</Group>
|
||||
</Title>
|
||||
<Group gap="xxs">{actions}</Group>
|
||||
</Flex>
|
||||
{facts.length > 0 && (
|
||||
<Group gap="xs">
|
||||
{facts.map((fact, index) => (
|
||||
<Group gap="xs" key={fact}>
|
||||
{index > 0 && <Text c="text-tertiary">·</Text>}
|
||||
<Text size="sm" c="text-secondary">
|
||||
{fact}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
{description && (
|
||||
<Text size="sm" c="text-secondary" maw="48rem">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Flex>
|
||||
<Flex className={S.browseMain} direction="column" wrap="nowrap" flex={1}>
|
||||
<Flex maw="64rem" mx="auto" w="100%">
|
||||
<Stack mb="xl" gap="lg" w="100%">
|
||||
{children}
|
||||
</Stack>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import _ from "underscore";
|
||||
|
||||
import NoResults from "assets/img/metrics_bot.svg";
|
||||
import { skipToken } from "metabase/api";
|
||||
import { DetailPanel } from "metabase/common/components/DetailPanel";
|
||||
import { EmptyState } from "metabase/common/components/EmptyState";
|
||||
import { ExternalLink } from "metabase/common/components/ExternalLink";
|
||||
import { ForwardRefLink, Link } from "metabase/common/components/Link";
|
||||
@@ -11,6 +12,7 @@ import { DelayedLoadingAndErrorWrapper } from "metabase/common/components/Loadin
|
||||
import { trackMetricCreateStarted } from "metabase/common/data-studio/analytics";
|
||||
import { useDocsUrl } from "metabase/common/hooks";
|
||||
import { canUserCreateQueries } from "metabase/current-user";
|
||||
import { useNavSection } from "metabase/nav/containers/MainNavbar/use-nav-section";
|
||||
import { PLUGIN_CONTENT_VERIFICATION, PLUGIN_LIBRARY } from "metabase/plugins";
|
||||
import { useSelector } from "metabase/redux";
|
||||
import {
|
||||
@@ -18,17 +20,15 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Group,
|
||||
Icon,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "metabase/ui";
|
||||
import * as Urls from "metabase/urls";
|
||||
import { isWithinIframe } from "metabase/utils/iframe";
|
||||
|
||||
import S from "../components/BrowseContainer.module.css";
|
||||
import { BrowsePageLayout } from "../components/BrowsePageLayout";
|
||||
import { partitionByAuthority } from "../utils";
|
||||
|
||||
import { MetricsTable } from "./MetricsTable";
|
||||
import { trackNewMetricInitiated } from "./analytics";
|
||||
@@ -49,6 +49,14 @@ export function BrowseMetrics() {
|
||||
const isEmpty = !isLoading && !error && !metrics?.length;
|
||||
const titleId = useMemo(() => _.uniqueId("browse-metrics"), []);
|
||||
|
||||
const { section, setSection } = useNavSection();
|
||||
const { official, unofficial } = useMemo(
|
||||
() => partitionByAuthority(metrics ?? []),
|
||||
[metrics],
|
||||
);
|
||||
const isOfficial = section === "official";
|
||||
const shown = isOfficial ? official : unofficial;
|
||||
|
||||
const { data: libraryMetricCollection } =
|
||||
PLUGIN_LIBRARY.useGetLibraryChildCollectionByType({
|
||||
type: "library-metrics",
|
||||
@@ -64,84 +72,80 @@ export function BrowseMetrics() {
|
||||
const canCreateMetric = !isEmbeddingIframe && hasDataAccess;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
className={S.browseContainer}
|
||||
flex={1}
|
||||
direction="column"
|
||||
wrap="nowrap"
|
||||
pt="lg"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<Flex
|
||||
className={S.browseHeader}
|
||||
direction="column"
|
||||
role="heading"
|
||||
data-testid="browse-metrics-header"
|
||||
>
|
||||
<Flex maw="64rem" mx="auto" w="100%">
|
||||
<Flex
|
||||
w="100%"
|
||||
h="2.25rem"
|
||||
direction="row"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
<BrowsePageLayout
|
||||
icon="metric"
|
||||
title={t`Metrics`}
|
||||
titleId={titleId}
|
||||
testId="browse-metrics-header"
|
||||
meta={[
|
||||
t`Showing ${shown.length} of ${official.length + unofficial.length} metrics`,
|
||||
isOfficial ? t`Official only` : t`Unofficial only`,
|
||||
]}
|
||||
description={t`The numbers your team has agreed on. Official metrics live in the Library or in a collection marked official; the rest are in the Playground.`}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
onClick={() => setSection(isOfficial ? "unofficial" : "official")}
|
||||
>
|
||||
<Title order={1} c="text-primary" id={titleId}>
|
||||
<Group gap="sm">
|
||||
<Icon size={24} c="icon-brand" name="metric" />
|
||||
{t`Metrics`}
|
||||
</Group>
|
||||
</Title>
|
||||
<Group gap="xxs">
|
||||
{canCreateMetric && (
|
||||
<Tooltip label={t`Create a new metric`} position="bottom">
|
||||
<ActionIcon
|
||||
aria-label={t`Create a new metric`}
|
||||
size={32}
|
||||
variant="viewHeader"
|
||||
component={ForwardRefLink}
|
||||
to={newMetricLink}
|
||||
onClick={() => {
|
||||
trackNewMetricInitiated();
|
||||
trackMetricCreateStarted("browse_metrics");
|
||||
}}
|
||||
>
|
||||
<Icon name="add" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{hasVerifiedMetrics && (
|
||||
<MetricFilterControls
|
||||
metricFilters={metricFilters}
|
||||
setMetricFilters={setMetricFilters}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex className={S.browseMain} direction="column" wrap="nowrap" flex={1}>
|
||||
<Flex maw="64rem" mx="auto" w="100%">
|
||||
<Stack mb="xl" gap="lg" w="100%">
|
||||
{isEmpty ? (
|
||||
<MetricsEmptyState
|
||||
canCreateMetric={canCreateMetric}
|
||||
newMetricLink={newMetricLink}
|
||||
/>
|
||||
) : (
|
||||
<DelayedLoadingAndErrorWrapper
|
||||
error={error}
|
||||
loading={isLoading}
|
||||
style={{ flex: 1 }}
|
||||
loader={<MetricsTable skeleton />}
|
||||
{isOfficial ? t`Show unofficial` : t`Show official`}
|
||||
</Button>
|
||||
{canCreateMetric && (
|
||||
<Tooltip label={t`Create a new metric`} position="bottom">
|
||||
<ActionIcon
|
||||
aria-label={t`Create a new metric`}
|
||||
size={32}
|
||||
variant="viewHeader"
|
||||
component={ForwardRefLink}
|
||||
to={newMetricLink}
|
||||
onClick={() => {
|
||||
trackNewMetricInitiated();
|
||||
trackMetricCreateStarted("browse_metrics");
|
||||
}}
|
||||
>
|
||||
<MetricsTable metrics={metrics} />
|
||||
</DelayedLoadingAndErrorWrapper>
|
||||
<Icon name="add" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{hasVerifiedMetrics && (
|
||||
<MetricFilterControls
|
||||
metricFilters={metricFilters}
|
||||
setMetricFilters={setMetricFilters}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<MetricsEmptyState
|
||||
canCreateMetric={canCreateMetric}
|
||||
newMetricLink={newMetricLink}
|
||||
/>
|
||||
) : (
|
||||
<DetailPanel
|
||||
flush
|
||||
title={isOfficial ? t`Official metrics` : t`Unofficial metrics`}
|
||||
>
|
||||
<DelayedLoadingAndErrorWrapper
|
||||
error={error}
|
||||
loading={isLoading}
|
||||
style={{ flex: 1 }}
|
||||
loader={<MetricsTable skeleton />}
|
||||
>
|
||||
{shown.length === 0 ? (
|
||||
<Text p="lg" c="text-secondary">
|
||||
{isOfficial
|
||||
? t`No official metrics yet. Publish a metric to the Library, or mark its collection official.`
|
||||
: t`Every metric is official.`}
|
||||
</Text>
|
||||
) : (
|
||||
<MetricsTable metrics={shown} />
|
||||
)}
|
||||
</Stack>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</DelayedLoadingAndErrorWrapper>
|
||||
</DetailPanel>
|
||||
)}
|
||||
</BrowsePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Columns } from "metabase/common/components/ItemsTable/Columns";
|
||||
import type { ResponsiveProps } from "metabase/common/components/ItemsTable/utils";
|
||||
import { Link } from "metabase/common/components/Link";
|
||||
import { MarkdownPreview } from "metabase/common/components/MarkdownPreview";
|
||||
import { UserAvatar } from "metabase/common/components/UserAvatar";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { useNavigate } from "metabase/router";
|
||||
import {
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
Menu,
|
||||
Repeat,
|
||||
Skeleton,
|
||||
Text,
|
||||
} from "metabase/ui";
|
||||
import * as Urls from "metabase/urls";
|
||||
import type { IconName, SortingOptions } from "metabase-types/api";
|
||||
@@ -72,11 +74,17 @@ const collectionProps: ResponsiveProps = {
|
||||
hideAtContainerBreakpoint: "sm",
|
||||
};
|
||||
|
||||
const ownerProps: ResponsiveProps = {
|
||||
...sharedProps,
|
||||
hideAtContainerBreakpoint: "md",
|
||||
};
|
||||
|
||||
const menuProps = {
|
||||
...sharedProps,
|
||||
};
|
||||
|
||||
const DOTMENU_WIDTH = 34;
|
||||
const OWNER_WIDTH = 180;
|
||||
|
||||
export function MetricsTable({
|
||||
skeleton = false,
|
||||
@@ -89,7 +97,7 @@ export function MetricsTable({
|
||||
const handleSortingOptionsChange = skeleton ? undefined : setSortingOptions;
|
||||
|
||||
/** The name column has an explicitly set width. The remaining columns divide the remaining width. This is the percentage allocated to the collection column */
|
||||
const collectionWidth = 30;
|
||||
const collectionWidth = 26;
|
||||
const descriptionWidth = 100 - collectionWidth;
|
||||
|
||||
return (
|
||||
@@ -99,6 +107,7 @@ export function MetricsTable({
|
||||
<col className={BrowseTableS.nameColumn} />
|
||||
<TableColumn {...collectionProps} width={`${collectionWidth}%`} />
|
||||
<TableColumn {...descriptionProps} width={`${descriptionWidth}%`} />
|
||||
<TableColumn {...ownerProps} width={OWNER_WIDTH} />
|
||||
<TableColumn {...menuProps} width={DOTMENU_WIDTH} />
|
||||
<Columns.RightEdge.Col />
|
||||
</colgroup>
|
||||
@@ -142,6 +151,10 @@ export function MetricsTable({
|
||||
>
|
||||
{t`Description`}
|
||||
</SortableColumnHeader>
|
||||
{/* Not sortable: `SortColumn` only covers name/collection/description. */}
|
||||
<ColumnHeader {...ownerProps} style={{ paddingInline: ".5rem" }}>
|
||||
{t`Owner`}
|
||||
</ColumnHeader>
|
||||
<ColumnHeader
|
||||
style={{
|
||||
paddingInline: ".5rem",
|
||||
@@ -208,6 +221,7 @@ function MetricRow({ metric }: { metric?: MetricResult }) {
|
||||
<NameCell metric={metric} />
|
||||
<CollectionCell metric={metric} />
|
||||
<DescriptionCell metric={metric} />
|
||||
<OwnerCell metric={metric} />
|
||||
<MenuCell metric={metric} />
|
||||
<Columns.RightEdge.Cell />
|
||||
</tr>
|
||||
@@ -310,6 +324,27 @@ function DescriptionCell({ metric }: { metric?: MetricResult }) {
|
||||
);
|
||||
}
|
||||
|
||||
function OwnerCell({ metric }: { metric?: MetricResult }) {
|
||||
const name = metric
|
||||
? (metric.last_editor_common_name ?? metric.creator_common_name)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<td className={cx(BrowseTableS.cell, BrowseTableS.hideAtMd)}>
|
||||
{!metric ? (
|
||||
<SkeletonText />
|
||||
) : name ? (
|
||||
<Flex align="center" gap="sm">
|
||||
<UserAvatar user={{ common_name: name }} size="1.25rem" decorative />
|
||||
<Text size="sm" truncate>
|
||||
{name}
|
||||
</Text>
|
||||
</Flex>
|
||||
) : null}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
type MetricAction = {
|
||||
key: string;
|
||||
title: string;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { getCollectionPathAsString } from "metabase/common/collections/utils";
|
||||
import { formatValue } from "metabase/value-formatting";
|
||||
import { isDate } from "metabase-lib/v1/types/utils/isa";
|
||||
import type { Dataset, SortingOptions } from "metabase-types/api";
|
||||
import type { SortingOptions } from "metabase-types/api";
|
||||
|
||||
import type { MetricResult, SortColumn } from "./types";
|
||||
|
||||
@@ -53,99 +51,3 @@ export function sortMetrics(
|
||||
return sort_direction === "asc" ? result : -result;
|
||||
});
|
||||
}
|
||||
|
||||
export function isDatasetScalar(dataset: Dataset) {
|
||||
if (dataset.error) {
|
||||
return false;
|
||||
}
|
||||
return dataset.data.cols.length === 1 && dataset.data.rows.length === 1;
|
||||
}
|
||||
|
||||
export function isDatasetTemporalMetric(dataset: Dataset) {
|
||||
if (dataset.error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cols = dataset.data.cols;
|
||||
if (cols.length !== 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const col = cols[0];
|
||||
|
||||
// TODO(romeovs): isDate should not be used but there is not easy alternative for now
|
||||
if (!isDate(col)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getDatasetValueForMetric(dataset: Dataset) {
|
||||
if (isDatasetScalar(dataset)) {
|
||||
return getMetricValueForScalarMetric(dataset);
|
||||
}
|
||||
if (isDatasetTemporalMetric(dataset)) {
|
||||
return getMetricValueForTemporalMetric(dataset);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getMetricValueForScalarMetric(dataset: Dataset) {
|
||||
const { cols, rows } = dataset.data;
|
||||
|
||||
const lastRow = rows?.at(-1) ?? [];
|
||||
|
||||
const [value] = lastRow;
|
||||
const [valueColumn] = cols;
|
||||
|
||||
if (value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: t`Overall`,
|
||||
value: formatValue(value, {
|
||||
jsx: true,
|
||||
rich: true,
|
||||
column: valueColumn,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function getMetricValueForTemporalMetric(dataset: Dataset) {
|
||||
// This returns the last row of the dataset, which usually represents the latest
|
||||
// value of a metric with a temporal breakout.
|
||||
// However, if the metric has values in the future, or a sort clause that changes
|
||||
// the order of the rows, this will not return the latest value per se.
|
||||
const { cols, rows } = dataset.data;
|
||||
|
||||
if (!rows || rows.length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastRow = rows?.at(-1);
|
||||
if (!lastRow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [label, value] = lastRow;
|
||||
const [labelColumn, valueColumn] = cols;
|
||||
|
||||
if (value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: formatValue(label, {
|
||||
jsx: true,
|
||||
rich: true,
|
||||
column: labelColumn,
|
||||
}),
|
||||
value: formatValue(value, {
|
||||
jsx: true,
|
||||
rich: true,
|
||||
column: valueColumn,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import {
|
||||
createMockCollection,
|
||||
createMockColumn,
|
||||
createMockDataset,
|
||||
createMockDatasetData,
|
||||
} from "metabase-types/api/mocks";
|
||||
import { createMockCollection } from "metabase-types/api/mocks";
|
||||
|
||||
import { createMockMetricResult } from "./test-utils";
|
||||
import type { MetricResult } from "./types";
|
||||
import {
|
||||
getDatasetValueForMetric,
|
||||
isDatasetScalar,
|
||||
sortMetrics,
|
||||
} from "./utils";
|
||||
import { sortMetrics } from "./utils";
|
||||
|
||||
describe("sortMetrics", () => {
|
||||
let id = 0;
|
||||
@@ -133,153 +124,3 @@ describe("sortMetrics", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDatasetScalar", () => {
|
||||
it("should return true for a dataset with a single column and a single row", () => {
|
||||
const dataset = createMockDataset({
|
||||
data: createMockDatasetData({
|
||||
cols: [createMockColumn({ name: "col1" })],
|
||||
rows: [[1]],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(isDatasetScalar(dataset)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for a dataset with more than one column", () => {
|
||||
const dataset = createMockDataset({
|
||||
data: createMockDatasetData({
|
||||
cols: [
|
||||
createMockColumn({ name: "col1" }),
|
||||
createMockColumn({ name: "col2" }),
|
||||
],
|
||||
rows: [[1, 2]],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(isDatasetScalar(dataset)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for a dataset with more than one row", () => {
|
||||
const dataset = createMockDataset({
|
||||
data: createMockDatasetData({
|
||||
cols: [createMockColumn({ name: "col1" })],
|
||||
rows: [[1], [2]],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(isDatasetScalar(dataset)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for a dataset with errors", () => {
|
||||
const dataset = createMockDataset({
|
||||
error: "error",
|
||||
data: createMockDatasetData({
|
||||
cols: [createMockColumn({ name: "col1" })],
|
||||
rows: [[1]],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(isDatasetScalar(dataset)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDatasetValueForMetric", () => {
|
||||
describe("scalar metric", () => {
|
||||
it("should return null if the dataset is not scalar", () => {
|
||||
const dataset = createMockDataset({
|
||||
data: createMockDatasetData({
|
||||
cols: [createMockColumn({ name: "col1" })],
|
||||
rows: [[1], [2]],
|
||||
}),
|
||||
});
|
||||
expect(getDatasetValueForMetric(dataset)).toBe(null);
|
||||
});
|
||||
|
||||
it("should return null for a scalar dataset with errors", () => {
|
||||
const dataset = createMockDataset({
|
||||
error: "error",
|
||||
data: createMockDatasetData({
|
||||
cols: [createMockColumn({ name: "col1" })],
|
||||
rows: [[1]],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(getDatasetValueForMetric(dataset)).toBe(null);
|
||||
});
|
||||
|
||||
it("should return the value if the dataset is scalar", () => {
|
||||
const value = 42;
|
||||
const column = createMockColumn({ name: "col1" });
|
||||
const dataset = createMockDataset({
|
||||
data: createMockDatasetData({
|
||||
cols: [column],
|
||||
rows: [[value]],
|
||||
}),
|
||||
});
|
||||
expect(getDatasetValueForMetric(dataset)).toEqual({
|
||||
value: "42",
|
||||
label: "Overall",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null for a scalar dataset with no value", () => {
|
||||
const dataset = createMockDataset({
|
||||
data: createMockDatasetData({
|
||||
cols: [createMockColumn({ name: "col2" })],
|
||||
rows: [],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(getDatasetValueForMetric(dataset)).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("temporal metric", () => {
|
||||
it("should return null for a timeseries dataset with errors", () => {
|
||||
const dataset = createMockDataset({
|
||||
error: "error",
|
||||
data: createMockDatasetData({
|
||||
cols: [
|
||||
createMockColumn({ name: "col1", base_type: "type/DateTime" }),
|
||||
createMockColumn({ name: "col2" }),
|
||||
],
|
||||
rows: [["2024-01-01T00:00:00.000Z", 1]],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(getDatasetValueForMetric(dataset)).toBe(null);
|
||||
});
|
||||
|
||||
it("should return the last row value a timeseries dataset", () => {
|
||||
const dataset = createMockDataset({
|
||||
data: createMockDatasetData({
|
||||
cols: [
|
||||
createMockColumn({ name: "col1", base_type: "type/DateTime" }),
|
||||
createMockColumn({ name: "col2" }),
|
||||
],
|
||||
rows: [["2024-01-01T00:00:00.000Z", 1]],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(getDatasetValueForMetric(dataset)).toEqual({
|
||||
value: "1",
|
||||
label: "January 1, 2024, 12:00 AM",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null for a temporal dataset with no value", () => {
|
||||
const dataset = createMockDataset({
|
||||
data: createMockDatasetData({
|
||||
cols: [
|
||||
createMockColumn({ name: "col1", base_type: "type/DateTime" }),
|
||||
createMockColumn({ name: "col2" }),
|
||||
],
|
||||
rows: [],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(getDatasetValueForMetric(dataset)).toEqual(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
import { skipToken, useListRecentsQuery } from "metabase/api";
|
||||
import { DetailPanel } from "metabase/common/components/DetailPanel";
|
||||
import { ExternalLink } from "metabase/common/components/ExternalLink";
|
||||
import { ForwardRefLink } from "metabase/common/components/Link";
|
||||
import { DelayedLoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper/DelayedLoadingAndErrorWrapper";
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
canUserCreateNativeQueries,
|
||||
canUserCreateQueries,
|
||||
} from "metabase/current-user";
|
||||
import { useNavSection } from "metabase/nav/containers/MainNavbar/use-nav-section";
|
||||
import {
|
||||
PLUGIN_COLLECTIONS,
|
||||
PLUGIN_CONTENT_VERIFICATION,
|
||||
@@ -20,8 +22,6 @@ import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Group,
|
||||
Icon,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -30,7 +30,8 @@ import {
|
||||
} from "metabase/ui";
|
||||
import { isWithinIframe } from "metabase/utils/iframe";
|
||||
|
||||
import S from "../components/BrowseContainer.module.css";
|
||||
import { BrowsePageLayout } from "../components/BrowsePageLayout";
|
||||
import { partitionByAuthority } from "../utils";
|
||||
|
||||
import { ModelsVideo } from "./EmptyStates";
|
||||
import { ModelExplanationBanner } from "./ModelExplanationBanner";
|
||||
@@ -57,6 +58,14 @@ export const BrowseModels = () => {
|
||||
const isEmpty = !isLoading && !error && models.length === 0;
|
||||
const titleId = useMemo(() => _.uniqueId("browse-models"), []);
|
||||
|
||||
const { section, setSection } = useNavSection();
|
||||
const { official, unofficial } = useMemo(
|
||||
() => partitionByAuthority(models),
|
||||
[models],
|
||||
);
|
||||
const isOfficial = section === "official";
|
||||
const shown = isOfficial ? official : unofficial;
|
||||
|
||||
const hasDataAccess = useSelector(canUserCreateQueries);
|
||||
const hasNativeWrite = useSelector(canUserCreateNativeQueries);
|
||||
const isEmbeddingIframe = isWithinIframe();
|
||||
@@ -65,112 +74,108 @@ export const BrowseModels = () => {
|
||||
!isEmbeddingIframe && hasDataAccess && hasNativeWrite;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
className={S.browseContainer}
|
||||
flex={1}
|
||||
direction="column"
|
||||
wrap="nowrap"
|
||||
pt="lg"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<Flex
|
||||
className={S.browseHeader}
|
||||
direction="column"
|
||||
role="heading"
|
||||
data-testid="browse-models-header"
|
||||
>
|
||||
<Flex maw="64rem" mx="auto" w="100%">
|
||||
<Flex
|
||||
w="100%"
|
||||
h="2.25rem"
|
||||
direction="row"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
<BrowsePageLayout
|
||||
icon="model"
|
||||
title={t`Models`}
|
||||
titleId={titleId}
|
||||
testId="browse-models-header"
|
||||
meta={[
|
||||
t`Showing ${shown.length} of ${official.length + unofficial.length} models`,
|
||||
isOfficial ? t`Official only` : t`Unofficial only`,
|
||||
]}
|
||||
description={t`Cleaned-up, combined tables ready to query. Official models live in the Library or in a collection marked official; the rest are in the Playground.`}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
onClick={() => setSection(isOfficial ? "unofficial" : "official")}
|
||||
>
|
||||
<Title order={1} c="text-primary" id={titleId}>
|
||||
<Group gap="sm">
|
||||
<Icon size={24} c="icon-brand" name="model" />
|
||||
{t`Models`}
|
||||
</Group>
|
||||
</Title>
|
||||
<Group gap="xxs">
|
||||
{canCreateNewModel && (
|
||||
<Tooltip label={t`Create a new model`} position="bottom">
|
||||
<ActionIcon
|
||||
aria-label={t`Create a new model`}
|
||||
size={32}
|
||||
variant="viewHeader"
|
||||
component={ForwardRefLink}
|
||||
to="/model/new"
|
||||
onClick={() => trackNewModelInitiated()}
|
||||
>
|
||||
<Icon name="add" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{hasVerifiedModels && (
|
||||
<ModelFilterControls
|
||||
modelFilters={modelFilters}
|
||||
setModelFilters={setModelFilters}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex className={S.browseMain} direction="column" wrap="nowrap" flex={1}>
|
||||
<Flex maw="64rem" mx="auto" w="100%">
|
||||
<Stack mb="xl" gap="lg" w="100%">
|
||||
{isEmpty ? (
|
||||
<Stack gap="xl" align="center" data-testid="empty-state">
|
||||
{showMetabaseLinks && (
|
||||
<Box maw="45rem" w="100%">
|
||||
<ModelsVideo autoplay={0} />
|
||||
</Box>
|
||||
)}
|
||||
<Stack gap="xxs" maw="28rem">
|
||||
<Title
|
||||
order={2}
|
||||
ta="center"
|
||||
>{t`Create models to clean up and combine tables to make your data easier to explore`}</Title>
|
||||
<Text
|
||||
ta="center"
|
||||
lh="1.25rem"
|
||||
>{t`Models are somewhat like virtual tables: do all your joins and custom columns once, save it as a model, then query it like a table.`}</Text>
|
||||
</Stack>
|
||||
{showMetabaseLinks && (
|
||||
<Button
|
||||
component={ExternalLink}
|
||||
href={url}
|
||||
variant="subtle"
|
||||
>{t`Read the docs`}</Button>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<ModelExplanationBanner />
|
||||
<DelayedLoadingAndErrorWrapper
|
||||
error={error}
|
||||
loading={isLoading}
|
||||
style={{ flex: 1 }}
|
||||
loader={<RecentModels skeleton />}
|
||||
>
|
||||
<RecentModels models={recentModels} />
|
||||
</DelayedLoadingAndErrorWrapper>
|
||||
<DelayedLoadingAndErrorWrapper
|
||||
error={error}
|
||||
loading={isLoading}
|
||||
style={{ flex: 1 }}
|
||||
loader={<ModelsTable skeleton />}
|
||||
>
|
||||
<ModelsTable models={models} />
|
||||
</DelayedLoadingAndErrorWrapper>
|
||||
</>
|
||||
)}
|
||||
{isOfficial ? t`Show unofficial` : t`Show official`}
|
||||
</Button>
|
||||
{canCreateNewModel && (
|
||||
<Tooltip label={t`Create a new model`} position="bottom">
|
||||
<ActionIcon
|
||||
aria-label={t`Create a new model`}
|
||||
size={32}
|
||||
variant="viewHeader"
|
||||
component={ForwardRefLink}
|
||||
to="/model/new"
|
||||
onClick={() => trackNewModelInitiated()}
|
||||
>
|
||||
<Icon name="add" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{hasVerifiedModels && (
|
||||
<ModelFilterControls
|
||||
modelFilters={modelFilters}
|
||||
setModelFilters={setModelFilters}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<Stack gap="xl" align="center" data-testid="empty-state">
|
||||
{showMetabaseLinks && (
|
||||
<Box maw="45rem" w="100%">
|
||||
<ModelsVideo autoplay={0} />
|
||||
</Box>
|
||||
)}
|
||||
<Stack gap="xxs" maw="28rem">
|
||||
<Title
|
||||
order={2}
|
||||
ta="center"
|
||||
>{t`Create models to clean up and combine tables to make your data easier to explore`}</Title>
|
||||
<Text
|
||||
ta="center"
|
||||
lh="1.25rem"
|
||||
>{t`Models are somewhat like virtual tables: do all your joins and custom columns once, save it as a model, then query it like a table.`}</Text>
|
||||
</Stack>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
{showMetabaseLinks && (
|
||||
<Button
|
||||
component={ExternalLink}
|
||||
href={url}
|
||||
variant="subtle"
|
||||
>{t`Read the docs`}</Button>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<ModelExplanationBanner />
|
||||
<DelayedLoadingAndErrorWrapper
|
||||
error={error}
|
||||
loading={isLoading}
|
||||
style={{ flex: 1 }}
|
||||
loader={<RecentModels skeleton />}
|
||||
>
|
||||
<RecentModels models={recentModels} />
|
||||
</DelayedLoadingAndErrorWrapper>
|
||||
<DetailPanel
|
||||
flush
|
||||
title={isOfficial ? t`Official models` : t`Unofficial models`}
|
||||
>
|
||||
<DelayedLoadingAndErrorWrapper
|
||||
error={error}
|
||||
loading={isLoading}
|
||||
style={{ flex: 1 }}
|
||||
loader={<ModelsTable skeleton />}
|
||||
>
|
||||
{shown.length === 0 ? (
|
||||
<Text p="lg" c="text-secondary">
|
||||
{isOfficial
|
||||
? t`No official models yet. Publish a model to the Library, or mark its collection official.`
|
||||
: t`Every model is official.`}
|
||||
</Text>
|
||||
) : (
|
||||
<ModelsTable models={shown} />
|
||||
)}
|
||||
</DelayedLoadingAndErrorWrapper>
|
||||
</DetailPanel>
|
||||
</>
|
||||
)}
|
||||
</BrowsePageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -490,7 +490,7 @@ describe("BrowseModels", () => {
|
||||
expect(recentModelsGrid).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render links that point directly to /model/{id}-{slug} (metabase#55166)", async () => {
|
||||
it("links recents to the model and table rows to its detail page (metabase#55166)", async () => {
|
||||
const { router } = setup({ modelCount: 25 });
|
||||
const recentModelsGrid = await screen.findByRole("grid", {
|
||||
name: /Recents/,
|
||||
@@ -508,10 +508,10 @@ describe("BrowseModels", () => {
|
||||
|
||||
expect(
|
||||
within(modelsTable).getByRole("link", { name: /Model 20/ }),
|
||||
).toHaveAttribute("href", "/model/20-model-20");
|
||||
).toHaveAttribute("href", "/model/20-model-20/detail");
|
||||
expect(
|
||||
within(modelsTable).getByRole("link", { name: /Model 21/ }),
|
||||
).toHaveAttribute("href", "/model/21-model-21");
|
||||
).toHaveAttribute("href", "/model/21-model-21/detail");
|
||||
|
||||
expect(screen.queryByTestId("model-detail-page")).not.toBeInTheDocument();
|
||||
await userEvent.click(within(recentModelsGrid).getByText("Model 1"));
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EntityIcon } from "metabase/common/components/EntityIcon";
|
||||
import { EntityItemName } from "metabase/common/components/EntityItemName";
|
||||
import { SortableColumnHeader } from "metabase/common/components/ItemsTable/BaseItemsTable";
|
||||
import {
|
||||
ColumnHeader,
|
||||
ItemNameCell,
|
||||
TBody,
|
||||
Table,
|
||||
@@ -17,6 +18,7 @@ import { Columns } from "metabase/common/components/ItemsTable/Columns";
|
||||
import type { ResponsiveProps } from "metabase/common/components/ItemsTable/utils";
|
||||
import { Link } from "metabase/common/components/Link";
|
||||
import { MarkdownPreview } from "metabase/common/components/MarkdownPreview";
|
||||
import { UserAvatar } from "metabase/common/components/UserAvatar";
|
||||
import { useGetIcon } from "metabase/hooks/use-icon";
|
||||
import { useNavigate } from "metabase/router";
|
||||
import {
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
Flex,
|
||||
Repeat,
|
||||
Skeleton,
|
||||
Text,
|
||||
} from "metabase/ui";
|
||||
import * as Urls from "metabase/urls";
|
||||
import type { SortingOptions } from "metabase-types/api";
|
||||
@@ -53,6 +56,13 @@ const collectionProps: ResponsiveProps = {
|
||||
containerName: itemsTableContainerName,
|
||||
};
|
||||
|
||||
const ownerProps: ResponsiveProps = {
|
||||
hideAtContainerBreakpoint: "md",
|
||||
containerName: itemsTableContainerName,
|
||||
};
|
||||
|
||||
const OWNER_WIDTH = 180;
|
||||
|
||||
const DEFAULT_SORTING_OPTIONS: SortingOptions<SortColumn> = {
|
||||
sort_column: "collection",
|
||||
sort_direction: "asc",
|
||||
@@ -67,7 +77,7 @@ export const ModelsTable = ({
|
||||
const sortedModels = sortModels(models, sortingOptions);
|
||||
|
||||
/** The name column has an explicitly set width. The remaining columns divide the remaining width. This is the percentage allocated to the collection column */
|
||||
const collectionWidth = 38.5;
|
||||
const collectionWidth = 34;
|
||||
const descriptionWidth = 100 - collectionWidth;
|
||||
|
||||
const handleUpdateSortOptions = skeleton
|
||||
@@ -88,6 +98,9 @@ export const ModelsTable = ({
|
||||
{/* <col> for Description column */}
|
||||
<TableColumn {...descriptionProps} width={`${descriptionWidth}%`} />
|
||||
|
||||
{/* <col> for Owner column */}
|
||||
<TableColumn {...ownerProps} width={OWNER_WIDTH} />
|
||||
|
||||
<Columns.RightEdge.Col />
|
||||
</colgroup>
|
||||
<thead>
|
||||
@@ -127,6 +140,10 @@ export const ModelsTable = ({
|
||||
>
|
||||
{t`Description`}
|
||||
</SortableColumnHeader>
|
||||
{/* Not sortable: `SortColumn` only covers name/collection/description. */}
|
||||
<ColumnHeader {...ownerProps} style={{ paddingInline: ".5rem" }}>
|
||||
{t`Owner`}
|
||||
</ColumnHeader>
|
||||
<Columns.RightEdge.Header />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -174,7 +191,7 @@ const ModelRow = ({ model }: { model?: ModelResult }) => {
|
||||
}
|
||||
|
||||
const { id, name } = model;
|
||||
const url = Urls.model({ id, name, type: "model" });
|
||||
const url = Urls.modelDetail({ id, name, type: "model" });
|
||||
const subpathSafeUrl = Urls.getSubpathSafeUrl(url);
|
||||
|
||||
trackModelClick(model.id);
|
||||
@@ -199,6 +216,7 @@ const ModelRow = ({ model }: { model?: ModelResult }) => {
|
||||
<NameCell model={model} />
|
||||
<CollectionCell model={model} />
|
||||
<DescriptionCell model={model} />
|
||||
<OwnerCell model={model} />
|
||||
<Columns.RightEdge.Cell />
|
||||
</tr>
|
||||
);
|
||||
@@ -220,7 +238,11 @@ function NameCell({ model }: { model?: ModelResult }) {
|
||||
/>
|
||||
{model ? (
|
||||
<Link
|
||||
to={Urls.model({ id: model.id, name: model.name, type: "model" })}
|
||||
to={Urls.modelDetail({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
type: "model",
|
||||
})}
|
||||
onClick={preventDefault}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
@@ -288,3 +310,24 @@ function DescriptionCell({ model }: { model?: ModelResult }) {
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function OwnerCell({ model }: { model?: ModelResult }) {
|
||||
const name = model
|
||||
? (model.last_editor_common_name ?? model.creator_common_name)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<td className={cx(BrowseTableS.cell, BrowseTableS.hideAtMd)}>
|
||||
{!model ? (
|
||||
<SkeletonText />
|
||||
) : name ? (
|
||||
<Flex align="center" gap="sm">
|
||||
<UserAvatar user={{ common_name: name }} size="1.25rem" decorative />
|
||||
<Text size="sm" truncate>
|
||||
{name}
|
||||
</Text>
|
||||
</Flex>
|
||||
) : null}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
35
frontend/src/metabase/browse/utils.ts
Normal file
35
frontend/src/metabase/browse/utils.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { PLUGIN_LIBRARY } from "metabase/plugins";
|
||||
import type {
|
||||
CollectionAuthorityLevel,
|
||||
CollectionEssentials,
|
||||
} from "metabase-types/api";
|
||||
|
||||
type AuthorityBearing = {
|
||||
collection_authority_level?: CollectionAuthorityLevel;
|
||||
collection?: CollectionEssentials | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Official means the item's own collection is marked official, or the item is in the Library.
|
||||
* Authority level does not inherit in Metabase, so neither does this.
|
||||
*/
|
||||
export function isOfficialItem(item: AuthorityBearing): boolean {
|
||||
return (
|
||||
item.collection_authority_level === "official" ||
|
||||
item.collection?.authority_level === "official" ||
|
||||
PLUGIN_LIBRARY.isLibraryCollectionType(item.collection?.type)
|
||||
);
|
||||
}
|
||||
|
||||
export function partitionByAuthority<T extends AuthorityBearing>(
|
||||
items: T[],
|
||||
): { official: T[]; unofficial: T[] } {
|
||||
const official: T[] = [];
|
||||
const unofficial: T[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
(isOfficialItem(item) ? official : unofficial).push(item);
|
||||
}
|
||||
|
||||
return { official, unofficial };
|
||||
}
|
||||
@@ -9,13 +9,13 @@ import {
|
||||
} from "metabase/common/collections/utils";
|
||||
import { EditableDescription } from "metabase/common/components/EditableDescription";
|
||||
import { EditableText } from "metabase/common/components/EditableText";
|
||||
import { getIsTenantUser, getUser } from "metabase/current-user";
|
||||
import { getUser } from "metabase/current-user";
|
||||
import {
|
||||
PLUGIN_COLLECTIONS,
|
||||
PLUGIN_COLLECTION_COMPONENTS,
|
||||
} from "metabase/plugins";
|
||||
import { useSelector } from "metabase/redux";
|
||||
import { Box, Flex, Icon, rem } from "metabase/ui";
|
||||
import { Box, Flex, Icon, isValidIconName, rem } from "metabase/ui";
|
||||
import type { Collection } from "metabase-types/api";
|
||||
|
||||
import S from "./CollectionCaption.module.css";
|
||||
@@ -90,8 +90,6 @@ export const CollectionCaption = ({
|
||||
};
|
||||
|
||||
const CollectionCaptionIcon = ({ collection }: { collection: Collection }) => {
|
||||
const isTenantUser = useSelector(getIsTenantUser);
|
||||
|
||||
if (isInstanceAnalyticsCollection(collection)) {
|
||||
return (
|
||||
<PLUGIN_COLLECTION_COMPONENTS.CollectionInstanceAnalyticsIcon
|
||||
@@ -103,11 +101,6 @@ const CollectionCaptionIcon = ({ collection }: { collection: Collection }) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (PLUGIN_COLLECTIONS.isSyncedCollection(collection) && !isTenantUser) {
|
||||
// external users should see the normal icon, they should not know about what synced collections are
|
||||
return <Icon name="synced_collection" size={24} c="core-brand" />;
|
||||
}
|
||||
|
||||
if (isRootTrashCollection(collection)) {
|
||||
return <Icon name="trash" size={24} c="text-disabled" />;
|
||||
}
|
||||
@@ -119,6 +112,10 @@ const CollectionCaptionIcon = ({ collection }: { collection: Collection }) => {
|
||||
return <Icon name="folder" size={24} c="text-disabled" />;
|
||||
}
|
||||
|
||||
if (collection.icon && isValidIconName(collection.icon)) {
|
||||
return <Icon name={collection.icon} size={24} c="core-brand" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PLUGIN_COLLECTION_COMPONENTS.CollectionAuthorityLevelIcon
|
||||
collection={collection}
|
||||
|
||||
@@ -9,10 +9,11 @@ import {
|
||||
} from "metabase/api";
|
||||
import type { CommentExtraRenderer } from "metabase/comments/types";
|
||||
import { getCommentNodeId } from "metabase/comments/utils";
|
||||
import { UserAvatar } from "metabase/common/components/UserAvatar";
|
||||
import { useToast } from "metabase/common/hooks";
|
||||
import { getUser } from "metabase/current-user";
|
||||
import { useSelector } from "metabase/redux";
|
||||
import { Avatar, Stack, Timeline, rem } from "metabase/ui";
|
||||
import { Stack, Timeline, rem } from "metabase/ui";
|
||||
import type {
|
||||
Comment,
|
||||
CommentEntityType,
|
||||
@@ -217,7 +218,7 @@ export const Discussion = ({
|
||||
{!comments[0]?.is_resolved && currentUser && (
|
||||
<Timeline.Item
|
||||
className={S.commentRoot}
|
||||
bullet={<Avatar name={currentUser.common_name} />}
|
||||
bullet={<UserAvatar user={currentUser} size="1.5rem" decorative />}
|
||||
>
|
||||
<CommentEditor
|
||||
active={false}
|
||||
|
||||
@@ -7,9 +7,10 @@ import { t } from "ttag";
|
||||
import type { CommentExtraRenderer } from "metabase/comments/types";
|
||||
import { formatCommentDate, getCommentNodeId } from "metabase/comments/utils";
|
||||
import ErrorBoundary from "metabase/common/components/ErrorBoundary";
|
||||
import { UserAvatar } from "metabase/common/components/UserAvatar";
|
||||
import { getUser } from "metabase/current-user";
|
||||
import { useSelector } from "metabase/redux";
|
||||
import { Avatar, Box, Group, Icon, Text, Timeline, Tooltip } from "metabase/ui";
|
||||
import { Box, Group, Icon, Text, Timeline, Tooltip } from "metabase/ui";
|
||||
import type { Comment, DocumentContent } from "metabase-types/api";
|
||||
|
||||
import { CommentEditor } from "../CommentEditor";
|
||||
@@ -114,7 +115,7 @@ export function DiscussionComment({
|
||||
className={cx(S.commentRoot, {
|
||||
[S.target]: isTarget,
|
||||
})}
|
||||
bullet={<Avatar name={comment.creator?.common_name} />}
|
||||
bullet={<UserAvatar user={comment.creator} size="1.5rem" decorative />}
|
||||
aria-current={isTarget ? "location" : undefined}
|
||||
data-testid="discussion-comment"
|
||||
id={commentNodeId}
|
||||
|
||||
@@ -22,11 +22,13 @@ const IRREGULAR_ICON_PROPS = {
|
||||
type CollectionBadgeInnerProps = {
|
||||
collection: CollectionType;
|
||||
onClick?: () => void;
|
||||
showIcon: boolean;
|
||||
};
|
||||
|
||||
const CollectionBadgeInner = ({
|
||||
collection,
|
||||
onClick,
|
||||
showIcon,
|
||||
}: CollectionBadgeInnerProps) => {
|
||||
const tc = useTranslateContent();
|
||||
const getIcon = useGetIcon();
|
||||
@@ -43,8 +45,8 @@ const CollectionBadgeInner = ({
|
||||
|
||||
return (
|
||||
<Breadcrumb
|
||||
icon={icon.name}
|
||||
iconColor={icon.color}
|
||||
icon={showIcon ? icon.name : undefined}
|
||||
iconColor={showIcon ? icon.color : undefined}
|
||||
to={
|
||||
onClick ? undefined : modelToUrl({ model: "collection", ...collection })
|
||||
}
|
||||
@@ -58,11 +60,13 @@ const CollectionBadgeInner = ({
|
||||
type CollectionBadgeProps = {
|
||||
collectionId?: CollectionId;
|
||||
onClick?: () => void;
|
||||
showIcon?: boolean;
|
||||
};
|
||||
|
||||
export const CollectionBadge = ({
|
||||
collectionId,
|
||||
onClick,
|
||||
showIcon = true,
|
||||
}: CollectionBadgeProps) => {
|
||||
const { data: collection } = useGetCollectionQuery({
|
||||
id: collectionId || "root",
|
||||
@@ -70,5 +74,11 @@ export const CollectionBadge = ({
|
||||
if (!collection) {
|
||||
return null;
|
||||
}
|
||||
return <CollectionBadgeInner collection={collection} onClick={onClick} />;
|
||||
return (
|
||||
<CollectionBadgeInner
|
||||
collection={collection}
|
||||
onClick={onClick}
|
||||
showIcon={showIcon}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -38,10 +38,18 @@ type CollectionRowMenuProps = {
|
||||
}) => void;
|
||||
onArchiveSuccess?: () => void;
|
||||
customArchiveMessage?: string;
|
||||
/** When supplied, the menu offers a "New folder" entry that invokes this. */
|
||||
onNewFolder?: () => void;
|
||||
};
|
||||
|
||||
export function CollectionRowMenu(props: CollectionRowMenuProps) {
|
||||
const { collection, onArchiveSuccess, onSave, customArchiveMessage } = props;
|
||||
const {
|
||||
collection,
|
||||
onArchiveSuccess,
|
||||
onSave,
|
||||
customArchiveMessage,
|
||||
onNewFolder,
|
||||
} = props;
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const isAdmin = useSelector(getUserIsAdmin);
|
||||
@@ -60,7 +68,10 @@ export function CollectionRowMenu(props: CollectionRowMenuProps) {
|
||||
(PLUGIN_LIBRARY.isEnabled || PLUGIN_SNIPPET_FOLDERS.isEnabled) &&
|
||||
collection.namespace !== "transforms";
|
||||
|
||||
const isRoot = isRootCollection(collection);
|
||||
// The seeded Library/Data/Metrics collections are renamed, moved and archived by the backend only;
|
||||
// `isRootCollection` covers the "Our analytics" sentinel.
|
||||
const isImmutable =
|
||||
isRootCollection(collection) || !!collection.is_library_root;
|
||||
|
||||
if (!collection.can_write || remoteSyncReadOnly) {
|
||||
return null;
|
||||
@@ -161,7 +172,15 @@ export function CollectionRowMenu(props: CollectionRowMenuProps) {
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{!isRoot && (
|
||||
{onNewFolder && (
|
||||
<Menu.Item
|
||||
leftSection={<Icon name="new_folder" />}
|
||||
onClick={onNewFolder}
|
||||
>
|
||||
{t`New folder`}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!isImmutable && (
|
||||
<Menu.Item
|
||||
leftSection={<Icon name="pencil" />}
|
||||
onClick={toggleEditModal}
|
||||
@@ -179,7 +198,7 @@ export function CollectionRowMenu(props: CollectionRowMenuProps) {
|
||||
{t`Change permissions`}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!isRoot && (
|
||||
{!isImmutable && (
|
||||
<Menu.Item
|
||||
leftSection={<Icon name="archive" />}
|
||||
onClick={onArchiveClick}
|
||||
|
||||
@@ -16,11 +16,13 @@ import { isItemInCollectionOrItsDescendants } from "metabase/common/components/P
|
||||
import {
|
||||
Form,
|
||||
FormErrorMessage,
|
||||
FormIconPicker,
|
||||
FormProvider,
|
||||
FormSubmitButton,
|
||||
FormTextInput,
|
||||
FormTextarea,
|
||||
} from "metabase/forms";
|
||||
import { PLUGIN_LIBRARY } from "metabase/plugins";
|
||||
import { Button, Group, Modal, Stack } from "metabase/ui";
|
||||
import type {
|
||||
Collection,
|
||||
@@ -53,6 +55,7 @@ export function EditCollectionModal(props: EditCollectionModalProps) {
|
||||
return {
|
||||
name: collection.name ?? "",
|
||||
description: collection.description ?? null,
|
||||
icon: collection.icon ?? null,
|
||||
parent_id: typeof parentId === "number" ? parentId : null,
|
||||
};
|
||||
}, [collection]);
|
||||
@@ -63,6 +66,7 @@ export function EditCollectionModal(props: EditCollectionModalProps) {
|
||||
id: collection.id,
|
||||
name: values.name,
|
||||
description: values.description ?? undefined,
|
||||
icon: values.icon,
|
||||
parent_id: values.parent_id,
|
||||
}).unwrap();
|
||||
onSave?.({
|
||||
@@ -84,6 +88,9 @@ export function EditCollectionModal(props: EditCollectionModalProps) {
|
||||
(e: KeyboardEvent) => e.stopPropagation(),
|
||||
[],
|
||||
);
|
||||
const isLibraryCollection = PLUGIN_LIBRARY.isLibraryCollectionType(
|
||||
collection.type,
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -108,6 +115,9 @@ export function EditCollectionModal(props: EditCollectionModalProps) {
|
||||
placeholder={t`My collection`}
|
||||
data-autofocus
|
||||
/>
|
||||
{isLibraryCollection && (
|
||||
<FormIconPicker name="icon" label={t`Icon`} />
|
||||
)}
|
||||
<FormTextarea
|
||||
name="description"
|
||||
label={t`Description`}
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
} from "__support__/server-mocks";
|
||||
import { createMockState } from "__support__/state";
|
||||
import { renderWithProviders, screen, waitFor } from "__support__/ui";
|
||||
import { PLUGIN_LIBRARY, reinitialize } from "metabase/plugins";
|
||||
import type { LibraryCollectionType } from "metabase/plugins/oss/library";
|
||||
import type { Collection, CollectionItem } from "metabase-types/api";
|
||||
import {
|
||||
createMockCollection,
|
||||
@@ -81,6 +83,7 @@ describe("EditCollectionModal", () => {
|
||||
expect(await request?.json()).toEqual({
|
||||
name: "Updated name",
|
||||
description: "Updated description",
|
||||
icon: null,
|
||||
parent_id: parentCollection.id,
|
||||
});
|
||||
});
|
||||
@@ -109,7 +112,53 @@ describe("EditCollectionModal", () => {
|
||||
)?.request;
|
||||
expect(await request?.json()).toEqual({
|
||||
name: "Updated item name",
|
||||
icon: null,
|
||||
parent_id: itemParentCollection.id,
|
||||
});
|
||||
});
|
||||
|
||||
describe("library collections", () => {
|
||||
beforeEach(() => {
|
||||
PLUGIN_LIBRARY.isLibraryCollectionType = (
|
||||
type?: string | null,
|
||||
): type is LibraryCollectionType =>
|
||||
type === "library" ||
|
||||
type === "library-data" ||
|
||||
type === "library-metrics";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
reinitialize();
|
||||
});
|
||||
|
||||
it("submits the icon picked for a library folder", async () => {
|
||||
const collection = createMockCollection({
|
||||
id: 3,
|
||||
name: "Finance",
|
||||
type: "library",
|
||||
parent_id: parentCollection.id,
|
||||
});
|
||||
setup(collection);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Icon" }));
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: "star" }),
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
fetchMock.callHistory.lastCall("update-collection-3"),
|
||||
).toBeTruthy(),
|
||||
);
|
||||
const request = fetchMock.callHistory.lastCall(
|
||||
"update-collection-3",
|
||||
)?.request;
|
||||
expect(await request?.json()).toEqual({
|
||||
name: "Finance",
|
||||
icon: "star",
|
||||
parent_id: parentCollection.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
import {
|
||||
Form,
|
||||
FormErrorMessage,
|
||||
FormIconPicker,
|
||||
FormProvider,
|
||||
FormSubmitButton,
|
||||
FormTextInput,
|
||||
@@ -21,9 +22,13 @@ import {
|
||||
} from "metabase/forms";
|
||||
import { PLUGIN_TENANTS } from "metabase/plugins";
|
||||
import { useLocation, useParams } from "metabase/router";
|
||||
import { Button, Flex } from "metabase/ui";
|
||||
import { Box, Button, Flex } from "metabase/ui";
|
||||
import * as Errors from "metabase/utils/errors";
|
||||
import type { Collection, CollectionNamespace } from "metabase-types/api";
|
||||
import type {
|
||||
Collection,
|
||||
CollectionNamespace,
|
||||
IconName,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { FormAuthorityLevelField } from "../../containers/FormAuthorityLevelFieldContainer";
|
||||
|
||||
@@ -33,6 +38,7 @@ const COLLECTION_SCHEMA = Yup.object({
|
||||
.max(100, Errors.maxLength)
|
||||
.default(""),
|
||||
description: Yup.string().nullable().max(255, Errors.maxLength).default(null),
|
||||
icon: Yup.mixed<IconName>().nullable().default(null),
|
||||
|
||||
authority_level: Yup.mixed().oneOf(["official", null]).default(null),
|
||||
parent_id: Yup.number().nullable(),
|
||||
@@ -41,6 +47,7 @@ const COLLECTION_SCHEMA = Yup.object({
|
||||
export interface CreateCollectionProperties {
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: IconName | null;
|
||||
parent_id: Collection["id"] | null;
|
||||
namespace?: CollectionNamespace;
|
||||
}
|
||||
@@ -55,6 +62,7 @@ export interface CreateCollectionFormOwnProps {
|
||||
pickerOptions?: EntityPickerOptions;
|
||||
namespaces?: CollectionNamespace[];
|
||||
showAuthorityLevelPicker?: boolean;
|
||||
showIconPicker?: boolean;
|
||||
}
|
||||
|
||||
type Props = CreateCollectionFormOwnProps;
|
||||
@@ -69,6 +77,7 @@ function CreateCollectionForm({
|
||||
pickerOptions,
|
||||
namespaces,
|
||||
showAuthorityLevelPicker = true,
|
||||
showIconPicker = false,
|
||||
}: Props) {
|
||||
const location = useLocation();
|
||||
const params = useParams();
|
||||
@@ -135,6 +144,11 @@ function CreateCollectionForm({
|
||||
data-autofocus
|
||||
mb="lg"
|
||||
/>
|
||||
{showIconPicker && (
|
||||
<Box mb="lg">
|
||||
<FormIconPicker name="icon" label={t`Icon`} />
|
||||
</Box>
|
||||
)}
|
||||
<FormTextarea
|
||||
name="description"
|
||||
label={t`Description`}
|
||||
|
||||
@@ -20,12 +20,14 @@ export interface CreateCollectionModalOwnProps extends Omit<
|
||||
onCreate?: (collection: Collection) => void;
|
||||
onClose: () => void;
|
||||
shouldNavigateOnCreate?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
function CreateCollectionModal({
|
||||
onCreate,
|
||||
onClose,
|
||||
shouldNavigateOnCreate = true,
|
||||
title = t`New collection`,
|
||||
...props
|
||||
}: CreateCollectionModalOwnProps) {
|
||||
const navigate = useNavigate();
|
||||
@@ -69,7 +71,7 @@ function CreateCollectionModal({
|
||||
size="lg"
|
||||
data-testid="new-collection-modal"
|
||||
padding="40px"
|
||||
title={t`New collection`}
|
||||
title={title}
|
||||
>
|
||||
<CreateCollectionForm
|
||||
{...props}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as Yup from "yup";
|
||||
|
||||
import * as Errors from "metabase/utils/errors";
|
||||
import type { IconName } from "metabase-types/api";
|
||||
|
||||
export const COLLECTION_FORM_SCHEMA = Yup.object({
|
||||
name: Yup.string()
|
||||
@@ -8,6 +9,7 @@ export const COLLECTION_FORM_SCHEMA = Yup.object({
|
||||
.max(100, Errors.maxLength)
|
||||
.default(""),
|
||||
description: Yup.string().nullable().max(255, Errors.maxLength).default(null),
|
||||
icon: Yup.mixed<IconName>().nullable().default(null),
|
||||
parent_id: Yup.number().nullable().default(null),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
type CollectionPlacementTarget,
|
||||
canPlaceEntityInCollection as canPlaceEntityInCollectionImpl,
|
||||
canPlaceEntityInCollectionOrDescendants as canPlaceEntityInCollectionOrDescendantsImpl,
|
||||
} from "metabase/common/data-studio/collection-utils";
|
||||
import { getUserPersonalCollectionId } from "metabase/current-user";
|
||||
import { PLUGIN_COLLECTIONS, PLUGIN_LIBRARY } from "metabase/plugins";
|
||||
import type { State } from "metabase/redux/store";
|
||||
import type { IconProps } from "metabase/ui";
|
||||
import { type IconProps, isValidIconName } from "metabase/ui";
|
||||
import { color } from "metabase/ui/colors";
|
||||
import type { ColorName } from "metabase/ui/colors/types";
|
||||
import {
|
||||
@@ -19,7 +20,6 @@ import {
|
||||
type CollectionId,
|
||||
type CollectionItem,
|
||||
type CollectionItemModel,
|
||||
type CollectionType,
|
||||
type IconName,
|
||||
type User,
|
||||
isBaseEntityID,
|
||||
@@ -130,17 +130,15 @@ export function isInstanceAnalyticsCustomCollection(
|
||||
);
|
||||
}
|
||||
|
||||
export function isSyncedCollection(collection: Partial<Collection>): boolean {
|
||||
return PLUGIN_COLLECTIONS.isSyncedCollection(collection);
|
||||
}
|
||||
|
||||
export function isLibraryCollection(
|
||||
collection: Pick<Collection, "type">,
|
||||
): boolean {
|
||||
return PLUGIN_LIBRARY.isLibraryCollectionType(collection.type);
|
||||
}
|
||||
|
||||
export function isExamplesCollection(collection: Collection): boolean {
|
||||
export function isExamplesCollection(
|
||||
collection: Pick<Collection, "is_sample" | "name">,
|
||||
): boolean {
|
||||
return !!collection.is_sample && collection.name === "Examples";
|
||||
}
|
||||
|
||||
@@ -298,19 +296,16 @@ export function canCopyItem(item: CollectionItem): item is CopyableItem {
|
||||
|
||||
export function canPlaceEntityInCollection(
|
||||
entityType: EntityType,
|
||||
collectionType: CollectionType | null | undefined,
|
||||
target: CollectionPlacementTarget,
|
||||
): boolean {
|
||||
return canPlaceEntityInCollectionImpl(entityType, collectionType);
|
||||
return canPlaceEntityInCollectionImpl(entityType, target);
|
||||
}
|
||||
|
||||
export function canPlaceEntityInCollectionOrDescendants(
|
||||
entityType: EntityType,
|
||||
collectionType: CollectionType | null | undefined,
|
||||
target: CollectionPlacementTarget,
|
||||
): boolean {
|
||||
return canPlaceEntityInCollectionOrDescendantsImpl(
|
||||
entityType,
|
||||
collectionType,
|
||||
);
|
||||
return canPlaceEntityInCollectionOrDescendantsImpl(entityType, target);
|
||||
}
|
||||
|
||||
export function coerceCollectionId(
|
||||
@@ -395,7 +390,7 @@ export const getCollectionPathAsArray = (collection: Collection): string[] => {
|
||||
|
||||
export function getCollectionIcon(
|
||||
collection: Partial<Collection>,
|
||||
{ tooltip = "default", isTenantUser = false } = {},
|
||||
{ tooltip = "default" } = {},
|
||||
): {
|
||||
name: IconName;
|
||||
color?: ColorName;
|
||||
@@ -413,9 +408,8 @@ export function getCollectionIcon(
|
||||
return { name: "person" };
|
||||
}
|
||||
|
||||
if (isSyncedCollection(collection) && !isTenantUser) {
|
||||
// tenant users see the normal icon, they don't know what a synced collection is
|
||||
return { name: "synced_collection" };
|
||||
if (collection.icon && isValidIconName(collection.icon)) {
|
||||
return { name: collection.icon };
|
||||
}
|
||||
|
||||
if (collection.is_library_root) {
|
||||
@@ -452,7 +446,12 @@ export function getCollectionType(
|
||||
return collectionId !== undefined ? "other" : null;
|
||||
}
|
||||
|
||||
export interface CollectionTreeItem extends Collection {
|
||||
// `icon` is narrowed to the resolved icon (never null) since the tree builder
|
||||
// always derives one via `getCollectionIcon`.
|
||||
export interface CollectionTreeItem extends Omit<
|
||||
Collection,
|
||||
"icon" | "children"
|
||||
> {
|
||||
icon: IconName | IconProps;
|
||||
children: CollectionTreeItem[];
|
||||
schemaName?: string;
|
||||
@@ -463,10 +462,8 @@ export function buildCollectionTree(
|
||||
collections: Collection[] = [],
|
||||
{
|
||||
modelFilter,
|
||||
isTenantUser = false,
|
||||
}: {
|
||||
modelFilter?: (model: CollectionContentModel) => boolean;
|
||||
isTenantUser?: boolean;
|
||||
} = {},
|
||||
): CollectionTreeItem[] {
|
||||
return collections.flatMap((collection) => {
|
||||
@@ -484,7 +481,7 @@ export function buildCollectionTree(
|
||||
const children = !isRootTrashCollection(collection)
|
||||
? buildCollectionTree(
|
||||
collection.children?.filter((child) => !child.archived) || [],
|
||||
{ modelFilter, isTenantUser },
|
||||
{ modelFilter },
|
||||
)
|
||||
: [];
|
||||
|
||||
@@ -495,7 +492,7 @@ export function buildCollectionTree(
|
||||
return {
|
||||
...collection,
|
||||
schemaName: collection.originalName || collection.name,
|
||||
icon: getCollectionIcon(collection, { isTenantUser }),
|
||||
icon: getCollectionIcon(collection),
|
||||
children,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -4,12 +4,12 @@ import styled from "@emotion/styled";
|
||||
import { NAV_SIDEBAR_WIDTH } from "metabase/nav/constants";
|
||||
import { Card } from "metabase/ui";
|
||||
|
||||
export const BulkActionsToast = styled.div<{ isNavbarOpen: boolean }>`
|
||||
export const BulkActionsToast = styled.div`
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
margin-left: ${(props) =>
|
||||
props.isNavbarOpen ? `${parseInt(NAV_SIDEBAR_WIDTH) / 2}px` : "0"};
|
||||
/* The nav rail is always shown, so the toast is always centred on the content, not the window. */
|
||||
margin-left: ${parseInt(NAV_SIDEBAR_WIDTH) / 2}px;
|
||||
margin-bottom: var(--mantine-spacing-lg);
|
||||
transform: translateX(-50%);
|
||||
z-index: 150;
|
||||
|
||||
@@ -2,8 +2,6 @@ import cx from "classnames";
|
||||
import type { HTMLAttributes } from "react";
|
||||
|
||||
import Animation from "metabase/css/core/animation.module.css";
|
||||
import { useSelector } from "metabase/redux";
|
||||
import { getIsNavbarOpen } from "metabase/selectors/app";
|
||||
import { type BoxProps, Flex, Portal, Text } from "metabase/ui";
|
||||
|
||||
import { BulkActionsToast, ToastCard } from "./BulkActionBar.styled";
|
||||
@@ -13,7 +11,6 @@ type BulkActionsProps = {
|
||||
message: string;
|
||||
children: React.ReactNode | React.ReactNode[];
|
||||
className?: string;
|
||||
isNavbarOpen?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -27,8 +24,7 @@ type BulkActionsProps = {
|
||||
* @returns
|
||||
*/
|
||||
export const BulkActionBar = (props: BulkActionsProps) => {
|
||||
const isNavbarOpen = useSelector(getIsNavbarOpen);
|
||||
return <BulkActionBarPortal {...props} isNavbarOpen={isNavbarOpen} />;
|
||||
return <BulkActionBarPortal {...props} />;
|
||||
};
|
||||
|
||||
export const BulkActionBarPortal = ({
|
||||
@@ -36,7 +32,6 @@ export const BulkActionBarPortal = ({
|
||||
message,
|
||||
children,
|
||||
className,
|
||||
isNavbarOpen = true,
|
||||
...props
|
||||
}: BulkActionsProps & BoxProps & HTMLAttributes<HTMLDivElement>) => {
|
||||
if (!opened) {
|
||||
@@ -44,10 +39,7 @@ export const BulkActionBarPortal = ({
|
||||
}
|
||||
return (
|
||||
<Portal>
|
||||
<BulkActionsToast
|
||||
isNavbarOpen={isNavbarOpen}
|
||||
className={cx(className, Animation.popToast)}
|
||||
>
|
||||
<BulkActionsToast className={cx(className, Animation.popToast)}>
|
||||
<ToastCard data-testid="toast-card" {...props}>
|
||||
{message && <Text c="tooltip-text">{message}</Text>}
|
||||
<Flex gap="sm" align="center">
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.rail {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Box, Flex, Stack } from "metabase/ui";
|
||||
|
||||
import S from "./DetailPageLayout.module.css";
|
||||
|
||||
export type DetailPageLayoutProps = {
|
||||
/** Sticky right-hand fact rail. */
|
||||
rail: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function DetailPageLayout({ rail, children }: DetailPageLayoutProps) {
|
||||
return (
|
||||
<Flex align="flex-start" gap="lg">
|
||||
<Stack flex={1} miw={0} gap="lg">
|
||||
{children}
|
||||
</Stack>
|
||||
<Box className={S.rail} flex="0 0 20rem" w="20rem">
|
||||
{rail}
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.header {
|
||||
border-bottom: 1px solid var(--mb-color-border);
|
||||
min-height: 2.5rem;
|
||||
|
||||
&[data-collapsed] {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle {
|
||||
flex: 1;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { type ReactNode, useId } from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
Collapse,
|
||||
Group,
|
||||
Icon,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from "metabase/ui";
|
||||
|
||||
import S from "./DetailPanel.module.css";
|
||||
|
||||
export type DetailPanelProps = {
|
||||
title: string;
|
||||
/** Right side of the title row: a single link or button out to the full editor/view. */
|
||||
actions?: ReactNode;
|
||||
/** Drop body padding. Tables and the embedded graph draw their own. */
|
||||
flush?: boolean;
|
||||
/** Let the title row expand and collapse the body, accordion-style. */
|
||||
collapsible?: boolean;
|
||||
/** Start collapsed. Only applies when `collapsible` is set. */
|
||||
defaultCollapsed?: boolean;
|
||||
children: ReactNode;
|
||||
"data-testid"?: string;
|
||||
};
|
||||
|
||||
export function DetailPanel({
|
||||
title,
|
||||
actions,
|
||||
flush,
|
||||
collapsible = false,
|
||||
defaultCollapsed = false,
|
||||
children,
|
||||
"data-testid": dataTestId,
|
||||
}: DetailPanelProps) {
|
||||
const [isOpened, { toggle }] = useDisclosure(
|
||||
!(collapsible && defaultCollapsed),
|
||||
);
|
||||
const bodyId = useId();
|
||||
const titleText = (
|
||||
<Text fw="bold" size="sm">
|
||||
{title}
|
||||
</Text>
|
||||
);
|
||||
const body = <Box p={flush ? 0 : "lg"}>{children}</Box>;
|
||||
|
||||
return (
|
||||
<Card withBorder shadow="none" p={0} data-testid={dataTestId}>
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
px="lg"
|
||||
py="sm"
|
||||
className={S.header}
|
||||
data-collapsed={!isOpened || undefined}
|
||||
>
|
||||
{collapsible ? (
|
||||
<UnstyledButton
|
||||
className={S.toggle}
|
||||
aria-expanded={isOpened}
|
||||
aria-controls={bodyId}
|
||||
onClick={toggle}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Icon
|
||||
name={isOpened ? "chevrondown" : "chevronright"}
|
||||
size={12}
|
||||
aria-hidden
|
||||
/>
|
||||
{titleText}
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
) : (
|
||||
titleText
|
||||
)}
|
||||
{actions}
|
||||
</Group>
|
||||
{collapsible ? (
|
||||
<Collapse in={isOpened} id={bodyId}>
|
||||
{body}
|
||||
</Collapse>
|
||||
) : (
|
||||
body
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { DetailPanel, type DetailPanelProps } from "./DetailPanel";
|
||||
export {
|
||||
DetailPageLayout,
|
||||
type DetailPageLayoutProps,
|
||||
} from "./DetailPageLayout";
|
||||
@@ -4,13 +4,13 @@ import { Link } from "metabase/common/components/Link/Link";
|
||||
import { Center, Group, Icon, Paper, Stack, Text, rem } from "metabase/ui";
|
||||
import type { IconName } from "metabase-types/api";
|
||||
|
||||
import S from "./MetadataCard.module.css";
|
||||
import S from "./FactCard.module.css";
|
||||
|
||||
interface MetadataCardProps {
|
||||
export interface FactCardProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function MetadataCard({ children }: MetadataCardProps) {
|
||||
export function FactCard({ children }: FactCardProps) {
|
||||
return (
|
||||
<Paper className={S.card}>
|
||||
<Stack gap={rem(12)} className={S.rows}>
|
||||
@@ -20,14 +20,14 @@ export function MetadataCard({ children }: MetadataCardProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface MetadataRowProps {
|
||||
export interface FactRowProps {
|
||||
icon: IconName;
|
||||
children: ReactNode;
|
||||
/** Empty-state styling: muted icon and text. */
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
export function MetadataRow({ icon, children, muted }: MetadataRowProps) {
|
||||
export function FactRow({ icon, children, muted }: FactRowProps) {
|
||||
const color = muted ? "text-secondary" : "text-primary";
|
||||
return (
|
||||
<Group gap={rem(10)} wrap="nowrap" align="center">
|
||||
@@ -46,13 +46,12 @@ export function MetadataRow({ icon, children, muted }: MetadataRowProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function MetadataRowLink({
|
||||
to,
|
||||
children,
|
||||
}: {
|
||||
export interface FactRowLinkProps {
|
||||
to: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
}
|
||||
|
||||
export function FactRowLink({ to, children }: FactRowLinkProps) {
|
||||
return (
|
||||
<Link to={to} className={S.link}>
|
||||
<Text component="span" fw={600} className={S.text}>
|
||||
@@ -2,17 +2,13 @@ import type { ReactNode } from "react";
|
||||
|
||||
import { Stack, type StackProps, Text, rem } from "metabase/ui";
|
||||
|
||||
interface MetricSubSectionProps {
|
||||
export interface FactSectionProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
mt?: StackProps["mt"];
|
||||
}
|
||||
|
||||
export function MetricSubSection({
|
||||
title,
|
||||
children,
|
||||
mt,
|
||||
}: MetricSubSectionProps) {
|
||||
export function FactSection({ title, children, mt }: FactSectionProps) {
|
||||
return (
|
||||
<Stack gap="sm" mt={mt}>
|
||||
<Text fz="sm" lh={rem(16)} fw={400} c="text-secondary">
|
||||
@@ -0,0 +1,56 @@
|
||||
import { c, t } from "ttag";
|
||||
|
||||
import { DateTime } from "metabase/common/components/DateTime";
|
||||
import { UserAvatar } from "metabase/common/components/UserAvatar";
|
||||
import { Group, Text } from "metabase/ui";
|
||||
import { getUserName } from "metabase/utils/user";
|
||||
import type { LastEditInfo, UserInfo } from "metabase-types/api";
|
||||
|
||||
import { FactCard, FactRow } from "./FactCard";
|
||||
import { FactSection } from "./FactSection";
|
||||
|
||||
export type MaintainerFactProps = {
|
||||
creator?: UserInfo;
|
||||
createdAt?: string | null;
|
||||
lastEditInfo?: LastEditInfo;
|
||||
};
|
||||
|
||||
export function MaintainerFact({
|
||||
creator,
|
||||
createdAt,
|
||||
lastEditInfo,
|
||||
}: MaintainerFactProps) {
|
||||
if (!creator && !lastEditInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FactSection title={t`Maintainer`}>
|
||||
<FactCard>
|
||||
{creator && (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<UserAvatar user={creator} size="1.5rem" decorative />
|
||||
<Text size="sm" truncate>
|
||||
{getUserName(creator)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{createdAt && (
|
||||
<FactRow icon="ai" muted>
|
||||
{c("Describes when an entity was created. {0} is a date.")
|
||||
.jt`Created ${<DateTime unit="day" value={createdAt} key="date" />}`}
|
||||
</FactRow>
|
||||
)}
|
||||
{lastEditInfo && (
|
||||
<FactRow icon="pencil" muted>
|
||||
{c(
|
||||
"Describes when an entity was last edited. {0} is a date and {1} is a person's name.",
|
||||
).jt`${(
|
||||
<DateTime unit="day" value={lastEditInfo.timestamp} key="date" />
|
||||
)} by ${getUserName(lastEditInfo)}`}
|
||||
</FactRow>
|
||||
)}
|
||||
</FactCard>
|
||||
</FactSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
FactCard,
|
||||
type FactCardProps,
|
||||
FactRow,
|
||||
type FactRowProps,
|
||||
FactRowLink,
|
||||
type FactRowLinkProps,
|
||||
} from "./FactCard";
|
||||
export { FactSection, type FactSectionProps } from "./FactSection";
|
||||
export { MaintainerFact, type MaintainerFactProps } from "./MaintainerFact";
|
||||
@@ -0,0 +1,4 @@
|
||||
.grid {
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user