mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2024-11-25 15:06:55 +00:00
Merge branch 'master' into users/languy/save-documentstab-prefs
This commit is contained in:
commit
c9398e303b
@ -174,7 +174,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
||||||
transformIgnorePatterns: ["/node_modules/", "/externals/"],
|
transformIgnorePatterns: ["/node_modules/(?!@fluentui/react-icons)", "/externals/"],
|
||||||
|
|
||||||
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
||||||
// unmockedModulePathPatterns: undefined,
|
// unmockedModulePathPatterns: undefined,
|
||||||
|
@ -2352,8 +2352,8 @@ a:link {
|
|||||||
|
|
||||||
.tabsManagerContainer {
|
.tabsManagerContainer {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-rows: 36px 36px 1fr;
|
flex-direction: column;
|
||||||
min-width: 0; // This prevents it to grow past the parent's width if its content is too wide
|
min-width: 0; // This prevents it to grow past the parent's width if its content is too wide
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2610,9 +2610,8 @@ a:link {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tabPanesContainer {
|
.tabPanesContainer {
|
||||||
grid-row: span 2; // Fill the remaining space
|
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
flex-grow: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
160
package-lock.json
generated
160
package-lock.json
generated
@ -2985,13 +2985,13 @@
|
|||||||
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ=="
|
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ=="
|
||||||
},
|
},
|
||||||
"node_modules/@fluentui/font-icons-mdl2": {
|
"node_modules/@fluentui/font-icons-mdl2": {
|
||||||
"version": "8.5.48",
|
"version": "8.5.47",
|
||||||
"resolved": "https://registry.npmjs.org/@fluentui/font-icons-mdl2/-/font-icons-mdl2-8.5.48.tgz",
|
"resolved": "https://registry.npmjs.org/@fluentui/font-icons-mdl2/-/font-icons-mdl2-8.5.47.tgz",
|
||||||
"integrity": "sha512-gkmHbZ1YXrxbq6WpfyqP9rxY7fp8xsTF1cyj3e9Ke2Pl3t6up+LM1MBunMHbeCk9Z4jUAk4HNF6DQn+glF066A==",
|
"integrity": "sha512-99d/cjEMz0ik9LnVrEDhZB4CnQavwgBvZuNa/EAaeHZMlQ7eheCzU3PNG4goPC7o4yg7XCNyngA7hEx3RUPUDA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fluentui/set-version": "^8.2.23",
|
"@fluentui/set-version": "^8.2.23",
|
||||||
"@fluentui/style-utilities": "^8.10.19",
|
"@fluentui/style-utilities": "^8.10.18",
|
||||||
"@fluentui/utilities": "^8.15.14",
|
"@fluentui/utilities": "^8.15.13",
|
||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -3001,14 +3001,14 @@
|
|||||||
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ=="
|
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ=="
|
||||||
},
|
},
|
||||||
"node_modules/@fluentui/foundation-legacy": {
|
"node_modules/@fluentui/foundation-legacy": {
|
||||||
"version": "8.4.14",
|
"version": "8.4.13",
|
||||||
"resolved": "https://registry.npmjs.org/@fluentui/foundation-legacy/-/foundation-legacy-8.4.14.tgz",
|
"resolved": "https://registry.npmjs.org/@fluentui/foundation-legacy/-/foundation-legacy-8.4.13.tgz",
|
||||||
"integrity": "sha512-5EXKmQiYVqRKzAaxl6IJzATaW4ZDBBpS2DbOUQtCXjdKJaUVcdLqvm2IGtLQ4hPxIyp74li71ilEGL81NkLerw==",
|
"integrity": "sha512-LIrqiDM0Fe45XLIx/XISwRfcaB5TfoMlkjic7K6goZtssi6VSNEAWjj+V2DOZNUaaFE3J3j61EspoZEKbqGazg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fluentui/merge-styles": "^8.6.12",
|
"@fluentui/merge-styles": "^8.6.12",
|
||||||
"@fluentui/set-version": "^8.2.23",
|
"@fluentui/set-version": "^8.2.23",
|
||||||
"@fluentui/style-utilities": "^8.10.19",
|
"@fluentui/style-utilities": "^8.10.18",
|
||||||
"@fluentui/utilities": "^8.15.14",
|
"@fluentui/utilities": "^8.15.13",
|
||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@ -3475,15 +3475,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fluentui/react-focus": {
|
"node_modules/@fluentui/react-focus": {
|
||||||
"version": "8.9.11",
|
"version": "8.9.10",
|
||||||
"resolved": "https://registry.npmjs.org/@fluentui/react-focus/-/react-focus-8.9.11.tgz",
|
"resolved": "https://registry.npmjs.org/@fluentui/react-focus/-/react-focus-8.9.10.tgz",
|
||||||
"integrity": "sha512-rydJUy8zkc2C7URdllG9O2+mtWYUnpx5vQ2At1ktq99grTmmsoB835kQRxfJuNJaSdKg48nSiXz9q8muitJ/rg==",
|
"integrity": "sha512-9kV15td8uuYhQS4bTLImxVo75dmbeOK0rZ4gQgOAY/0nKRYwiCLfH9SwQuEa+eCmjsBTNuDlXgghjQJyKFh5+A==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fluentui/keyboard-key": "^0.4.23",
|
"@fluentui/keyboard-key": "^0.4.23",
|
||||||
"@fluentui/merge-styles": "^8.6.12",
|
"@fluentui/merge-styles": "^8.6.12",
|
||||||
"@fluentui/set-version": "^8.2.23",
|
"@fluentui/set-version": "^8.2.23",
|
||||||
"@fluentui/style-utilities": "^8.10.19",
|
"@fluentui/style-utilities": "^8.10.18",
|
||||||
"@fluentui/utilities": "^8.15.14",
|
"@fluentui/utilities": "^8.15.13",
|
||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@ -3497,13 +3497,13 @@
|
|||||||
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ=="
|
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ=="
|
||||||
},
|
},
|
||||||
"node_modules/@fluentui/react-hooks": {
|
"node_modules/@fluentui/react-hooks": {
|
||||||
"version": "8.8.11",
|
"version": "8.8.10",
|
||||||
"resolved": "https://registry.npmjs.org/@fluentui/react-hooks/-/react-hooks-8.8.11.tgz",
|
"resolved": "https://registry.npmjs.org/@fluentui/react-hooks/-/react-hooks-8.8.10.tgz",
|
||||||
"integrity": "sha512-p+LeygeyydQH1jThwUlQ0sIRdY4DIuCw1Fn7GsF4LwhaZwZH69+dCUnyRTpmiLvfKgwsQJ00OdVdg+J0Ctuvdg==",
|
"integrity": "sha512-Xvnn6uKMsinMg/zo79KBNCDABnl0gpmArQYNQya9FCNRzvmHUCDvuQCqv4IKslvPvuC0Ya8mR2NORm2w0JoZiw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fluentui/react-window-provider": "^2.2.28",
|
"@fluentui/react-window-provider": "^2.2.28",
|
||||||
"@fluentui/set-version": "^8.2.23",
|
"@fluentui/set-version": "^8.2.23",
|
||||||
"@fluentui/utilities": "^8.15.14",
|
"@fluentui/utilities": "^8.15.13",
|
||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@ -4461,14 +4461,14 @@
|
|||||||
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ=="
|
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ=="
|
||||||
},
|
},
|
||||||
"node_modules/@fluentui/style-utilities": {
|
"node_modules/@fluentui/style-utilities": {
|
||||||
"version": "8.10.19",
|
"version": "8.10.18",
|
||||||
"resolved": "https://registry.npmjs.org/@fluentui/style-utilities/-/style-utilities-8.10.19.tgz",
|
"resolved": "https://registry.npmjs.org/@fluentui/style-utilities/-/style-utilities-8.10.18.tgz",
|
||||||
"integrity": "sha512-8cHkBblNb7c8HQL6jyz6prlK/JTH49LxiQIxMG5A+WnypVkwvu88BiEYv3mr+HfE+I39fhZnHq9bPV7tHfXcIw==",
|
"integrity": "sha512-nsXc6LI/UaPrJUh71WIqR19+mmfPl0b4qhaBUOzBGznGKU8jKlHT94pJbAIhWIjytdS8Zk8qtgStI+oYMxz9xg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fluentui/merge-styles": "^8.6.12",
|
"@fluentui/merge-styles": "^8.6.12",
|
||||||
"@fluentui/set-version": "^8.2.23",
|
"@fluentui/set-version": "^8.2.23",
|
||||||
"@fluentui/theme": "^2.6.57",
|
"@fluentui/theme": "^2.6.56",
|
||||||
"@fluentui/utilities": "^8.15.14",
|
"@fluentui/utilities": "^8.15.13",
|
||||||
"@microsoft/load-themed-styles": "^1.10.26",
|
"@microsoft/load-themed-styles": "^1.10.26",
|
||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
@ -4479,13 +4479,13 @@
|
|||||||
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ=="
|
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ=="
|
||||||
},
|
},
|
||||||
"node_modules/@fluentui/theme": {
|
"node_modules/@fluentui/theme": {
|
||||||
"version": "2.6.57",
|
"version": "2.6.56",
|
||||||
"resolved": "https://registry.npmjs.org/@fluentui/theme/-/theme-2.6.57.tgz",
|
"resolved": "https://registry.npmjs.org/@fluentui/theme/-/theme-2.6.56.tgz",
|
||||||
"integrity": "sha512-mm6UJJeGCbySmYW61Wc91JZ0lNb3pUzJIXuLYIari/qhF4cXHU3DnGbIwUehzBSOh5X3PEFIuXbpbstis+JhqQ==",
|
"integrity": "sha512-uUDfZpye7e+oXpmP0DOboBYKlyAxbLamnVdWs1a7l6fWEqTNfwDPIPZpMkdDmIBTjE6Q9eHP1u1PmQpMSlz0wA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fluentui/merge-styles": "^8.6.12",
|
"@fluentui/merge-styles": "^8.6.12",
|
||||||
"@fluentui/set-version": "^8.2.23",
|
"@fluentui/set-version": "^8.2.23",
|
||||||
"@fluentui/utilities": "^8.15.14",
|
"@fluentui/utilities": "^8.15.13",
|
||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@ -4506,9 +4506,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fluentui/utilities": {
|
"node_modules/@fluentui/utilities": {
|
||||||
"version": "8.15.14",
|
"version": "8.15.13",
|
||||||
"resolved": "https://registry.npmjs.org/@fluentui/utilities/-/utilities-8.15.14.tgz",
|
"resolved": "https://registry.npmjs.org/@fluentui/utilities/-/utilities-8.15.13.tgz",
|
||||||
"integrity": "sha512-TCOkX+1EN2UZKGdvaxaozjDbJcr+BhocdE23uZMZ+XphPW+2Dqij0+2k5jWO4UMCigKdcbLFZzhSc5YRpT+aFg==",
|
"integrity": "sha512-DrPv5baKHYtwB+OFqtGiOucdHFbqbnW7TSyxigADYkZQzJj1lnw5DoEGsVyMMVacD4vR21L3JfkMmfrhWm6hyw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fluentui/dom-utilities": "^2.3.7",
|
"@fluentui/dom-utilities": "^2.3.7",
|
||||||
"@fluentui/merge-styles": "^8.6.12",
|
"@fluentui/merge-styles": "^8.6.12",
|
||||||
@ -10160,20 +10160,6 @@
|
|||||||
"node": ">=16"
|
"node": ">=16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test/node_modules/fsevents": {
|
|
||||||
"version": "2.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@playwright/test/node_modules/playwright": {
|
"node_modules/@playwright/test/node_modules/playwright": {
|
||||||
"version": "1.44.0",
|
"version": "1.44.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@ -11398,9 +11384,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@shikijs/core": {
|
"node_modules/@shikijs/core": {
|
||||||
"version": "1.12.1",
|
"version": "1.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.12.1.tgz",
|
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.12.0.tgz",
|
||||||
"integrity": "sha512-biCz/mnkMktImI6hMfMX3H9kOeqsInxWEyCHbSlL8C/2TR1FqfmGxTLRNwYCKsyCyxWLbB8rEqXRVZuyxuLFmA==",
|
"integrity": "sha512-mc1cLbm6UQ8RxLc0dZES7v5rkH+99LxQp/ZvTqV3NLyYsO/fD6JhEflP1H5b2SDq9gI0+0G36AVZWxvounfR9w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/hast": "^3.0.4"
|
"@types/hast": "^3.0.4"
|
||||||
@ -13491,8 +13477,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/allotment": {
|
"node_modules/allotment": {
|
||||||
"version": "1.20.2",
|
"version": "1.20.2",
|
||||||
"resolved": "https://registry.npmjs.org/allotment/-/allotment-1.20.2.tgz",
|
"license": "MIT",
|
||||||
"integrity": "sha512-TaCuHfYNcsJS9EPk04M7TlG5Rl3vbAdHeAyrTE9D5vbpzV+wxnRoUrulDbfnzaQcPIZKpHJNixDOoZNuzliKEA==",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"classnames": "^2.3.0",
|
"classnames": "^2.3.0",
|
||||||
"eventemitter3": "^5.0.0",
|
"eventemitter3": "^5.0.0",
|
||||||
@ -13508,8 +13493,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/allotment/node_modules/eventemitter3": {
|
"node_modules/allotment/node_modules/eventemitter3": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
"license": "MIT"
|
||||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="
|
|
||||||
},
|
},
|
||||||
"node_modules/anser": {
|
"node_modules/anser": {
|
||||||
"version": "1.4.10",
|
"version": "1.4.10",
|
||||||
@ -14791,15 +14775,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bindings": {
|
|
||||||
"version": "1.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
|
||||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"file-uri-to-path": "1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bintrees": {
|
"node_modules/bintrees": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@ -15167,9 +15142,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001649",
|
"version": "1.0.30001651",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001649.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz",
|
||||||
"integrity": "sha512-fJegqZZ0ZX8HOWr6rcafGr72+xcgJKI9oWfDW5DrD7ExUtgZC7a7R7ZYmZqplh7XDocFdGeIFn7roAxhOeYrPQ==",
|
"integrity": "sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@ -17789,9 +17764,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.4.tgz",
|
||||||
"integrity": "sha512-QR7/A7ZkMS8tZuoftC/jfqNkZLQO779SSW3YuZHP4eXpj3EffGLFcB/Xu9AAZQzLccTiCV+EmUo3ha4mQ9wnlA=="
|
"integrity": "sha512-orzA81VqLyIGUEA77YkVA1D+N+nNfl2isJVjjmOyrlxuooZ19ynb+dOlaDTqd/idKRS9lDCSBmtzM+kyCsMnkA=="
|
||||||
},
|
},
|
||||||
"node_modules/emitter-listener": {
|
"node_modules/emitter-listener": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
@ -19462,12 +19437,6 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/file-uri-to-path": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"node_modules/filesize": {
|
"node_modules/filesize": {
|
||||||
"version": "8.0.7",
|
"version": "8.0.7",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@ -20055,19 +20024,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
|
||||||
"version": "2.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -24173,24 +24129,6 @@
|
|||||||
"fsevents": "^1.2.7"
|
"fsevents": "^1.2.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jest-haste-map/node_modules/fsevents": {
|
|
||||||
"version": "1.2.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
|
|
||||||
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
|
|
||||||
"deprecated": "The v1 package contains DANGEROUS / INSECURE binaries. Upgrade to safe fsevents v2",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"dependencies": {
|
|
||||||
"bindings": "^1.5.0",
|
|
||||||
"nan": "^2.12.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jest-haste-map/node_modules/jest-worker": {
|
"node_modules/jest-haste-map/node_modules/jest-worker": {
|
||||||
"version": "24.9.0",
|
"version": "24.9.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -27933,8 +27871,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/lodash.clamp": {
|
"node_modules/lodash.clamp": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.clamp/-/lodash.clamp-4.0.3.tgz",
|
"license": "MIT"
|
||||||
"integrity": "sha512-HvzRFWjtcguTW7yd8NJBshuNaCa8aqNFtnswdT7f/cMd/1YKy5Zzoq4W/Oxvnx9l7aeY258uSdDfM793+eLsVg=="
|
|
||||||
},
|
},
|
||||||
"node_modules/lodash.clonedeep": {
|
"node_modules/lodash.clonedeep": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
@ -33562,12 +33499,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/shiki": {
|
"node_modules/shiki": {
|
||||||
"version": "1.12.1",
|
"version": "1.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/shiki/-/shiki-1.12.1.tgz",
|
"resolved": "https://registry.npmjs.org/shiki/-/shiki-1.12.0.tgz",
|
||||||
"integrity": "sha512-nwmjbHKnOYYAe1aaQyEBHvQymJgfm86ZSS7fT8OaPRr4sbAcBNz7PbfAikMEFSDQ6se2j2zobkXvVKcBOm0ysg==",
|
"integrity": "sha512-BuAxWOm5JhRcbSOl7XCei8wGjgJJonnV0oipUupPY58iULxUGyHhW5CF+9FRMuM1pcJ5cGEJGll1LusX6FwpPA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shikijs/core": "1.12.1",
|
"@shikijs/core": "1.12.0",
|
||||||
"@types/hast": "^3.0.4"
|
"@types/hast": "^3.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -35665,8 +35602,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/use-resize-observer": {
|
"node_modules/use-resize-observer": {
|
||||||
"version": "9.1.0",
|
"version": "9.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-resize-observer/-/use-resize-observer-9.1.0.tgz",
|
"license": "MIT",
|
||||||
"integrity": "sha512-R25VqO9Wb3asSD4eqtcxk8sJalvIOYBqS8MNZlpDSQ4l4xMQxC/J7Id9HoTqPq8FwULIn0PVW+OAqF2dyYbjow==",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@juggle/resize-observer": "^3.3.1"
|
"@juggle/resize-observer": "^3.3.1"
|
||||||
},
|
},
|
||||||
|
@ -12,7 +12,6 @@ export default defineConfig({
|
|||||||
reporter: process.env.CI ? "blob" : "html",
|
reporter: process.env.CI ? "blob" : "html",
|
||||||
timeout: 10 * 60 * 1000,
|
timeout: 10 * 60 * 1000,
|
||||||
use: {
|
use: {
|
||||||
actionTimeout: 5 * 60 * 1000,
|
|
||||||
trace: "off",
|
trace: "off",
|
||||||
video: "off",
|
video: "off",
|
||||||
screenshot: "on",
|
screenshot: "on",
|
||||||
@ -23,7 +22,8 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
|
|
||||||
expect: {
|
expect: {
|
||||||
timeout: 5 * 60 * 1000,
|
// Many of our expectations take a little longer than the default 5 seconds.
|
||||||
|
timeout: 15 * 1000,
|
||||||
},
|
},
|
||||||
|
|
||||||
projects: [
|
projects: [
|
||||||
|
@ -53,7 +53,8 @@ const replaceKnownError = (errorMessage: string): string => {
|
|||||||
return "Partition key paths must contain only valid characters and not contain a trailing slash or wildcard character.";
|
return "Partition key paths must contain only valid characters and not contain a trailing slash or wildcard character.";
|
||||||
} else if (
|
} else if (
|
||||||
errorMessage?.indexOf("The user aborted a request") >= 0 ||
|
errorMessage?.indexOf("The user aborted a request") >= 0 ||
|
||||||
errorMessage?.indexOf("The operation was aborted") >= 0
|
errorMessage?.indexOf("The operation was aborted") >= 0 ||
|
||||||
|
errorMessage === "signal is aborted without reason"
|
||||||
) {
|
) {
|
||||||
return "User aborted query.";
|
return "User aborted query.";
|
||||||
}
|
}
|
||||||
|
@ -550,6 +550,49 @@ export function deleteDocument_ToBeDeprecated(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteDocuments(
|
||||||
|
databaseId: string,
|
||||||
|
collection: Collection,
|
||||||
|
documentIds: DocumentId[],
|
||||||
|
): Promise<{
|
||||||
|
deletedCount: number;
|
||||||
|
isAcknowledged: boolean;
|
||||||
|
}> {
|
||||||
|
const { databaseAccount } = userContext;
|
||||||
|
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
|
||||||
|
|
||||||
|
const rids = documentIds.map((documentId) => documentId.id());
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
databaseID: databaseId,
|
||||||
|
collectionID: collection.id(),
|
||||||
|
resourceUrl: `${resourceEndpoint}`,
|
||||||
|
resourceIDs: rids,
|
||||||
|
subscriptionID: userContext.subscriptionId,
|
||||||
|
resourceGroup: userContext.resourceGroup,
|
||||||
|
databaseAccountName: databaseAccount.name,
|
||||||
|
};
|
||||||
|
const endpoint = getFeatureEndpointOrDefault("bulkdelete");
|
||||||
|
|
||||||
|
return window
|
||||||
|
.fetch(`${endpoint}/bulkdelete`, {
|
||||||
|
method: "DELETE",
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
headers: {
|
||||||
|
...defaultHeaders,
|
||||||
|
...authHeaders(),
|
||||||
|
[HttpHeaders.contentType]: ContentType.applicationJson,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(async (response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return await errorHandling(response, "deleting documents", params);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function createMongoCollectionWithProxy(
|
export function createMongoCollectionWithProxy(
|
||||||
params: DataModels.CreateCollectionParams,
|
params: DataModels.CreateCollectionParams,
|
||||||
): Promise<DataModels.Collection> {
|
): Promise<DataModels.Collection> {
|
||||||
@ -677,7 +720,7 @@ export function useMongoProxyEndpoint(api: string): boolean {
|
|||||||
MongoProxyEndpoints.Local,
|
MongoProxyEndpoints.Local,
|
||||||
MongoProxyEndpoints.Mpac,
|
MongoProxyEndpoints.Mpac,
|
||||||
MongoProxyEndpoints.Prod,
|
MongoProxyEndpoints.Prod,
|
||||||
MongoProxyEndpoints.Fairfax,
|
// MongoProxyEndpoints.Fairfax,
|
||||||
];
|
];
|
||||||
let canAccessMongoProxy: boolean = userContext.databaseAccount.properties.publicNetworkAccess === "Enabled";
|
let canAccessMongoProxy: boolean = userContext.databaseAccount.properties.publicNetworkAccess === "Enabled";
|
||||||
if (
|
if (
|
||||||
|
212
src/Common/QueryError.ts
Normal file
212
src/Common/QueryError.ts
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
import { getErrorMessage } from "Common/ErrorHandlingUtils";
|
||||||
|
import { monaco } from "Explorer/LazyMonaco";
|
||||||
|
|
||||||
|
export enum QueryErrorSeverity {
|
||||||
|
Error = "Error",
|
||||||
|
Warning = "Warning",
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QueryErrorLocation {
|
||||||
|
constructor(
|
||||||
|
public start: ErrorPosition,
|
||||||
|
public end: ErrorPosition,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorPosition {
|
||||||
|
constructor(
|
||||||
|
public offset: number,
|
||||||
|
public lineNumber?: number,
|
||||||
|
public column?: number,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps severities to numbers for sorting.
|
||||||
|
const severityMap: Record<QueryErrorSeverity, number> = {
|
||||||
|
Error: 1,
|
||||||
|
Warning: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function compareSeverity(left: QueryErrorSeverity, right: QueryErrorSeverity): number {
|
||||||
|
return severityMap[left] - severityMap[right];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMonacoErrorLocationResolver(
|
||||||
|
editor: monaco.editor.IStandaloneCodeEditor,
|
||||||
|
selection?: monaco.Selection,
|
||||||
|
): (location: { start: number; end: number }) => QueryErrorLocation {
|
||||||
|
return ({ start, end }) => {
|
||||||
|
// Start and end are absolute offsets (character index) in the document.
|
||||||
|
// But we need line numbers and columns for the monaco editor.
|
||||||
|
// To get those, we use the editor's model to convert the offsets to positions.
|
||||||
|
const model = editor.getModel();
|
||||||
|
if (!model) {
|
||||||
|
return new QueryErrorLocation(new ErrorPosition(start), new ErrorPosition(end));
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the error was found in a selection, adjust the start and end positions to be relative to the document.
|
||||||
|
if (selection) {
|
||||||
|
// Get the character index of the start of the selection.
|
||||||
|
const selectionStartOffset = model.getOffsetAt(selection.getStartPosition());
|
||||||
|
|
||||||
|
// Adjust the start and end positions to be relative to the document.
|
||||||
|
start = selectionStartOffset + start;
|
||||||
|
end = selectionStartOffset + end;
|
||||||
|
|
||||||
|
// Now, when we resolve the positions, they will be relative to the document and appear in the correct location.
|
||||||
|
}
|
||||||
|
|
||||||
|
const startPos = model.getPositionAt(start);
|
||||||
|
const endPos = model.getPositionAt(end);
|
||||||
|
return new QueryErrorLocation(
|
||||||
|
new ErrorPosition(start, startPos.lineNumber, startPos.column),
|
||||||
|
new ErrorPosition(end, endPos.lineNumber, endPos.column),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createMonacoMarkersForQueryErrors = (errors: QueryError[]) => {
|
||||||
|
if (!errors) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
.map((error): monaco.editor.IMarkerData => {
|
||||||
|
// Validate that we have what we need to make a marker
|
||||||
|
if (
|
||||||
|
error.location === undefined ||
|
||||||
|
error.location.start === undefined ||
|
||||||
|
error.location.end === undefined ||
|
||||||
|
error.location.start.lineNumber === undefined ||
|
||||||
|
error.location.end.lineNumber === undefined ||
|
||||||
|
error.location.start.column === undefined ||
|
||||||
|
error.location.end.column === undefined
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: error.message,
|
||||||
|
severity: error.getMonacoSeverity(),
|
||||||
|
startLineNumber: error.location.start.lineNumber,
|
||||||
|
startColumn: error.location.start.column,
|
||||||
|
endLineNumber: error.location.end.lineNumber,
|
||||||
|
endColumn: error.location.end.column,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((marker) => !!marker);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class QueryError {
|
||||||
|
constructor(
|
||||||
|
public message: string,
|
||||||
|
public severity: QueryErrorSeverity,
|
||||||
|
public code?: string,
|
||||||
|
public location?: QueryErrorLocation,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getMonacoSeverity(): monaco.MarkerSeverity {
|
||||||
|
// It's very difficult to use the monaco.MarkerSeverity enum from here, so we'll just use the numbers directly.
|
||||||
|
// See: https://microsoft.github.io/monaco-editor/typedoc/enums/MarkerSeverity.html
|
||||||
|
switch (this.severity) {
|
||||||
|
case QueryErrorSeverity.Error:
|
||||||
|
return 8;
|
||||||
|
case QueryErrorSeverity.Warning:
|
||||||
|
return 4;
|
||||||
|
default:
|
||||||
|
return 2; // Info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attempts to parse a query error from a string or object.
|
||||||
|
*
|
||||||
|
* @param error The error to parse.
|
||||||
|
* @returns An array of query errors if the error could be parsed, or null otherwise.
|
||||||
|
*/
|
||||||
|
static tryParse(
|
||||||
|
error: unknown,
|
||||||
|
locationResolver?: (location: { start: number; end: number }) => QueryErrorLocation,
|
||||||
|
): QueryError[] {
|
||||||
|
locationResolver =
|
||||||
|
locationResolver ||
|
||||||
|
(({ start, end }) => new QueryErrorLocation(new ErrorPosition(start), new ErrorPosition(end)));
|
||||||
|
const errors = QueryError.tryParseObject(error, locationResolver);
|
||||||
|
if (errors !== null) {
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = getErrorMessage(error as string | Error);
|
||||||
|
|
||||||
|
// Map some well known messages to richer errors
|
||||||
|
const knownError = knownErrors[errorMessage];
|
||||||
|
if (knownError) {
|
||||||
|
return [knownError];
|
||||||
|
} else {
|
||||||
|
return [new QueryError(errorMessage, QueryErrorSeverity.Error)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static read(
|
||||||
|
error: unknown,
|
||||||
|
locationResolver: (location: { start: number; end: number }) => QueryErrorLocation,
|
||||||
|
): QueryError | null {
|
||||||
|
if (typeof error !== "object" || error === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = "message" in error && typeof error.message === "string" ? error.message : undefined;
|
||||||
|
if (!message) {
|
||||||
|
return null; // Invalid error (no message).
|
||||||
|
}
|
||||||
|
|
||||||
|
const severity =
|
||||||
|
"severity" in error && typeof error.severity === "string" ? (error.severity as QueryErrorSeverity) : undefined;
|
||||||
|
const location =
|
||||||
|
"location" in error && typeof error.location === "object"
|
||||||
|
? locationResolver(error.location as { start: number; end: number })
|
||||||
|
: undefined;
|
||||||
|
const code = "code" in error && typeof error.code === "string" ? error.code : undefined;
|
||||||
|
return new QueryError(message, severity, code, location);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static tryParseObject(
|
||||||
|
error: unknown,
|
||||||
|
locationResolver: (location: { start: number; end: number }) => QueryErrorLocation,
|
||||||
|
): QueryError[] | null {
|
||||||
|
if (typeof error === "object" && "message" in error) {
|
||||||
|
error = error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign to a new variable because of a TypeScript flow typing quirk, see below.
|
||||||
|
let message = error;
|
||||||
|
if (message.startsWith("Message: ")) {
|
||||||
|
// Reassigning this to 'error' restores the original type of 'error', which is 'unknown'.
|
||||||
|
// So we use a separate variable to avoid this.
|
||||||
|
message = message.substring("Message: ".length);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = message.split("\n");
|
||||||
|
message = lines[0].trim();
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(message);
|
||||||
|
} catch (e) {
|
||||||
|
// Not a query error.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof parsed === "object" && "errors" in parsed && Array.isArray(parsed.errors)) {
|
||||||
|
return parsed.errors.map((e) => QueryError.read(e, locationResolver)).filter((e) => e !== null);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownErrors: Record<string, QueryError> = {
|
||||||
|
"User aborted query.": new QueryError("User aborted query.", QueryErrorSeverity.Warning),
|
||||||
|
};
|
@ -109,14 +109,15 @@ let configContext: Readonly<ConfigContext> = {
|
|||||||
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Prod,
|
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Prod,
|
||||||
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
||||||
NEW_MONGO_APIS: [
|
NEW_MONGO_APIS: [
|
||||||
// "resourcelist",
|
"resourcelist",
|
||||||
// "queryDocuments",
|
"queryDocuments",
|
||||||
// "createDocument",
|
"createDocument",
|
||||||
// "readDocument",
|
"readDocument",
|
||||||
// "updateDocument",
|
"updateDocument",
|
||||||
// "deleteDocument",
|
"deleteDocument",
|
||||||
// "createCollectionWithProxy",
|
"createCollectionWithProxy",
|
||||||
// "legacyMongoShell",
|
"legacyMongoShell",
|
||||||
|
"bulkdelete",
|
||||||
],
|
],
|
||||||
MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED: false,
|
MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED: false,
|
||||||
CASSANDRA_PROXY_ENDPOINT: CassandraProxyEndpoints.Prod,
|
CASSANDRA_PROXY_ENDPOINT: CassandraProxyEndpoints.Prod,
|
||||||
|
@ -41,6 +41,10 @@ export interface DatabaseContextMenuButtonParams {
|
|||||||
* New resource tree (in ReactJS)
|
* New resource tree (in ReactJS)
|
||||||
*/
|
*/
|
||||||
export const createDatabaseContextMenu = (container: Explorer, databaseId: string): TreeNodeMenuItem[] => {
|
export const createDatabaseContextMenu = (container: Explorer, databaseId: string): TreeNodeMenuItem[] => {
|
||||||
|
if (configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const items: TreeNodeMenuItem[] = [
|
const items: TreeNodeMenuItem[] = [
|
||||||
{
|
{
|
||||||
iconSrc: AddCollectionIcon,
|
iconSrc: AddCollectionIcon,
|
||||||
|
@ -3,6 +3,37 @@ import * as React from "react";
|
|||||||
import { loadMonaco, monaco } from "../../LazyMonaco";
|
import { loadMonaco, monaco } from "../../LazyMonaco";
|
||||||
// import "./EditorReact.less";
|
// import "./EditorReact.less";
|
||||||
|
|
||||||
|
// In development, add a function to window to allow us to get the editor instance for a given element
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const win = window as any;
|
||||||
|
win._monaco_getEditorForElement =
|
||||||
|
win._monaco_getEditorForElement ||
|
||||||
|
((element: HTMLElement) => {
|
||||||
|
const editorId = element.dataset["monacoEditorId"];
|
||||||
|
if (!editorId || !win.__monaco_editors || typeof win.__monaco_editors !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return win.__monaco_editors[editorId];
|
||||||
|
});
|
||||||
|
|
||||||
|
win._monaco_getEditorContentForElement =
|
||||||
|
win._monaco_getEditorContentForElement ||
|
||||||
|
((element: HTMLElement) => {
|
||||||
|
const editor = win._monaco_getEditorForElement(element);
|
||||||
|
return editor ? editor.getValue() : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
win._monaco_setEditorContentForElement =
|
||||||
|
win._monaco_setEditorContentForElement ||
|
||||||
|
((element: HTMLElement, text: string) => {
|
||||||
|
const editor = win._monaco_getEditorForElement(element);
|
||||||
|
if (editor) {
|
||||||
|
editor.setValue(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
interface EditorReactStates {
|
interface EditorReactStates {
|
||||||
showEditor: boolean;
|
showEditor: boolean;
|
||||||
}
|
}
|
||||||
@ -11,7 +42,7 @@ export interface EditorReactProps {
|
|||||||
content: string;
|
content: string;
|
||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
ariaLabel: string; // Sets what will be read to the user to define the control
|
ariaLabel: string; // Sets what will be read to the user to define the control
|
||||||
onContentSelected?: (selectedContent: string) => void; // Called when text is selected
|
onContentSelected?: (selectedContent: string, selection: monaco.Selection) => void; // Called when text is selected
|
||||||
onContentChanged?: (newContent: string) => void; // Called when text is changed
|
onContentChanged?: (newContent: string) => void; // Called when text is changed
|
||||||
theme?: string; // Monaco editor theme
|
theme?: string; // Monaco editor theme
|
||||||
wordWrap?: monaco.editor.IEditorOptions["wordWrap"];
|
wordWrap?: monaco.editor.IEditorOptions["wordWrap"];
|
||||||
@ -25,6 +56,7 @@ export interface EditorReactProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
spinnerClassName?: string;
|
spinnerClassName?: string;
|
||||||
|
|
||||||
|
modelMarkers?: monaco.editor.IMarkerData[];
|
||||||
enableWordWrapContextMenuItem?: boolean; // Enable/Disable "Word Wrap" context menu item
|
enableWordWrapContextMenuItem?: boolean; // Enable/Disable "Word Wrap" context menu item
|
||||||
onWordWrapChanged?: (wordWrap: "on" | "off") => void; // Called when word wrap is changed
|
onWordWrapChanged?: (wordWrap: "on" | "off") => void; // Called when word wrap is changed
|
||||||
}
|
}
|
||||||
@ -32,10 +64,25 @@ export interface EditorReactProps {
|
|||||||
export class EditorReact extends React.Component<EditorReactProps, EditorReactStates> {
|
export class EditorReact extends React.Component<EditorReactProps, EditorReactStates> {
|
||||||
private static readonly VIEWING_OPTIONS_GROUP_ID = "viewingoptions"; // Group ID for the context menu group
|
private static readonly VIEWING_OPTIONS_GROUP_ID = "viewingoptions"; // Group ID for the context menu group
|
||||||
private rootNode: HTMLElement;
|
private rootNode: HTMLElement;
|
||||||
private editor: monaco.editor.IStandaloneCodeEditor;
|
public editor: monaco.editor.IStandaloneCodeEditor;
|
||||||
private selectionListener: monaco.IDisposable;
|
private selectionListener: monaco.IDisposable;
|
||||||
|
monacoApi: {
|
||||||
private monacoEditorOptionsWordWrap: monaco.editor.EditorOption;
|
default: typeof monaco;
|
||||||
|
Emitter: typeof monaco.Emitter;
|
||||||
|
MarkerTag: typeof monaco.MarkerTag;
|
||||||
|
MarkerSeverity: typeof monaco.MarkerSeverity;
|
||||||
|
CancellationTokenSource: typeof monaco.CancellationTokenSource;
|
||||||
|
Uri: typeof monaco.Uri;
|
||||||
|
KeyCode: typeof monaco.KeyCode;
|
||||||
|
KeyMod: typeof monaco.KeyMod;
|
||||||
|
Position: typeof monaco.Position;
|
||||||
|
Range: typeof monaco.Range;
|
||||||
|
Selection: typeof monaco.Selection;
|
||||||
|
SelectionDirection: typeof monaco.SelectionDirection;
|
||||||
|
Token: typeof monaco.Token;
|
||||||
|
editor: typeof monaco.editor;
|
||||||
|
languages: typeof monaco.languages;
|
||||||
|
};
|
||||||
|
|
||||||
public constructor(props: EditorReactProps) {
|
public constructor(props: EditorReactProps) {
|
||||||
super(props);
|
super(props);
|
||||||
@ -64,7 +111,7 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
|||||||
|
|
||||||
if (this.props.content !== existingContent) {
|
if (this.props.content !== existingContent) {
|
||||||
if (this.props.isReadOnly) {
|
if (this.props.isReadOnly) {
|
||||||
this.editor.setValue(this.props.content);
|
this.editor.setValue(this.props.content || ""); // Monaco throws an error if you set the value to undefined.
|
||||||
} else {
|
} else {
|
||||||
this.editor.pushUndoStop();
|
this.editor.pushUndoStop();
|
||||||
this.editor.executeEdits("", [
|
this.editor.executeEdits("", [
|
||||||
@ -75,6 +122,8 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.monacoApi.editor.setModelMarkers(this.editor.getModel(), "owner", this.props.modelMarkers || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentWillUnmount(): void {
|
public componentWillUnmount(): void {
|
||||||
@ -88,6 +137,7 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
|||||||
<Spinner size={SpinnerSize.large} className={this.props.spinnerClassName || "spinner"} />
|
<Spinner size={SpinnerSize.large} className={this.props.spinnerClassName || "spinner"} />
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
|
data-test="EditorReact/Host/Unloaded"
|
||||||
className={this.props.className || "jsonEditor"}
|
className={this.props.className || "jsonEditor"}
|
||||||
style={this.props.monacoContainerStyles}
|
style={this.props.monacoContainerStyles}
|
||||||
ref={(elt: HTMLElement) => this.setRef(elt)}
|
ref={(elt: HTMLElement) => this.setRef(elt)}
|
||||||
@ -98,6 +148,18 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
|||||||
|
|
||||||
protected configureEditor(editor: monaco.editor.IStandaloneCodeEditor) {
|
protected configureEditor(editor: monaco.editor.IStandaloneCodeEditor) {
|
||||||
this.editor = editor;
|
this.editor = editor;
|
||||||
|
this.rootNode.dataset["test"] = "EditorReact/Host/Loaded";
|
||||||
|
|
||||||
|
// In development, we want to be able to access the editor instance from the console
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
this.rootNode.dataset["monacoEditorId"] = this.editor.getId();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const win = window as any;
|
||||||
|
|
||||||
|
win["__monaco_editors"] = win["__monaco_editors"] || {};
|
||||||
|
win["__monaco_editors"][this.editor.getId()] = this.editor;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.props.isReadOnly && this.props.onContentChanged) {
|
if (!this.props.isReadOnly && this.props.onContentChanged) {
|
||||||
// Hooking the model's onDidChangeContent event because of some event ordering issues.
|
// Hooking the model's onDidChangeContent event because of some event ordering issues.
|
||||||
// If a single user input causes BOTH the editor content to change AND the cursor selection to change (which is likely),
|
// If a single user input causes BOTH the editor content to change AND the cursor selection to change (which is likely),
|
||||||
@ -115,7 +177,7 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
|||||||
this.selectionListener = this.editor.onDidChangeCursorSelection(
|
this.selectionListener = this.editor.onDidChangeCursorSelection(
|
||||||
(event: monaco.editor.ICursorSelectionChangedEvent) => {
|
(event: monaco.editor.ICursorSelectionChangedEvent) => {
|
||||||
const selectedContent: string = this.editor.getModel().getValueInRange(event.selection);
|
const selectedContent: string = this.editor.getModel().getValueInRange(event.selection);
|
||||||
this.props.onContentSelected(selectedContent);
|
this.props.onContentSelected(selectedContent, event.selection);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -130,7 +192,7 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
|||||||
// Method that will be executed when the action is triggered.
|
// Method that will be executed when the action is triggered.
|
||||||
// @param editor The editor instance is passed in as a convenience
|
// @param editor The editor instance is passed in as a convenience
|
||||||
run: (ed) => {
|
run: (ed) => {
|
||||||
const newOption = ed.getOption(this.monacoEditorOptionsWordWrap) === "on" ? "off" : "on";
|
const newOption = ed.getOption(this.monacoApi.editor.EditorOption.wordWrap) === "on" ? "off" : "on";
|
||||||
ed.updateOptions({ wordWrap: newOption });
|
ed.updateOptions({ wordWrap: newOption });
|
||||||
this.props.onWordWrapChanged(newOption);
|
this.props.onWordWrapChanged(newOption);
|
||||||
},
|
},
|
||||||
@ -156,16 +218,14 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
|||||||
lineDecorationsWidth: this.props.lineDecorationsWidth,
|
lineDecorationsWidth: this.props.lineDecorationsWidth,
|
||||||
minimap: this.props.minimap,
|
minimap: this.props.minimap,
|
||||||
scrollBeyondLastLine: this.props.scrollBeyondLastLine,
|
scrollBeyondLastLine: this.props.scrollBeyondLastLine,
|
||||||
|
fixedOverflowWidgets: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.rootNode.innerHTML = "";
|
this.rootNode.innerHTML = "";
|
||||||
const lazymonaco = await loadMonaco();
|
this.monacoApi = await loadMonaco();
|
||||||
|
|
||||||
// We can only get this constant after loading monaco lazily
|
|
||||||
this.monacoEditorOptionsWordWrap = lazymonaco.editor.EditorOption.wordWrap;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
createCallback(lazymonaco?.editor?.create(this.rootNode, options));
|
createCallback(this.monacoApi.editor.create(this.rootNode, options));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// This could happen if the parent node suddenly disappears during create()
|
// This could happen if the parent node suddenly disappears during create()
|
||||||
console.error("Unable to create EditorReact", error);
|
console.error("Unable to create EditorReact", error);
|
||||||
|
37
src/Explorer/Controls/IndeterminateProgressBar.tsx
Normal file
37
src/Explorer/Controls/IndeterminateProgressBar.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { ProgressBar, makeStyles } from "@fluentui/react-components";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
indeterminateProgressBarRoot: {
|
||||||
|
"@media screen and (prefers-reduced-motion: reduce)": {
|
||||||
|
animationIterationCount: "infinite",
|
||||||
|
animationDuration: "3s",
|
||||||
|
animationName: {
|
||||||
|
"0%": {
|
||||||
|
opacity: ".2", // matches indeterminate bar width
|
||||||
|
},
|
||||||
|
"50%": {
|
||||||
|
opacity: "1",
|
||||||
|
},
|
||||||
|
"100%": {
|
||||||
|
opacity: ".2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
indeterminateProgressBarBar: {
|
||||||
|
"@media screen and (prefers-reduced-motion: reduce)": {
|
||||||
|
maxWidth: "100%",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const IndeterminateProgressBar: React.FC = () => {
|
||||||
|
const styles = useStyles();
|
||||||
|
return (
|
||||||
|
<ProgressBar
|
||||||
|
bar={{ className: styles.indeterminateProgressBarBar }}
|
||||||
|
className={styles.indeterminateProgressBarRoot}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
68
src/Explorer/Controls/MessageBanner.tsx
Normal file
68
src/Explorer/Controls/MessageBanner.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { Button, MessageBar, MessageBarActions, MessageBarBody } from "@fluentui/react-components";
|
||||||
|
import { DismissRegular } from "@fluentui/react-icons";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
export enum MessageBannerState {
|
||||||
|
/** The banner should be visible if the triggering conditions are met. */
|
||||||
|
Allowed = "allowed",
|
||||||
|
|
||||||
|
/** The banner has been dismissed by the user and will not be shown until the component is recreated, even if the visibility condition is true. */
|
||||||
|
Dismissed = "dismissed",
|
||||||
|
|
||||||
|
/** The banner has been supressed by the user and will not be shown at all, even if the visibility condition is true. */
|
||||||
|
Suppressed = "suppressed",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MessageBannerProps = {
|
||||||
|
/** A CSS class for the root MessageBar component */
|
||||||
|
className: string;
|
||||||
|
|
||||||
|
/** A unique ID for the message that will be used to store it's dismiss/suppress state across sessions. */
|
||||||
|
messageId: string;
|
||||||
|
|
||||||
|
/** The current visibility state for the banner IGNORING the user's dimiss/suppress preference
|
||||||
|
*
|
||||||
|
* If this value is true but the user has dismissed the banner, the banner will NOT be shown.
|
||||||
|
*/
|
||||||
|
visible: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A component that shows a message banner which can be dismissed by the user.
|
||||||
|
*
|
||||||
|
* In the future, this can also support persisting the dismissed state in local storage without requiring changes to all the components that use it.
|
||||||
|
*
|
||||||
|
* A message banner can be in three "states":
|
||||||
|
* - Allowed: The banner should be visible if the triggering conditions are met.
|
||||||
|
* - Dismissed: The banner has been dismissed by the user and will not be shown until the component is recreated, even if the visibility condition is true.
|
||||||
|
* - Suppressed: The banner has been supressed by the user and will not be shown at all, even if the visibility condition is true.
|
||||||
|
*
|
||||||
|
* The "Dismissed" state represents the user clicking the "x" in the banner to dismiss it.
|
||||||
|
* The "Suppressed" state represents the user clicking "Don't show this again".
|
||||||
|
*/
|
||||||
|
export const MessageBanner: React.FC<MessageBannerProps> = ({ visible, className, children }) => {
|
||||||
|
const [state, setState] = useState<MessageBannerState>(MessageBannerState.Allowed);
|
||||||
|
|
||||||
|
if (state !== MessageBannerState.Allowed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!visible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageBar className={className}>
|
||||||
|
<MessageBarBody>{children}</MessageBarBody>
|
||||||
|
<MessageBarActions
|
||||||
|
containerAction={
|
||||||
|
<Button
|
||||||
|
aria-label="dismiss"
|
||||||
|
appearance="transparent"
|
||||||
|
icon={<DismissRegular />}
|
||||||
|
onClick={() => setState(MessageBannerState.Dismissed)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
></MessageBarActions>
|
||||||
|
</MessageBar>
|
||||||
|
);
|
||||||
|
};
|
@ -12,17 +12,5 @@
|
|||||||
.tab {
|
.tab {
|
||||||
margin-right: @MediumSpace;
|
margin-right: @MediumSpace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggleSwitch {
|
|
||||||
.toggleSwitch();
|
|
||||||
}
|
|
||||||
|
|
||||||
.selectedToggle {
|
|
||||||
.selectedToggle();
|
|
||||||
}
|
|
||||||
|
|
||||||
.unselectedToggle {
|
|
||||||
.unselectedToggle();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { AccessibleElement } from "../../Controls/AccessibleElement/AccessibleElement";
|
import { Pivot, PivotItem } from "@fluentui/react";
|
||||||
import "./TabComponent.less";
|
import "./TabComponent.less";
|
||||||
|
|
||||||
export interface TabContent {
|
export interface TabContent {
|
||||||
@ -35,58 +35,36 @@ export class TabComponent extends React.Component<TabComponentProps> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setActiveTab(index: number): void {
|
private setActiveTab(index: number): void {
|
||||||
this.setState({ activeTabIndex: index });
|
|
||||||
this.props.onTabIndexChange(index);
|
this.props.onTabIndexChange(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderTabTitles(): JSX.Element[] {
|
|
||||||
return this.props.tabs.map((tab: Tab, index: number) => {
|
|
||||||
if (!tab.isVisible()) {
|
|
||||||
return <React.Fragment key={index} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
let className = "toggleSwitch";
|
|
||||||
let ariaselected;
|
|
||||||
if (index === this.props.currentTabIndex) {
|
|
||||||
className += " selectedToggle";
|
|
||||||
ariaselected = true;
|
|
||||||
} else {
|
|
||||||
className += " unselectedToggle";
|
|
||||||
ariaselected = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="tab" key={index}>
|
|
||||||
<AccessibleElement
|
|
||||||
as="span"
|
|
||||||
className={className}
|
|
||||||
role="tab"
|
|
||||||
onActivated={() => this.setActiveTab(index)}
|
|
||||||
aria-label={`Select tab: ${tab.title}`}
|
|
||||||
aria-selected={ariaselected}
|
|
||||||
>
|
|
||||||
{tab.title}
|
|
||||||
</AccessibleElement>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public render(): JSX.Element {
|
public render(): JSX.Element {
|
||||||
const currentTabContent = this.props.tabs[this.props.currentTabIndex].content;
|
const { tabs, currentTabIndex, hideHeader } = this.props;
|
||||||
|
const currentTabContent = tabs[currentTabIndex].content;
|
||||||
let className = "tabComponentContent";
|
let className = "tabComponentContent";
|
||||||
if (currentTabContent.className) {
|
if (currentTabContent.className) {
|
||||||
className += ` ${currentTabContent.className}`;
|
className += ` ${currentTabContent.className}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tabComponentContainer">
|
<div className="tabComponentContainer">
|
||||||
{!this.props.hideHeader && (
|
<div className="tabs tabSwitch">
|
||||||
<div className="tabs tabSwitch" role="tablist">
|
{!hideHeader && (
|
||||||
{this.renderTabTitles()}
|
<Pivot
|
||||||
</div>
|
aria-label="Tab navigation"
|
||||||
|
selectedKey={currentTabIndex.toString()}
|
||||||
|
linkSize="normal"
|
||||||
|
onLinkClick={(item) => this.setActiveTab(parseInt(item?.props.itemKey || ""))}
|
||||||
|
>
|
||||||
|
{tabs.map((tab: Tab, index: number) => {
|
||||||
|
if (!tab.isVisible()) {
|
||||||
|
return null; // Skip rendering invisible tabs
|
||||||
|
}
|
||||||
|
return <PivotItem key={index} headerText={tab.title} itemKey={index.toString()} />;
|
||||||
|
})}
|
||||||
|
</Pivot>
|
||||||
)}
|
)}
|
||||||
<div className={className}>{currentTabContent.render()}</div>
|
</div>
|
||||||
|
<div className={className}>{tabs[currentTabIndex].content.render()}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -25,7 +25,9 @@ export const useTreeStyles = makeStyles({
|
|||||||
height: `var(${treeIconWidth})`,
|
height: `var(${treeIconWidth})`,
|
||||||
},
|
},
|
||||||
treeItem: {},
|
treeItem: {},
|
||||||
nodeLabel: {},
|
nodeLabel: {
|
||||||
|
whiteSpace: "nowrap", // Don't wrap text, there will be a scrollbar.
|
||||||
|
},
|
||||||
treeItemLayout: {
|
treeItemLayout: {
|
||||||
fontSize: tokens.fontSizeBase300,
|
fontSize: tokens.fontSizeBase300,
|
||||||
height: tokens.layoutRowHeight,
|
height: tokens.layoutRowHeight,
|
||||||
|
@ -158,9 +158,9 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
|
|||||||
node.iconSrc
|
node.iconSrc
|
||||||
)
|
)
|
||||||
) : openItems.includes(treeNodeId) ? (
|
) : openItems.includes(treeNodeId) ? (
|
||||||
<ChevronDown20Regular />
|
<ChevronDown20Regular data-test="TreeNode/CollapseIcon" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight20Regular />
|
<ChevronRight20Regular data-text="TreeNode/ExpandIcon" />
|
||||||
);
|
);
|
||||||
|
|
||||||
const treeItem = (
|
const treeItem = (
|
||||||
@ -205,7 +205,7 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
|
|||||||
<span className={treeStyles.nodeLabel}>{node.label}</span>
|
<span className={treeStyles.nodeLabel}>{node.label}</span>
|
||||||
</TreeItemLayout>
|
</TreeItemLayout>
|
||||||
{!node.isLoading && node.children?.length > 0 && (
|
{!node.isLoading && node.children?.length > 0 && (
|
||||||
<Tree className={treeStyles.tree}>
|
<Tree data-test={`Tree:${treeNodeId}`} className={treeStyles.tree}>
|
||||||
{getSortedChildren(node).map((childNode: TreeNode) => (
|
{getSortedChildren(node).map((childNode: TreeNode) => (
|
||||||
<TreeNodeComponent
|
<TreeNodeComponent
|
||||||
openItems={openItems}
|
openItems={openItems}
|
||||||
|
@ -12,10 +12,14 @@ exports[`TreeNodeComponent does not render children if the node is loading 1`] =
|
|||||||
actions={false}
|
actions={false}
|
||||||
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
|
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
|
||||||
data-test="TreeNode:root"
|
data-test="TreeNode:root"
|
||||||
expandIcon={<ChevronRight20Regular />}
|
expandIcon={
|
||||||
|
<ChevronRight20Regular
|
||||||
|
data-text="TreeNode/ExpandIcon"
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className=""
|
className="___1h29e9h_0000000 fz5stix"
|
||||||
>
|
>
|
||||||
rootLabel
|
rootLabel
|
||||||
</span>
|
</span>
|
||||||
@ -133,6 +137,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||||
|
data-text="TreeNode/ExpandIcon"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
height="20"
|
height="20"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
@ -161,6 +166,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||||
|
data-text="TreeNode/ExpandIcon"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
height="20"
|
height="20"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
@ -177,7 +183,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
class="fui-TreeItemLayout__main rklbe47"
|
class="fui-TreeItemLayout__main rklbe47"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class=""
|
class="___1h29e9h_0000000 fz5stix"
|
||||||
>
|
>
|
||||||
rootLabel
|
rootLabel
|
||||||
</span>
|
</span>
|
||||||
@ -212,6 +218,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||||
|
data-text="TreeNode/ExpandIcon"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
height="20"
|
height="20"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
@ -228,7 +235,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
class="fui-TreeItemLayout__main rklbe47"
|
class="fui-TreeItemLayout__main rklbe47"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class=""
|
class="___1h29e9h_0000000 fz5stix"
|
||||||
>
|
>
|
||||||
rootLabel
|
rootLabel
|
||||||
</span>
|
</span>
|
||||||
@ -236,6 +243,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="fui-Tree rnv2ez3 ___jy13a00_lpffjy0 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
|
class="fui-Tree rnv2ez3 ___jy13a00_lpffjy0 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
|
||||||
|
data-test="Tree:root"
|
||||||
role="tree"
|
role="tree"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@ -258,6 +266,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||||
|
data-text="TreeNode/ExpandIcon"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
height="20"
|
height="20"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
@ -274,7 +283,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
class="fui-TreeItemLayout__main rklbe47"
|
class="fui-TreeItemLayout__main rklbe47"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class=""
|
class="___1h29e9h_0000000 fz5stix"
|
||||||
>
|
>
|
||||||
child1Label
|
child1Label
|
||||||
</span>
|
</span>
|
||||||
@ -301,6 +310,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||||
|
data-text="TreeNode/ExpandIcon"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
height="20"
|
height="20"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
@ -317,7 +327,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
class="fui-TreeItemLayout__main rklbe47"
|
class="fui-TreeItemLayout__main rklbe47"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class=""
|
class="___1h29e9h_0000000 fz5stix"
|
||||||
>
|
>
|
||||||
child2LoadingLabel
|
child2LoadingLabel
|
||||||
</span>
|
</span>
|
||||||
@ -357,7 +367,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
class="fui-TreeItemLayout__main rklbe47"
|
class="fui-TreeItemLayout__main rklbe47"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class=""
|
class="___1h29e9h_0000000 fz5stix"
|
||||||
>
|
>
|
||||||
child3ExpandingLabel
|
child3ExpandingLabel
|
||||||
</span>
|
</span>
|
||||||
@ -375,7 +385,11 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
actions={false}
|
actions={false}
|
||||||
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
|
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
|
||||||
data-test="TreeNode:root"
|
data-test="TreeNode:root"
|
||||||
expandIcon={<ChevronRight20Regular />}
|
expandIcon={
|
||||||
|
<ChevronRight20Regular
|
||||||
|
data-text="TreeNode/ExpandIcon"
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||||
@ -385,10 +399,13 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
aria-hidden={true}
|
aria-hidden={true}
|
||||||
className="fui-TreeItemLayout__expandIcon rh4pu5o"
|
className="fui-TreeItemLayout__expandIcon rh4pu5o"
|
||||||
>
|
>
|
||||||
<ChevronRight20Regular>
|
<ChevronRight20Regular
|
||||||
|
data-text="TreeNode/ExpandIcon"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-hidden={true}
|
aria-hidden={true}
|
||||||
className="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
className="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||||
|
data-text="TreeNode/ExpandIcon"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
height="20"
|
height="20"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
@ -406,7 +423,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
className="fui-TreeItemLayout__main rklbe47"
|
className="fui-TreeItemLayout__main rklbe47"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className=""
|
className="___1h29e9h_0000000 fz5stix"
|
||||||
>
|
>
|
||||||
rootLabel
|
rootLabel
|
||||||
</span>
|
</span>
|
||||||
@ -415,6 +432,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
</TreeItemLayout>
|
</TreeItemLayout>
|
||||||
<Tree
|
<Tree
|
||||||
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
|
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
|
||||||
|
data-test="Tree:root"
|
||||||
>
|
>
|
||||||
<TreeProvider
|
<TreeProvider
|
||||||
value={
|
value={
|
||||||
@ -482,6 +500,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="fui-Tree rnv2ez3 ___jy13a00_lpffjy0 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
|
className="fui-Tree rnv2ez3 ___jy13a00_lpffjy0 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
|
||||||
|
data-test="Tree:root"
|
||||||
role="tree"
|
role="tree"
|
||||||
>
|
>
|
||||||
<TreeNodeComponent
|
<TreeNodeComponent
|
||||||
@ -549,6 +568,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||||
|
data-text="TreeNode/ExpandIcon"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
height="20"
|
height="20"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
@ -577,6 +597,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||||
|
data-text="TreeNode/ExpandIcon"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
height="20"
|
height="20"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
@ -593,7 +614,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
class="fui-TreeItemLayout__main rklbe47"
|
class="fui-TreeItemLayout__main rklbe47"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class=""
|
class="___1h29e9h_0000000 fz5stix"
|
||||||
>
|
>
|
||||||
child1Label
|
child1Label
|
||||||
</span>
|
</span>
|
||||||
@ -628,6 +649,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||||
|
data-text="TreeNode/ExpandIcon"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
height="20"
|
height="20"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
@ -644,7 +666,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
class="fui-TreeItemLayout__main rklbe47"
|
class="fui-TreeItemLayout__main rklbe47"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class=""
|
class="___1h29e9h_0000000 fz5stix"
|
||||||
>
|
>
|
||||||
child1Label
|
child1Label
|
||||||
</span>
|
</span>
|
||||||
@ -660,7 +682,11 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
actions={false}
|
actions={false}
|
||||||
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
|
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
|
||||||
data-test="TreeNode:root/child1Label"
|
data-test="TreeNode:root/child1Label"
|
||||||
expandIcon={<ChevronRight20Regular />}
|
expandIcon={
|
||||||
|
<ChevronRight20Regular
|
||||||
|
data-text="TreeNode/ExpandIcon"
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||||
@ -670,10 +696,13 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
aria-hidden={true}
|
aria-hidden={true}
|
||||||
className="fui-TreeItemLayout__expandIcon rh4pu5o"
|
className="fui-TreeItemLayout__expandIcon rh4pu5o"
|
||||||
>
|
>
|
||||||
<ChevronRight20Regular>
|
<ChevronRight20Regular
|
||||||
|
data-text="TreeNode/ExpandIcon"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-hidden={true}
|
aria-hidden={true}
|
||||||
className="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
className="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||||
|
data-text="TreeNode/ExpandIcon"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
height="20"
|
height="20"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
@ -691,7 +720,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
className="fui-TreeItemLayout__main rklbe47"
|
className="fui-TreeItemLayout__main rklbe47"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className=""
|
className="___1h29e9h_0000000 fz5stix"
|
||||||
>
|
>
|
||||||
child1Label
|
child1Label
|
||||||
</span>
|
</span>
|
||||||
@ -700,6 +729,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
</TreeItemLayout>
|
</TreeItemLayout>
|
||||||
<Tree
|
<Tree
|
||||||
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
|
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
|
||||||
|
data-test="Tree:root/child1Label"
|
||||||
>
|
>
|
||||||
<TreeProvider
|
<TreeProvider
|
||||||
value={
|
value={
|
||||||
@ -772,6 +802,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||||
|
data-text="TreeNode/ExpandIcon"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
height="20"
|
height="20"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
@ -800,6 +831,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||||
|
data-text="TreeNode/ExpandIcon"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
height="20"
|
height="20"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
@ -816,7 +848,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
class="fui-TreeItemLayout__main rklbe47"
|
class="fui-TreeItemLayout__main rklbe47"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class=""
|
class="___1h29e9h_0000000 fz5stix"
|
||||||
>
|
>
|
||||||
child2LoadingLabel
|
child2LoadingLabel
|
||||||
</span>
|
</span>
|
||||||
@ -851,6 +883,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||||
|
data-text="TreeNode/ExpandIcon"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
height="20"
|
height="20"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
@ -867,7 +900,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
class="fui-TreeItemLayout__main rklbe47"
|
class="fui-TreeItemLayout__main rklbe47"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class=""
|
class="___1h29e9h_0000000 fz5stix"
|
||||||
>
|
>
|
||||||
child2LoadingLabel
|
child2LoadingLabel
|
||||||
</span>
|
</span>
|
||||||
@ -883,7 +916,11 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
actions={false}
|
actions={false}
|
||||||
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
|
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
|
||||||
data-test="TreeNode:root/child2LoadingLabel"
|
data-test="TreeNode:root/child2LoadingLabel"
|
||||||
expandIcon={<ChevronRight20Regular />}
|
expandIcon={
|
||||||
|
<ChevronRight20Regular
|
||||||
|
data-text="TreeNode/ExpandIcon"
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||||
@ -893,10 +930,13 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
aria-hidden={true}
|
aria-hidden={true}
|
||||||
className="fui-TreeItemLayout__expandIcon rh4pu5o"
|
className="fui-TreeItemLayout__expandIcon rh4pu5o"
|
||||||
>
|
>
|
||||||
<ChevronRight20Regular>
|
<ChevronRight20Regular
|
||||||
|
data-text="TreeNode/ExpandIcon"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-hidden={true}
|
aria-hidden={true}
|
||||||
className="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
className="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||||
|
data-text="TreeNode/ExpandIcon"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
height="20"
|
height="20"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
@ -914,7 +954,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
className="fui-TreeItemLayout__main rklbe47"
|
className="fui-TreeItemLayout__main rklbe47"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className=""
|
className="___1h29e9h_0000000 fz5stix"
|
||||||
>
|
>
|
||||||
child2LoadingLabel
|
child2LoadingLabel
|
||||||
</span>
|
</span>
|
||||||
@ -1023,7 +1063,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
class="fui-TreeItemLayout__main rklbe47"
|
class="fui-TreeItemLayout__main rklbe47"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class=""
|
class="___1h29e9h_0000000 fz5stix"
|
||||||
>
|
>
|
||||||
child3ExpandingLabel
|
child3ExpandingLabel
|
||||||
</span>
|
</span>
|
||||||
@ -1071,7 +1111,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
class="fui-TreeItemLayout__main rklbe47"
|
class="fui-TreeItemLayout__main rklbe47"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class=""
|
class="___1h29e9h_0000000 fz5stix"
|
||||||
>
|
>
|
||||||
child3ExpandingLabel
|
child3ExpandingLabel
|
||||||
</span>
|
</span>
|
||||||
@ -1113,7 +1153,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||||||
className="fui-TreeItemLayout__main rklbe47"
|
className="fui-TreeItemLayout__main rklbe47"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className=""
|
className="___1h29e9h_0000000 fz5stix"
|
||||||
>
|
>
|
||||||
child3ExpandingLabel
|
child3ExpandingLabel
|
||||||
</span>
|
</span>
|
||||||
@ -1155,7 +1195,7 @@ exports[`TreeNodeComponent renders a loading spinner if the node is loading: loa
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className=""
|
className="___1h29e9h_0000000 fz5stix"
|
||||||
>
|
>
|
||||||
rootLabel
|
rootLabel
|
||||||
</span>
|
</span>
|
||||||
@ -1182,7 +1222,7 @@ exports[`TreeNodeComponent renders a loading spinner if the node is loading: loa
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className=""
|
className="___1h29e9h_0000000 fz5stix"
|
||||||
>
|
>
|
||||||
rootLabel
|
rootLabel
|
||||||
</span>
|
</span>
|
||||||
@ -1202,10 +1242,14 @@ exports[`TreeNodeComponent renders a node as expandable if it has empty, but def
|
|||||||
actions={false}
|
actions={false}
|
||||||
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
|
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
|
||||||
data-test="TreeNode:root"
|
data-test="TreeNode:root"
|
||||||
expandIcon={<ChevronRight20Regular />}
|
expandIcon={
|
||||||
|
<ChevronRight20Regular
|
||||||
|
data-text="TreeNode/ExpandIcon"
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className=""
|
className="___1h29e9h_0000000 fz5stix"
|
||||||
>
|
>
|
||||||
rootLabel
|
rootLabel
|
||||||
</span>
|
</span>
|
||||||
@ -1280,7 +1324,7 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className=""
|
className="___1h29e9h_0000000 fz5stix"
|
||||||
>
|
>
|
||||||
rootLabel
|
rootLabel
|
||||||
</span>
|
</span>
|
||||||
@ -1330,7 +1374,7 @@ exports[`TreeNodeComponent renders a single node 1`] = `
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className=""
|
className="___1h29e9h_0000000 fz5stix"
|
||||||
>
|
>
|
||||||
rootLabel
|
rootLabel
|
||||||
</span>
|
</span>
|
||||||
@ -1359,7 +1403,7 @@ exports[`TreeNodeComponent renders an icon if the node has one 1`] = `
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className=""
|
className="___1h29e9h_0000000 fz5stix"
|
||||||
>
|
>
|
||||||
rootLabel
|
rootLabel
|
||||||
</span>
|
</span>
|
||||||
@ -1379,16 +1423,21 @@ exports[`TreeNodeComponent renders selected parent node as selected if no descen
|
|||||||
actions={false}
|
actions={false}
|
||||||
className="___kqkdor0_ihxn0o0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1nfm20t f1do9gdl"
|
className="___kqkdor0_ihxn0o0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1nfm20t f1do9gdl"
|
||||||
data-test="TreeNode:root"
|
data-test="TreeNode:root"
|
||||||
expandIcon={<ChevronRight20Regular />}
|
expandIcon={
|
||||||
|
<ChevronRight20Regular
|
||||||
|
data-text="TreeNode/ExpandIcon"
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className=""
|
className="___1h29e9h_0000000 fz5stix"
|
||||||
>
|
>
|
||||||
rootLabel
|
rootLabel
|
||||||
</span>
|
</span>
|
||||||
</TreeItemLayout>
|
</TreeItemLayout>
|
||||||
<Tree
|
<Tree
|
||||||
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
|
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
|
||||||
|
data-test="Tree:root"
|
||||||
>
|
>
|
||||||
<TreeNodeComponent
|
<TreeNodeComponent
|
||||||
key="child1Label"
|
key="child1Label"
|
||||||
@ -1450,16 +1499,21 @@ exports[`TreeNodeComponent renders selected parent node as unselected if any des
|
|||||||
actions={false}
|
actions={false}
|
||||||
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
|
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
|
||||||
data-test="TreeNode:root"
|
data-test="TreeNode:root"
|
||||||
expandIcon={<ChevronRight20Regular />}
|
expandIcon={
|
||||||
|
<ChevronRight20Regular
|
||||||
|
data-text="TreeNode/ExpandIcon"
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className=""
|
className="___1h29e9h_0000000 fz5stix"
|
||||||
>
|
>
|
||||||
rootLabel
|
rootLabel
|
||||||
</span>
|
</span>
|
||||||
</TreeItemLayout>
|
</TreeItemLayout>
|
||||||
<Tree
|
<Tree
|
||||||
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
|
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
|
||||||
|
data-test="Tree:root"
|
||||||
>
|
>
|
||||||
<TreeNodeComponent
|
<TreeNodeComponent
|
||||||
key="child1Label"
|
key="child1Label"
|
||||||
@ -1531,7 +1585,7 @@ exports[`TreeNodeComponent renders single selected leaf node as selected 1`] = `
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className=""
|
className="___1h29e9h_0000000 fz5stix"
|
||||||
>
|
>
|
||||||
rootLabel
|
rootLabel
|
||||||
</span>
|
</span>
|
||||||
|
@ -295,7 +295,7 @@ export default class Explorer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public openNPSSurveyDialog(): void {
|
public openNPSSurveyDialog(): void {
|
||||||
if (!Platform.Portal) {
|
if (!Platform.Portal || !["Postgres", "SQL", "Mongo"].includes(userContext.apiType)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -632,24 +632,15 @@ describe("GraphExplorer", () => {
|
|||||||
|
|
||||||
it("should display RU consumption", () => {
|
it("should display RU consumption", () => {
|
||||||
// Find link for query stats
|
// Find link for query stats
|
||||||
const links = wrapper.find(".toggleSwitch");
|
const queryStatsTab = wrapper.find(`button[name="${GraphExplorer.QUERY_STATS_BUTTON_LABEL}"]`);
|
||||||
let isRUDisplayed = false;
|
queryStatsTab.simulate("click");
|
||||||
for (let i = 0; i < links.length; i++) {
|
|
||||||
const link = links.at(i);
|
|
||||||
if (link.text() === GraphExplorer.QUERY_STATS_BUTTON_LABEL) {
|
|
||||||
link.simulate("click");
|
|
||||||
|
|
||||||
const values = wrapper.find(".queryMetricsSummary td");
|
const values = wrapper.find(".queryMetricsSummary td");
|
||||||
for (let j = 0; j < values.length; j++) {
|
let isRUDisplayed = false;
|
||||||
if (Number(values.at(j).text()) === gVRU) {
|
values.forEach((value) => {
|
||||||
|
if (Number(value.text()) === gVRU) {
|
||||||
isRUDisplayed = true;
|
isRUDisplayed = true;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(isRUDisplayed).toBe(true);
|
expect(isRUDisplayed).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -348,8 +348,9 @@ export class NodePropertiesComponent extends React.Component<
|
|||||||
as="span"
|
as="span"
|
||||||
onActivated={this.setIsDeleteConfirm.bind(this, true)}
|
onActivated={this.setIsDeleteConfirm.bind(this, true)}
|
||||||
aria-label="Delete this vertex"
|
aria-label="Delete this vertex"
|
||||||
|
role="button"
|
||||||
>
|
>
|
||||||
<img src={DeleteIcon} alt="Delete" role="button" />
|
<img src={DeleteIcon} alt="Delete" aria-label="hidden" />
|
||||||
</AccessibleElement>
|
</AccessibleElement>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@ -405,8 +406,9 @@ export class NodePropertiesComponent extends React.Component<
|
|||||||
as="span"
|
as="span"
|
||||||
aria-label="Edit properties"
|
aria-label="Edit properties"
|
||||||
onActivated={expandClickHandler}
|
onActivated={expandClickHandler}
|
||||||
|
role="button"
|
||||||
>
|
>
|
||||||
<img src={EditIcon} alt="Edit" role="button" />
|
<img src={EditIcon} alt="Edit" aria-label="hidden" />
|
||||||
</AccessibleElement>
|
</AccessibleElement>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -167,15 +167,11 @@ export function createContextCommandBarButtons(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createControlCommandBarButtons(container: Explorer): CommandButtonComponentProps[] {
|
export function createControlCommandBarButtons(container: Explorer): CommandButtonComponentProps[] {
|
||||||
const buttons: CommandButtonComponentProps[] =
|
const buttons: CommandButtonComponentProps[] = [
|
||||||
configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
{
|
{
|
||||||
iconSrc: SettingsIcon,
|
iconSrc: SettingsIcon,
|
||||||
iconAlt: "Settings",
|
iconAlt: "Settings",
|
||||||
onCommandClick: () =>
|
onCommandClick: () => useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={container} />),
|
||||||
useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={container} />),
|
|
||||||
commandButtonLabel: undefined,
|
commandButtonLabel: undefined,
|
||||||
ariaLabel: "Settings",
|
ariaLabel: "Settings",
|
||||||
tooltipText: "Settings",
|
tooltipText: "Settings",
|
||||||
|
@ -131,6 +131,7 @@ export class NotificationConsoleComponent extends React.Component<
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="expandCollapseButton"
|
className="expandCollapseButton"
|
||||||
|
data-test="NotificationConsole/ExpandCollapseButton"
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={"console button" + (this.props.isConsoleExpanded ? " expanded" : " collapsed")}
|
aria-label={"console button" + (this.props.isConsoleExpanded ? " expanded" : " collapsed")}
|
||||||
@ -147,7 +148,7 @@ export class NotificationConsoleComponent extends React.Component<
|
|||||||
height={this.props.isConsoleExpanded ? "auto" : 0}
|
height={this.props.isConsoleExpanded ? "auto" : 0}
|
||||||
onAnimationEnd={this.onConsoleWasExpanded}
|
onAnimationEnd={this.onConsoleWasExpanded}
|
||||||
>
|
>
|
||||||
<div className="notificationConsoleContents">
|
<div data-test="NotificationConsole/Contents" className="notificationConsoleContents">
|
||||||
<div className="notificationConsoleControls">
|
<div className="notificationConsoleControls">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
label="Filter:"
|
label="Filter:"
|
||||||
|
@ -74,6 +74,7 @@ exports[`NotificationConsoleComponent renders the console 1`] = `
|
|||||||
aria-expanded={true}
|
aria-expanded={true}
|
||||||
aria-label="console button collapsed"
|
aria-label="console button collapsed"
|
||||||
className="expandCollapseButton"
|
className="expandCollapseButton"
|
||||||
|
data-test="NotificationConsole/ExpandCollapseButton"
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
@ -109,6 +110,7 @@ exports[`NotificationConsoleComponent renders the console 1`] = `
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="notificationConsoleContents"
|
className="notificationConsoleContents"
|
||||||
|
data-test="NotificationConsole/Contents"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="notificationConsoleControls"
|
className="notificationConsoleControls"
|
||||||
@ -245,6 +247,7 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
|
|||||||
aria-expanded={true}
|
aria-expanded={true}
|
||||||
aria-label="console button collapsed"
|
aria-label="console button collapsed"
|
||||||
className="expandCollapseButton"
|
className="expandCollapseButton"
|
||||||
|
data-test="NotificationConsole/ExpandCollapseButton"
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
@ -280,6 +283,7 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="notificationConsoleContents"
|
className="notificationConsoleContents"
|
||||||
|
data-test="NotificationConsole/Contents"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="notificationConsoleControls"
|
className="notificationConsoleControls"
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Checkbox,
|
Checkbox,
|
||||||
ChoiceGroup,
|
ChoiceGroup,
|
||||||
|
DefaultButton,
|
||||||
IChoiceGroupOption,
|
IChoiceGroupOption,
|
||||||
ISpinButtonStyles,
|
ISpinButtonStyles,
|
||||||
IToggleStyles,
|
IToggleStyles,
|
||||||
@ -12,11 +13,15 @@ import {
|
|||||||
Toggle,
|
Toggle,
|
||||||
TooltipHost,
|
TooltipHost,
|
||||||
} from "@fluentui/react";
|
} from "@fluentui/react";
|
||||||
|
import { makeStyles } from "@fluentui/react-components";
|
||||||
|
import { AuthType } from "AuthType";
|
||||||
import * as Constants from "Common/Constants";
|
import * as Constants from "Common/Constants";
|
||||||
import { SplitterDirection } from "Common/Splitter";
|
import { SplitterDirection } from "Common/Splitter";
|
||||||
import { InfoTooltip } from "Common/Tooltip/InfoTooltip";
|
import { InfoTooltip } from "Common/Tooltip/InfoTooltip";
|
||||||
import { Platform, configContext } from "ConfigContext";
|
import { Platform, configContext } from "ConfigContext";
|
||||||
|
import { useDialog } from "Explorer/Controls/Dialog";
|
||||||
import { useDatabases } from "Explorer/useDatabases";
|
import { useDatabases } from "Explorer/useDatabases";
|
||||||
|
import { deleteAllStates } from "Shared/AppStatePersistenceUtility";
|
||||||
import {
|
import {
|
||||||
DefaultRUThreshold,
|
DefaultRUThreshold,
|
||||||
LocalStorageUtility,
|
LocalStorageUtility,
|
||||||
@ -29,14 +34,13 @@ import * as StringUtility from "Shared/StringUtility";
|
|||||||
import { updateUserContext, userContext } from "UserContext";
|
import { updateUserContext, userContext } from "UserContext";
|
||||||
import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils";
|
import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils";
|
||||||
import * as PriorityBasedExecutionUtils from "Utils/PriorityBasedExecutionUtils";
|
import * as PriorityBasedExecutionUtils from "Utils/PriorityBasedExecutionUtils";
|
||||||
|
import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
|
||||||
import { useQueryCopilot } from "hooks/useQueryCopilot";
|
import { useQueryCopilot } from "hooks/useQueryCopilot";
|
||||||
import { useSidePanel } from "hooks/useSidePanel";
|
import { useSidePanel } from "hooks/useSidePanel";
|
||||||
import React, { FunctionComponent, useState } from "react";
|
import React, { FunctionComponent, useState } from "react";
|
||||||
|
import create, { UseStore } from "zustand";
|
||||||
import Explorer from "../../Explorer";
|
import Explorer from "../../Explorer";
|
||||||
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
|
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
|
||||||
import { AuthType } from "AuthType";
|
|
||||||
import create, { UseStore } from "zustand";
|
|
||||||
import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
|
|
||||||
|
|
||||||
export interface DataPlaneRbacState {
|
export interface DataPlaneRbacState {
|
||||||
dataPlaneRbacEnabled: boolean;
|
dataPlaneRbacEnabled: boolean;
|
||||||
@ -50,6 +54,13 @@ export interface DataPlaneRbacState {
|
|||||||
|
|
||||||
type DataPlaneRbacStore = UseStore<Partial<DataPlaneRbacState>>;
|
type DataPlaneRbacStore = UseStore<Partial<DataPlaneRbacState>>;
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
bulletList: {
|
||||||
|
listStyleType: "disc",
|
||||||
|
paddingLeft: "20px",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const useDataPlaneRbac: DataPlaneRbacStore = create(() => ({
|
export const useDataPlaneRbac: DataPlaneRbacStore = create(() => ({
|
||||||
dataPlaneRbacEnabled: false,
|
dataPlaneRbacEnabled: false,
|
||||||
}));
|
}));
|
||||||
@ -133,6 +144,9 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
|||||||
const [copilotSampleDBEnabled, setCopilotSampleDBEnabled] = useState<boolean>(
|
const [copilotSampleDBEnabled, setCopilotSampleDBEnabled] = useState<boolean>(
|
||||||
LocalStorageUtility.getEntryString(StorageKey.CopilotSampleDBEnabled) === "true",
|
LocalStorageUtility.getEntryString(StorageKey.CopilotSampleDBEnabled) === "true",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const styles = useStyles();
|
||||||
|
|
||||||
const explorerVersion = configContext.gitSha;
|
const explorerVersion = configContext.gitSha;
|
||||||
const shouldShowQueryPageOptions = userContext.apiType === "SQL";
|
const shouldShowQueryPageOptions = userContext.apiType === "SQL";
|
||||||
const shouldShowGraphAutoVizOption = userContext.apiType === "Gremlin";
|
const shouldShowGraphAutoVizOption = userContext.apiType === "Gremlin";
|
||||||
@ -153,6 +167,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
|||||||
|
|
||||||
LocalStorageUtility.setEntryNumber(StorageKey.CustomItemPerPage, customItemPerPage);
|
LocalStorageUtility.setEntryNumber(StorageKey.CustomItemPerPage, customItemPerPage);
|
||||||
|
|
||||||
|
if (configContext.platform !== Platform.Fabric) {
|
||||||
LocalStorageUtility.setEntryString(StorageKey.DataPlaneRbacEnabled, enableDataPlaneRBACOption);
|
LocalStorageUtility.setEntryString(StorageKey.DataPlaneRbacEnabled, enableDataPlaneRBACOption);
|
||||||
if (
|
if (
|
||||||
enableDataPlaneRBACOption === Constants.RBACOptions.setTrueRBACOption ||
|
enableDataPlaneRBACOption === Constants.RBACOptions.setTrueRBACOption ||
|
||||||
@ -192,6 +207,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
|||||||
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: false });
|
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LocalStorageUtility.setEntryBoolean(StorageKey.RUThresholdEnabled, ruThresholdEnabled);
|
LocalStorageUtility.setEntryBoolean(StorageKey.RUThresholdEnabled, ruThresholdEnabled);
|
||||||
LocalStorageUtility.setEntryBoolean(StorageKey.QueryTimeoutEnabled, queryTimeoutEnabled);
|
LocalStorageUtility.setEntryBoolean(StorageKey.QueryTimeoutEnabled, queryTimeoutEnabled);
|
||||||
@ -476,7 +492,9 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{userContext.apiType === "SQL" && userContext.authType === AuthType.AAD && (
|
{userContext.apiType === "SQL" &&
|
||||||
|
userContext.authType === AuthType.AAD &&
|
||||||
|
configContext.platform !== Platform.Fabric && (
|
||||||
<>
|
<>
|
||||||
<div className="settingsSection">
|
<div className="settingsSection">
|
||||||
<div className="settingsSectionPart">
|
<div className="settingsSectionPart">
|
||||||
@ -487,8 +505,8 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
|||||||
<TooltipHost
|
<TooltipHost
|
||||||
content={
|
content={
|
||||||
<>
|
<>
|
||||||
Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable Entra
|
Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable
|
||||||
ID RBAC.
|
Entra ID RBAC.
|
||||||
<a
|
<a
|
||||||
href="https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac#use-data-explorer"
|
href="https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac#use-data-explorer"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@ -830,6 +848,34 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="settingsSection">
|
||||||
|
<div className="settingsSectionPart">
|
||||||
|
<DefaultButton
|
||||||
|
onClick={() => {
|
||||||
|
useDialog.getState().showOkCancelModalDialog(
|
||||||
|
"Clear History",
|
||||||
|
undefined,
|
||||||
|
"Are you sure you want to proceed?",
|
||||||
|
() => deleteAllStates(),
|
||||||
|
"Cancel",
|
||||||
|
undefined,
|
||||||
|
<>
|
||||||
|
<span>
|
||||||
|
This action will clear the all customizations for this account in this browser, including:
|
||||||
|
</span>
|
||||||
|
<ul className={styles.bulletList}>
|
||||||
|
<li>Reset your customized tab layout, including the splitter positions</li>
|
||||||
|
<li>Erase your table column preferences, including any custom columns</li>
|
||||||
|
<li>Clear your filter history</li>
|
||||||
|
</ul>
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear History
|
||||||
|
</DefaultButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="settingsSection">
|
<div className="settingsSection">
|
||||||
<div className="settingsSectionPart">
|
<div className="settingsSectionPart">
|
||||||
<div className="settingsSectionLabel">Explorer Version</div>
|
<div className="settingsSectionLabel">Explorer Version</div>
|
||||||
|
@ -485,6 +485,19 @@ exports[`Settings Pane should render Default properly 1`] = `
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
className="settingsSection"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="settingsSectionPart"
|
||||||
|
>
|
||||||
|
<CustomizedDefaultButton
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
Clear History
|
||||||
|
</CustomizedDefaultButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className="settingsSection"
|
className="settingsSection"
|
||||||
>
|
>
|
||||||
@ -708,6 +721,19 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
className="settingsSection"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="settingsSectionPart"
|
||||||
|
>
|
||||||
|
<CustomizedDefaultButton
|
||||||
|
onClick={[Function]}
|
||||||
|
>
|
||||||
|
Clear History
|
||||||
|
</CustomizedDefaultButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className="settingsSection"
|
className="settingsSection"
|
||||||
>
|
>
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { MinimalQueryIterator } from "Common/IteratorUtilities";
|
import { MinimalQueryIterator } from "Common/IteratorUtilities";
|
||||||
|
import QueryError from "Common/QueryError";
|
||||||
import { QueryResults } from "Contracts/ViewModels";
|
import { QueryResults } from "Contracts/ViewModels";
|
||||||
import { CopilotMessage } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
|
import { CopilotMessage } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
|
||||||
import { guid } from "Explorer/Tables/Utilities";
|
import { guid } from "Explorer/Tables/Utilities";
|
||||||
@ -28,7 +29,7 @@ const CopilotProvider = ({ children }: { children: React.ReactNode }): JSX.Eleme
|
|||||||
showSamplePrompts: false,
|
showSamplePrompts: false,
|
||||||
queryIterator: undefined,
|
queryIterator: undefined,
|
||||||
queryResults: undefined,
|
queryResults: undefined,
|
||||||
errorMessage: "",
|
errors: [],
|
||||||
isSamplePromptsOpen: false,
|
isSamplePromptsOpen: false,
|
||||||
showPromptTeachingBubble: true,
|
showPromptTeachingBubble: true,
|
||||||
showDeletePopup: false,
|
showDeletePopup: false,
|
||||||
@ -64,7 +65,7 @@ const CopilotProvider = ({ children }: { children: React.ReactNode }): JSX.Eleme
|
|||||||
setShowSamplePrompts: (showSamplePrompts: boolean) => set({ showSamplePrompts }),
|
setShowSamplePrompts: (showSamplePrompts: boolean) => set({ showSamplePrompts }),
|
||||||
setQueryIterator: (queryIterator: MinimalQueryIterator | undefined) => set({ queryIterator }),
|
setQueryIterator: (queryIterator: MinimalQueryIterator | undefined) => set({ queryIterator }),
|
||||||
setQueryResults: (queryResults: QueryResults | undefined) => set({ queryResults }),
|
setQueryResults: (queryResults: QueryResults | undefined) => set({ queryResults }),
|
||||||
setErrorMessage: (errorMessage: string) => set({ errorMessage }),
|
setErrors: (errors: QueryError[]) => set({ errors }),
|
||||||
setIsSamplePromptsOpen: (isSamplePromptsOpen: boolean) => set({ isSamplePromptsOpen }),
|
setIsSamplePromptsOpen: (isSamplePromptsOpen: boolean) => set({ isSamplePromptsOpen }),
|
||||||
setShowPromptTeachingBubble: (showPromptTeachingBubble: boolean) => set({ showPromptTeachingBubble }),
|
setShowPromptTeachingBubble: (showPromptTeachingBubble: boolean) => set({ showPromptTeachingBubble }),
|
||||||
setShowDeletePopup: (showDeletePopup: boolean) => set({ showDeletePopup }),
|
setShowDeletePopup: (showDeletePopup: boolean) => set({ showDeletePopup }),
|
||||||
|
@ -18,8 +18,9 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextField,
|
TextField,
|
||||||
} from "@fluentui/react";
|
} from "@fluentui/react";
|
||||||
import { HttpStatusCodes } from "Common/Constants";
|
import { HttpStatusCodes, NormalizedEventKey } from "Common/Constants";
|
||||||
import { handleError } from "Common/ErrorHandlingUtils";
|
import { handleError } from "Common/ErrorHandlingUtils";
|
||||||
|
import QueryError, { QueryErrorSeverity } from "Common/QueryError";
|
||||||
import { createUri } from "Common/UrlUtility";
|
import { createUri } from "Common/UrlUtility";
|
||||||
import { CopyPopup } from "Explorer/QueryCopilot/Popup/CopyPopup";
|
import { CopyPopup } from "Explorer/QueryCopilot/Popup/CopyPopup";
|
||||||
import { DeletePopup } from "Explorer/QueryCopilot/Popup/DeletePopup";
|
import { DeletePopup } from "Explorer/QueryCopilot/Popup/DeletePopup";
|
||||||
@ -34,7 +35,7 @@ import { SamplePrompts, SamplePromptsProps } from "Explorer/QueryCopilot/Shared/
|
|||||||
import { Action } from "Shared/Telemetry/TelemetryConstants";
|
import { Action } from "Shared/Telemetry/TelemetryConstants";
|
||||||
import { userContext } from "UserContext";
|
import { userContext } from "UserContext";
|
||||||
import { useQueryCopilot } from "hooks/useQueryCopilot";
|
import { useQueryCopilot } from "hooks/useQueryCopilot";
|
||||||
import React, { useRef, useState } from "react";
|
import React, { useMemo, useRef, useState } from "react";
|
||||||
import HintIcon from "../../../images/Hint.svg";
|
import HintIcon from "../../../images/Hint.svg";
|
||||||
import RecentIcon from "../../../images/Recent.svg";
|
import RecentIcon from "../../../images/Recent.svg";
|
||||||
import errorIcon from "../../../images/close-black.svg";
|
import errorIcon from "../../../images/close-black.svg";
|
||||||
@ -70,6 +71,8 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
|||||||
}: QueryCopilotPromptProps): JSX.Element => {
|
}: QueryCopilotPromptProps): JSX.Element => {
|
||||||
const [copilotTeachingBubbleVisible, setCopilotTeachingBubbleVisible] = useState<boolean>(false);
|
const [copilotTeachingBubbleVisible, setCopilotTeachingBubbleVisible] = useState<boolean>(false);
|
||||||
const inputEdited = useRef(false);
|
const inputEdited = useRef(false);
|
||||||
|
const itemRefs = useRef([]);
|
||||||
|
const searchInputRef = useRef(null);
|
||||||
const {
|
const {
|
||||||
openFeedbackModal,
|
openFeedbackModal,
|
||||||
hideFeedbackModalForLikedQueries,
|
hideFeedbackModalForLikedQueries,
|
||||||
@ -105,10 +108,10 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
|||||||
setShowErrorMessageBar,
|
setShowErrorMessageBar,
|
||||||
setGeneratedQueryComments,
|
setGeneratedQueryComments,
|
||||||
setQueryResults,
|
setQueryResults,
|
||||||
setErrorMessage,
|
setErrors,
|
||||||
errorMessage,
|
errors,
|
||||||
} = useCopilotStore();
|
} = useCopilotStore();
|
||||||
|
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||||
const sampleProps: SamplePromptsProps = {
|
const sampleProps: SamplePromptsProps = {
|
||||||
isSamplePromptsOpen: isSamplePromptsOpen,
|
isSamplePromptsOpen: isSamplePromptsOpen,
|
||||||
setIsSamplePromptsOpen: setIsSamplePromptsOpen,
|
setIsSamplePromptsOpen: setIsSamplePromptsOpen,
|
||||||
@ -141,6 +144,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
|||||||
: getSuggestedPrompts();
|
: getSuggestedPrompts();
|
||||||
const [filteredHistories, setFilteredHistories] = useState<string[]>(histories);
|
const [filteredHistories, setFilteredHistories] = useState<string[]>(histories);
|
||||||
const [filteredSuggestedPrompts, setFilteredSuggestedPrompts] = useState<SuggestedPrompt[]>(suggestedPrompts);
|
const [filteredSuggestedPrompts, setFilteredSuggestedPrompts] = useState<SuggestedPrompt[]>(suggestedPrompts);
|
||||||
|
const { UpArrow, DownArrow, Enter } = NormalizedEventKey;
|
||||||
|
|
||||||
const handleUserPromptChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleUserPromptChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
inputEdited.current = true;
|
inputEdited.current = true;
|
||||||
@ -179,7 +183,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
|||||||
|
|
||||||
const resetQueryResults = (): void => {
|
const resetQueryResults = (): void => {
|
||||||
setQueryResults(null);
|
setQueryResults(null);
|
||||||
setErrorMessage("");
|
setErrors([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateSQLQuery = async (): Promise<void> => {
|
const generateSQLQuery = async (): Promise<void> => {
|
||||||
@ -243,7 +247,12 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
|||||||
handleError(JSON.stringify(generateSQLQueryResponse), "copilotTooManyRequestError");
|
handleError(JSON.stringify(generateSQLQueryResponse), "copilotTooManyRequestError");
|
||||||
useTabs.getState().setIsQueryErrorThrown(true);
|
useTabs.getState().setIsQueryErrorThrown(true);
|
||||||
setShowErrorMessageBar(true);
|
setShowErrorMessageBar(true);
|
||||||
setErrorMessage("Ratelimit exceeded 5 per 1 minute. Please try again after sometime");
|
setErrors([
|
||||||
|
new QueryError(
|
||||||
|
"Ratelimit exceeded 5 per 1 minute. Please try again after sometime",
|
||||||
|
QueryErrorSeverity.Error,
|
||||||
|
),
|
||||||
|
]);
|
||||||
TelemetryProcessor.traceFailure(Action.QueryGenerationFromCopilotPrompt, {
|
TelemetryProcessor.traceFailure(Action.QueryGenerationFromCopilotPrompt, {
|
||||||
databaseName: databaseId,
|
databaseName: databaseId,
|
||||||
collectionId: containerId,
|
collectionId: containerId,
|
||||||
@ -301,7 +310,38 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
|||||||
return "Content is updated";
|
return "Content is updated";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const openSamplePrompts = () => {
|
||||||
|
inputEdited.current = true;
|
||||||
|
setShowSamplePrompts(true);
|
||||||
|
};
|
||||||
|
const totalSuggestions = useMemo(
|
||||||
|
() => [...filteredSuggestedPrompts, ...filteredHistories],
|
||||||
|
[filteredSuggestedPrompts, filteredHistories],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDownForInput = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key === DownArrow) {
|
||||||
|
setFocusedIndex(0);
|
||||||
|
itemRefs.current[0]?.current?.focus();
|
||||||
|
} else if (event.key === Enter && userPrompt) {
|
||||||
|
inputEdited.current = true;
|
||||||
|
startGenerateQueryProcess();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDownForItem = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key === UpArrow && focusedIndex > 0) {
|
||||||
|
itemRefs.current[focusedIndex - 1].current?.focus();
|
||||||
|
setFocusedIndex((prevIndex) => prevIndex - 1);
|
||||||
|
} else if (event.key === DownArrow && focusedIndex < totalSuggestions.length - 1) {
|
||||||
|
itemRefs.current[focusedIndex + 1].current?.focus();
|
||||||
|
setFocusedIndex((prevIndex) => prevIndex + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
itemRefs.current = totalSuggestions.map(() => React.createRef());
|
||||||
|
}, [totalSuggestions]);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
useTabs.getState().setIsQueryErrorThrown(false);
|
useTabs.getState().setIsQueryErrorThrown(false);
|
||||||
}, []);
|
}, []);
|
||||||
@ -331,23 +371,14 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
|||||||
id="naturalLanguageInput"
|
id="naturalLanguageInput"
|
||||||
value={userPrompt}
|
value={userPrompt}
|
||||||
onChange={handleUserPromptChange}
|
onChange={handleUserPromptChange}
|
||||||
onClick={() => {
|
onClick={openSamplePrompts}
|
||||||
inputEdited.current = true;
|
onFocus={() => setShowSamplePrompts(true)}
|
||||||
setShowSamplePrompts(true);
|
elementRef={searchInputRef}
|
||||||
}}
|
onKeyDown={handleKeyDownForInput}
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" && userPrompt) {
|
|
||||||
inputEdited.current = true;
|
|
||||||
startGenerateQueryProcess();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{ lineHeight: 30 }}
|
style={{ lineHeight: 30 }}
|
||||||
styles={{
|
styles={{
|
||||||
root: { width: "100%" },
|
root: { width: "100%" },
|
||||||
suffix: {
|
suffix: { background: "none", padding: 0 },
|
||||||
background: "none",
|
|
||||||
padding: 0,
|
|
||||||
},
|
|
||||||
fieldGroup: {
|
fieldGroup: {
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
borderColor: "#D1D1D1",
|
borderColor: "#D1D1D1",
|
||||||
@ -360,7 +391,8 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
disabled={isGeneratingQuery}
|
disabled={isGeneratingQuery}
|
||||||
autoComplete="off"
|
autoComplete="list"
|
||||||
|
aria-expanded={showSamplePrompts}
|
||||||
placeholder="Ask a question in natural language and we’ll generate the query for you."
|
placeholder="Ask a question in natural language and we’ll generate the query for you."
|
||||||
aria-labelledby="copilot-textfield-label"
|
aria-labelledby="copilot-textfield-label"
|
||||||
onRenderSuffix={() => {
|
onRenderSuffix={() => {
|
||||||
@ -432,6 +464,8 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
|||||||
setShowSamplePrompts(false);
|
setShowSamplePrompts(false);
|
||||||
inputEdited.current = true;
|
inputEdited.current = true;
|
||||||
}}
|
}}
|
||||||
|
elementRef={itemRefs.current[i]}
|
||||||
|
onKeyDown={handleKeyDownForItem}
|
||||||
onRenderIcon={() => <Image src={RecentIcon} styles={{ root: { overflow: "unset" } }} />}
|
onRenderIcon={() => <Image src={RecentIcon} styles={{ root: { overflow: "unset" } }} />}
|
||||||
styles={promptStyles}
|
styles={promptStyles}
|
||||||
>
|
>
|
||||||
@ -454,14 +488,16 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
|||||||
>
|
>
|
||||||
Suggested Prompts
|
Suggested Prompts
|
||||||
</Text>
|
</Text>
|
||||||
{filteredSuggestedPrompts.map((prompt) => (
|
{filteredSuggestedPrompts.map((prompt, index) => (
|
||||||
<DefaultButton
|
<DefaultButton
|
||||||
key={prompt.id}
|
key={prompt.id}
|
||||||
|
elementRef={itemRefs.current[filteredHistories.length + index]}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUserPrompt(prompt.text);
|
setUserPrompt(prompt.text);
|
||||||
setShowSamplePrompts(false);
|
setShowSamplePrompts(false);
|
||||||
inputEdited.current = true;
|
inputEdited.current = true;
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={handleKeyDownForItem}
|
||||||
onRenderIcon={() => <Image src={HintIcon} />}
|
onRenderIcon={() => <Image src={HintIcon} />}
|
||||||
styles={promptStyles}
|
styles={promptStyles}
|
||||||
>
|
>
|
||||||
@ -514,7 +550,9 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
|||||||
</Link>
|
</Link>
|
||||||
{showErrorMessageBar && (
|
{showErrorMessageBar && (
|
||||||
<MessageBar messageBarType={MessageBarType.error}>
|
<MessageBar messageBarType={MessageBarType.error}>
|
||||||
{errorMessage ? errorMessage : "We ran into an error and were not able to execute query."}
|
{errors.length > 0
|
||||||
|
? errors[0].message
|
||||||
|
: "We ran into an error and were not able to execute query."}
|
||||||
</MessageBar>
|
</MessageBar>
|
||||||
)}
|
)}
|
||||||
{showInvalidQueryMessageBar && (
|
{showInvalidQueryMessageBar && (
|
||||||
|
@ -13,6 +13,7 @@ import {
|
|||||||
import { getErrorMessage, getErrorStack, handleError } from "Common/ErrorHandlingUtils";
|
import { getErrorMessage, getErrorStack, handleError } from "Common/ErrorHandlingUtils";
|
||||||
import { shouldEnableCrossPartitionKey } from "Common/HeadersUtility";
|
import { shouldEnableCrossPartitionKey } from "Common/HeadersUtility";
|
||||||
import { MinimalQueryIterator } from "Common/IteratorUtilities";
|
import { MinimalQueryIterator } from "Common/IteratorUtilities";
|
||||||
|
import QueryError from "Common/QueryError";
|
||||||
import { createUri } from "Common/UrlUtility";
|
import { createUri } from "Common/UrlUtility";
|
||||||
import { queryDocumentsPage } from "Common/dataAccess/queryDocumentsPage";
|
import { queryDocumentsPage } from "Common/dataAccess/queryDocumentsPage";
|
||||||
import { configContext } from "ConfigContext";
|
import { configContext } from "ConfigContext";
|
||||||
@ -354,7 +355,7 @@ export const QueryDocumentsPerPage = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
useQueryCopilot.getState().setQueryResults(queryResults);
|
useQueryCopilot.getState().setQueryResults(queryResults);
|
||||||
useQueryCopilot.getState().setErrorMessage("");
|
useQueryCopilot.getState().setErrors([]);
|
||||||
useQueryCopilot.getState().setShowErrorMessageBar(false);
|
useQueryCopilot.getState().setShowErrorMessageBar(false);
|
||||||
traceSuccess(Action.ExecuteQueryGeneratedFromQueryCopilot, {
|
traceSuccess(Action.ExecuteQueryGeneratedFromQueryCopilot, {
|
||||||
correlationId: useQueryCopilot.getState().correlationId,
|
correlationId: useQueryCopilot.getState().correlationId,
|
||||||
@ -366,12 +367,13 @@ export const QueryDocumentsPerPage = async (
|
|||||||
const errorMessage = getErrorMessage(error);
|
const errorMessage = getErrorMessage(error);
|
||||||
traceFailure(Action.ExecuteQueryGeneratedFromQueryCopilot, {
|
traceFailure(Action.ExecuteQueryGeneratedFromQueryCopilot, {
|
||||||
correlationId: useQueryCopilot.getState().correlationId,
|
correlationId: useQueryCopilot.getState().correlationId,
|
||||||
errorMessage: errorMessage,
|
errorMessage,
|
||||||
});
|
});
|
||||||
handleError(errorMessage, "executeQueryCopilotTab");
|
handleError(errorMessage, "executeQueryCopilotTab");
|
||||||
useTabs.getState().setIsQueryErrorThrown(true);
|
useTabs.getState().setIsQueryErrorThrown(true);
|
||||||
if (isCopilotActive) {
|
if (isCopilotActive) {
|
||||||
useQueryCopilot.getState().setErrorMessage(errorMessage);
|
const queryErrors = QueryError.tryParse(error);
|
||||||
|
useQueryCopilot.getState().setErrors(queryErrors);
|
||||||
useQueryCopilot.getState().setShowErrorMessageBar(true);
|
useQueryCopilot.getState().setShowErrorMessageBar(true);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -8,7 +8,7 @@ export const QueryCopilotResults: React.FC = (): JSX.Element => {
|
|||||||
<QueryResultSection
|
<QueryResultSection
|
||||||
isMongoDB={false}
|
isMongoDB={false}
|
||||||
queryEditorContent={useQueryCopilot.getState().selectedQuery || useQueryCopilot.getState().query}
|
queryEditorContent={useQueryCopilot.getState().selectedQuery || useQueryCopilot.getState().query}
|
||||||
error={useQueryCopilot.getState().errorMessage}
|
errors={useQueryCopilot.getState().errors}
|
||||||
queryResults={useQueryCopilot.getState().queryResults}
|
queryResults={useQueryCopilot.getState().queryResults}
|
||||||
isExecuting={useQueryCopilot.getState().isExecuting}
|
isExecuting={useQueryCopilot.getState().isExecuting}
|
||||||
executeQueryDocumentsPage={(firstItemIndex: number) =>
|
executeQueryDocumentsPage={(firstItemIndex: number) =>
|
||||||
|
@ -24,7 +24,9 @@ export const QuickstartCarousel: React.FC<QuickstartCarouselProps> = ({
|
|||||||
>
|
>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack horizontal horizontalAlign="space-between" style={{ padding: 16 }}>
|
<Stack horizontal horizontalAlign="space-between" style={{ padding: 16 }}>
|
||||||
<Text variant="xLarge">{getHeaderText(page)}</Text>
|
<Text role="heading" aria-level={1} variant="xLarge">
|
||||||
|
{getHeaderText(page)}
|
||||||
|
</Text>
|
||||||
<IconButton iconProps={{ iconName: "Cancel" }} onClick={() => setPage(4)} ariaLabel="Close" />
|
<IconButton iconProps={{ iconName: "Cancel" }} onClick={() => setPage(4)} ariaLabel="Close" />
|
||||||
</Stack>
|
</Stack>
|
||||||
{getContent(page)}
|
{getContent(page)}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Menu,
|
Menu,
|
||||||
|
MenuButton,
|
||||||
MenuButtonProps,
|
MenuButtonProps,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
MenuList,
|
MenuList,
|
||||||
@ -60,6 +61,7 @@ const useSidebarStyles = makeStyles({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyItems: "center",
|
justifyItems: "center",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
containerType: "size", // Use this container for "@container" queries below this.
|
||||||
...cosmosShorthands.borderBottom(),
|
...cosmosShorthands.borderBottom(),
|
||||||
},
|
},
|
||||||
loadingProgressBar: {
|
loadingProgressBar: {
|
||||||
@ -83,6 +85,18 @@ const useSidebarStyles = makeStyles({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
globalCommandsMenuButton: {
|
||||||
|
display: "initial",
|
||||||
|
"@container (min-width: 250px)": {
|
||||||
|
display: "none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
globalCommandsSplitButton: {
|
||||||
|
display: "none",
|
||||||
|
"@container (min-width: 250px)": {
|
||||||
|
display: "flex",
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
interface GlobalCommandsProps {
|
interface GlobalCommandsProps {
|
||||||
@ -171,13 +185,19 @@ const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
|
|||||||
<Menu positioning="below-end">
|
<Menu positioning="below-end">
|
||||||
<MenuTrigger disableButtonEnhancement>
|
<MenuTrigger disableButtonEnhancement>
|
||||||
{(triggerProps: MenuButtonProps) => (
|
{(triggerProps: MenuButtonProps) => (
|
||||||
|
<>
|
||||||
<SplitButton
|
<SplitButton
|
||||||
menuButton={{ ...triggerProps, "aria-label": "More commands" }}
|
menuButton={{ ...triggerProps, "aria-label": "More commands" }}
|
||||||
primaryActionButton={{ onClick: onPrimaryActionClick }}
|
primaryActionButton={{ onClick: onPrimaryActionClick }}
|
||||||
|
className={styles.globalCommandsSplitButton}
|
||||||
icon={primaryAction.icon}
|
icon={primaryAction.icon}
|
||||||
>
|
>
|
||||||
{primaryAction.label}
|
{primaryAction.label}
|
||||||
</SplitButton>
|
</SplitButton>
|
||||||
|
<MenuButton {...triggerProps} icon={primaryAction.icon} className={styles.globalCommandsMenuButton}>
|
||||||
|
New...
|
||||||
|
</MenuButton>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</MenuTrigger>
|
</MenuTrigger>
|
||||||
<MenuPopover>
|
<MenuPopover>
|
||||||
@ -199,7 +219,7 @@ interface SidebarProps {
|
|||||||
explorer: Explorer;
|
explorer: Explorer;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CollapseThreshold = 50;
|
const CollapseThreshold = 140;
|
||||||
|
|
||||||
export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
|
export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
|
||||||
const styles = useSidebarStyles();
|
const styles = useSidebarStyles();
|
||||||
@ -249,6 +269,12 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [setLoading]);
|
}, [setLoading]);
|
||||||
|
|
||||||
|
const hasGlobalCommands = !(
|
||||||
|
configContext.platform === Platform.Fabric ||
|
||||||
|
userContext.apiType === "Postgres" ||
|
||||||
|
userContext.apiType === "VCoreMongo"
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Allotment ref={allotment} onChange={onChange} onDragEnd={onDragEnd} className="resourceTreeAndTabs">
|
<Allotment ref={allotment} onChange={onChange} onDragEnd={onDragEnd} className="resourceTreeAndTabs">
|
||||||
{/* Collections Tree - Start */}
|
{/* Collections Tree - Start */}
|
||||||
@ -268,6 +294,7 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
|
|||||||
<div className={styles.floatingControls}>
|
<div className={styles.floatingControls}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-test="Sidebar/RefreshButton"
|
||||||
className={styles.floatingControlButton}
|
className={styles.floatingControlButton}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
title="Refresh"
|
title="Refresh"
|
||||||
@ -285,8 +312,11 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.expandedContent}>
|
<div
|
||||||
<GlobalCommands explorer={explorer} />
|
className={styles.expandedContent}
|
||||||
|
style={!hasGlobalCommands ? { gridTemplateRows: "1fr" } : undefined}
|
||||||
|
>
|
||||||
|
{hasGlobalCommands && <GlobalCommands explorer={explorer} />}
|
||||||
<ResourceTree explorer={explorer} />
|
<ResourceTree explorer={explorer} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -304,7 +334,7 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
|
|||||||
</CosmosFluentProvider>
|
</CosmosFluentProvider>
|
||||||
</Allotment.Pane>
|
</Allotment.Pane>
|
||||||
)}
|
)}
|
||||||
<Allotment.Pane minSize={800}>
|
<Allotment.Pane minSize={200}>
|
||||||
<Tabs explorer={explorer} />
|
<Tabs explorer={explorer} />
|
||||||
</Allotment.Pane>
|
</Allotment.Pane>
|
||||||
</Allotment>
|
</Allotment>
|
||||||
|
100
src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts
Normal file
100
src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
// Definitions of State data
|
||||||
|
|
||||||
|
import { deleteState, loadState, saveState, saveStateDebounced } from "Shared/AppStatePersistenceUtility";
|
||||||
|
import { userContext } from "UserContext";
|
||||||
|
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||||
|
import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
|
||||||
|
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
|
||||||
|
|
||||||
|
const componentName = "DocumentsTab";
|
||||||
|
export enum SubComponentName {
|
||||||
|
ColumnSizes = "ColumnSizes",
|
||||||
|
FilterHistory = "FilterHistory",
|
||||||
|
MainTabDivider = "MainTabDivider",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ColumnSizesMap = { [columnId: string]: WidthDefinition };
|
||||||
|
export type WidthDefinition = { idealWidth?: number; minWidth?: number };
|
||||||
|
export type TabDivider = { leftPaneWidthPercent: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param subComponentName
|
||||||
|
* @param collection
|
||||||
|
* @param defaultValue Will be returned if persisted state is not found
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const readSubComponentState = <T>(
|
||||||
|
subComponentName: SubComponentName,
|
||||||
|
collection: ViewModels.CollectionBase,
|
||||||
|
defaultValue: T,
|
||||||
|
): T => {
|
||||||
|
const globalAccountName = userContext.databaseAccount?.name;
|
||||||
|
if (!globalAccountName) {
|
||||||
|
const message = "Database account name not found in userContext";
|
||||||
|
console.error(message);
|
||||||
|
TelemetryProcessor.traceFailure(Action.ReadPersistedTabState, { message, componentName });
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = loadState({
|
||||||
|
componentName: componentName,
|
||||||
|
subComponentName,
|
||||||
|
globalAccountName,
|
||||||
|
databaseName: collection.databaseId,
|
||||||
|
containerName: collection.id(),
|
||||||
|
}) as T;
|
||||||
|
|
||||||
|
return state || defaultValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param subComponentName
|
||||||
|
* @param collection
|
||||||
|
* @param state State to save
|
||||||
|
* @param debounce true for high-frequency calls (e.g mouse drag events)
|
||||||
|
*/
|
||||||
|
export const saveSubComponentState = <T>(
|
||||||
|
subComponentName: SubComponentName,
|
||||||
|
collection: ViewModels.CollectionBase,
|
||||||
|
state: T,
|
||||||
|
debounce?: boolean,
|
||||||
|
): void => {
|
||||||
|
const globalAccountName = userContext.databaseAccount?.name;
|
||||||
|
if (!globalAccountName) {
|
||||||
|
const message = "Database account name not found in userContext";
|
||||||
|
console.error(message);
|
||||||
|
TelemetryProcessor.traceFailure(Action.SavePersistedTabState, { message, componentName });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(debounce ? saveStateDebounced : saveState)(
|
||||||
|
{
|
||||||
|
componentName: componentName,
|
||||||
|
subComponentName,
|
||||||
|
globalAccountName,
|
||||||
|
databaseName: collection.databaseId,
|
||||||
|
containerName: collection.id(),
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteSubComponentState = (subComponentName: SubComponentName, collection: ViewModels.CollectionBase) => {
|
||||||
|
const globalAccountName = userContext.databaseAccount?.name;
|
||||||
|
if (!globalAccountName) {
|
||||||
|
const message = "Database account name not found in userContext";
|
||||||
|
console.error(message);
|
||||||
|
TelemetryProcessor.traceFailure(Action.DeletePersistedTabState, { message, componentName });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteState({
|
||||||
|
componentName: componentName,
|
||||||
|
subComponentName,
|
||||||
|
globalAccountName,
|
||||||
|
databaseName: collection.databaseId,
|
||||||
|
containerName: collection.id(),
|
||||||
|
});
|
||||||
|
};
|
@ -13,6 +13,7 @@ import {
|
|||||||
SAVE_BUTTON_ID,
|
SAVE_BUTTON_ID,
|
||||||
UPDATE_BUTTON_ID,
|
UPDATE_BUTTON_ID,
|
||||||
UPLOAD_BUTTON_ID,
|
UPLOAD_BUTTON_ID,
|
||||||
|
addStringsNoDuplicate,
|
||||||
buildQuery,
|
buildQuery,
|
||||||
getDiscardExistingDocumentChangesButtonState,
|
getDiscardExistingDocumentChangesButtonState,
|
||||||
getDiscardNewDocumentChangesButtonState,
|
getDiscardNewDocumentChangesButtonState,
|
||||||
@ -339,7 +340,10 @@ describe("Documents tab (noSql API)", () => {
|
|||||||
const createMockProps = (): IDocumentsTabComponentProps => ({
|
const createMockProps = (): IDocumentsTabComponentProps => ({
|
||||||
isPreferredApiMongoDB: false,
|
isPreferredApiMongoDB: false,
|
||||||
documentIds: [],
|
documentIds: [],
|
||||||
collection: undefined,
|
collection: {
|
||||||
|
id: ko.observable<string>("collectionId"),
|
||||||
|
databaseId: "databaseId",
|
||||||
|
} as ViewModels.CollectionBase,
|
||||||
partitionKey: { kind: "Hash", paths: ["/foo"], version: 2 },
|
partitionKey: { kind: "Hash", paths: ["/foo"], version: 2 },
|
||||||
onLoadStartKey: 0,
|
onLoadStartKey: 0,
|
||||||
tabTitle: "",
|
tabTitle: "",
|
||||||
@ -380,7 +384,7 @@ describe("Documents tab (noSql API)", () => {
|
|||||||
.findWhere((node) => node.text() === "Edit Filter")
|
.findWhere((node) => node.text() === "Edit Filter")
|
||||||
.at(0)
|
.at(0)
|
||||||
.simulate("click");
|
.simulate("click");
|
||||||
expect(wrapper.find("#filterInput").exists()).toBeTruthy();
|
expect(wrapper.find("Input.filterInput").exists()).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -474,3 +478,13 @@ describe("Documents tab (noSql API)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Documents tab", () => {
|
||||||
|
it("should add strings to array without duplicate", () => {
|
||||||
|
const array1 = ["a", "b", "c"];
|
||||||
|
const array2 = ["b", "c", "d"];
|
||||||
|
|
||||||
|
const array3 = addStringsNoDuplicate(array1, array2);
|
||||||
|
expect(array3).toEqual(["a", "b", "c", "d"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -20,12 +20,13 @@ import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
|
|||||||
import Explorer from "Explorer/Explorer";
|
import Explorer from "Explorer/Explorer";
|
||||||
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
|
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
|
||||||
import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities";
|
import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities";
|
||||||
import { usePrevious } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
|
|
||||||
import {
|
import {
|
||||||
DocumentsTabPrefs,
|
SubComponentName,
|
||||||
readDocumentsTabPrefs,
|
TabDivider,
|
||||||
saveDocumentsTabPrefsDebounced,
|
readSubComponentState,
|
||||||
} from "Explorer/Tabs/DocumentsTabV2/documentsTabPrefs";
|
saveSubComponentState,
|
||||||
|
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
|
||||||
|
import { usePrevious } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
|
||||||
import { CosmosFluentProvider, LayoutConstants, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil";
|
import { CosmosFluentProvider, LayoutConstants, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil";
|
||||||
import { useSelectedNode } from "Explorer/useSelectedNode";
|
import { useSelectedNode } from "Explorer/useSelectedNode";
|
||||||
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
|
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
|
||||||
@ -48,6 +49,7 @@ import * as Logger from "../../../Common/Logger";
|
|||||||
import * as MongoProxyClient from "../../../Common/MongoProxyClient";
|
import * as MongoProxyClient from "../../../Common/MongoProxyClient";
|
||||||
import * as DataModels from "../../../Contracts/DataModels";
|
import * as DataModels from "../../../Contracts/DataModels";
|
||||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||||
|
import { CollectionBase } from "../../../Contracts/ViewModels";
|
||||||
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
|
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
|
||||||
import * as QueryUtils from "../../../Utils/QueryUtils";
|
import * as QueryUtils from "../../../Utils/QueryUtils";
|
||||||
import { defaultQueryFields, extractPartitionKeyValues } from "../../../Utils/QueryUtils";
|
import { defaultQueryFields, extractPartitionKeyValues } from "../../../Utils/QueryUtils";
|
||||||
@ -56,6 +58,8 @@ import ObjectId from "../../Tree/ObjectId";
|
|||||||
import TabsBase from "../TabsBase";
|
import TabsBase from "../TabsBase";
|
||||||
import { ColumnDefinition, DocumentsTableComponent, DocumentsTableComponentItem } from "./DocumentsTableComponent";
|
import { ColumnDefinition, DocumentsTableComponent, DocumentsTableComponentItem } from "./DocumentsTableComponent";
|
||||||
|
|
||||||
|
const MAX_FILTER_HISTORY_COUNT = 100; // Datalist will become scrollable, so we can afford to keep more items than fit on the screen
|
||||||
|
|
||||||
const loadMoreHeight = LayoutConstants.rowHeight;
|
const loadMoreHeight = LayoutConstants.rowHeight;
|
||||||
export const useDocumentsTabStyles = makeStyles({
|
export const useDocumentsTabStyles = makeStyles({
|
||||||
container: {
|
container: {
|
||||||
@ -486,6 +490,24 @@ export const buildQuery = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export to expose to unit tests
|
||||||
|
*
|
||||||
|
* Add array2 to array1 without duplicates
|
||||||
|
* @param array1
|
||||||
|
* @param array2
|
||||||
|
* @return array1 with array2 added without duplicates
|
||||||
|
*/
|
||||||
|
export const addStringsNoDuplicate = (array1: string[], array2: string[]): string[] => {
|
||||||
|
const result = [...array1];
|
||||||
|
array2.forEach((item) => {
|
||||||
|
if (!result.includes(item)) {
|
||||||
|
result.push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
// Export to expose to unit tests
|
// Export to expose to unit tests
|
||||||
export interface IDocumentsTabComponentProps {
|
export interface IDocumentsTabComponentProps {
|
||||||
isPreferredApiMongoDB: boolean;
|
isPreferredApiMongoDB: boolean;
|
||||||
@ -500,6 +522,11 @@ export interface IDocumentsTabComponentProps {
|
|||||||
isTabActive: boolean;
|
isTabActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getUniqueId = (collection: ViewModels.CollectionBase): string => `${collection.databaseId}-${collection.id()}`;
|
||||||
|
|
||||||
|
const defaultSqlFilters = ['WHERE c.id = "foo"', "ORDER BY c._ts DESC", 'WHERE c.id = "foo" ORDER BY c._ts DESC'];
|
||||||
|
const defaultMongoFilters = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"];
|
||||||
|
|
||||||
// Extend DocumentId to include fields displayed in the table
|
// Extend DocumentId to include fields displayed in the table
|
||||||
type ExtendedDocumentId = DocumentId & { tableFields?: DocumentsTableComponentItem };
|
type ExtendedDocumentId = DocumentId & { tableFields?: DocumentsTableComponentItem };
|
||||||
|
|
||||||
@ -553,8 +580,12 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
ViewModels.DocumentExplorerState.noDocumentSelected,
|
ViewModels.DocumentExplorerState.noDocumentSelected,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Preferences
|
// State
|
||||||
const [prefs, setPrefs] = useState<DocumentsTabPrefs>(readDocumentsTabPrefs());
|
const [tabStateData, setTabStateData] = useState<TabDivider>(() =>
|
||||||
|
readSubComponentState(SubComponentName.MainTabDivider, _collection, {
|
||||||
|
leftPaneWidthPercent: 35,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const isQueryCopilotSampleContainer =
|
const isQueryCopilotSampleContainer =
|
||||||
_collection?.isSampleCollection &&
|
_collection?.isSampleCollection &&
|
||||||
@ -564,6 +595,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
// For Mongo only
|
// For Mongo only
|
||||||
const [continuationToken, setContinuationToken] = useState<string>(undefined);
|
const [continuationToken, setContinuationToken] = useState<string>(undefined);
|
||||||
|
|
||||||
|
// User's filter history
|
||||||
|
const [lastFilterContents, setLastFilterContents] = useState<string[]>(() =>
|
||||||
|
readSubComponentState(SubComponentName.FilterHistory, _collection, []),
|
||||||
|
);
|
||||||
|
|
||||||
const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB);
|
const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -589,8 +625,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
}
|
}
|
||||||
}, [documentIds, clickedRowIndex, editorState]);
|
}, [documentIds, clickedRowIndex, editorState]);
|
||||||
|
|
||||||
let lastFilterContents = ['WHERE c.id = "foo"', "ORDER BY c._ts DESC", 'WHERE c.id = "foo" ORDER BY c._ts DESC'];
|
|
||||||
|
|
||||||
const applyFilterButton = {
|
const applyFilterButton = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
visible: true,
|
visible: true,
|
||||||
@ -930,7 +964,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
/**
|
/**
|
||||||
* Implementation using bulk delete NoSQL API
|
* Implementation using bulk delete NoSQL API
|
||||||
*/
|
*/
|
||||||
let _deleteDocuments = useCallback(
|
const _deleteDocuments = useCallback(
|
||||||
async (toDeleteDocumentIds: DocumentId[]): Promise<DocumentId[]> => {
|
async (toDeleteDocumentIds: DocumentId[]): Promise<DocumentId[]> => {
|
||||||
onExecutionErrorChange(false);
|
onExecutionErrorChange(false);
|
||||||
const startKey: number = TelemetryProcessor.traceStart(Action.DeleteDocuments, {
|
const startKey: number = TelemetryProcessor.traceStart(Action.DeleteDocuments, {
|
||||||
@ -941,11 +975,29 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
|
|
||||||
// TODO: Once JS SDK Bug fix for bulk deleting legacy containers (whose systemKey==1) is released:
|
// TODO: Once JS SDK Bug fix for bulk deleting legacy containers (whose systemKey==1) is released:
|
||||||
// Remove the check for systemKey, remove call to deleteNoSqlDocument(). deleteNoSqlDocuments() should always be called.
|
// Remove the check for systemKey, remove call to deleteNoSqlDocument(). deleteNoSqlDocuments() should always be called.
|
||||||
return (
|
const _deleteNoSqlDocuments = async (
|
||||||
partitionKey.systemKey
|
collection: CollectionBase,
|
||||||
? deleteNoSqlDocument(_collection, toDeleteDocumentIds[0]).then(() => [toDeleteDocumentIds[0]])
|
toDeleteDocumentIds: DocumentId[],
|
||||||
: deleteNoSqlDocuments(_collection, toDeleteDocumentIds)
|
): Promise<DocumentId[]> => {
|
||||||
)
|
return partitionKey.systemKey
|
||||||
|
? deleteNoSqlDocument(collection, toDeleteDocumentIds[0]).then(() => [toDeleteDocumentIds[0]])
|
||||||
|
: deleteNoSqlDocuments(collection, toDeleteDocumentIds);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deletePromise = !isPreferredApiMongoDB
|
||||||
|
? _deleteNoSqlDocuments(_collection, toDeleteDocumentIds)
|
||||||
|
: MongoProxyClient.deleteDocuments(
|
||||||
|
_collection.databaseId,
|
||||||
|
_collection as ViewModels.Collection,
|
||||||
|
toDeleteDocumentIds,
|
||||||
|
).then(({ deletedCount, isAcknowledged }) => {
|
||||||
|
if (deletedCount === toDeleteDocumentIds.length && isAcknowledged) {
|
||||||
|
return toDeleteDocumentIds;
|
||||||
|
}
|
||||||
|
throw new Error(`Delete failed with deletedCount: ${deletedCount} and isAcknowledged: ${isAcknowledged}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return deletePromise
|
||||||
.then(
|
.then(
|
||||||
(deletedIds) => {
|
(deletedIds) => {
|
||||||
TelemetryProcessor.traceSuccess(
|
TelemetryProcessor.traceSuccess(
|
||||||
@ -976,7 +1028,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
)
|
)
|
||||||
.finally(() => setIsExecuting(false));
|
.finally(() => setIsExecuting(false));
|
||||||
},
|
},
|
||||||
[_collection, onExecutionErrorChange, tabTitle],
|
[_collection, isPreferredApiMongoDB, onExecutionErrorChange, tabTitle],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteDocuments = useCallback(
|
const deleteDocuments = useCallback(
|
||||||
@ -1001,7 +1053,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
(error: Error) =>
|
(error: Error) =>
|
||||||
useDialog
|
useDialog
|
||||||
.getState()
|
.getState()
|
||||||
.showOkModalDialog("Delete documents", `Document(s) deleted failed (${JSON.stringify(error)})`),
|
.showOkModalDialog("Delete documents", `Deleting document(s) failed (${error.message})`),
|
||||||
)
|
)
|
||||||
.finally(() => setIsExecuting(false));
|
.finally(() => setIsExecuting(false));
|
||||||
},
|
},
|
||||||
@ -1274,7 +1326,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
|
|
||||||
const onFilterKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
const onFilterKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
refreshDocumentsGrid(true);
|
onApplyFilterClick();
|
||||||
|
|
||||||
// Suppress the default behavior of the key
|
// Suppress the default behavior of the key
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -1518,7 +1570,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
return partitionKey;
|
return partitionKey;
|
||||||
};
|
};
|
||||||
|
|
||||||
lastFilterContents = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"];
|
|
||||||
partitionKeyProperties = partitionKeyProperties?.map((partitionKeyProperty, i) => {
|
partitionKeyProperties = partitionKeyProperties?.map((partitionKeyProperty, i) => {
|
||||||
if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) {
|
if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) {
|
||||||
partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, "");
|
partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, "");
|
||||||
@ -1533,62 +1584,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
return partitionKeyProperty;
|
return partitionKeyProperty;
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Mongo implementation
|
|
||||||
* TODO: update proxy to use mongo driver deleteMany
|
|
||||||
*/
|
|
||||||
_deleteDocuments = (toDeleteDocumentIds: DocumentId[]): Promise<DocumentId[]> => {
|
|
||||||
const promises = toDeleteDocumentIds.map((documentId) => _deleteDocument(documentId));
|
|
||||||
return Promise.all(promises);
|
|
||||||
};
|
|
||||||
|
|
||||||
const __deleteDocument = async (documentId: DocumentId): Promise<DocumentId> => {
|
|
||||||
await MongoProxyClient.deleteDocument(_collection.databaseId, _collection as ViewModels.Collection, documentId);
|
|
||||||
return documentId;
|
|
||||||
};
|
|
||||||
|
|
||||||
const _deleteDocument = useCallback(
|
|
||||||
(documentId: DocumentId): Promise<DocumentId> => {
|
|
||||||
onExecutionErrorChange(false);
|
|
||||||
const startKey: number = TelemetryProcessor.traceStart(Action.DeleteDocument, {
|
|
||||||
dataExplorerArea: Constants.Areas.Tab,
|
|
||||||
tabTitle,
|
|
||||||
});
|
|
||||||
setIsExecuting(true);
|
|
||||||
return __deleteDocument(documentId)
|
|
||||||
.then(
|
|
||||||
(deletedDocumentId) => {
|
|
||||||
TelemetryProcessor.traceSuccess(
|
|
||||||
Action.DeleteDocument,
|
|
||||||
{
|
|
||||||
dataExplorerArea: Constants.Areas.Tab,
|
|
||||||
tabTitle,
|
|
||||||
},
|
|
||||||
startKey,
|
|
||||||
);
|
|
||||||
return deletedDocumentId;
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
onExecutionErrorChange(true);
|
|
||||||
console.error(error);
|
|
||||||
TelemetryProcessor.traceFailure(
|
|
||||||
Action.DeleteDocument,
|
|
||||||
{
|
|
||||||
dataExplorerArea: Constants.Areas.Tab,
|
|
||||||
tabTitle,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
errorStack: getErrorStack(error),
|
|
||||||
},
|
|
||||||
startKey,
|
|
||||||
);
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.finally(() => setIsExecuting(false));
|
|
||||||
},
|
|
||||||
[__deleteDocument, onExecutionErrorChange, tabTitle],
|
|
||||||
);
|
|
||||||
|
|
||||||
onSaveNewDocumentClick = useCallback((): Promise<unknown> => {
|
onSaveNewDocumentClick = useCallback((): Promise<unknown> => {
|
||||||
const documentContent = JSON.parse(selectedDocumentContent);
|
const documentContent = JSON.parse(selectedDocumentContent);
|
||||||
const startKey: number = TelemetryProcessor.traceStart(Action.CreateDocument, {
|
const startKey: number = TelemetryProcessor.traceStart(Action.CreateDocument, {
|
||||||
@ -1795,6 +1790,24 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
}
|
}
|
||||||
// ***************** Mongo ***************************
|
// ***************** Mongo ***************************
|
||||||
|
|
||||||
|
const onApplyFilterClick = (): void => {
|
||||||
|
refreshDocumentsGrid(true);
|
||||||
|
|
||||||
|
// Remove duplicates, but keep order
|
||||||
|
if (lastFilterContents.includes(filterContent)) {
|
||||||
|
lastFilterContents.splice(lastFilterContents.indexOf(filterContent), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save filter content to local storage
|
||||||
|
lastFilterContents.unshift(filterContent);
|
||||||
|
|
||||||
|
// Keep the list size under MAX_FILTER_HISTORY_COUNT. Drop last element if needed.
|
||||||
|
const limitedLastFilterContents = lastFilterContents.slice(0, MAX_FILTER_HISTORY_COUNT);
|
||||||
|
|
||||||
|
setLastFilterContents(limitedLastFilterContents);
|
||||||
|
saveSubComponentState(SubComponentName.FilterHistory, _collection, lastFilterContents);
|
||||||
|
};
|
||||||
|
|
||||||
const refreshDocumentsGrid = useCallback(
|
const refreshDocumentsGrid = useCallback(
|
||||||
(applyFilterButtonPressed: boolean): void => {
|
(applyFilterButtonPressed: boolean): void => {
|
||||||
// clear documents grid
|
// clear documents grid
|
||||||
@ -1825,15 +1838,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
[createIterator, filterContent],
|
[createIterator, filterContent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onTableColumnResize = (columnId: string, width: number) => {
|
|
||||||
if (!prefs.columnWidths) {
|
|
||||||
prefs.columnWidths = {};
|
|
||||||
}
|
|
||||||
prefs.columnWidths[columnId] = width;
|
|
||||||
saveDocumentsTabPrefsDebounced(prefs);
|
|
||||||
setPrefs({ ...prefs });
|
|
||||||
};
|
|
||||||
|
|
||||||
const onColumnSelectionChange = (newSelectedColumnIds: string[]): void => {
|
const onColumnSelectionChange = (newSelectedColumnIds: string[]): void => {
|
||||||
// Do not allow to unselecting all columns
|
// Do not allow to unselecting all columns
|
||||||
if (newSelectedColumnIds.length === 0) {
|
if (newSelectedColumnIds.length === 0) {
|
||||||
@ -1892,12 +1896,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
<div className={styles.filterRow}>
|
<div className={styles.filterRow}>
|
||||||
{!isPreferredApiMongoDB && <span> SELECT * FROM c </span>}
|
{!isPreferredApiMongoDB && <span> SELECT * FROM c </span>}
|
||||||
<Input
|
<Input
|
||||||
id="filterInput"
|
|
||||||
ref={filterInput}
|
ref={filterInput}
|
||||||
type="text"
|
type="text"
|
||||||
size="small"
|
size="small"
|
||||||
list="filtersList"
|
list={`filtersList-${getUniqueId(_collection)}`}
|
||||||
className={styles.filterInput}
|
className={`filterInput ${styles.filterInput}`}
|
||||||
title="Type a query predicate or choose one from the list."
|
title="Type a query predicate or choose one from the list."
|
||||||
placeholder={
|
placeholder={
|
||||||
isPreferredApiMongoDB
|
isPreferredApiMongoDB
|
||||||
@ -1911,8 +1914,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
onBlur={() => setIsFilterFocused(false)}
|
onBlur={() => setIsFilterFocused(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<datalist id="filtersList">
|
<datalist id={`filtersList-${getUniqueId(_collection)}`}>
|
||||||
{lastFilterContents.map((filter) => (
|
{addStringsNoDuplicate(
|
||||||
|
lastFilterContents,
|
||||||
|
isPreferredApiMongoDB ? defaultMongoFilters : defaultSqlFilters,
|
||||||
|
).map((filter) => (
|
||||||
<option key={filter} value={filter} />
|
<option key={filter} value={filter} />
|
||||||
))}
|
))}
|
||||||
</datalist>
|
</datalist>
|
||||||
@ -1920,7 +1926,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
<Button
|
<Button
|
||||||
appearance="primary"
|
appearance="primary"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => refreshDocumentsGrid(true)}
|
onClick={onApplyFilterClick}
|
||||||
disabled={!applyFilterButton.enabled}
|
disabled={!applyFilterButton.enabled}
|
||||||
aria-label="Apply filter"
|
aria-label="Apply filter"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@ -1951,11 +1957,16 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* <Split> doesn't like to be a flex child */}
|
{/* <Split> doesn't like to be a flex child */}
|
||||||
<div style={{ overflow: "hidden", height: "100%" }}>
|
<div style={{ overflow: "hidden", height: "100%" }}>
|
||||||
<Allotment>
|
<Allotment
|
||||||
<Allotment.Pane preferredSize="35%" minSize={175}>
|
onDragEnd={(sizes: number[]) => {
|
||||||
|
tabStateData.leftPaneWidthPercent = (100 * sizes[0]) / (sizes[0] + sizes[1]);
|
||||||
|
saveSubComponentState(SubComponentName.MainTabDivider, _collection, tabStateData);
|
||||||
|
setTabStateData(tabStateData);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Allotment.Pane preferredSize={`${tabStateData.leftPaneWidthPercent}%`} minSize={55}>
|
||||||
<div style={{ height: "100%", width: "100%", overflow: "hidden" }} ref={tableContainerRef}>
|
<div style={{ height: "100%", width: "100%", overflow: "hidden" }} ref={tableContainerRef}>
|
||||||
<div className={styles.floatingControlsContainer}>
|
<div className={styles.floatingControlsContainer}>
|
||||||
<div className={styles.floatingControls}>
|
<div className={styles.floatingControls}>
|
||||||
@ -1993,9 +2004,9 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
isSelectionDisabled={
|
isSelectionDisabled={
|
||||||
configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly
|
configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly
|
||||||
}
|
}
|
||||||
onColumnResize={onTableColumnResize}
|
|
||||||
onColumnSelectionChange={onColumnSelectionChange}
|
onColumnSelectionChange={onColumnSelectionChange}
|
||||||
defaultColumnSelection={getInitialColumnSelection()}
|
defaultColumnSelection={getInitialColumnSelection()}
|
||||||
|
collection={_collection}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -2012,7 +2023,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Allotment.Pane>
|
</Allotment.Pane>
|
||||||
<Allotment.Pane preferredSize="65%" minSize={300}>
|
<Allotment.Pane minSize={30}>
|
||||||
<div style={{ height: "100%", width: "100%" }}>
|
<div style={{ height: "100%", width: "100%" }}>
|
||||||
{isTabActive && selectedDocumentContent && selectedRows.size <= 1 && (
|
{isTabActive && selectedDocumentContent && selectedRows.size <= 1 && (
|
||||||
<EditorReact
|
<EditorReact
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { deleteDocument } from "Common/MongoProxyClient";
|
import { deleteDocuments } from "Common/MongoProxyClient";
|
||||||
import { Platform, updateConfigContext } from "ConfigContext";
|
import { Platform, updateConfigContext } from "ConfigContext";
|
||||||
import { EditorReactProps } from "Explorer/Controls/Editor/EditorReact";
|
import { EditorReactProps } from "Explorer/Controls/Editor/EditorReact";
|
||||||
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
|
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
|
||||||
@ -49,7 +49,7 @@ jest.mock("Common/MongoProxyClient", () => ({
|
|||||||
id: "id1",
|
id: "id1",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
deleteDocument: jest.fn(() => Promise.resolve()),
|
deleteDocuments: jest.fn(() => Promise.resolve()),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("Explorer/Controls/Editor/EditorReact", () => ({
|
jest.mock("Explorer/Controls/Editor/EditorReact", () => ({
|
||||||
@ -179,8 +179,8 @@ describe("Documents tab (Mongo API)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("clicking Delete Document asks for confirmation", () => {
|
it("clicking Delete Document asks for confirmation", () => {
|
||||||
const mockDeleteDocument = deleteDocument as jest.Mock;
|
const mockDeleteDocuments = deleteDocuments as jest.Mock;
|
||||||
mockDeleteDocument.mockClear();
|
mockDeleteDocuments.mockClear();
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
useCommandBar
|
useCommandBar
|
||||||
@ -189,7 +189,7 @@ describe("Documents tab (Mongo API)", () => {
|
|||||||
.onCommandClick(undefined);
|
.onCommandClick(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockDeleteDocument).toHaveBeenCalled();
|
expect(mockDeleteDocuments).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { TableRowId } from "@fluentui/react-components";
|
import { TableRowId } from "@fluentui/react-components";
|
||||||
import { mount } from "enzyme";
|
import { mount } from "enzyme";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||||
import { DocumentsTableComponent, IDocumentsTableComponentProps } from "./DocumentsTableComponent";
|
import { DocumentsTableComponent, IDocumentsTableComponentProps } from "./DocumentsTableComponent";
|
||||||
|
|
||||||
const PARTITION_KEY_HEADER = "partitionKey";
|
const PARTITION_KEY_HEADER = "partitionKey";
|
||||||
@ -20,11 +21,19 @@ describe("DocumentsTableComponent", () => {
|
|||||||
height: 0,
|
height: 0,
|
||||||
width: 0,
|
width: 0,
|
||||||
},
|
},
|
||||||
columnsDefinition: [
|
columnDefinitions: [
|
||||||
{ id: ID_HEADER, label: "ID" },
|
{ id: ID_HEADER, label: "ID", isPartitionKey: false },
|
||||||
{ id: PARTITION_KEY_HEADER, label: "Partition Key" },
|
{ id: PARTITION_KEY_HEADER, label: "Partition Key", isPartitionKey: true },
|
||||||
],
|
],
|
||||||
isSelectionDisabled: false,
|
isSelectionDisabled: false,
|
||||||
|
collection: {
|
||||||
|
databaseId: "db",
|
||||||
|
id: ((): string => "coll") as ko.Observable<string>,
|
||||||
|
} as ViewModels.CollectionBase,
|
||||||
|
onRefreshTable: (): void => {
|
||||||
|
throw new Error("Function not implemented.");
|
||||||
|
},
|
||||||
|
selectedColumnIds: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render documents and partition keys in header", () => {
|
it("should render documents and partition keys in header", () => {
|
||||||
|
@ -37,6 +37,13 @@ import {
|
|||||||
} from "@fluentui/react-icons";
|
} from "@fluentui/react-icons";
|
||||||
import { NormalizedEventKey } from "Common/Constants";
|
import { NormalizedEventKey } from "Common/Constants";
|
||||||
import { TableColumnSelectionPane } from "Explorer/Panes/TableColumnSelectionPane/TableColumnSelectionPane";
|
import { TableColumnSelectionPane } from "Explorer/Panes/TableColumnSelectionPane/TableColumnSelectionPane";
|
||||||
|
import {
|
||||||
|
ColumnSizesMap,
|
||||||
|
readSubComponentState,
|
||||||
|
saveSubComponentState,
|
||||||
|
SubComponentName,
|
||||||
|
WidthDefinition,
|
||||||
|
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
|
||||||
import { INITIAL_SELECTED_ROW_INDEX, useDocumentsTabStyles } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2";
|
import { INITIAL_SELECTED_ROW_INDEX, useDocumentsTabStyles } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2";
|
||||||
import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
|
import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
|
||||||
import { LayoutConstants } from "Explorer/Theme/ThemeUtil";
|
import { LayoutConstants } from "Explorer/Theme/ThemeUtil";
|
||||||
@ -44,6 +51,7 @@ import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/Keybo
|
|||||||
import { useSidePanel } from "hooks/useSidePanel";
|
import { useSidePanel } from "hooks/useSidePanel";
|
||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
import { FixedSizeList as List, ListChildComponentProps } from "react-window";
|
import { FixedSizeList as List, ListChildComponentProps } from "react-window";
|
||||||
|
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||||
|
|
||||||
export type DocumentsTableComponentItem = {
|
export type DocumentsTableComponentItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -53,7 +61,6 @@ export type ColumnDefinition = {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
isPartitionKey: boolean;
|
isPartitionKey: boolean;
|
||||||
defaultWidthPx?: number;
|
|
||||||
};
|
};
|
||||||
export interface IDocumentsTableComponentProps {
|
export interface IDocumentsTableComponentProps {
|
||||||
onRefreshTable: () => void;
|
onRefreshTable: () => void;
|
||||||
@ -66,7 +73,7 @@ export interface IDocumentsTableComponentProps {
|
|||||||
columnDefinitions: ColumnDefinition[];
|
columnDefinitions: ColumnDefinition[];
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
isSelectionDisabled?: boolean;
|
isSelectionDisabled?: boolean;
|
||||||
onColumnResize?: (columnId: string, width: number) => void;
|
collection: ViewModels.CollectionBase;
|
||||||
onColumnSelectionChange?: (newSelectedColumnIds: string[]) => void;
|
onColumnSelectionChange?: (newSelectedColumnIds: string[]) => void;
|
||||||
defaultColumnSelection?: string[];
|
defaultColumnSelection?: string[];
|
||||||
}
|
}
|
||||||
@ -81,8 +88,6 @@ interface ReactWindowRenderFnProps extends ListChildComponentProps {
|
|||||||
data: TableRowData[];
|
data: TableRowData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_COLUMN_WIDTH_PX = 200;
|
|
||||||
const MIN_COLUMN_WIDTH_PX = 20;
|
|
||||||
const COLUMNS_MENU_NAME = "columnsMenu";
|
const COLUMNS_MENU_NAME = "columnsMenu";
|
||||||
|
|
||||||
export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> = ({
|
export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> = ({
|
||||||
@ -95,21 +100,26 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
|
|||||||
selectedColumnIds,
|
selectedColumnIds,
|
||||||
columnDefinitions,
|
columnDefinitions,
|
||||||
isSelectionDisabled,
|
isSelectionDisabled,
|
||||||
onColumnResize: _onColumnResize,
|
collection,
|
||||||
onColumnSelectionChange,
|
onColumnSelectionChange,
|
||||||
defaultColumnSelection,
|
defaultColumnSelection,
|
||||||
}: IDocumentsTableComponentProps) => {
|
}: IDocumentsTableComponentProps) => {
|
||||||
const styles = useDocumentsTabStyles();
|
const styles = useDocumentsTabStyles();
|
||||||
|
|
||||||
const initialSizingOptions: TableColumnSizingOptions = {};
|
const defaultSize: WidthDefinition = {
|
||||||
columnDefinitions.forEach((column) => {
|
idealWidth: 200,
|
||||||
initialSizingOptions[column.id] = {
|
minWidth: 50,
|
||||||
idealWidth: column.defaultWidthPx || DEFAULT_COLUMN_WIDTH_PX, // 0 is not a valid width
|
|
||||||
minWidth: MIN_COLUMN_WIDTH_PX,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(() => {
|
||||||
|
const columnSizesMap: ColumnSizesMap = readSubComponentState(SubComponentName.ColumnSizes, collection, {});
|
||||||
|
const columnSizesPx: ColumnSizesMap = {};
|
||||||
|
selectedColumnIds.forEach((columnId) => {
|
||||||
|
columnSizesPx[columnId] = (columnSizesMap && columnSizesMap[columnId]) || defaultSize;
|
||||||
|
});
|
||||||
|
return columnSizesPx;
|
||||||
});
|
});
|
||||||
|
|
||||||
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(initialSizingOptions);
|
|
||||||
const [sortState, setSortState] = React.useState<{
|
const [sortState, setSortState] = React.useState<{
|
||||||
sortDirection: "ascending" | "descending";
|
sortDirection: "ascending" | "descending";
|
||||||
sortColumn: TableColumnId | undefined;
|
sortColumn: TableColumnId | undefined;
|
||||||
@ -118,19 +128,21 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
|
|||||||
sortColumn: undefined,
|
sortColumn: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onColumnResize = React.useCallback(
|
const onColumnResize = React.useCallback((_, { columnId, width }) => {
|
||||||
(_, { columnId, width }) => {
|
setColumnSizingOptions((state) => {
|
||||||
setColumnSizingOptions((state) => ({
|
const newSizingOptions = {
|
||||||
...state,
|
...state,
|
||||||
[columnId]: {
|
[columnId]: {
|
||||||
...state[columnId],
|
...state[columnId],
|
||||||
idealWidth: width,
|
idealWidth: width,
|
||||||
},
|
},
|
||||||
}));
|
};
|
||||||
_onColumnResize(columnId, width);
|
|
||||||
},
|
saveSubComponentState(SubComponentName.ColumnSizes, collection, newSizingOptions, true);
|
||||||
[_onColumnResize],
|
|
||||||
);
|
return newSizingOptions;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// const restoreFocusTargetAttribute = useRestoreFocusTarget();
|
// const restoreFocusTargetAttribute = useRestoreFocusTarget();
|
||||||
|
|
||||||
|
@ -38,9 +38,11 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Allotment>
|
<Allotment
|
||||||
|
onDragEnd={[Function]}
|
||||||
|
>
|
||||||
<Allotment.Pane
|
<Allotment.Pane
|
||||||
minSize={175}
|
minSize={55}
|
||||||
preferredSize="35%"
|
preferredSize="35%"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@ -85,6 +87,12 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<DocumentsTableComponent
|
<DocumentsTableComponent
|
||||||
|
collection={
|
||||||
|
{
|
||||||
|
"databaseId": "databaseId",
|
||||||
|
"id": [Function],
|
||||||
|
}
|
||||||
|
}
|
||||||
columnDefinitions={
|
columnDefinitions={
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@ -94,9 +102,13 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
defaultColumnSelection={
|
||||||
|
[
|
||||||
|
"id",
|
||||||
|
]
|
||||||
|
}
|
||||||
isSelectionDisabled={true}
|
isSelectionDisabled={true}
|
||||||
items={[]}
|
items={[]}
|
||||||
onColumnResize={[Function]}
|
|
||||||
onColumnSelectionChange={[Function]}
|
onColumnSelectionChange={[Function]}
|
||||||
onItemClicked={[Function]}
|
onItemClicked={[Function]}
|
||||||
onRefreshTable={[Function]}
|
onRefreshTable={[Function]}
|
||||||
@ -117,8 +129,7 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
|||||||
</div>
|
</div>
|
||||||
</Allotment.Pane>
|
</Allotment.Pane>
|
||||||
<Allotment.Pane
|
<Allotment.Pane
|
||||||
minSize={300}
|
minSize={30}
|
||||||
preferredSize="65%"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={
|
style={
|
||||||
|
@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
exports[`DocumentsTableComponent should not render selection column when isSelectionDisabled is true 1`] = `
|
exports[`DocumentsTableComponent should not render selection column when isSelectionDisabled is true 1`] = `
|
||||||
<DocumentsTableComponent
|
<DocumentsTableComponent
|
||||||
|
collection={
|
||||||
|
{
|
||||||
|
"databaseId": "db",
|
||||||
|
"id": [Function],
|
||||||
|
}
|
||||||
|
}
|
||||||
columnHeaders={
|
columnHeaders={
|
||||||
{
|
{
|
||||||
"idHeader": "id",
|
"idHeader": "id",
|
||||||
@ -1003,6 +1009,12 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
|
|||||||
|
|
||||||
exports[`DocumentsTableComponent should render documents and partition keys in header 1`] = `
|
exports[`DocumentsTableComponent should render documents and partition keys in header 1`] = `
|
||||||
<DocumentsTableComponent
|
<DocumentsTableComponent
|
||||||
|
collection={
|
||||||
|
{
|
||||||
|
"databaseId": "db",
|
||||||
|
"id": [Function],
|
||||||
|
}
|
||||||
|
}
|
||||||
columnHeaders={
|
columnHeaders={
|
||||||
{
|
{
|
||||||
"idHeader": "id",
|
"idHeader": "id",
|
||||||
|
@ -1,34 +0,0 @@
|
|||||||
// Utility functions to manage DocumentsTab preferences
|
|
||||||
|
|
||||||
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
|
|
||||||
|
|
||||||
export interface DocumentsTabPrefs {
|
|
||||||
leftPaneWidthPercent: number;
|
|
||||||
columnWidths?: { [columnId: string]: number }; // TODO save per database/collection
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultPrefs: DocumentsTabPrefs = {
|
|
||||||
leftPaneWidthPercent: 35,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const readDocumentsTabPrefs = (): DocumentsTabPrefs => {
|
|
||||||
const prefs = LocalStorageUtility.getEntryObject<DocumentsTabPrefs>(StorageKey.DocumentsTabPrefs);
|
|
||||||
return prefs || defaultPrefs;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const saveDocumentsTabPrefs = (prefs: DocumentsTabPrefs): void => {
|
|
||||||
LocalStorageUtility.setEntryObject(StorageKey.DocumentsTabPrefs, prefs);
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEBOUNCE_TIMEOUT_MS = 300;
|
|
||||||
let timeoutId: NodeJS.Timeout | undefined;
|
|
||||||
/**
|
|
||||||
* Wait for a short period of time before saving the preferences to avoid too many updates.
|
|
||||||
* @param prefs
|
|
||||||
*/
|
|
||||||
export const saveDocumentsTabPrefsDebounced = (prefs: DocumentsTabPrefs): void => {
|
|
||||||
if (timeoutId) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
timeoutId = setTimeout(() => saveDocumentsTabPrefs(prefs), DEBOUNCE_TIMEOUT_MS);
|
|
||||||
};
|
|
@ -3,7 +3,7 @@ import MongoUtility from "../../../Common/MongoUtility";
|
|||||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||||
import Explorer from "../../Explorer";
|
import Explorer from "../../Explorer";
|
||||||
import { NewQueryTab } from "../QueryTab/QueryTab";
|
import { NewQueryTab } from "../QueryTab/QueryTab";
|
||||||
import QueryTabComponent, { IQueryTabComponentProps, ITabAccessor } from "../QueryTab/QueryTabComponent";
|
import { IQueryTabComponentProps, ITabAccessor, QueryTabComponent } from "../QueryTab/QueryTabComponent";
|
||||||
|
|
||||||
export interface IMongoQueryTabProps {
|
export interface IMongoQueryTabProps {
|
||||||
container: Explorer;
|
container: Explorer;
|
||||||
|
124
src/Explorer/Tabs/QueryTab/ErrorList.tsx
Normal file
124
src/Explorer/Tabs/QueryTab/ErrorList.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DataGrid,
|
||||||
|
DataGridBody,
|
||||||
|
DataGridCell,
|
||||||
|
DataGridHeader,
|
||||||
|
DataGridHeaderCell,
|
||||||
|
DataGridRow,
|
||||||
|
TableCellLayout,
|
||||||
|
TableColumnDefinition,
|
||||||
|
TableColumnSizingOptions,
|
||||||
|
createTableColumn,
|
||||||
|
tokens,
|
||||||
|
} from "@fluentui/react-components";
|
||||||
|
import { ErrorCircleFilled, MoreHorizontalRegular, WarningFilled } from "@fluentui/react-icons";
|
||||||
|
import QueryError, { QueryErrorSeverity, compareSeverity } from "Common/QueryError";
|
||||||
|
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
|
||||||
|
import { useNotificationConsole } from "hooks/useNotificationConsole";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const severityIcons = {
|
||||||
|
[QueryErrorSeverity.Error]: <ErrorCircleFilled color={tokens.colorPaletteRedBackground3} />,
|
||||||
|
[QueryErrorSeverity.Warning]: <WarningFilled color={tokens.colorPaletteYellowForeground1} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ErrorList: React.FC<{ errors: QueryError[] }> = ({ errors }) => {
|
||||||
|
const styles = useQueryTabStyles();
|
||||||
|
const onErrorDetailsClick = (): boolean => {
|
||||||
|
useNotificationConsole.getState().expandConsole();
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: TableColumnDefinition<QueryError>[] = [
|
||||||
|
createTableColumn<QueryError>({
|
||||||
|
columnId: "code",
|
||||||
|
compare: (item1, item2) => item1.code.localeCompare(item2.code),
|
||||||
|
renderHeaderCell: () => null,
|
||||||
|
renderCell: (item) => item.code,
|
||||||
|
}),
|
||||||
|
createTableColumn<QueryError>({
|
||||||
|
columnId: "severity",
|
||||||
|
compare: (item1, item2) => compareSeverity(item1.severity, item2.severity),
|
||||||
|
renderHeaderCell: () => null,
|
||||||
|
renderCell: (item) => <TableCellLayout media={severityIcons[item.severity]}>{item.severity}</TableCellLayout>,
|
||||||
|
}),
|
||||||
|
createTableColumn<QueryError>({
|
||||||
|
columnId: "location",
|
||||||
|
compare: (item1, item2) => item1.location?.start?.offset - item2.location?.start?.offset,
|
||||||
|
renderHeaderCell: () => "Location",
|
||||||
|
renderCell: (item) =>
|
||||||
|
item.location
|
||||||
|
? item.location.start.lineNumber
|
||||||
|
? `Line ${item.location.start.lineNumber}`
|
||||||
|
: "<unknown>"
|
||||||
|
: "<no location>",
|
||||||
|
}),
|
||||||
|
createTableColumn<QueryError>({
|
||||||
|
columnId: "message",
|
||||||
|
compare: (item1, item2) => item1.message.localeCompare(item2.message),
|
||||||
|
renderHeaderCell: () => "Message",
|
||||||
|
renderCell: (item) => (
|
||||||
|
<div className={styles.errorListMessageCell}>
|
||||||
|
<div className={styles.errorListMessage}>{item.message}</div>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
aria-label="Details"
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<MoreHorizontalRegular />}
|
||||||
|
onClick={onErrorDetailsClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const columnSizingOptions: TableColumnSizingOptions = {
|
||||||
|
code: {
|
||||||
|
minWidth: 75,
|
||||||
|
idealWidth: 75,
|
||||||
|
defaultWidth: 75,
|
||||||
|
},
|
||||||
|
severity: {
|
||||||
|
minWidth: 100,
|
||||||
|
idealWidth: 100,
|
||||||
|
defaultWidth: 100,
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
minWidth: 100,
|
||||||
|
idealWidth: 100,
|
||||||
|
defaultWidth: 100,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
minWidth: 500,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGrid
|
||||||
|
data-test="QueryTab/ResultsPane/ErrorList"
|
||||||
|
items={errors}
|
||||||
|
columns={columns}
|
||||||
|
sortable
|
||||||
|
resizableColumns
|
||||||
|
columnSizingOptions={columnSizingOptions}
|
||||||
|
focusMode="composite"
|
||||||
|
>
|
||||||
|
<DataGridHeader>
|
||||||
|
<DataGridRow>
|
||||||
|
{({ renderHeaderCell }) => <DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>}
|
||||||
|
</DataGridRow>
|
||||||
|
</DataGridHeader>
|
||||||
|
<DataGridBody<QueryError>>
|
||||||
|
{({ item, rowId }) => (
|
||||||
|
<DataGridRow<QueryError> key={rowId} data-test={`Row:${rowId}`}>
|
||||||
|
{({ columnId, renderCell }) => (
|
||||||
|
<DataGridCell data-test={`Row:${rowId}/Column:${columnId}`}>{renderCell(item)}</DataGridCell>
|
||||||
|
)}
|
||||||
|
</DataGridRow>
|
||||||
|
)}
|
||||||
|
</DataGridBody>
|
||||||
|
</DataGrid>
|
||||||
|
);
|
||||||
|
};
|
@ -1,544 +1,93 @@
|
|||||||
import {
|
import { Link } from "@fluentui/react-components";
|
||||||
DetailsList,
|
import QueryError from "Common/QueryError";
|
||||||
DetailsListLayoutMode,
|
import { IndeterminateProgressBar } from "Explorer/Controls/IndeterminateProgressBar";
|
||||||
IColumn,
|
import { MessageBanner } from "Explorer/Controls/MessageBanner";
|
||||||
Icon,
|
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
|
||||||
IconButton,
|
|
||||||
Link,
|
|
||||||
Pivot,
|
|
||||||
PivotItem,
|
|
||||||
SelectionMode,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
TooltipHost,
|
|
||||||
} from "@fluentui/react";
|
|
||||||
import { HttpHeaders, NormalizedEventKey } from "Common/Constants";
|
|
||||||
import MongoUtility from "Common/MongoUtility";
|
|
||||||
import { QueryMetrics } from "Contracts/DataModels";
|
|
||||||
import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
|
|
||||||
import { IDocument } from "Explorer/Tabs/QueryTab/QueryTabComponent";
|
|
||||||
import { userContext } from "UserContext";
|
|
||||||
import copy from "clipboard-copy";
|
|
||||||
import { useNotificationConsole } from "hooks/useNotificationConsole";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import CopilotCopy from "../../../../images/CopilotCopy.svg";
|
|
||||||
import DownloadQueryMetrics from "../../../../images/DownloadQuery.svg";
|
|
||||||
import QueryEditorNext from "../../../../images/Query-Editor-Next.svg";
|
|
||||||
import RunQuery from "../../../../images/RunQuery.png";
|
import RunQuery from "../../../../images/RunQuery.png";
|
||||||
import InfoColor from "../../../../images/info_color.svg";
|
|
||||||
import { QueryResults } from "../../../Contracts/ViewModels";
|
import { QueryResults } from "../../../Contracts/ViewModels";
|
||||||
|
import { ErrorList } from "./ErrorList";
|
||||||
|
import { ResultsView } from "./ResultsView";
|
||||||
|
|
||||||
interface QueryResultProps {
|
export interface ResultsViewProps {
|
||||||
isMongoDB: boolean;
|
isMongoDB: boolean;
|
||||||
queryEditorContent: string;
|
|
||||||
error: string;
|
|
||||||
isExecuting: boolean;
|
|
||||||
queryResults: QueryResults;
|
queryResults: QueryResults;
|
||||||
executeQueryDocumentsPage: (firstItemIndex: number) => Promise<void>;
|
executeQueryDocumentsPage: (firstItemIndex: number) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface QueryResultProps extends ResultsViewProps {
|
||||||
|
queryEditorContent: string;
|
||||||
|
errors: QueryError[];
|
||||||
|
isExecuting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExecuteQueryCallToAction: React.FC = () => {
|
||||||
|
const styles = useQueryTabStyles();
|
||||||
|
return (
|
||||||
|
<div data-test="QueryTab/ResultsPane/ExecuteCTA" className={styles.executeCallToAction}>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
<img src={RunQuery} aria-hidden="true" />
|
||||||
|
</p>
|
||||||
|
<p>Execute a query to see the results</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const QueryResultSection: React.FC<QueryResultProps> = ({
|
export const QueryResultSection: React.FC<QueryResultProps> = ({
|
||||||
isMongoDB,
|
isMongoDB,
|
||||||
queryEditorContent,
|
queryEditorContent,
|
||||||
error,
|
errors,
|
||||||
queryResults,
|
queryResults,
|
||||||
isExecuting,
|
|
||||||
executeQueryDocumentsPage,
|
executeQueryDocumentsPage,
|
||||||
|
isExecuting,
|
||||||
}: QueryResultProps): JSX.Element => {
|
}: QueryResultProps): JSX.Element => {
|
||||||
const queryMetrics = React.useRef(queryResults?.headers?.[HttpHeaders.queryMetrics]);
|
const styles = useQueryTabStyles();
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const latestQueryMetrics = queryResults?.headers?.[HttpHeaders.queryMetrics];
|
|
||||||
if (latestQueryMetrics && Object.keys(latestQueryMetrics).length > 0) {
|
|
||||||
queryMetrics.current = latestQueryMetrics;
|
|
||||||
}
|
|
||||||
}, [queryResults]);
|
|
||||||
|
|
||||||
const onRender = (item: IDocument): JSX.Element => (
|
|
||||||
<>
|
|
||||||
<Text style={{ paddingLeft: 10, margin: 0 }}>{`${item.metric}`}</Text>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
const columns: IColumn[] = [
|
|
||||||
{
|
|
||||||
key: "column1",
|
|
||||||
name: "Description",
|
|
||||||
iconName: "Info",
|
|
||||||
isIconOnly: true,
|
|
||||||
minWidth: 10,
|
|
||||||
maxWidth: 12,
|
|
||||||
iconClassName: "iconheadercell",
|
|
||||||
data: String,
|
|
||||||
fieldName: "",
|
|
||||||
onRender: (item: IDocument) => {
|
|
||||||
if (item.toolTip !== "") {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<TooltipHost content={`${item.toolTip}`}>
|
|
||||||
<Link style={{ color: "#323130" }}>
|
|
||||||
<Icon iconName="Info" ariaLabel={`${item.toolTip}`} className="panelInfoIcon" tabIndex={0} />
|
|
||||||
</Link>
|
|
||||||
</TooltipHost>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "column2",
|
|
||||||
name: "METRIC",
|
|
||||||
minWidth: 200,
|
|
||||||
data: String,
|
|
||||||
fieldName: "metric",
|
|
||||||
onRender,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "column3",
|
|
||||||
name: "VALUE",
|
|
||||||
minWidth: 200,
|
|
||||||
data: String,
|
|
||||||
fieldName: "value",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const maybeSubQuery = queryEditorContent && /.*\(.*SELECT.*\)/i.test(queryEditorContent);
|
const maybeSubQuery = queryEditorContent && /.*\(.*SELECT.*\)/i.test(queryEditorContent);
|
||||||
const queryResultsString = queryResults
|
|
||||||
? isMongoDB
|
|
||||||
? MongoUtility.tojson(queryResults.documents, undefined, false)
|
|
||||||
: JSON.stringify(queryResults.documents, undefined, 4)
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const onErrorDetailsClick = (): boolean => {
|
|
||||||
useNotificationConsole.getState().expandConsole();
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onErrorDetailsKeyPress = (event: React.KeyboardEvent<HTMLAnchorElement>): boolean => {
|
|
||||||
if (event.key === NormalizedEventKey.Space || event.key === NormalizedEventKey.Enter) {
|
|
||||||
onErrorDetailsClick();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDownloadQueryMetricsCsvClick = (): boolean => {
|
|
||||||
downloadQueryMetricsCsvData();
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDownloadQueryMetricsCsvKeyPress = (event: React.KeyboardEvent<HTMLAnchorElement>): boolean => {
|
|
||||||
if (event.key === NormalizedEventKey.Space || NormalizedEventKey.Enter) {
|
|
||||||
downloadQueryMetricsCsvData();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadQueryMetricsCsvData = (): void => {
|
|
||||||
const csvData: string = generateQueryMetricsCsvData();
|
|
||||||
if (!csvData) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (navigator.msSaveBlob) {
|
|
||||||
// for IE and Edge
|
|
||||||
navigator.msSaveBlob(
|
|
||||||
new Blob([csvData], { type: "data:text/csv;charset=utf-8" }),
|
|
||||||
"PerPartitionQueryMetrics.csv",
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const downloadLink: HTMLAnchorElement = document.createElement("a");
|
|
||||||
downloadLink.href = "data:text/csv;charset=utf-8," + encodeURI(csvData);
|
|
||||||
downloadLink.target = "_self";
|
|
||||||
downloadLink.download = "QueryMetricsPerPartition.csv";
|
|
||||||
|
|
||||||
// for some reason, FF displays the download prompt only when
|
|
||||||
// the link is added to the dom so we add and remove it
|
|
||||||
document.body.appendChild(downloadLink);
|
|
||||||
downloadLink.click();
|
|
||||||
downloadLink.remove();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAggregatedQueryMetrics = (): QueryMetrics => {
|
|
||||||
const aggregatedQueryMetrics = {
|
|
||||||
documentLoadTime: 0,
|
|
||||||
documentWriteTime: 0,
|
|
||||||
indexHitDocumentCount: 0,
|
|
||||||
outputDocumentCount: 0,
|
|
||||||
outputDocumentSize: 0,
|
|
||||||
indexLookupTime: 0,
|
|
||||||
retrievedDocumentCount: 0,
|
|
||||||
retrievedDocumentSize: 0,
|
|
||||||
vmExecutionTime: 0,
|
|
||||||
runtimeExecutionTimes: {
|
|
||||||
queryEngineExecutionTime: 0,
|
|
||||||
systemFunctionExecutionTime: 0,
|
|
||||||
userDefinedFunctionExecutionTime: 0,
|
|
||||||
},
|
|
||||||
totalQueryExecutionTime: 0,
|
|
||||||
} as QueryMetrics;
|
|
||||||
|
|
||||||
if (queryMetrics.current) {
|
|
||||||
Object.keys(queryMetrics.current).forEach((partitionKeyRangeId) => {
|
|
||||||
const queryMetricsPerPartition = queryMetrics.current[partitionKeyRangeId];
|
|
||||||
if (!queryMetricsPerPartition) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
aggregatedQueryMetrics.documentLoadTime += queryMetricsPerPartition.documentLoadTime?.totalMilliseconds() || 0;
|
|
||||||
aggregatedQueryMetrics.documentWriteTime +=
|
|
||||||
queryMetricsPerPartition.documentWriteTime?.totalMilliseconds() || 0;
|
|
||||||
aggregatedQueryMetrics.indexHitDocumentCount += queryMetricsPerPartition.indexHitDocumentCount || 0;
|
|
||||||
aggregatedQueryMetrics.outputDocumentCount += queryMetricsPerPartition.outputDocumentCount || 0;
|
|
||||||
aggregatedQueryMetrics.outputDocumentSize += queryMetricsPerPartition.outputDocumentSize || 0;
|
|
||||||
aggregatedQueryMetrics.indexLookupTime += queryMetricsPerPartition.indexLookupTime?.totalMilliseconds() || 0;
|
|
||||||
aggregatedQueryMetrics.retrievedDocumentCount += queryMetricsPerPartition.retrievedDocumentCount || 0;
|
|
||||||
aggregatedQueryMetrics.retrievedDocumentSize += queryMetricsPerPartition.retrievedDocumentSize || 0;
|
|
||||||
aggregatedQueryMetrics.vmExecutionTime += queryMetricsPerPartition.vmExecutionTime?.totalMilliseconds() || 0;
|
|
||||||
aggregatedQueryMetrics.totalQueryExecutionTime +=
|
|
||||||
queryMetricsPerPartition.totalQueryExecutionTime?.totalMilliseconds() || 0;
|
|
||||||
aggregatedQueryMetrics.runtimeExecutionTimes.queryEngineExecutionTime +=
|
|
||||||
queryMetricsPerPartition.runtimeExecutionTimes?.queryEngineExecutionTime?.totalMilliseconds() || 0;
|
|
||||||
aggregatedQueryMetrics.runtimeExecutionTimes.systemFunctionExecutionTime +=
|
|
||||||
queryMetricsPerPartition.runtimeExecutionTimes?.systemFunctionExecutionTime?.totalMilliseconds() || 0;
|
|
||||||
aggregatedQueryMetrics.runtimeExecutionTimes.userDefinedFunctionExecutionTime +=
|
|
||||||
queryMetricsPerPartition.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.totalMilliseconds() || 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return aggregatedQueryMetrics;
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateQueryMetricsCsvData = (): string => {
|
|
||||||
if (queryMetrics.current) {
|
|
||||||
let csvData =
|
|
||||||
[
|
|
||||||
"Partition key range id",
|
|
||||||
"Retrieved document count",
|
|
||||||
"Retrieved document size (in bytes)",
|
|
||||||
"Output document count",
|
|
||||||
"Output document size (in bytes)",
|
|
||||||
"Index hit document count",
|
|
||||||
"Index lookup time (ms)",
|
|
||||||
"Document load time (ms)",
|
|
||||||
"Query engine execution time (ms)",
|
|
||||||
"System function execution time (ms)",
|
|
||||||
"User defined function execution time (ms)",
|
|
||||||
"Document write time (ms)",
|
|
||||||
].join(",") + "\n";
|
|
||||||
|
|
||||||
Object.keys(queryMetrics.current).forEach((partitionKeyRangeId) => {
|
|
||||||
const queryMetricsPerPartition = queryMetrics.current[partitionKeyRangeId];
|
|
||||||
csvData +=
|
|
||||||
[
|
|
||||||
partitionKeyRangeId,
|
|
||||||
queryMetricsPerPartition.retrievedDocumentCount,
|
|
||||||
queryMetricsPerPartition.retrievedDocumentSize,
|
|
||||||
queryMetricsPerPartition.outputDocumentCount,
|
|
||||||
queryMetricsPerPartition.outputDocumentSize,
|
|
||||||
queryMetricsPerPartition.indexHitDocumentCount,
|
|
||||||
queryMetricsPerPartition.indexLookupTime?.totalMilliseconds(),
|
|
||||||
queryMetricsPerPartition.documentLoadTime?.totalMilliseconds(),
|
|
||||||
queryMetricsPerPartition.runtimeExecutionTimes?.queryEngineExecutionTime?.totalMilliseconds(),
|
|
||||||
queryMetricsPerPartition.runtimeExecutionTimes?.systemFunctionExecutionTime?.totalMilliseconds(),
|
|
||||||
queryMetricsPerPartition.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.totalMilliseconds(),
|
|
||||||
queryMetricsPerPartition.documentWriteTime?.totalMilliseconds(),
|
|
||||||
].join(",") + "\n";
|
|
||||||
});
|
|
||||||
|
|
||||||
return csvData;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onFetchNextPageClick = async (): Promise<void> => {
|
|
||||||
const { firstItemIndex, itemCount } = queryResults;
|
|
||||||
await executeQueryDocumentsPage(firstItemIndex + itemCount - 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateQueryStatsItems = (): IDocument[] => {
|
|
||||||
const items: IDocument[] = [
|
|
||||||
{
|
|
||||||
metric: "Request Charge",
|
|
||||||
value: `${queryResults.requestCharge} RUs`,
|
|
||||||
toolTip: "Request Charge",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
metric: "Showing Results",
|
|
||||||
value: queryResults.itemCount > 0 ? `${queryResults.firstItemIndex} - ${queryResults.lastItemIndex}` : `0 - 0`,
|
|
||||||
toolTip: "Showing Results",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (userContext.apiType === "SQL") {
|
|
||||||
const aggregatedQueryMetrics = getAggregatedQueryMetrics();
|
|
||||||
items.push(
|
|
||||||
{
|
|
||||||
metric: "Retrieved document count",
|
|
||||||
value: aggregatedQueryMetrics.retrievedDocumentCount?.toString() || "",
|
|
||||||
toolTip: "Total number of retrieved documents",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
metric: "Retrieved document size",
|
|
||||||
value: `${aggregatedQueryMetrics.retrievedDocumentSize?.toString() || 0} bytes`,
|
|
||||||
toolTip: "Total size of retrieved documents in bytes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
metric: "Output document count",
|
|
||||||
value: aggregatedQueryMetrics.outputDocumentCount?.toString() || "",
|
|
||||||
toolTip: "Number of output documents",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
metric: "Output document size",
|
|
||||||
value: `${aggregatedQueryMetrics.outputDocumentSize?.toString() || 0} bytes`,
|
|
||||||
toolTip: "Total size of output documents in bytes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
metric: "Index hit document count",
|
|
||||||
value: aggregatedQueryMetrics.indexHitDocumentCount?.toString() || "",
|
|
||||||
toolTip: "Total number of documents matched by the filter",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
metric: "Index lookup time",
|
|
||||||
value: `${aggregatedQueryMetrics.indexLookupTime?.toString() || 0} ms`,
|
|
||||||
toolTip: "Time spent in physical index layer",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
metric: "Document load time",
|
|
||||||
value: `${aggregatedQueryMetrics.documentLoadTime?.toString() || 0} ms`,
|
|
||||||
toolTip: "Time spent in loading documents",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
metric: "Query engine execution time",
|
|
||||||
value: `${aggregatedQueryMetrics.runtimeExecutionTimes?.queryEngineExecutionTime?.toString() || 0} ms`,
|
|
||||||
toolTip:
|
|
||||||
"Time spent by the query engine to execute the query expression (excludes other execution times like load documents or write results)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
metric: "System function execution time",
|
|
||||||
value: `${aggregatedQueryMetrics.runtimeExecutionTimes?.systemFunctionExecutionTime?.toString() || 0} ms`,
|
|
||||||
toolTip: "Total time spent executing system (built-in) functions",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
metric: "User defined function execution time",
|
|
||||||
value: `${
|
|
||||||
aggregatedQueryMetrics.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.toString() || 0
|
|
||||||
} ms`,
|
|
||||||
toolTip: "Total time spent executing user-defined functions",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
metric: "Document write time",
|
|
||||||
value: `${aggregatedQueryMetrics.documentWriteTime.toString() || 0} ms`,
|
|
||||||
toolTip: "Time spent to write query result set to response buffer",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (queryResults.roundTrips) {
|
|
||||||
items.push({
|
|
||||||
metric: "Round Trips",
|
|
||||||
value: queryResults.roundTrips?.toString(),
|
|
||||||
toolTip: "Number of round trips",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (queryResults.activityId) {
|
|
||||||
items.push({
|
|
||||||
metric: "Activity id",
|
|
||||||
value: queryResults.activityId,
|
|
||||||
toolTip: "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onClickCopyResults = (): void => {
|
|
||||||
copy(queryResultsString);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack style={{ height: "100%" }}>
|
<div data-test="QueryTab/ResultsPane" className={styles.queryResultsPanel}>
|
||||||
{isMongoDB && queryEditorContent.length === 0 && (
|
{isExecuting && <IndeterminateProgressBar />}
|
||||||
<div className="mongoQueryHelper">
|
<MessageBanner
|
||||||
|
messageId="QueryEditor.EmptyMongoQuery"
|
||||||
|
visible={isMongoDB && queryEditorContent.length === 0}
|
||||||
|
className={styles.queryResultsMessage}
|
||||||
|
>
|
||||||
Start by writing a Mongo query, for example: <strong>{"{'id':'foo'}"}</strong> or{" "}
|
Start by writing a Mongo query, for example: <strong>{"{'id':'foo'}"}</strong> or{" "}
|
||||||
<strong>
|
<strong>
|
||||||
{"{ "}
|
{"{ "}
|
||||||
{" }"}
|
{" }"}
|
||||||
</strong>{" "}
|
</strong>{" "}
|
||||||
to get all the documents.
|
to get all the documents.
|
||||||
</div>
|
</MessageBanner>
|
||||||
)}
|
{/* {maybeSubQuery && ( */}
|
||||||
{maybeSubQuery && (
|
<MessageBanner
|
||||||
<div className="warningErrorContainer" aria-live="assertive">
|
messageId="QueryEditor.SubQueryWarning"
|
||||||
<div className="warningErrorContent">
|
visible={maybeSubQuery}
|
||||||
<span>
|
className={styles.queryResultsMessage}
|
||||||
<img className="paneErrorIcon" src={InfoColor} alt="Error" />
|
>
|
||||||
</span>
|
|
||||||
<span className="warningErrorDetailsLinkContainer">
|
|
||||||
We detected you may be using a subquery. To learn more about subqueries effectively,{" "}
|
We detected you may be using a subquery. To learn more about subqueries effectively,{" "}
|
||||||
<a
|
<Link
|
||||||
href="https://learn.microsoft.com/azure/cosmos-db/nosql/query/subquery"
|
href="https://learn.microsoft.com/azure/cosmos-db/nosql/query/subquery"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer noopener"
|
||||||
>
|
>
|
||||||
visit the documentation
|
visit the documentation
|
||||||
</a>
|
</Link>
|
||||||
</span>
|
</MessageBanner>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* <!-- Query Errors Tab - Start--> */}
|
|
||||||
{error && (
|
|
||||||
<div className="active queryErrorsHeaderContainer">
|
|
||||||
<span className="queryErrors" data-toggle="tab">
|
|
||||||
Errors
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* <!-- Query Errors Tab - End --> */}
|
|
||||||
{/* <!-- Query Results & Errors Content Container - Start--> */}
|
{/* <!-- Query Results & Errors Content Container - Start--> */}
|
||||||
<div className="queryResultErrorContentContainer">
|
{errors.length > 0 ? (
|
||||||
{!queryResults && !error && !isExecuting && (
|
<ErrorList errors={errors} />
|
||||||
<div className="queryEditorWatermark">
|
) : queryResults ? (
|
||||||
<p>
|
<ResultsView
|
||||||
<img src={RunQuery} alt="Execute Query Watermark" />
|
queryResults={queryResults}
|
||||||
</p>
|
executeQueryDocumentsPage={executeQueryDocumentsPage}
|
||||||
<p className="queryEditorWatermarkText">Execute a query to see the results</p>
|
isMongoDB={isMongoDB}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(queryResults || !!error) && (
|
|
||||||
<div className="queryResultsErrorsContent">
|
|
||||||
{!error && (
|
|
||||||
<Pivot aria-label="Successful execution" style={{ height: "100%" }}>
|
|
||||||
<PivotItem
|
|
||||||
headerText="Results"
|
|
||||||
headerButtonProps={{
|
|
||||||
"data-order": 1,
|
|
||||||
"data-title": "Results",
|
|
||||||
}}
|
|
||||||
style={{ height: "100%" }}
|
|
||||||
>
|
|
||||||
<div className="result-metadata">
|
|
||||||
<span>
|
|
||||||
<span>
|
|
||||||
{queryResults.itemCount > 0
|
|
||||||
? `${queryResults.firstItemIndex} - ${queryResults.lastItemIndex}`
|
|
||||||
: `0 - 0`}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
{queryResults.hasMoreResults && (
|
|
||||||
<>
|
|
||||||
<span className="queryResultDivider">|</span>
|
|
||||||
<span className="queryResultNextEnable">
|
|
||||||
<a onClick={() => onFetchNextPageClick()}>
|
|
||||||
<span>Load more</span>
|
|
||||||
<img className="queryResultnextImg" src={QueryEditorNext} alt="Fetch next page" />
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<IconButton
|
|
||||||
style={{
|
|
||||||
height: "100%",
|
|
||||||
verticalAlign: "middle",
|
|
||||||
float: "right",
|
|
||||||
}}
|
|
||||||
iconProps={{ imageProps: { src: CopilotCopy } }}
|
|
||||||
title="Copy to Clipboard"
|
|
||||||
ariaLabel="Copy"
|
|
||||||
onClick={onClickCopyResults}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
) : (
|
||||||
{queryResults && queryResultsString?.length > 0 && !error && (
|
<ExecuteQueryCallToAction />
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
paddingBottom: "100px",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EditorReact
|
|
||||||
language={"json"}
|
|
||||||
content={queryResultsString}
|
|
||||||
isReadOnly={true}
|
|
||||||
ariaLabel={"Query results"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</PivotItem>
|
|
||||||
<PivotItem
|
|
||||||
headerText="Query Stats"
|
|
||||||
headerButtonProps={{
|
|
||||||
"data-order": 2,
|
|
||||||
"data-title": "Query Stats",
|
|
||||||
}}
|
|
||||||
style={{ height: "100%", overflowY: "scroll" }}
|
|
||||||
>
|
|
||||||
{queryResults && !error && (
|
|
||||||
<div className="queryMetricsSummaryContainer">
|
|
||||||
<div className="queryMetricsSummary">
|
|
||||||
<h3>Query Statistics</h3>
|
|
||||||
<DetailsList
|
|
||||||
items={generateQueryStatsItems()}
|
|
||||||
columns={columns}
|
|
||||||
selectionMode={SelectionMode.none}
|
|
||||||
layoutMode={DetailsListLayoutMode.justified}
|
|
||||||
compact={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{userContext.apiType === "SQL" && (
|
|
||||||
<div className="downloadMetricsLinkContainer">
|
|
||||||
<a
|
|
||||||
id="downloadMetricsLink"
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => onDownloadQueryMetricsCsvClick()}
|
|
||||||
onKeyPress={(event: React.KeyboardEvent<HTMLAnchorElement>) =>
|
|
||||||
onDownloadQueryMetricsCsvKeyPress(event)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
className="downloadCsvImg"
|
|
||||||
src={DownloadQueryMetrics}
|
|
||||||
alt="download query metrics csv"
|
|
||||||
/>
|
|
||||||
<span>Per-partition query metrics (CSV)</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</PivotItem>
|
|
||||||
</Pivot>
|
|
||||||
)}
|
|
||||||
{/* <!-- Query Errors Content - Start--> */}
|
|
||||||
{!!error && (
|
|
||||||
<div className="tab-pane active">
|
|
||||||
<div className="errorContent">
|
|
||||||
<span className="errorMessage">{error}</span>
|
|
||||||
<span className="errorDetailsLink">
|
|
||||||
<a
|
|
||||||
onClick={() => onErrorDetailsClick()}
|
|
||||||
onKeyPress={(event: React.KeyboardEvent<HTMLAnchorElement>) => onErrorDetailsKeyPress(event)}
|
|
||||||
id="error-display"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label="Error details link"
|
|
||||||
>
|
|
||||||
More details
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* <!-- Query Errors Content - End--> */}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Stack>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -7,10 +7,11 @@ import * as DataModels from "../../../Contracts/DataModels";
|
|||||||
import type { QueryTabOptions } from "../../../Contracts/ViewModels";
|
import type { QueryTabOptions } from "../../../Contracts/ViewModels";
|
||||||
import { useTabs } from "../../../hooks/useTabs";
|
import { useTabs } from "../../../hooks/useTabs";
|
||||||
import Explorer from "../../Explorer";
|
import Explorer from "../../Explorer";
|
||||||
import QueryTabComponent, {
|
import {
|
||||||
IQueryTabComponentProps,
|
IQueryTabComponentProps,
|
||||||
ITabAccessor,
|
ITabAccessor,
|
||||||
QueryTabFunctionComponent,
|
QueryTabComponent,
|
||||||
|
QueryTabCopilotComponent,
|
||||||
} from "../../Tabs/QueryTab/QueryTabComponent";
|
} from "../../Tabs/QueryTab/QueryTabComponent";
|
||||||
import TabsBase from "../TabsBase";
|
import TabsBase from "../TabsBase";
|
||||||
|
|
||||||
@ -49,7 +50,7 @@ export class NewQueryTab extends TabsBase {
|
|||||||
public render(): JSX.Element {
|
public render(): JSX.Element {
|
||||||
return userContext.apiType === "SQL" ? (
|
return userContext.apiType === "SQL" ? (
|
||||||
<CopilotProvider>
|
<CopilotProvider>
|
||||||
<QueryTabFunctionComponent {...this.iQueryTabComponentProps} />
|
<QueryTabCopilotComponent {...this.iQueryTabComponentProps} />
|
||||||
</CopilotProvider>
|
</CopilotProvider>
|
||||||
) : (
|
) : (
|
||||||
<QueryTabComponent {...this.iQueryTabComponentProps} />
|
<QueryTabComponent {...this.iQueryTabComponentProps} />
|
||||||
|
@ -2,9 +2,10 @@ import { fireEvent, render } from "@testing-library/react";
|
|||||||
import { CollectionTabKind } from "Contracts/ViewModels";
|
import { CollectionTabKind } from "Contracts/ViewModels";
|
||||||
import { CopilotProvider } from "Explorer/QueryCopilot/QueryCopilotContext";
|
import { CopilotProvider } from "Explorer/QueryCopilot/QueryCopilotContext";
|
||||||
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
|
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
|
||||||
import QueryTabComponent, {
|
import {
|
||||||
IQueryTabComponentProps,
|
IQueryTabComponentProps,
|
||||||
QueryTabFunctionComponent,
|
QueryTabComponent,
|
||||||
|
QueryTabCopilotComponent,
|
||||||
} from "Explorer/Tabs/QueryTab/QueryTabComponent";
|
} from "Explorer/Tabs/QueryTab/QueryTabComponent";
|
||||||
import TabsBase from "Explorer/Tabs/TabsBase";
|
import TabsBase from "Explorer/Tabs/TabsBase";
|
||||||
import { updateUserContext, userContext } from "UserContext";
|
import { updateUserContext, userContext } from "UserContext";
|
||||||
@ -42,7 +43,7 @@ describe("QueryTabComponent", () => {
|
|||||||
|
|
||||||
const { container } = render(<QueryTabComponent {...propsMock} />);
|
const { container } = render(<QueryTabComponent {...propsMock} />);
|
||||||
|
|
||||||
const launchCopilotButton = container.querySelector(".queryEditorWatermarkText");
|
const launchCopilotButton = container.querySelector('[data-test="QueryTab/ResultsPane/ExecuteCTA"]');
|
||||||
fireEvent.keyDown(launchCopilotButton, { key: "c", altKey: true });
|
fireEvent.keyDown(launchCopilotButton, { key: "c", altKey: true });
|
||||||
|
|
||||||
expect(mockStore.setShowCopilotSidebar).toHaveBeenCalledWith(true);
|
expect(mockStore.setShowCopilotSidebar).toHaveBeenCalledWith(true);
|
||||||
@ -70,7 +71,7 @@ describe("QueryTabComponent", () => {
|
|||||||
|
|
||||||
const container = mount(
|
const container = mount(
|
||||||
<CopilotProvider>
|
<CopilotProvider>
|
||||||
<QueryTabFunctionComponent {...propsMock} />
|
<QueryTabCopilotComponent {...propsMock} />
|
||||||
</CopilotProvider>,
|
</CopilotProvider>,
|
||||||
);
|
);
|
||||||
expect(container.find(QueryCopilotPromptbar).exists()).toBe(true);
|
expect(container.find(QueryCopilotPromptbar).exists()).toBe(true);
|
||||||
|
@ -1,15 +1,19 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
import { FeedOptions, QueryOperationOptions } from "@azure/cosmos";
|
import { FeedOptions, QueryOperationOptions } from "@azure/cosmos";
|
||||||
|
import QueryError, { createMonacoErrorLocationResolver, createMonacoMarkersForQueryErrors } from "Common/QueryError";
|
||||||
import { SplitterDirection } from "Common/Splitter";
|
import { SplitterDirection } from "Common/Splitter";
|
||||||
import { Platform, configContext } from "ConfigContext";
|
import { Platform, configContext } from "ConfigContext";
|
||||||
import { useDialog } from "Explorer/Controls/Dialog";
|
import { useDialog } from "Explorer/Controls/Dialog";
|
||||||
|
import { monaco } from "Explorer/LazyMonaco";
|
||||||
import { QueryCopilotFeedbackModal } from "Explorer/QueryCopilot/Modal/QueryCopilotFeedbackModal";
|
import { QueryCopilotFeedbackModal } from "Explorer/QueryCopilot/Modal/QueryCopilotFeedbackModal";
|
||||||
import { useCopilotStore } from "Explorer/QueryCopilot/QueryCopilotContext";
|
import { useCopilotStore } from "Explorer/QueryCopilot/QueryCopilotContext";
|
||||||
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
|
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
|
||||||
import { OnExecuteQueryClick, QueryDocumentsPerPage } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
|
import { OnExecuteQueryClick, QueryDocumentsPerPage } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
|
||||||
import { QueryCopilotSidebar } from "Explorer/QueryCopilot/V2/Sidebar/QueryCopilotSidebar";
|
import { QueryCopilotSidebar } from "Explorer/QueryCopilot/V2/Sidebar/QueryCopilotSidebar";
|
||||||
import { QueryResultSection } from "Explorer/Tabs/QueryTab/QueryResultSection";
|
import { QueryResultSection } from "Explorer/Tabs/QueryTab/QueryResultSection";
|
||||||
|
import { QueryTabStyles, useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
|
||||||
|
import { CosmosFluentProvider } from "Explorer/Theme/ThemeUtil";
|
||||||
import { useSelectedNode } from "Explorer/useSelectedNode";
|
import { useSelectedNode } from "Explorer/useSelectedNode";
|
||||||
import { KeyboardAction } from "KeyboardShortcuts";
|
import { KeyboardAction } from "KeyboardShortcuts";
|
||||||
import { QueryConstants } from "Shared/Constants";
|
import { QueryConstants } from "Shared/Constants";
|
||||||
@ -21,10 +25,10 @@ import {
|
|||||||
ruThresholdEnabled,
|
ruThresholdEnabled,
|
||||||
} from "Shared/StorageUtility";
|
} from "Shared/StorageUtility";
|
||||||
import { Action } from "Shared/Telemetry/TelemetryConstants";
|
import { Action } from "Shared/Telemetry/TelemetryConstants";
|
||||||
|
import { Allotment } from "allotment";
|
||||||
import { QueryCopilotState, useQueryCopilot } from "hooks/useQueryCopilot";
|
import { QueryCopilotState, useQueryCopilot } from "hooks/useQueryCopilot";
|
||||||
import { TabsState, useTabs } from "hooks/useTabs";
|
import { TabsState, useTabs } from "hooks/useTabs";
|
||||||
import React, { Fragment } from "react";
|
import React, { Fragment, createRef } from "react";
|
||||||
import SplitterLayout from "react-splitter-layout";
|
|
||||||
import "react-splitter-layout/lib/index.css";
|
import "react-splitter-layout/lib/index.css";
|
||||||
import { format } from "react-string-format";
|
import { format } from "react-string-format";
|
||||||
import QueryCommandIcon from "../../../../images/CopilotCommand.svg";
|
import QueryCommandIcon from "../../../../images/CopilotCommand.svg";
|
||||||
@ -35,7 +39,6 @@ import ExecuteQueryIcon from "../../../../images/ExecuteQuery.svg";
|
|||||||
import CheckIcon from "../../../../images/check-1.svg";
|
import CheckIcon from "../../../../images/check-1.svg";
|
||||||
import SaveQueryIcon from "../../../../images/save-cosmos.svg";
|
import SaveQueryIcon from "../../../../images/save-cosmos.svg";
|
||||||
import { NormalizedEventKey } from "../../../Common/Constants";
|
import { NormalizedEventKey } from "../../../Common/Constants";
|
||||||
import { getErrorMessage } from "../../../Common/ErrorHandlingUtils";
|
|
||||||
import * as HeadersUtility from "../../../Common/HeadersUtility";
|
import * as HeadersUtility from "../../../Common/HeadersUtility";
|
||||||
import { MinimalQueryIterator } from "../../../Common/IteratorUtilities";
|
import { MinimalQueryIterator } from "../../../Common/IteratorUtilities";
|
||||||
import { queryIterator } from "../../../Common/MongoProxyClient";
|
import { queryIterator } from "../../../Common/MongoProxyClient";
|
||||||
@ -102,8 +105,9 @@ interface IQueryTabStates {
|
|||||||
toggleState: ToggleState;
|
toggleState: ToggleState;
|
||||||
sqlQueryEditorContent: string;
|
sqlQueryEditorContent: string;
|
||||||
selectedContent: string;
|
selectedContent: string;
|
||||||
|
selection?: monaco.Selection;
|
||||||
|
executedSelection?: monaco.Selection; // We need to capture the selection that was used when executing, in case the user changes their section while the query is executing.
|
||||||
queryResults: ViewModels.QueryResults;
|
queryResults: ViewModels.QueryResults;
|
||||||
error: string;
|
|
||||||
isExecutionError: boolean;
|
isExecutionError: boolean;
|
||||||
isExecuting: boolean;
|
isExecuting: boolean;
|
||||||
showCopilotSidebar: boolean;
|
showCopilotSidebar: boolean;
|
||||||
@ -112,9 +116,12 @@ interface IQueryTabStates {
|
|||||||
copilotActive: boolean;
|
copilotActive: boolean;
|
||||||
currentTabActive: boolean;
|
currentTabActive: boolean;
|
||||||
queryResultsView: SplitterDirection;
|
queryResultsView: SplitterDirection;
|
||||||
|
errors?: QueryError[];
|
||||||
|
modelMarkers?: monaco.editor.IMarkerData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const QueryTabFunctionComponent = (props: IQueryTabComponentProps): any => {
|
export const QueryTabCopilotComponent = (props: IQueryTabComponentProps): any => {
|
||||||
|
const styles = useQueryTabStyles();
|
||||||
const copilotStore = useCopilotStore();
|
const copilotStore = useCopilotStore();
|
||||||
const isSampleCopilotActive = useSelectedNode.getState().isQueryCopilotCollectionSelected();
|
const isSampleCopilotActive = useSelectedNode.getState().isQueryCopilotCollectionSelected();
|
||||||
const queryTabProps = {
|
const queryTabProps = {
|
||||||
@ -125,10 +132,20 @@ export const QueryTabFunctionComponent = (props: IQueryTabComponentProps): any =
|
|||||||
isSampleCopilotActive: isSampleCopilotActive,
|
isSampleCopilotActive: isSampleCopilotActive,
|
||||||
copilotStore: copilotStore,
|
copilotStore: copilotStore,
|
||||||
};
|
};
|
||||||
return <QueryTabComponent {...queryTabProps}></QueryTabComponent>;
|
return <QueryTabComponentImpl styles={styles} {...queryTabProps}></QueryTabComponentImpl>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class QueryTabComponent extends React.Component<IQueryTabComponentProps, IQueryTabStates> {
|
export const QueryTabComponent = (props: IQueryTabComponentProps): any => {
|
||||||
|
const styles = useQueryTabStyles();
|
||||||
|
return <QueryTabComponentImpl styles={styles} {...props}></QueryTabComponentImpl>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QueryTabComponentImplProps = IQueryTabComponentProps & {
|
||||||
|
styles: QueryTabStyles;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inner (legacy) class component. We only use this component via one of the two functional components above (since we need to use the `useQueryTabStyles` hook).
|
||||||
|
class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps, IQueryTabStates> {
|
||||||
public queryEditorId: string;
|
public queryEditorId: string;
|
||||||
public executeQueryButton: Button;
|
public executeQueryButton: Button;
|
||||||
public saveQueryButton: Button;
|
public saveQueryButton: Button;
|
||||||
@ -139,16 +156,19 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
|
|||||||
public isCopilotTabActive: boolean;
|
public isCopilotTabActive: boolean;
|
||||||
private _iterator: MinimalQueryIterator;
|
private _iterator: MinimalQueryIterator;
|
||||||
private queryAbortController: AbortController;
|
private queryAbortController: AbortController;
|
||||||
|
queryEditor: React.RefObject<EditorReact>;
|
||||||
|
|
||||||
constructor(props: IQueryTabComponentProps) {
|
constructor(props: QueryTabComponentImplProps) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
|
this.queryEditor = createRef<EditorReact>();
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
toggleState: ToggleState.Result,
|
toggleState: ToggleState.Result,
|
||||||
sqlQueryEditorContent: props.isPreferredApiMongoDB ? "{}" : props.queryText || "SELECT * FROM c",
|
sqlQueryEditorContent: props.isPreferredApiMongoDB ? "{}" : props.queryText || "SELECT * FROM c",
|
||||||
selectedContent: "",
|
selectedContent: "",
|
||||||
queryResults: undefined,
|
queryResults: undefined,
|
||||||
error: "",
|
errors: [],
|
||||||
isExecutionError: this.props.isExecutionError,
|
isExecutionError: this.props.isExecutionError,
|
||||||
isExecuting: false,
|
isExecuting: false,
|
||||||
showCopilotSidebar: useQueryCopilot.getState().showCopilotSidebar,
|
showCopilotSidebar: useQueryCopilot.getState().showCopilotSidebar,
|
||||||
@ -221,9 +241,10 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
|
|||||||
|
|
||||||
public onExecuteQueryClick = async (): Promise<void> => {
|
public onExecuteQueryClick = async (): Promise<void> => {
|
||||||
this._iterator = undefined;
|
this._iterator = undefined;
|
||||||
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
await this._executeQueryDocumentsPage(0);
|
await this._executeQueryDocumentsPage(0);
|
||||||
}, 100);
|
}, 100); // TODO: Revert this
|
||||||
if (this.state.copilotActive) {
|
if (this.state.copilotActive) {
|
||||||
const query = this.state.sqlQueryEditorContent.split("\r\n")?.pop();
|
const query = this.state.sqlQueryEditorContent.split("\r\n")?.pop();
|
||||||
const isqueryEdited = this.props.copilotStore.generatedQuery && this.props.copilotStore.generatedQuery !== query;
|
const isqueryEdited = this.props.copilotStore.generatedQuery && this.props.copilotStore.generatedQuery !== query;
|
||||||
@ -302,23 +323,22 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _executeQueryDocumentsPage(firstItemIndex: number): Promise<void> {
|
private async _executeQueryDocumentsPage(firstItemIndex: number): Promise<void> {
|
||||||
|
// Capture the query content and the selection being executed (if any).
|
||||||
|
const query = this.state.selectedContent || this.state.sqlQueryEditorContent;
|
||||||
|
const selection = this.state.selection;
|
||||||
|
this.setState({
|
||||||
|
// Track the executed selection so that we can evaluate error positions relative to it, even if the user changes their current selection.
|
||||||
|
executedSelection: selection,
|
||||||
|
});
|
||||||
|
|
||||||
this.queryAbortController = new AbortController();
|
this.queryAbortController = new AbortController();
|
||||||
if (this._iterator === undefined) {
|
if (this._iterator === undefined) {
|
||||||
this._iterator = this.props.isPreferredApiMongoDB
|
this._iterator = this.props.isPreferredApiMongoDB
|
||||||
? queryIterator(
|
? queryIterator(this.props.collection.databaseId, this.props.viewModelcollection, query)
|
||||||
this.props.collection.databaseId,
|
: queryDocuments(this.props.collection.databaseId, this.props.collection.id(), query, {
|
||||||
this.props.viewModelcollection,
|
|
||||||
this.state.selectedContent || this.state.sqlQueryEditorContent,
|
|
||||||
)
|
|
||||||
: queryDocuments(
|
|
||||||
this.props.collection.databaseId,
|
|
||||||
this.props.collection.id(),
|
|
||||||
this.state.selectedContent || this.state.sqlQueryEditorContent,
|
|
||||||
{
|
|
||||||
enableCrossPartitionQuery: HeadersUtility.shouldEnableCrossPartitionKey(),
|
enableCrossPartitionQuery: HeadersUtility.shouldEnableCrossPartitionKey(),
|
||||||
abortSignal: this.queryAbortController.signal,
|
abortSignal: this.queryAbortController.signal,
|
||||||
} as unknown as FeedOptions,
|
} as unknown as FeedOptions);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this._queryDocumentsPage(firstItemIndex);
|
await this._queryDocumentsPage(firstItemIndex);
|
||||||
@ -383,18 +403,22 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
|
|||||||
firstItemIndex,
|
firstItemIndex,
|
||||||
queryDocuments,
|
queryDocuments,
|
||||||
);
|
);
|
||||||
this.setState({ queryResults, error: "" });
|
this.setState({ queryResults, errors: [] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.props.tabsBaseInstance.isExecutionError(true);
|
this.props.tabsBaseInstance.isExecutionError(true);
|
||||||
this.setState({
|
this.setState({
|
||||||
isExecutionError: true,
|
isExecutionError: true,
|
||||||
});
|
});
|
||||||
const errorMessage = getErrorMessage(error);
|
|
||||||
this.setState({
|
|
||||||
error: errorMessage,
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("error-display").focus();
|
// Try to parse this as a query error
|
||||||
|
const queryErrors = QueryError.tryParse(
|
||||||
|
error,
|
||||||
|
createMonacoErrorLocationResolver(this.queryEditor.current.editor, this.state.executedSelection),
|
||||||
|
);
|
||||||
|
this.setState({
|
||||||
|
errors: queryErrors,
|
||||||
|
modelMarkers: createMonacoMarkersForQueryErrors(queryErrors),
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
this.props.tabsBaseInstance.isExecuting(false);
|
this.props.tabsBaseInstance.isExecuting(false);
|
||||||
this.setState({
|
this.setState({
|
||||||
@ -584,6 +608,9 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
|
|||||||
this.setState({
|
this.setState({
|
||||||
sqlQueryEditorContent: newContent,
|
sqlQueryEditorContent: newContent,
|
||||||
queryCopilotGeneratedQuery: "",
|
queryCopilotGeneratedQuery: "",
|
||||||
|
|
||||||
|
// Clear the markers when the user edits the document.
|
||||||
|
modelMarkers: [],
|
||||||
});
|
});
|
||||||
if (this.isPreferredApiMongoDB) {
|
if (this.isPreferredApiMongoDB) {
|
||||||
if (newContent.length > 0) {
|
if (newContent.length > 0) {
|
||||||
@ -604,14 +631,16 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
|
|||||||
useCommandBar.getState().setContextButtons(this.getTabsButtons());
|
useCommandBar.getState().setContextButtons(this.getTabsButtons());
|
||||||
}
|
}
|
||||||
|
|
||||||
public onSelectedContent(selectedContent: string): void {
|
public onSelectedContent(selectedContent: string, selection: monaco.Selection): void {
|
||||||
if (selectedContent.trim().length > 0) {
|
if (selectedContent.trim().length > 0) {
|
||||||
this.setState({
|
this.setState({
|
||||||
selectedContent,
|
selectedContent,
|
||||||
|
selection,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.setState({
|
this.setState({
|
||||||
selectedContent: "",
|
selectedContent: "",
|
||||||
|
selection: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -668,9 +697,10 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getEditorAndQueryResult(): JSX.Element {
|
private getEditorAndQueryResult(): JSX.Element {
|
||||||
|
const vertical = this.state.queryResultsView === SplitterDirection.Horizontal;
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<div className="tab-pane" id={this.props.tabId} role="tabpanel">
|
<CosmosFluentProvider id={this.props.tabId} className={this.props.styles.queryTab} role="tabpanel">
|
||||||
{this.props.copilotEnabled && this.state.currentTabActive && this.state.copilotActive && (
|
{this.props.copilotEnabled && this.state.currentTabActive && this.state.copilotActive && (
|
||||||
<QueryCopilotPromptbar
|
<QueryCopilotPromptbar
|
||||||
explorer={this.props.collection.container}
|
explorer={this.props.collection.container}
|
||||||
@ -679,34 +709,33 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
|
|||||||
containerId={this.props.collection.id()}
|
containerId={this.props.collection.id()}
|
||||||
></QueryCopilotPromptbar>
|
></QueryCopilotPromptbar>
|
||||||
)}
|
)}
|
||||||
<div className="tabPaneContentContainer">
|
{/* Set 'key' to the value of vertical to force re-rendering when vertical changes, to work around https://github.com/johnwalley/allotment/issues/457 */}
|
||||||
<SplitterLayout
|
<Allotment key={vertical.toString()} vertical={vertical}>
|
||||||
vertical={this.state.queryResultsView === SplitterDirection.Vertical}
|
<Allotment.Pane data-test="QueryTab/EditorPane">
|
||||||
primaryIndex={0}
|
|
||||||
primaryMinSize={100}
|
|
||||||
secondaryMinSize={200}
|
|
||||||
>
|
|
||||||
<Fragment>
|
|
||||||
<div className="queryEditor" style={{ height: "100%" }}>
|
|
||||||
<EditorReact
|
<EditorReact
|
||||||
|
ref={this.queryEditor}
|
||||||
|
className={this.props.styles.queryEditor}
|
||||||
language={"sql"}
|
language={"sql"}
|
||||||
content={this.getEditorContent()}
|
content={this.getEditorContent()}
|
||||||
|
modelMarkers={this.state.modelMarkers}
|
||||||
isReadOnly={false}
|
isReadOnly={false}
|
||||||
wordWrap={"on"}
|
wordWrap={"on"}
|
||||||
ariaLabel={"Editing Query"}
|
ariaLabel={"Editing Query"}
|
||||||
lineNumbers={"on"}
|
lineNumbers={"on"}
|
||||||
onContentChanged={(newContent: string) => this.onChangeContent(newContent)}
|
onContentChanged={(newContent: string) => this.onChangeContent(newContent)}
|
||||||
onContentSelected={(selectedContent: string) => this.onSelectedContent(selectedContent)}
|
onContentSelected={(selectedContent: string, selection: monaco.Selection) =>
|
||||||
|
this.onSelectedContent(selectedContent, selection)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Allotment.Pane>
|
||||||
</Fragment>
|
<Allotment.Pane>
|
||||||
{this.props.isSampleCopilotActive ? (
|
{this.props.isSampleCopilotActive ? (
|
||||||
<QueryResultSection
|
<QueryResultSection
|
||||||
isMongoDB={this.props.isPreferredApiMongoDB}
|
isMongoDB={this.props.isPreferredApiMongoDB}
|
||||||
queryEditorContent={this.state.sqlQueryEditorContent}
|
queryEditorContent={this.state.sqlQueryEditorContent}
|
||||||
error={this.props.copilotStore?.errorMessage}
|
errors={this.props.copilotStore?.errors}
|
||||||
queryResults={this.props.copilotStore?.queryResults}
|
|
||||||
isExecuting={this.props.copilotStore?.isExecuting}
|
isExecuting={this.props.copilotStore?.isExecuting}
|
||||||
|
queryResults={this.props.copilotStore?.queryResults}
|
||||||
executeQueryDocumentsPage={(firstItemIndex: number) =>
|
executeQueryDocumentsPage={(firstItemIndex: number) =>
|
||||||
QueryDocumentsPerPage(
|
QueryDocumentsPerPage(
|
||||||
firstItemIndex,
|
firstItemIndex,
|
||||||
@ -719,17 +748,17 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
|
|||||||
<QueryResultSection
|
<QueryResultSection
|
||||||
isMongoDB={this.props.isPreferredApiMongoDB}
|
isMongoDB={this.props.isPreferredApiMongoDB}
|
||||||
queryEditorContent={this.state.sqlQueryEditorContent}
|
queryEditorContent={this.state.sqlQueryEditorContent}
|
||||||
error={this.state.error}
|
errors={this.state.errors}
|
||||||
queryResults={this.state.queryResults}
|
|
||||||
isExecuting={this.state.isExecuting}
|
isExecuting={this.state.isExecuting}
|
||||||
|
queryResults={this.state.queryResults}
|
||||||
executeQueryDocumentsPage={(firstItemIndex: number) =>
|
executeQueryDocumentsPage={(firstItemIndex: number) =>
|
||||||
this._executeQueryDocumentsPage(firstItemIndex)
|
this._executeQueryDocumentsPage(firstItemIndex)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</SplitterLayout>
|
</Allotment.Pane>
|
||||||
</div>
|
</Allotment>
|
||||||
</div>
|
</CosmosFluentProvider>
|
||||||
{this.props.copilotEnabled && this.props.copilotStore?.showFeedbackModal && (
|
{this.props.copilotEnabled && this.props.copilotStore?.showFeedbackModal && (
|
||||||
<QueryCopilotFeedbackModal
|
<QueryCopilotFeedbackModal
|
||||||
explorer={this.props.collection.container}
|
explorer={this.props.collection.container}
|
||||||
@ -745,7 +774,7 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
|
|||||||
render(): JSX.Element {
|
render(): JSX.Element {
|
||||||
const shouldScaleElements = this.state.showCopilotSidebar && this.isCopilotTabActive;
|
const shouldScaleElements = this.state.showCopilotSidebar && this.isCopilotTabActive;
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "row", height: "100%" }}>
|
<div data-test="QueryTab" style={{ display: "flex", flexDirection: "row", height: "100%" }}>
|
||||||
<div style={{ width: shouldScaleElements ? "70%" : "100%", height: "100%" }}>
|
<div style={{ width: shouldScaleElements ? "70%" : "100%", height: "100%" }}>
|
||||||
{this.getEditorAndQueryResult()}
|
{this.getEditorAndQueryResult()}
|
||||||
</div>
|
</div>
|
||||||
|
396
src/Explorer/Tabs/QueryTab/ResultsView.tsx
Normal file
396
src/Explorer/Tabs/QueryTab/ResultsView.tsx
Normal file
@ -0,0 +1,396 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DataGrid,
|
||||||
|
DataGridBody,
|
||||||
|
DataGridCell,
|
||||||
|
DataGridHeader,
|
||||||
|
DataGridHeaderCell,
|
||||||
|
DataGridRow,
|
||||||
|
SelectTabData,
|
||||||
|
SelectTabEvent,
|
||||||
|
Tab,
|
||||||
|
TabList,
|
||||||
|
TableColumnDefinition,
|
||||||
|
createTableColumn,
|
||||||
|
} from "@fluentui/react-components";
|
||||||
|
import { ArrowDownloadRegular, CopyRegular } from "@fluentui/react-icons";
|
||||||
|
import { HttpHeaders } from "Common/Constants";
|
||||||
|
import MongoUtility from "Common/MongoUtility";
|
||||||
|
import { QueryMetrics } from "Contracts/DataModels";
|
||||||
|
import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
|
||||||
|
import { IDocument } from "Explorer/Tabs/QueryTab/QueryTabComponent";
|
||||||
|
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
|
||||||
|
import { userContext } from "UserContext";
|
||||||
|
import copy from "clipboard-copy";
|
||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
import { ResultsViewProps } from "./QueryResultSection";
|
||||||
|
|
||||||
|
enum ResultsTabs {
|
||||||
|
Results = "results",
|
||||||
|
QueryStats = "queryStats",
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResultsTab: React.FC<ResultsViewProps> = ({ queryResults, isMongoDB, executeQueryDocumentsPage }) => {
|
||||||
|
const styles = useQueryTabStyles();
|
||||||
|
const queryResultsString = queryResults
|
||||||
|
? isMongoDB
|
||||||
|
? MongoUtility.tojson(queryResults.documents, undefined, false)
|
||||||
|
: JSON.stringify(queryResults.documents, undefined, 4)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const onClickCopyResults = (): void => {
|
||||||
|
copy(queryResultsString);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFetchNextPageClick = async (): Promise<void> => {
|
||||||
|
const { firstItemIndex, itemCount } = queryResults;
|
||||||
|
await executeQueryDocumentsPage(firstItemIndex + itemCount - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.queryResultsBar}>
|
||||||
|
<div>
|
||||||
|
{queryResults.itemCount > 0 ? `${queryResults.firstItemIndex} - ${queryResults.lastItemIndex}` : `0 - 0`}
|
||||||
|
</div>
|
||||||
|
{queryResults.hasMoreResults && (
|
||||||
|
<a href="#" onClick={() => onFetchNextPageClick()}>
|
||||||
|
Load more
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<div className={styles.flexGrowSpacer} />
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="transparent"
|
||||||
|
icon={<CopyRegular />}
|
||||||
|
title="Copy to Clipboard"
|
||||||
|
aria-label="Copy"
|
||||||
|
onClick={onClickCopyResults}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.queryResultsViewer}>
|
||||||
|
<EditorReact language={"json"} content={queryResultsString} isReadOnly={true} ariaLabel={"Query results"} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const QueryStatsTab: React.FC<Pick<ResultsViewProps, "queryResults">> = ({ queryResults }) => {
|
||||||
|
const styles = useQueryTabStyles();
|
||||||
|
const queryMetrics = React.useRef(queryResults?.headers?.[HttpHeaders.queryMetrics]);
|
||||||
|
React.useEffect(() => {
|
||||||
|
const latestQueryMetrics = queryResults?.headers?.[HttpHeaders.queryMetrics];
|
||||||
|
if (latestQueryMetrics && Object.keys(latestQueryMetrics).length > 0) {
|
||||||
|
queryMetrics.current = latestQueryMetrics;
|
||||||
|
}
|
||||||
|
}, [queryResults]);
|
||||||
|
|
||||||
|
const getAggregatedQueryMetrics = (): QueryMetrics => {
|
||||||
|
const aggregatedQueryMetrics = {
|
||||||
|
documentLoadTime: 0,
|
||||||
|
documentWriteTime: 0,
|
||||||
|
indexHitDocumentCount: 0,
|
||||||
|
outputDocumentCount: 0,
|
||||||
|
outputDocumentSize: 0,
|
||||||
|
indexLookupTime: 0,
|
||||||
|
retrievedDocumentCount: 0,
|
||||||
|
retrievedDocumentSize: 0,
|
||||||
|
vmExecutionTime: 0,
|
||||||
|
runtimeExecutionTimes: {
|
||||||
|
queryEngineExecutionTime: 0,
|
||||||
|
systemFunctionExecutionTime: 0,
|
||||||
|
userDefinedFunctionExecutionTime: 0,
|
||||||
|
},
|
||||||
|
totalQueryExecutionTime: 0,
|
||||||
|
} as QueryMetrics;
|
||||||
|
|
||||||
|
if (queryMetrics.current) {
|
||||||
|
Object.keys(queryMetrics.current).forEach((partitionKeyRangeId) => {
|
||||||
|
const queryMetricsPerPartition = queryMetrics.current[partitionKeyRangeId];
|
||||||
|
if (!queryMetricsPerPartition) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
aggregatedQueryMetrics.documentLoadTime += queryMetricsPerPartition.documentLoadTime?.totalMilliseconds() || 0;
|
||||||
|
aggregatedQueryMetrics.documentWriteTime +=
|
||||||
|
queryMetricsPerPartition.documentWriteTime?.totalMilliseconds() || 0;
|
||||||
|
aggregatedQueryMetrics.indexHitDocumentCount += queryMetricsPerPartition.indexHitDocumentCount || 0;
|
||||||
|
aggregatedQueryMetrics.outputDocumentCount += queryMetricsPerPartition.outputDocumentCount || 0;
|
||||||
|
aggregatedQueryMetrics.outputDocumentSize += queryMetricsPerPartition.outputDocumentSize || 0;
|
||||||
|
aggregatedQueryMetrics.indexLookupTime += queryMetricsPerPartition.indexLookupTime?.totalMilliseconds() || 0;
|
||||||
|
aggregatedQueryMetrics.retrievedDocumentCount += queryMetricsPerPartition.retrievedDocumentCount || 0;
|
||||||
|
aggregatedQueryMetrics.retrievedDocumentSize += queryMetricsPerPartition.retrievedDocumentSize || 0;
|
||||||
|
aggregatedQueryMetrics.vmExecutionTime += queryMetricsPerPartition.vmExecutionTime?.totalMilliseconds() || 0;
|
||||||
|
aggregatedQueryMetrics.totalQueryExecutionTime +=
|
||||||
|
queryMetricsPerPartition.totalQueryExecutionTime?.totalMilliseconds() || 0;
|
||||||
|
aggregatedQueryMetrics.runtimeExecutionTimes.queryEngineExecutionTime +=
|
||||||
|
queryMetricsPerPartition.runtimeExecutionTimes?.queryEngineExecutionTime?.totalMilliseconds() || 0;
|
||||||
|
aggregatedQueryMetrics.runtimeExecutionTimes.systemFunctionExecutionTime +=
|
||||||
|
queryMetricsPerPartition.runtimeExecutionTimes?.systemFunctionExecutionTime?.totalMilliseconds() || 0;
|
||||||
|
aggregatedQueryMetrics.runtimeExecutionTimes.userDefinedFunctionExecutionTime +=
|
||||||
|
queryMetricsPerPartition.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.totalMilliseconds() || 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return aggregatedQueryMetrics;
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: TableColumnDefinition<IDocument>[] = [
|
||||||
|
createTableColumn<IDocument>({
|
||||||
|
columnId: "metric",
|
||||||
|
renderHeaderCell: () => "Metric",
|
||||||
|
renderCell: (item) => item.metric,
|
||||||
|
}),
|
||||||
|
createTableColumn<IDocument>({
|
||||||
|
columnId: "value",
|
||||||
|
renderHeaderCell: () => "Value",
|
||||||
|
renderCell: (item) => item.value,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const generateQueryStatsItems = (): IDocument[] => {
|
||||||
|
const items: IDocument[] = [
|
||||||
|
{
|
||||||
|
metric: "Request Charge",
|
||||||
|
value: `${queryResults.requestCharge} RUs`,
|
||||||
|
toolTip: "Request Charge",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: "Showing Results",
|
||||||
|
value: queryResults.itemCount > 0 ? `${queryResults.firstItemIndex} - ${queryResults.lastItemIndex}` : `0 - 0`,
|
||||||
|
toolTip: "Showing Results",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (userContext.apiType === "SQL") {
|
||||||
|
const aggregatedQueryMetrics = getAggregatedQueryMetrics();
|
||||||
|
items.push(
|
||||||
|
{
|
||||||
|
metric: "Retrieved document count",
|
||||||
|
value: aggregatedQueryMetrics.retrievedDocumentCount?.toString() || "",
|
||||||
|
toolTip: "Total number of retrieved documents",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: "Retrieved document size",
|
||||||
|
value: `${aggregatedQueryMetrics.retrievedDocumentSize?.toString() || 0} bytes`,
|
||||||
|
toolTip: "Total size of retrieved documents in bytes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: "Output document count",
|
||||||
|
value: aggregatedQueryMetrics.outputDocumentCount?.toString() || "",
|
||||||
|
toolTip: "Number of output documents",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: "Output document size",
|
||||||
|
value: `${aggregatedQueryMetrics.outputDocumentSize?.toString() || 0} bytes`,
|
||||||
|
toolTip: "Total size of output documents in bytes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: "Index hit document count",
|
||||||
|
value: aggregatedQueryMetrics.indexHitDocumentCount?.toString() || "",
|
||||||
|
toolTip: "Total number of documents matched by the filter",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: "Index lookup time",
|
||||||
|
value: `${aggregatedQueryMetrics.indexLookupTime?.toString() || 0} ms`,
|
||||||
|
toolTip: "Time spent in physical index layer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: "Document load time",
|
||||||
|
value: `${aggregatedQueryMetrics.documentLoadTime?.toString() || 0} ms`,
|
||||||
|
toolTip: "Time spent in loading documents",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: "Query engine execution time",
|
||||||
|
value: `${aggregatedQueryMetrics.runtimeExecutionTimes?.queryEngineExecutionTime?.toString() || 0} ms`,
|
||||||
|
toolTip:
|
||||||
|
"Time spent by the query engine to execute the query expression (excludes other execution times like load documents or write results)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: "System function execution time",
|
||||||
|
value: `${aggregatedQueryMetrics.runtimeExecutionTimes?.systemFunctionExecutionTime?.toString() || 0} ms`,
|
||||||
|
toolTip: "Total time spent executing system (built-in) functions",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: "User defined function execution time",
|
||||||
|
value: `${
|
||||||
|
aggregatedQueryMetrics.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.toString() || 0
|
||||||
|
} ms`,
|
||||||
|
toolTip: "Total time spent executing user-defined functions",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: "Document write time",
|
||||||
|
value: `${aggregatedQueryMetrics.documentWriteTime.toString() || 0} ms`,
|
||||||
|
toolTip: "Time spent to write query result set to response buffer",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryResults.roundTrips) {
|
||||||
|
items.push({
|
||||||
|
metric: "Round Trips",
|
||||||
|
value: queryResults.roundTrips?.toString(),
|
||||||
|
toolTip: "Number of round trips",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryResults.activityId) {
|
||||||
|
items.push({
|
||||||
|
metric: "Activity id",
|
||||||
|
value: queryResults.activityId,
|
||||||
|
toolTip: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateQueryMetricsCsvData = (): string => {
|
||||||
|
if (queryMetrics.current) {
|
||||||
|
let csvData =
|
||||||
|
[
|
||||||
|
"Partition key range id",
|
||||||
|
"Retrieved document count",
|
||||||
|
"Retrieved document size (in bytes)",
|
||||||
|
"Output document count",
|
||||||
|
"Output document size (in bytes)",
|
||||||
|
"Index hit document count",
|
||||||
|
"Index lookup time (ms)",
|
||||||
|
"Document load time (ms)",
|
||||||
|
"Query engine execution time (ms)",
|
||||||
|
"System function execution time (ms)",
|
||||||
|
"User defined function execution time (ms)",
|
||||||
|
"Document write time (ms)",
|
||||||
|
].join(",") + "\n";
|
||||||
|
|
||||||
|
Object.keys(queryMetrics.current).forEach((partitionKeyRangeId) => {
|
||||||
|
const queryMetricsPerPartition = queryMetrics.current[partitionKeyRangeId];
|
||||||
|
csvData +=
|
||||||
|
[
|
||||||
|
partitionKeyRangeId,
|
||||||
|
queryMetricsPerPartition.retrievedDocumentCount,
|
||||||
|
queryMetricsPerPartition.retrievedDocumentSize,
|
||||||
|
queryMetricsPerPartition.outputDocumentCount,
|
||||||
|
queryMetricsPerPartition.outputDocumentSize,
|
||||||
|
queryMetricsPerPartition.indexHitDocumentCount,
|
||||||
|
queryMetricsPerPartition.indexLookupTime?.totalMilliseconds(),
|
||||||
|
queryMetricsPerPartition.documentLoadTime?.totalMilliseconds(),
|
||||||
|
queryMetricsPerPartition.runtimeExecutionTimes?.queryEngineExecutionTime?.totalMilliseconds(),
|
||||||
|
queryMetricsPerPartition.runtimeExecutionTimes?.systemFunctionExecutionTime?.totalMilliseconds(),
|
||||||
|
queryMetricsPerPartition.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.totalMilliseconds(),
|
||||||
|
queryMetricsPerPartition.documentWriteTime?.totalMilliseconds(),
|
||||||
|
].join(",") + "\n";
|
||||||
|
});
|
||||||
|
|
||||||
|
return csvData;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadQueryMetricsCsvData = (): void => {
|
||||||
|
const csvData: string = generateQueryMetricsCsvData();
|
||||||
|
if (!csvData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigator.msSaveBlob) {
|
||||||
|
// for IE and Edge
|
||||||
|
navigator.msSaveBlob(
|
||||||
|
new Blob([csvData], { type: "data:text/csv;charset=utf-8" }),
|
||||||
|
"PerPartitionQueryMetrics.csv",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const downloadLink: HTMLAnchorElement = document.createElement("a");
|
||||||
|
downloadLink.href = "data:text/csv;charset=utf-8," + encodeURI(csvData);
|
||||||
|
downloadLink.target = "_self";
|
||||||
|
downloadLink.download = "QueryMetricsPerPartition.csv";
|
||||||
|
|
||||||
|
// for some reason, FF displays the download prompt only when
|
||||||
|
// the link is added to the dom so we add and remove it
|
||||||
|
document.body.appendChild(downloadLink);
|
||||||
|
downloadLink.click();
|
||||||
|
downloadLink.remove();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDownloadQueryMetricsCsvClick = (): boolean => {
|
||||||
|
downloadQueryMetricsCsvData();
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.metricsGridContainer}>
|
||||||
|
<DataGrid
|
||||||
|
data-test="QueryTab/ResultsPane/ResultsView/QueryStatsList"
|
||||||
|
className={styles.queryStatsGrid}
|
||||||
|
items={generateQueryStatsItems()}
|
||||||
|
columns={columns}
|
||||||
|
sortable
|
||||||
|
getRowId={(item) => item.metric}
|
||||||
|
focusMode="composite"
|
||||||
|
>
|
||||||
|
<DataGridHeader>
|
||||||
|
<DataGridRow>
|
||||||
|
{({ renderHeaderCell }) => <DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>}
|
||||||
|
</DataGridRow>
|
||||||
|
</DataGridHeader>
|
||||||
|
<DataGridBody<IDocument>>
|
||||||
|
{({ item, rowId }) => (
|
||||||
|
<DataGridRow<IDocument> key={rowId} data-test={`Row:${rowId}`}>
|
||||||
|
{({ columnId, renderCell }) => (
|
||||||
|
<DataGridCell data-test={`Row:${rowId}/Column:${columnId}`}>{renderCell(item)}</DataGridCell>
|
||||||
|
)}
|
||||||
|
</DataGridRow>
|
||||||
|
)}
|
||||||
|
</DataGridBody>
|
||||||
|
</DataGrid>
|
||||||
|
<div className={styles.metricsGridButtons}>
|
||||||
|
{userContext.apiType === "SQL" && (
|
||||||
|
<Button appearance="subtle" onClick={() => onDownloadQueryMetricsCsvClick()} icon={<ArrowDownloadRegular />}>
|
||||||
|
Per-partition query metrics (CSV)
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResults, executeQueryDocumentsPage }) => {
|
||||||
|
const styles = useQueryTabStyles();
|
||||||
|
const [activeTab, setActiveTab] = useState<ResultsTabs>(ResultsTabs.Results);
|
||||||
|
|
||||||
|
const onTabSelect = useCallback((event: SelectTabEvent, data: SelectTabData) => {
|
||||||
|
setActiveTab(data.value as ResultsTabs);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-test="QueryTab/ResultsPane/ResultsView" className={styles.queryResultsTabPanel}>
|
||||||
|
<TabList selectedValue={activeTab} onTabSelect={onTabSelect}>
|
||||||
|
<Tab
|
||||||
|
data-test="QueryTab/ResultsPane/ResultsView/ResultsTab"
|
||||||
|
id={ResultsTabs.Results}
|
||||||
|
value={ResultsTabs.Results}
|
||||||
|
>
|
||||||
|
Results
|
||||||
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
data-test="QueryTab/ResultsPane/ResultsView/QueryStatsTab"
|
||||||
|
id={ResultsTabs.QueryStats}
|
||||||
|
value={ResultsTabs.QueryStats}
|
||||||
|
>
|
||||||
|
Query Stats
|
||||||
|
</Tab>
|
||||||
|
</TabList>
|
||||||
|
<div className={styles.queryResultsTabContentContainer}>
|
||||||
|
{activeTab === ResultsTabs.Results && (
|
||||||
|
<ResultsTab
|
||||||
|
queryResults={queryResults}
|
||||||
|
isMongoDB={isMongoDB}
|
||||||
|
executeQueryDocumentsPage={executeQueryDocumentsPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === ResultsTabs.QueryStats && <QueryStatsTab queryResults={queryResults} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
84
src/Explorer/Tabs/QueryTab/Styles.ts
Normal file
84
src/Explorer/Tabs/QueryTab/Styles.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { makeStyles, shorthands } from "@fluentui/react-components";
|
||||||
|
import { cosmosShorthands } from "Explorer/Theme/ThemeUtil";
|
||||||
|
|
||||||
|
export type QueryTabStyles = ReturnType<typeof useQueryTabStyles>;
|
||||||
|
export const useQueryTabStyles = makeStyles({
|
||||||
|
queryTab: {
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
},
|
||||||
|
queryEditor: {
|
||||||
|
...shorthands.border("none"),
|
||||||
|
paddingTop: "4px",
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
executeCallToAction: {
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
queryResultsPanel: {
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
},
|
||||||
|
queryResultsMessage: {
|
||||||
|
...shorthands.margin("5px"),
|
||||||
|
},
|
||||||
|
queryResultsBody: {
|
||||||
|
flexGrow: 1,
|
||||||
|
justifySelf: "stretch",
|
||||||
|
},
|
||||||
|
queryResultsTabPanel: {
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
rowGap: "12px",
|
||||||
|
flexDirection: "column",
|
||||||
|
},
|
||||||
|
queryResultsTabContentContainer: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
flexGrow: 1,
|
||||||
|
paddingLeft: "12px",
|
||||||
|
paddingRight: "12px",
|
||||||
|
overflow: "auto",
|
||||||
|
},
|
||||||
|
queryResultsViewer: {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
queryResultsBar: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
columnGap: "4px",
|
||||||
|
paddingBottom: "4px",
|
||||||
|
},
|
||||||
|
flexGrowSpacer: {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
queryStatsGrid: {
|
||||||
|
flexGrow: 1,
|
||||||
|
overflow: "auto",
|
||||||
|
},
|
||||||
|
metricsGridContainer: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
paddingBottom: "6px",
|
||||||
|
maxHeight: "100%",
|
||||||
|
},
|
||||||
|
metricsGridButtons: {
|
||||||
|
...cosmosShorthands.borderTop(),
|
||||||
|
},
|
||||||
|
errorListMessageCell: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
width: "100%",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
errorListMessage: {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
});
|
@ -57,7 +57,8 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
|
|||||||
const defaultMessageBarStyles: IMessageBarStyles = {
|
const defaultMessageBarStyles: IMessageBarStyles = {
|
||||||
root: {
|
root: {
|
||||||
height: `${LayoutConstants.rowHeight}px`,
|
height: `${LayoutConstants.rowHeight}px`,
|
||||||
overflow: "auto",
|
overflow: "hidden",
|
||||||
|
flexDirection: "row",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -298,11 +299,15 @@ function TabPane({ tab, active }: { tab: Tab; active: boolean }) {
|
|||||||
|
|
||||||
if (tab) {
|
if (tab) {
|
||||||
if ("render" in tab) {
|
if ("render" in tab) {
|
||||||
return <div {...attrs}>{tab.render()}</div>;
|
return (
|
||||||
|
<div data-test={`Tab:${tab.tabId}`} {...attrs}>
|
||||||
|
{tab.render()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div {...attrs} ref={ref} data-bind="html:html" />;
|
return <div data-test={`Tab:${tab.tabId}`} {...attrs} ref={ref} data-bind="html:html" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const onKeyPressReactTab = (e: KeyboardEvent, tabKind: ReactTabKind): void => {
|
const onKeyPressReactTab = (e: KeyboardEvent, tabKind: ReactTabKind): void => {
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
BrandVariants,
|
BrandVariants,
|
||||||
|
ComponentProps,
|
||||||
FluentProvider,
|
FluentProvider,
|
||||||
|
FluentProviderSlots,
|
||||||
Theme,
|
Theme,
|
||||||
createLightTheme,
|
createLightTheme,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
@ -10,16 +12,19 @@ import {
|
|||||||
webLightTheme,
|
webLightTheme,
|
||||||
} from "@fluentui/react-components";
|
} from "@fluentui/react-components";
|
||||||
import { Platform, configContext } from "ConfigContext";
|
import { Platform, configContext } from "ConfigContext";
|
||||||
import React, { PropsWithChildren } from "react";
|
import React from "react";
|
||||||
import { appThemeFabricTealBrandRamp } from "../../Platform/Fabric/FabricTheme";
|
import { appThemeFabricTealBrandRamp } from "../../Platform/Fabric/FabricTheme";
|
||||||
|
|
||||||
export const LayoutConstants = {
|
export const LayoutConstants = {
|
||||||
rowHeight: 36,
|
rowHeight: 36,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CosmosFluentProviderProps = PropsWithChildren<{
|
// Our CosmosFluentProvider has the same props as a FluentProvider.
|
||||||
className?: string;
|
export type CosmosFluentProviderProps = Omit<ComponentProps<FluentProviderSlots, "root">, "dir">;
|
||||||
}>;
|
|
||||||
|
// PropsWithChildren<{
|
||||||
|
// className?: string;
|
||||||
|
// }>;
|
||||||
|
|
||||||
const useDefaultRootStyles = makeStyles({
|
const useDefaultRootStyles = makeStyles({
|
||||||
fluentProvider: {
|
fluentProvider: {
|
||||||
@ -32,15 +37,37 @@ const useDefaultRootStyles = makeStyles({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CosmosFluentProvider: React.FC<CosmosFluentProviderProps> = ({ children, className }) => {
|
const FluentProviderContext = React.createContext({
|
||||||
|
isInFluentProvider: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CosmosFluentProvider: React.FC<CosmosFluentProviderProps> = ({ children, className, ...props }) => {
|
||||||
|
// We use a React context to ensure that nested CosmosFluentProviders don't create nested FluentProviders.
|
||||||
|
// This helps during the transition from Fluent UI 8 to Fluent UI 9.
|
||||||
|
// As we convert components to Fluent UI 9, if we end up with nested FluentProviders, the inner FluentProvider will be a no-op.
|
||||||
|
const { isInFluentProvider } = React.useContext(FluentProviderContext);
|
||||||
const styles = useDefaultRootStyles();
|
const styles = useDefaultRootStyles();
|
||||||
|
|
||||||
|
if (isInFluentProvider) {
|
||||||
|
// We're already in a fluent context, don't create another.
|
||||||
|
console.warn("Nested CosmosFluentProvider detected. This is likely a bug.");
|
||||||
return (
|
return (
|
||||||
|
<div className={className} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FluentProviderContext.Provider value={{ isInFluentProvider: true }}>
|
||||||
<FluentProvider
|
<FluentProvider
|
||||||
theme={getPlatformTheme(configContext.platform)}
|
theme={getPlatformTheme(configContext.platform)}
|
||||||
className={mergeClasses(styles.fluentProvider, className)}
|
className={mergeClasses(styles.fluentProvider, className)}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</FluentProvider>
|
</FluentProvider>
|
||||||
|
</FluentProviderContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -6,6 +6,7 @@ import { initializeIcons, loadTheme } from "@fluentui/react";
|
|||||||
import { QuickstartCarousel } from "Explorer/Quickstart/QuickstartCarousel";
|
import { QuickstartCarousel } from "Explorer/Quickstart/QuickstartCarousel";
|
||||||
import { MongoQuickstartTutorial } from "Explorer/Quickstart/Tutorials/MongoQuickstartTutorial";
|
import { MongoQuickstartTutorial } from "Explorer/Quickstart/Tutorials/MongoQuickstartTutorial";
|
||||||
import { SQLQuickstartTutorial } from "Explorer/Quickstart/Tutorials/SQLQuickstartTutorial";
|
import { SQLQuickstartTutorial } from "Explorer/Quickstart/Tutorials/SQLQuickstartTutorial";
|
||||||
|
import "allotment/dist/style.css";
|
||||||
import "bootstrap/dist/css/bootstrap.css";
|
import "bootstrap/dist/css/bootstrap.css";
|
||||||
import { useCarousel } from "hooks/useCarousel";
|
import { useCarousel } from "hooks/useCarousel";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
170
src/Shared/AppStatePersistenceUtility.test.ts
Normal file
170
src/Shared/AppStatePersistenceUtility.test.ts
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import { createKeyFromPath, deleteState, loadState, MAX_ENTRY_NB, saveState } from "Shared/AppStatePersistenceUtility";
|
||||||
|
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
|
||||||
|
|
||||||
|
jest.mock("Shared/StorageUtility", () => ({
|
||||||
|
LocalStorageUtility: {
|
||||||
|
getEntryObject: jest.fn(),
|
||||||
|
setEntryObject: jest.fn(),
|
||||||
|
},
|
||||||
|
StorageKey: {
|
||||||
|
AppState: "AppState",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("AppStatePersistenceUtility", () => {
|
||||||
|
const storePath = {
|
||||||
|
componentName: "a",
|
||||||
|
subComponentName: "b",
|
||||||
|
globalAccountName: "c",
|
||||||
|
databaseName: "d",
|
||||||
|
containerName: "e",
|
||||||
|
};
|
||||||
|
const key = createKeyFromPath(storePath);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue({
|
||||||
|
key0: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
timestamp: 0,
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("saveState()", () => {
|
||||||
|
const testState = { aa: 1, bb: "2", cc: [3, 4] };
|
||||||
|
|
||||||
|
it("should save state", () => {
|
||||||
|
saveState(storePath, testState);
|
||||||
|
expect(LocalStorageUtility.setEntryObject).toHaveBeenCalledTimes(1);
|
||||||
|
expect(LocalStorageUtility.setEntryObject).toHaveBeenCalledWith(StorageKey.AppState, expect.any(Object));
|
||||||
|
|
||||||
|
const passedState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
|
||||||
|
expect(passedState[key].data).toHaveProperty("aa", 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should save state with timestamp", () => {
|
||||||
|
saveState(storePath, testState);
|
||||||
|
const passedState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
|
||||||
|
expect(passedState[key]).toHaveProperty("timestamp");
|
||||||
|
expect(passedState[key].timestamp).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add state to existing state", () => {
|
||||||
|
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue({
|
||||||
|
key0: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
timestamp: 0,
|
||||||
|
data: { dd: 5 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
saveState(storePath, testState);
|
||||||
|
const passedState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
|
||||||
|
expect(passedState["key0"].data).toHaveProperty("dd", 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove the oldest entry when the number of entries exceeds the limit", () => {
|
||||||
|
// Fill up storage with MAX entries
|
||||||
|
const currentAppState = {};
|
||||||
|
for (let i = 0; i < MAX_ENTRY_NB; i++) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(currentAppState as any)[`key${i}`] = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
timestamp: i,
|
||||||
|
data: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue(currentAppState);
|
||||||
|
|
||||||
|
saveState(storePath, testState);
|
||||||
|
|
||||||
|
// Verify that the new entry is saved
|
||||||
|
const passedState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
|
||||||
|
expect(passedState[key].data).toHaveProperty("aa", 1);
|
||||||
|
|
||||||
|
// Verify that the oldest entry is removed (smallest timestamp)
|
||||||
|
const passedAppState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
|
||||||
|
expect(Object.keys(passedAppState).length).toBe(MAX_ENTRY_NB);
|
||||||
|
expect(passedAppState).not.toHaveProperty("key0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not remove the oldest entry when the number of entries does not exceed the limit", () => {
|
||||||
|
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue({
|
||||||
|
key0: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
timestamp: 0,
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
key1: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
timestamp: 1,
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
saveState(storePath, testState);
|
||||||
|
const passedAppState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
|
||||||
|
expect(Object.keys(passedAppState).length).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadState()", () => {
|
||||||
|
it("should load state", () => {
|
||||||
|
const data = { aa: 1, bb: "2", cc: [3, 4] };
|
||||||
|
const testState = {
|
||||||
|
[key]: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
timestamp: 0,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue(testState);
|
||||||
|
const state = loadState(storePath);
|
||||||
|
expect(state).toEqual(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return undefined if the state is not found", () => {
|
||||||
|
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue(null);
|
||||||
|
const state = loadState(storePath);
|
||||||
|
expect(state).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteState()", () => {
|
||||||
|
it("should delete state", () => {
|
||||||
|
const key = createKeyFromPath(storePath);
|
||||||
|
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue({
|
||||||
|
[key]: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
timestamp: 0,
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
otherKey: {
|
||||||
|
schemaVersion: 2,
|
||||||
|
timestamp: 0,
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteState(storePath);
|
||||||
|
expect(LocalStorageUtility.setEntryObject).toHaveBeenCalledTimes(1);
|
||||||
|
const passedAppState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
|
||||||
|
expect(passedAppState).not.toHaveProperty(key);
|
||||||
|
expect(passedAppState).toHaveProperty("otherKey");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("createKeyFromPath()", () => {
|
||||||
|
it("should create path that contains all components", () => {
|
||||||
|
const key = createKeyFromPath(storePath);
|
||||||
|
expect(key).toContain(storePath.componentName);
|
||||||
|
expect(key).toContain(storePath.subComponentName);
|
||||||
|
expect(key).toContain(storePath.globalAccountName);
|
||||||
|
expect(key).toContain(storePath.databaseName);
|
||||||
|
expect(key).toContain(storePath.containerName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
109
src/Shared/AppStatePersistenceUtility.ts
Normal file
109
src/Shared/AppStatePersistenceUtility.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
|
||||||
|
|
||||||
|
// The component name whose state is being saved. Component name must not include special characters.
|
||||||
|
export type ComponentName = "DocumentsTab";
|
||||||
|
|
||||||
|
const SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
|
// Export for testing purposes
|
||||||
|
export const MAX_ENTRY_NB = 100_000; // Limit number of entries to 100k
|
||||||
|
|
||||||
|
export interface StateData {
|
||||||
|
schemaVersion: number;
|
||||||
|
timestamp: number;
|
||||||
|
data: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorePath = {
|
||||||
|
componentName: string;
|
||||||
|
subComponentName?: string;
|
||||||
|
globalAccountName?: string;
|
||||||
|
databaseName?: string;
|
||||||
|
containerName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load and save state data
|
||||||
|
export const loadState = (path: StorePath): unknown => {
|
||||||
|
const appState =
|
||||||
|
LocalStorageUtility.getEntryObject<ApplicationState>(StorageKey.AppState) || ({} as ApplicationState);
|
||||||
|
const key = createKeyFromPath(path);
|
||||||
|
return appState[key]?.data;
|
||||||
|
};
|
||||||
|
export const saveState = (path: StorePath, state: unknown): void => {
|
||||||
|
// Retrieve state object
|
||||||
|
const appState =
|
||||||
|
LocalStorageUtility.getEntryObject<ApplicationState>(StorageKey.AppState) || ({} as ApplicationState);
|
||||||
|
const key = createKeyFromPath(path);
|
||||||
|
appState[key] = {
|
||||||
|
schemaVersion: SCHEMA_VERSION,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
data: state,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Object.keys(appState).length > MAX_ENTRY_NB) {
|
||||||
|
// Remove the oldest entry
|
||||||
|
const oldestKey = Object.keys(appState).reduce((oldest, current) =>
|
||||||
|
appState[current].timestamp < appState[oldest].timestamp ? current : oldest,
|
||||||
|
);
|
||||||
|
delete appState[oldestKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalStorageUtility.setEntryObject(StorageKey.AppState, appState);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteState = (path: StorePath): void => {
|
||||||
|
// Retrieve state object
|
||||||
|
const appState =
|
||||||
|
LocalStorageUtility.getEntryObject<ApplicationState>(StorageKey.AppState) || ({} as ApplicationState);
|
||||||
|
const key = createKeyFromPath(path);
|
||||||
|
delete appState[key];
|
||||||
|
LocalStorageUtility.setEntryObject(StorageKey.AppState, appState);
|
||||||
|
};
|
||||||
|
|
||||||
|
// This is for high-frequency state changes
|
||||||
|
let timeoutId: NodeJS.Timeout | undefined;
|
||||||
|
export const saveStateDebounced = (path: StorePath, state: unknown, debounceDelayMs = 1000): void => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
timeoutId = setTimeout(() => saveState(path, state), debounceDelayMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ApplicationState {
|
||||||
|
[statePath: string]: StateData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedPathSegments: (keyof StorePath)[] = [
|
||||||
|
"subComponentName",
|
||||||
|
"globalAccountName",
|
||||||
|
"databaseName",
|
||||||
|
"containerName",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /componentName/subComponentName/globalAccountName/databaseName/containerName/
|
||||||
|
* Any of the path segments can be "" except componentName
|
||||||
|
* Export for testing purposes
|
||||||
|
* @param path
|
||||||
|
*/
|
||||||
|
export const createKeyFromPath = (path: StorePath): string => {
|
||||||
|
if (path.componentName.includes("/")) {
|
||||||
|
throw new Error(`Invalid component name: ${path.componentName}`);
|
||||||
|
}
|
||||||
|
let key = `/${path.componentName}`; // ComponentName is always there
|
||||||
|
orderedPathSegments.forEach((segment) => {
|
||||||
|
const segmentValue = path[segment as keyof StorePath];
|
||||||
|
if (segmentValue.includes("/")) {
|
||||||
|
throw new Error(`Invalid setting path segment: ${segment}`);
|
||||||
|
}
|
||||||
|
key += `/${segmentValue !== undefined ? segmentValue : ""}`;
|
||||||
|
});
|
||||||
|
return key;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the entire app state key from local storage
|
||||||
|
*/
|
||||||
|
export const deleteAllStates = (): void => {
|
||||||
|
LocalStorageUtility.removeEntry(StorageKey.AppState);
|
||||||
|
};
|
@ -24,7 +24,6 @@ export const setEntryBoolean = (key: StorageKey, value: boolean): void =>
|
|||||||
export const setEntryObject = (key: StorageKey, value: unknown): void => {
|
export const setEntryObject = (key: StorageKey, value: unknown): void => {
|
||||||
localStorage.setItem(StorageKey[key], JSON.stringify(value));
|
localStorage.setItem(StorageKey[key], JSON.stringify(value));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getEntryObject = <T>(key: StorageKey): T | null => {
|
export const getEntryObject = <T>(key: StorageKey): T | null => {
|
||||||
const item = localStorage.getItem(StorageKey[key]);
|
const item = localStorage.getItem(StorageKey[key]);
|
||||||
if (item) {
|
if (item) {
|
||||||
|
@ -31,6 +31,7 @@ export enum StorageKey {
|
|||||||
PriorityLevel,
|
PriorityLevel,
|
||||||
DocumentsTabPrefs,
|
DocumentsTabPrefs,
|
||||||
DefaultQueryResultsView,
|
DefaultQueryResultsView,
|
||||||
|
AppState,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const hasRUThresholdBeenConfigured = (): boolean => {
|
export const hasRUThresholdBeenConfigured = (): boolean => {
|
||||||
|
@ -139,6 +139,9 @@ export enum Action {
|
|||||||
QueryEdited,
|
QueryEdited,
|
||||||
ExecuteQueryGeneratedFromQueryCopilot,
|
ExecuteQueryGeneratedFromQueryCopilot,
|
||||||
DeleteDocuments,
|
DeleteDocuments,
|
||||||
|
ReadPersistedTabState,
|
||||||
|
SavePersistedTabState,
|
||||||
|
DeletePersistedTabState,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ActionModifiers = {
|
export const ActionModifiers = {
|
||||||
|
@ -108,6 +108,7 @@ describe("Query Utils", () => {
|
|||||||
},
|
},
|
||||||
Elevation: 3742,
|
Elevation: 3742,
|
||||||
Type: "Stratovolcano",
|
Type: "Stratovolcano",
|
||||||
|
Category: "",
|
||||||
Status: "Tephrochronology",
|
Status: "Tephrochronology",
|
||||||
"Last Known Eruption": "Last known eruption from A.D. 1-1499, inclusive",
|
"Last Known Eruption": "Last known eruption from A.D. 1-1499, inclusive",
|
||||||
id: "9e3c494e-8367-3f50-1f56-8c6fcb961363",
|
id: "9e3c494e-8367-3f50-1f56-8c6fcb961363",
|
||||||
@ -146,19 +147,5 @@ describe("Query Utils", () => {
|
|||||||
expect(expectedPartitionKeyValues).toContain(documentContent["Type"]);
|
expect(expectedPartitionKeyValues).toContain(documentContent["Type"]);
|
||||||
expect(expectedPartitionKeyValues).toContain(documentContent["Status"]);
|
expect(expectedPartitionKeyValues).toContain(documentContent["Status"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should extract no partition key values", () => {
|
|
||||||
const singlePartitionKeyDefinition: PartitionKeyDefinition = {
|
|
||||||
kind: PartitionKeyKind.Hash,
|
|
||||||
paths: ["/InvalidPartitionKeyPath"],
|
|
||||||
};
|
|
||||||
|
|
||||||
const partitionKeyValues: PartitionKey[] = extractPartitionKeyValues(
|
|
||||||
documentContent,
|
|
||||||
singlePartitionKeyDefinition,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(partitionKeyValues.length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -4,6 +4,7 @@ import { DATA_EXPLORER_RPC_VERSION } from "Contracts/DataExplorerMessagesContrac
|
|||||||
import { FabricMessageTypes } from "Contracts/FabricMessageTypes";
|
import { FabricMessageTypes } from "Contracts/FabricMessageTypes";
|
||||||
import { FABRIC_RPC_VERSION, FabricMessageV2 } from "Contracts/FabricMessagesContract";
|
import { FABRIC_RPC_VERSION, FabricMessageV2 } from "Contracts/FabricMessagesContract";
|
||||||
import Explorer from "Explorer/Explorer";
|
import Explorer from "Explorer/Explorer";
|
||||||
|
import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
|
||||||
import { useSelectedNode } from "Explorer/useSelectedNode";
|
import { useSelectedNode } from "Explorer/useSelectedNode";
|
||||||
import { scheduleRefreshDatabaseResourceToken } from "Platform/Fabric/FabricUtil";
|
import { scheduleRefreshDatabaseResourceToken } from "Platform/Fabric/FabricUtil";
|
||||||
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
|
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
|
||||||
@ -15,6 +16,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { AuthType } from "../AuthType";
|
import { AuthType } from "../AuthType";
|
||||||
import { AccountKind, Flights } from "../Common/Constants";
|
import { AccountKind, Flights } from "../Common/Constants";
|
||||||
import { normalizeArmEndpoint } from "../Common/EnvironmentUtility";
|
import { normalizeArmEndpoint } from "../Common/EnvironmentUtility";
|
||||||
|
import * as Logger from "../Common/Logger";
|
||||||
import { handleCachedDataMessage, sendMessage, sendReadyMessage } from "../Common/MessageHandler";
|
import { handleCachedDataMessage, sendMessage, sendReadyMessage } from "../Common/MessageHandler";
|
||||||
import { Platform, configContext, updateConfigContext } from "../ConfigContext";
|
import { Platform, configContext, updateConfigContext } from "../ConfigContext";
|
||||||
import { ActionType, DataExplorerAction, TabKind } from "../Contracts/ActionContracts";
|
import { ActionType, DataExplorerAction, TabKind } from "../Contracts/ActionContracts";
|
||||||
@ -42,8 +44,6 @@ import { acquireTokenWithMsal, getAuthorizationHeader, getMsalInstance } from ".
|
|||||||
import { isInvalidParentFrameOrigin, shouldProcessMessage } from "../Utils/MessageValidation";
|
import { isInvalidParentFrameOrigin, shouldProcessMessage } from "../Utils/MessageValidation";
|
||||||
import { getReadOnlyKeys, listKeys } from "../Utils/arm/generatedClients/cosmos/databaseAccounts";
|
import { getReadOnlyKeys, listKeys } from "../Utils/arm/generatedClients/cosmos/databaseAccounts";
|
||||||
import { applyExplorerBindings } from "../applyExplorerBindings";
|
import { applyExplorerBindings } from "../applyExplorerBindings";
|
||||||
import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
|
|
||||||
import * as Logger from "../Common/Logger";
|
|
||||||
|
|
||||||
// This hook will create a new instance of Explorer.ts and bind it to the DOM
|
// This hook will create a new instance of Explorer.ts and bind it to the DOM
|
||||||
// This hook has a LOT of magic, but ideally we can delete it once we have removed KO and switched entirely to React
|
// This hook has a LOT of magic, but ideally we can delete it once we have removed KO and switched entirely to React
|
||||||
@ -83,6 +83,7 @@ export function useKnockoutExplorer(platform: Platform): Explorer {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (explorer) {
|
if (explorer) {
|
||||||
applyExplorerBindings(explorer);
|
applyExplorerBindings(explorer);
|
||||||
|
explorer.openNPSSurveyDialog();
|
||||||
}
|
}
|
||||||
}, [explorer]);
|
}, [explorer]);
|
||||||
|
|
||||||
@ -588,10 +589,6 @@ async function configurePortal(): Promise<Explorer> {
|
|||||||
explorer = new Explorer();
|
explorer = new Explorer();
|
||||||
resolve(explorer);
|
resolve(explorer);
|
||||||
|
|
||||||
if (userContext.apiType === "Postgres" || userContext.apiType === "SQL" || userContext.apiType === "Mongo") {
|
|
||||||
setTimeout(() => explorer.openNPSSurveyDialog(), 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (openAction) {
|
if (openAction) {
|
||||||
handleOpenAction(openAction, useDatabases.getState().databases, explorer);
|
handleOpenAction(openAction, useDatabases.getState().databases, explorer);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { MinimalQueryIterator } from "Common/IteratorUtilities";
|
import { MinimalQueryIterator } from "Common/IteratorUtilities";
|
||||||
|
import QueryError from "Common/QueryError";
|
||||||
import { QueryResults } from "Contracts/ViewModels";
|
import { QueryResults } from "Contracts/ViewModels";
|
||||||
import { CopilotMessage, CopilotSchemaAllocationInfo } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
|
import { CopilotMessage, CopilotSchemaAllocationInfo } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
|
||||||
import { guid } from "Explorer/Tables/Utilities";
|
import { guid } from "Explorer/Tables/Utilities";
|
||||||
@ -27,7 +28,7 @@ export interface QueryCopilotState {
|
|||||||
showSamplePrompts: boolean;
|
showSamplePrompts: boolean;
|
||||||
queryIterator: MinimalQueryIterator | undefined;
|
queryIterator: MinimalQueryIterator | undefined;
|
||||||
queryResults: QueryResults | undefined;
|
queryResults: QueryResults | undefined;
|
||||||
errorMessage: string;
|
errors: QueryError[];
|
||||||
isSamplePromptsOpen: boolean;
|
isSamplePromptsOpen: boolean;
|
||||||
showPromptTeachingBubble: boolean;
|
showPromptTeachingBubble: boolean;
|
||||||
showDeletePopup: boolean;
|
showDeletePopup: boolean;
|
||||||
@ -70,7 +71,7 @@ export interface QueryCopilotState {
|
|||||||
setShowSamplePrompts: (showSamplePrompts: boolean) => void;
|
setShowSamplePrompts: (showSamplePrompts: boolean) => void;
|
||||||
setQueryIterator: (queryIterator: MinimalQueryIterator | undefined) => void;
|
setQueryIterator: (queryIterator: MinimalQueryIterator | undefined) => void;
|
||||||
setQueryResults: (queryResults: QueryResults | undefined) => void;
|
setQueryResults: (queryResults: QueryResults | undefined) => void;
|
||||||
setErrorMessage: (errorMessage: string) => void;
|
setErrors: (errors: QueryError[]) => void;
|
||||||
setIsSamplePromptsOpen: (isSamplePromptsOpen: boolean) => void;
|
setIsSamplePromptsOpen: (isSamplePromptsOpen: boolean) => void;
|
||||||
setShowPromptTeachingBubble: (showPromptTeachingBubble: boolean) => void;
|
setShowPromptTeachingBubble: (showPromptTeachingBubble: boolean) => void;
|
||||||
setShowDeletePopup: (showDeletePopup: boolean) => void;
|
setShowDeletePopup: (showDeletePopup: boolean) => void;
|
||||||
@ -117,7 +118,7 @@ export const useQueryCopilot: QueryCopilotStore = create((set) => ({
|
|||||||
showSamplePrompts: false,
|
showSamplePrompts: false,
|
||||||
queryIterator: undefined,
|
queryIterator: undefined,
|
||||||
queryResults: undefined,
|
queryResults: undefined,
|
||||||
errorMessage: "",
|
errors: [],
|
||||||
isSamplePromptsOpen: false,
|
isSamplePromptsOpen: false,
|
||||||
showDeletePopup: false,
|
showDeletePopup: false,
|
||||||
showFeedbackBar: false,
|
showFeedbackBar: false,
|
||||||
@ -170,7 +171,7 @@ export const useQueryCopilot: QueryCopilotStore = create((set) => ({
|
|||||||
setShowSamplePrompts: (showSamplePrompts: boolean) => set({ showSamplePrompts }),
|
setShowSamplePrompts: (showSamplePrompts: boolean) => set({ showSamplePrompts }),
|
||||||
setQueryIterator: (queryIterator: MinimalQueryIterator | undefined) => set({ queryIterator }),
|
setQueryIterator: (queryIterator: MinimalQueryIterator | undefined) => set({ queryIterator }),
|
||||||
setQueryResults: (queryResults: QueryResults | undefined) => set({ queryResults }),
|
setQueryResults: (queryResults: QueryResults | undefined) => set({ queryResults }),
|
||||||
setErrorMessage: (errorMessage: string) => set({ errorMessage }),
|
setErrors: (errors: QueryError[]) => set({ errors }),
|
||||||
setIsSamplePromptsOpen: (isSamplePromptsOpen: boolean) => set({ isSamplePromptsOpen }),
|
setIsSamplePromptsOpen: (isSamplePromptsOpen: boolean) => set({ isSamplePromptsOpen }),
|
||||||
setShowDeletePopup: (showDeletePopup: boolean) => set({ showDeletePopup }),
|
setShowDeletePopup: (showDeletePopup: boolean) => set({ showDeletePopup }),
|
||||||
setShowFeedbackBar: (showFeedbackBar: boolean) => set({ showFeedbackBar }),
|
setShowFeedbackBar: (showFeedbackBar: boolean) => set({ showFeedbackBar }),
|
||||||
@ -225,7 +226,7 @@ export const useQueryCopilot: QueryCopilotStore = create((set) => ({
|
|||||||
showSamplePrompts: false,
|
showSamplePrompts: false,
|
||||||
queryIterator: undefined,
|
queryIterator: undefined,
|
||||||
queryResults: undefined,
|
queryResults: undefined,
|
||||||
errorMessage: "",
|
errors: [],
|
||||||
isSamplePromptsOpen: false,
|
isSamplePromptsOpen: false,
|
||||||
showDeletePopup: false,
|
showDeletePopup: false,
|
||||||
showFeedbackBar: false,
|
showFeedbackBar: false,
|
||||||
|
@ -98,7 +98,7 @@ If you used all the standard deployment scripts and naming scheme, you can set t
|
|||||||
If Azure Powershell's current subscription is not the one you want to use for testing, you can set the subscription using the following command:
|
If Azure Powershell's current subscription is not the one you want to use for testing, you can set the subscription using the following command:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\test\scripts\set-test-subscription.ps1 -Subscription "My Subscription"
|
.\test\scripts\set-test-accounts.ps1 -Subscription "My Subscription"
|
||||||
```
|
```
|
||||||
|
|
||||||
That script will confirm the resource group exists and then set the necessary environment variables:
|
That script will confirm the resource group exists and then set the necessary environment variables:
|
||||||
@ -151,3 +151,42 @@ npx playwright test --ui
|
|||||||
The UI allows you to select a specific test to run and to see the results of the test in the browser.
|
The UI allows you to select a specific test to run and to see the results of the test in the browser.
|
||||||
|
|
||||||
See the [Playwright docs](https://playwright.dev/docs/running-tests) for more information on running tests.
|
See the [Playwright docs](https://playwright.dev/docs/running-tests) for more information on running tests.
|
||||||
|
|
||||||
|
## Clean-up
|
||||||
|
|
||||||
|
Tests should clean-up after themselves if they succeed (and sometimes even when they fail).
|
||||||
|
However, this is not guaranteed, and you may find that you have resources left over from failed tests.
|
||||||
|
Any resource (database, container, etc.) prefixed with `t_` is a test resource and can be safely deleted if you aren't currently running tests.
|
||||||
|
The `test/scripts/clean-test-accounts.ps1` script will attempt to clean all the test resources.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\test\scripts\clean-test-accounts.ps1 -Subscription "My Subscription"
|
||||||
|
```
|
||||||
|
|
||||||
|
That script will confirm the resource group exists and then prompt you to confirm the deletion of the resources:
|
||||||
|
|
||||||
|
```
|
||||||
|
Found a resource with the default resource prefix (ashleyst-e2e-). Configuring that prefix for E2E testing.
|
||||||
|
Cleaning E2E Testing Resources
|
||||||
|
Subscription: cosmosdb-portalteam-generaltest-msft (b9c77f10-b438-4c32-9819-eef8a654e478)
|
||||||
|
Resource Group: ashleyst-e2e-testing
|
||||||
|
Resource Prefix: ashleyst-e2e-
|
||||||
|
|
||||||
|
All databases with the prefix 't_' will be deleted.
|
||||||
|
Are you sure you want to delete these resources? (y/n): y
|
||||||
|
Cleaning Mongo Account: ashleyst-e2e-mongo
|
||||||
|
Cleaning Gremlin Account: ashleyst-e2e-gremlin
|
||||||
|
Cleaning Table Account: ashleyst-e2e-tables
|
||||||
|
Cleaning Cassandra Account: ashleyst-e2e-cassandra
|
||||||
|
Cleaning Keyspace: t_db90_1722888413729
|
||||||
|
Cleaning Keyspace: t_db76_1722882571248
|
||||||
|
Cleaning Keyspace: t_db3a_1722882413947
|
||||||
|
Cleaning Keyspace: t_db4d_1722882342943
|
||||||
|
Cleaning Keyspace: t_db64_1722888944788
|
||||||
|
Cleaning Keyspace: t_db90_1722882507916
|
||||||
|
Cleaning Keyspace: t_dbf5_1722888997915
|
||||||
|
Cleaning Keyspace: t_db7e_1722882689913
|
||||||
|
Cleaning SQL Account: ashleyst-e2e-sql
|
||||||
|
Cleaning Database: t_db32_1722890547089
|
||||||
|
Cleaning Mongo Account: ashleyst-e2e-mongo32
|
||||||
|
```
|
@ -1,39 +1,50 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
import { DataExplorer, TestAccount, generateDatabaseNameWithTimestamp, generateUniqueName } from "../fx";
|
import { DataExplorer, TestAccount, generateUniqueName } from "../fx";
|
||||||
|
|
||||||
test("Cassandra keyspace and table CRUD", async ({ page }) => {
|
test("Cassandra keyspace and table CRUD", async ({ page }) => {
|
||||||
const keyspaceId = generateDatabaseNameWithTimestamp();
|
const keyspaceId = generateUniqueName("db");
|
||||||
const tableId = generateUniqueName("table");
|
const tableId = "testtable"; // A unique table name isn't needed because the keyspace is unique
|
||||||
|
|
||||||
const explorer = await DataExplorer.open(page, TestAccount.Cassandra);
|
const explorer = await DataExplorer.open(page, TestAccount.Cassandra);
|
||||||
|
|
||||||
await explorer.globalCommandButton("New Table").click();
|
await explorer.globalCommandButton("New Table").click();
|
||||||
await explorer.whilePanelOpen("Add Table", async (panel, okButton) => {
|
await explorer.whilePanelOpen(
|
||||||
|
"Add Table",
|
||||||
|
async (panel, okButton) => {
|
||||||
await panel.getByPlaceholder("Type a new keyspace id").fill(keyspaceId);
|
await panel.getByPlaceholder("Type a new keyspace id").fill(keyspaceId);
|
||||||
await panel.getByPlaceholder("Enter table Id").fill(tableId);
|
await panel.getByPlaceholder("Enter table Id").fill(tableId);
|
||||||
await panel.getByLabel("Table max RU/s").fill("1000");
|
await panel.getByLabel("Table max RU/s").fill("1000");
|
||||||
await okButton.click();
|
await okButton.click();
|
||||||
});
|
},
|
||||||
|
{ closeTimeout: 5 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
|
||||||
const keyspaceNode = explorer.treeNode(keyspaceId);
|
const keyspaceNode = await explorer.waitForNode(keyspaceId);
|
||||||
await keyspaceNode.expand();
|
const tableNode = await explorer.waitForContainerNode(keyspaceId, tableId);
|
||||||
const tableNode = explorer.treeNode(`${keyspaceId}/${tableId}`);
|
|
||||||
|
|
||||||
await tableNode.openContextMenu();
|
await tableNode.openContextMenu();
|
||||||
await tableNode.contextMenuItem("Delete Table").click();
|
await tableNode.contextMenuItem("Delete Table").click();
|
||||||
await explorer.whilePanelOpen("Delete Table", async (panel, okButton) => {
|
await explorer.whilePanelOpen(
|
||||||
|
"Delete Table",
|
||||||
|
async (panel, okButton) => {
|
||||||
await panel.getByRole("textbox", { name: "Confirm by typing the table id" }).fill(tableId);
|
await panel.getByRole("textbox", { name: "Confirm by typing the table id" }).fill(tableId);
|
||||||
await okButton.click();
|
await okButton.click();
|
||||||
});
|
},
|
||||||
|
{ closeTimeout: 5 * 60 * 1000 },
|
||||||
|
);
|
||||||
await expect(tableNode.element).not.toBeAttached();
|
await expect(tableNode.element).not.toBeAttached();
|
||||||
|
|
||||||
await keyspaceNode.openContextMenu();
|
await keyspaceNode.openContextMenu();
|
||||||
await keyspaceNode.contextMenuItem("Delete Keyspace").click();
|
await keyspaceNode.contextMenuItem("Delete Keyspace").click();
|
||||||
await explorer.whilePanelOpen("Delete Keyspace", async (panel, okButton) => {
|
await explorer.whilePanelOpen(
|
||||||
|
"Delete Keyspace",
|
||||||
|
async (panel, okButton) => {
|
||||||
await panel.getByRole("textbox", { name: "Confirm by typing the Keyspace id" }).fill(keyspaceId);
|
await panel.getByRole("textbox", { name: "Confirm by typing the Keyspace id" }).fill(keyspaceId);
|
||||||
await okButton.click();
|
await okButton.click();
|
||||||
});
|
},
|
||||||
|
{ closeTimeout: 5 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
|
||||||
await expect(keyspaceNode.element).not.toBeAttached();
|
await expect(keyspaceNode.element).not.toBeAttached();
|
||||||
});
|
});
|
||||||
|
192
test/fx.ts
192
test/fx.ts
@ -2,13 +2,22 @@ import { AzureCliCredentials } from "@azure/ms-rest-nodeauth";
|
|||||||
import { expect, Frame, Locator, Page } from "@playwright/test";
|
import { expect, Frame, Locator, Page } from "@playwright/test";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
|
|
||||||
export function generateUniqueName(baseName = "", length = 4): string {
|
const RETRY_COUNT = 3;
|
||||||
return `${baseName}${crypto.randomBytes(length).toString("hex")}`;
|
|
||||||
|
export interface TestNameOptions {
|
||||||
|
length?: number;
|
||||||
|
timestampped?: boolean;
|
||||||
|
prefixed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateDatabaseNameWithTimestamp(baseName = "db", length = 1): string {
|
export function generateUniqueName(baseName, options?: TestNameOptions): string {
|
||||||
// We use '_' as the separator because it's supported across all the API types.
|
const length = options?.length ?? 1;
|
||||||
return `${baseName}${crypto.randomBytes(length).toString("hex")}_${Date.now()}`;
|
const timestamp = options?.timestampped === undefined ? true : options.timestampped;
|
||||||
|
const prefixed = options?.prefixed === undefined ? true : options.prefixed;
|
||||||
|
|
||||||
|
const prefix = prefixed ? "t_" : "";
|
||||||
|
const suffix = timestamp ? `_${Date.now()}` : "";
|
||||||
|
return `${prefix}${baseName}${crypto.randomBytes(length).toString("hex")}${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAzureCLICredentials(): Promise<AzureCliCredentials> {
|
export async function getAzureCLICredentials(): Promise<AzureCliCredentials> {
|
||||||
@ -97,25 +106,132 @@ class TreeNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async expand(): Promise<void> {
|
async expand(): Promise<void> {
|
||||||
// Sometimes, the expand button doesn't load at all, because the node didn't have children when it was initially loaded.
|
|
||||||
// Still, clicking the node will trigger loading and expansion. So if the node isn't expanded, we click it.
|
|
||||||
|
|
||||||
// The "aria-expanded" attribute is applied to the TreeItem. But we have the TreeItemLayout selected because the TreeItem contains the child tree as well.
|
|
||||||
// So, we need to find the TreeItem that contains this TreeItemLayout.
|
|
||||||
const treeNodeContainer = this.frame.getByTestId(`TreeNodeContainer:${this.id}`);
|
const treeNodeContainer = this.frame.getByTestId(`TreeNodeContainer:${this.id}`);
|
||||||
|
const tree = this.frame.getByTestId(`Tree:${this.id}`);
|
||||||
|
|
||||||
|
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
|
||||||
|
const expandNode = async () => {
|
||||||
if ((await treeNodeContainer.getAttribute("aria-expanded")) !== "true") {
|
if ((await treeNodeContainer.getAttribute("aria-expanded")) !== "true") {
|
||||||
// Click the node, to trigger loading and expansion
|
// Click the node, to trigger loading and expansion
|
||||||
await this.element.click();
|
await this.element.click();
|
||||||
}
|
}
|
||||||
await expect(treeNodeContainer).toHaveAttribute("aria-expanded", "true");
|
|
||||||
|
// Try three times to wait for the node to expand.
|
||||||
|
for (let i = 0; i < RETRY_COUNT; i++) {
|
||||||
|
try {
|
||||||
|
await tree.waitFor({ state: "visible" });
|
||||||
|
// The tree has expanded, let's get out of here
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// Just try again
|
||||||
|
if ((await treeNodeContainer.getAttribute("aria-expanded")) !== "true") {
|
||||||
|
// We might have collapsed the node, try expanding it again, then retry.
|
||||||
|
await this.element.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (await expandNode()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tree never expanded. OR, it may have expanded in between when we found the "ExpandIcon" and when we clicked it (it's happened before)
|
||||||
|
// So, let's try one more time to expand it.
|
||||||
|
if (!(await expandNode())) {
|
||||||
|
// The tree never expanded. This is a problem.
|
||||||
|
throw new Error(`Node ${this.id} did not expand after clicking it.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We did it. It took a lot of weird messing around, but we expanded a tree node... I hope.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class Editor {
|
||||||
|
constructor(
|
||||||
|
public frame: Frame,
|
||||||
|
public locator: Locator,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
text(): Promise<string | null> {
|
||||||
|
return this.locator.evaluate((e) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const win = e.ownerDocument.defaultView as any;
|
||||||
|
if (win._monaco_getEditorContentForElement) {
|
||||||
|
return win._monaco_getEditorContentForElement(e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async setText(text: string): Promise<void> {
|
||||||
|
// We trust that Monaco can handle the keyboard, and it's _extremely_ flaky to try and enter text using browser commands.
|
||||||
|
// So we use a hook we installed in 'window' to set the content of the editor.
|
||||||
|
|
||||||
|
// NOTE: This function is serialized and sent to the browser for execution
|
||||||
|
// So you can't use any variables from the outer scope, but we can send a string (via the second argument to evaluate)
|
||||||
|
await this.locator.evaluate((e, content) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const win = e.ownerDocument.defaultView as any;
|
||||||
|
if (win._monaco_setEditorContentForElement) {
|
||||||
|
win._monaco_setEditorContentForElement(e, content);
|
||||||
|
}
|
||||||
|
}, text);
|
||||||
|
|
||||||
|
expect(await this.text()).toEqual(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QueryTab {
|
||||||
|
resultsPane: Locator;
|
||||||
|
resultsView: Locator;
|
||||||
|
executeCTA: Locator;
|
||||||
|
errorList: Locator;
|
||||||
|
queryStatsList: Locator;
|
||||||
|
resultsEditor: Editor;
|
||||||
|
resultsTab: Locator;
|
||||||
|
queryStatsTab: Locator;
|
||||||
|
constructor(
|
||||||
|
public frame: Frame,
|
||||||
|
public tabId: string,
|
||||||
|
public tab: Locator,
|
||||||
|
public locator: Locator,
|
||||||
|
) {
|
||||||
|
this.resultsPane = locator.getByTestId("QueryTab/ResultsPane");
|
||||||
|
this.resultsView = locator.getByTestId("QueryTab/ResultsPane/ResultsView");
|
||||||
|
this.executeCTA = locator.getByTestId("QueryTab/ResultsPane/ExecuteCTA");
|
||||||
|
this.errorList = locator.getByTestId("QueryTab/ResultsPane/ErrorList");
|
||||||
|
this.resultsEditor = new Editor(this.frame, this.resultsView.getByTestId("EditorReact/Host/Loaded"));
|
||||||
|
this.queryStatsList = locator.getByTestId("QueryTab/ResultsPane/ResultsView/QueryStatsList");
|
||||||
|
this.resultsTab = this.resultsView.getByTestId("QueryTab/ResultsPane/ResultsView/ResultsTab");
|
||||||
|
this.queryStatsTab = this.resultsView.getByTestId("QueryTab/ResultsPane/ResultsView/QueryStatsTab");
|
||||||
|
}
|
||||||
|
|
||||||
|
editor(): Editor {
|
||||||
|
const locator = this.locator.getByTestId("EditorReact/Host/Loaded");
|
||||||
|
return new Editor(this.frame, locator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PanelOpenOptions = {
|
||||||
|
closeTimeout?: number;
|
||||||
|
};
|
||||||
|
|
||||||
/** Helper class that provides locator methods for DataExplorer components, on top of a Frame */
|
/** Helper class that provides locator methods for DataExplorer components, on top of a Frame */
|
||||||
export class DataExplorer {
|
export class DataExplorer {
|
||||||
constructor(public frame: Frame) {}
|
constructor(public frame: Frame) {}
|
||||||
|
|
||||||
|
tab(tabId: string): Locator {
|
||||||
|
return this.frame.getByTestId(`Tab:${tabId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryTab(tabId: string): QueryTab {
|
||||||
|
const tab = this.tab(tabId);
|
||||||
|
const queryTab = tab.getByTestId("QueryTab");
|
||||||
|
return new QueryTab(this.frame, tabId, tab, queryTab);
|
||||||
|
}
|
||||||
|
|
||||||
/** Select the primary global command button.
|
/** Select the primary global command button.
|
||||||
*
|
*
|
||||||
* There's only a single "primary" button, but we still require you to pass the label to confirm you're selecting the right button.
|
* There's only a single "primary" button, but we still require you to pass the label to confirm you're selecting the right button.
|
||||||
@ -134,18 +250,68 @@ export class DataExplorer {
|
|||||||
return this.frame.getByTestId(`Panel:${title}`);
|
return this.frame.getByTestId(`Panel:${title}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async waitForNode(treeNodeId: string): Promise<TreeNode> {
|
||||||
|
const node = this.treeNode(treeNodeId);
|
||||||
|
|
||||||
|
// Is the node already visible?
|
||||||
|
if (await node.element.isVisible()) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No, try refreshing the tree
|
||||||
|
const refreshButton = this.frame.getByTestId("Sidebar/RefreshButton");
|
||||||
|
await refreshButton.click();
|
||||||
|
|
||||||
|
// Try a few times to find the node
|
||||||
|
for (let i = 0; i < RETRY_COUNT; i++) {
|
||||||
|
try {
|
||||||
|
await node.element.waitFor();
|
||||||
|
return node;
|
||||||
|
} catch {
|
||||||
|
// Just try again
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We tried 3 times, but the node never appeared
|
||||||
|
throw new Error(`Node ${treeNodeId} not found and did not appear after refreshing.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForContainerNode(databaseId: string, containerId: string): Promise<TreeNode> {
|
||||||
|
const databaseNode = await this.waitForNode(databaseId);
|
||||||
|
|
||||||
|
// The container node may be auto-expanded. Wait 5s for that to happen
|
||||||
|
try {
|
||||||
|
const containerNode = this.treeNode(`${databaseId}/${containerId}`);
|
||||||
|
await containerNode.element.waitFor({ state: "visible", timeout: 5 * 1000 });
|
||||||
|
return containerNode;
|
||||||
|
} catch {
|
||||||
|
// It didn't auto-expand, that's fine, we'll expand it ourselves
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ok, expand the database node.
|
||||||
|
await databaseNode.expand();
|
||||||
|
|
||||||
|
return await this.waitForNode(`${databaseId}/${containerId}`);
|
||||||
|
}
|
||||||
|
|
||||||
/** Select the tree node with the specified id */
|
/** Select the tree node with the specified id */
|
||||||
treeNode(id: string): TreeNode {
|
treeNode(id: string): TreeNode {
|
||||||
return new TreeNode(this.frame.getByTestId(`TreeNode:${id}`), this.frame, id);
|
return new TreeNode(this.frame.getByTestId(`TreeNode:${id}`), this.frame, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Waits for the panel with the specified title to be open, then runs the provided callback. After the callback completes, waits for the panel to close. */
|
/** Waits for the panel with the specified title to be open, then runs the provided callback. After the callback completes, waits for the panel to close. */
|
||||||
async whilePanelOpen(title: string, action: (panel: Locator, okButton: Locator) => Promise<void>): Promise<void> {
|
async whilePanelOpen(
|
||||||
|
title: string,
|
||||||
|
action: (panel: Locator, okButton: Locator) => Promise<void>,
|
||||||
|
options?: PanelOpenOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
options ||= {};
|
||||||
|
|
||||||
const panel = this.panel(title);
|
const panel = this.panel(title);
|
||||||
await panel.waitFor();
|
await panel.waitFor();
|
||||||
const okButton = panel.getByTestId("Panel/OkButton");
|
const okButton = panel.getByTestId("Panel/OkButton");
|
||||||
await action(panel, okButton);
|
await action(panel, okButton);
|
||||||
await panel.waitFor({ state: "detached" });
|
await panel.waitFor({ state: "detached", timeout: options.closeTimeout });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Waits for the Data Explorer app to load */
|
/** Waits for the Data Explorer app to load */
|
||||||
|
@ -1,41 +1,52 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
import { DataExplorer, TestAccount, generateDatabaseNameWithTimestamp, generateUniqueName } from "../fx";
|
import { DataExplorer, TestAccount, generateUniqueName } from "../fx";
|
||||||
|
|
||||||
test("Gremlin graph CRUD", async ({ page }) => {
|
test("Gremlin graph CRUD", async ({ page }) => {
|
||||||
const databaseId = generateDatabaseNameWithTimestamp();
|
const databaseId = generateUniqueName("db");
|
||||||
const graphId = generateUniqueName("graph");
|
const graphId = "testgraph"; // A unique graph name isn't needed because the database is unique
|
||||||
|
|
||||||
const explorer = await DataExplorer.open(page, TestAccount.Gremlin);
|
const explorer = await DataExplorer.open(page, TestAccount.Gremlin);
|
||||||
|
|
||||||
// Create new database and graph
|
// Create new database and graph
|
||||||
await explorer.globalCommandButton("New Graph").click();
|
await explorer.globalCommandButton("New Graph").click();
|
||||||
await explorer.whilePanelOpen("New Graph", async (panel, okButton) => {
|
await explorer.whilePanelOpen(
|
||||||
|
"New Graph",
|
||||||
|
async (panel, okButton) => {
|
||||||
await panel.getByPlaceholder("Type a new database id").fill(databaseId);
|
await panel.getByPlaceholder("Type a new database id").fill(databaseId);
|
||||||
await panel.getByRole("textbox", { name: "Graph id, Example Graph1" }).fill(graphId);
|
await panel.getByRole("textbox", { name: "Graph id, Example Graph1" }).fill(graphId);
|
||||||
await panel.getByRole("textbox", { name: "Partition key" }).fill("/pk");
|
await panel.getByRole("textbox", { name: "Partition key" }).fill("/pk");
|
||||||
await panel.getByLabel("Database max RU/s").fill("1000");
|
await panel.getByLabel("Database max RU/s").fill("1000");
|
||||||
await okButton.click();
|
await okButton.click();
|
||||||
});
|
},
|
||||||
|
{ closeTimeout: 5 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
|
||||||
const databaseNode = explorer.treeNode(databaseId);
|
const databaseNode = await explorer.waitForNode(databaseId);
|
||||||
await databaseNode.expand();
|
const graphNode = await explorer.waitForContainerNode(databaseId, graphId);
|
||||||
const graphNode = explorer.treeNode(`${databaseId}/${graphId}`);
|
|
||||||
|
|
||||||
await graphNode.openContextMenu();
|
await graphNode.openContextMenu();
|
||||||
await graphNode.contextMenuItem("Delete Graph").click();
|
await graphNode.contextMenuItem("Delete Graph").click();
|
||||||
await explorer.whilePanelOpen("Delete Graph", async (panel, okButton) => {
|
await explorer.whilePanelOpen(
|
||||||
|
"Delete Graph",
|
||||||
|
async (panel, okButton) => {
|
||||||
await panel.getByRole("textbox", { name: "Confirm by typing the graph id" }).fill(graphId);
|
await panel.getByRole("textbox", { name: "Confirm by typing the graph id" }).fill(graphId);
|
||||||
await okButton.click();
|
await okButton.click();
|
||||||
});
|
},
|
||||||
|
{ closeTimeout: 5 * 60 * 1000 },
|
||||||
|
);
|
||||||
await expect(graphNode.element).not.toBeAttached();
|
await expect(graphNode.element).not.toBeAttached();
|
||||||
|
|
||||||
await databaseNode.openContextMenu();
|
await databaseNode.openContextMenu();
|
||||||
await databaseNode.contextMenuItem("Delete Database").click();
|
await databaseNode.contextMenuItem("Delete Database").click();
|
||||||
await explorer.whilePanelOpen("Delete Database", async (panel, okButton) => {
|
await explorer.whilePanelOpen(
|
||||||
|
"Delete Database",
|
||||||
|
async (panel, okButton) => {
|
||||||
await panel.getByRole("textbox", { name: "Confirm by typing the Database id" }).fill(databaseId);
|
await panel.getByRole("textbox", { name: "Confirm by typing the Database id" }).fill(databaseId);
|
||||||
await okButton.click();
|
await okButton.click();
|
||||||
});
|
},
|
||||||
|
{ closeTimeout: 5 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
|
||||||
await expect(databaseNode.element).not.toBeAttached();
|
await expect(databaseNode.element).not.toBeAttached();
|
||||||
});
|
});
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
import { DataExplorer, TestAccount, generateDatabaseNameWithTimestamp, generateUniqueName } from "../fx";
|
import { DataExplorer, TestAccount, generateUniqueName } from "../fx";
|
||||||
|
|
||||||
(
|
(
|
||||||
[
|
[
|
||||||
@ -9,38 +9,49 @@ import { DataExplorer, TestAccount, generateDatabaseNameWithTimestamp, generateU
|
|||||||
] as [string, TestAccount][]
|
] as [string, TestAccount][]
|
||||||
).forEach(([apiVersionDescription, accountType]) => {
|
).forEach(([apiVersionDescription, accountType]) => {
|
||||||
test(`Mongo CRUD using ${apiVersionDescription}`, async ({ page }) => {
|
test(`Mongo CRUD using ${apiVersionDescription}`, async ({ page }) => {
|
||||||
const databaseId = generateDatabaseNameWithTimestamp();
|
const databaseId = generateUniqueName("db");
|
||||||
const collectionId = generateUniqueName("collection");
|
const collectionId = "testcollection"; // A unique collection name isn't needed because the database is unique
|
||||||
|
|
||||||
const explorer = await DataExplorer.open(page, accountType);
|
const explorer = await DataExplorer.open(page, accountType);
|
||||||
|
|
||||||
await explorer.globalCommandButton("New Collection").click();
|
await explorer.globalCommandButton("New Collection").click();
|
||||||
await explorer.whilePanelOpen("New Collection", async (panel, okButton) => {
|
await explorer.whilePanelOpen(
|
||||||
|
"New Collection",
|
||||||
|
async (panel, okButton) => {
|
||||||
await panel.getByPlaceholder("Type a new database id").fill(databaseId);
|
await panel.getByPlaceholder("Type a new database id").fill(databaseId);
|
||||||
await panel.getByRole("textbox", { name: "Collection id, Example Collection1" }).fill(collectionId);
|
await panel.getByRole("textbox", { name: "Collection id, Example Collection1" }).fill(collectionId);
|
||||||
await panel.getByRole("textbox", { name: "Shard key" }).fill("pk");
|
await panel.getByRole("textbox", { name: "Shard key" }).fill("pk");
|
||||||
await panel.getByLabel("Database max RU/s").fill("1000");
|
await panel.getByLabel("Database max RU/s").fill("1000");
|
||||||
await okButton.click();
|
await okButton.click();
|
||||||
});
|
},
|
||||||
|
{ closeTimeout: 5 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
|
||||||
const databaseNode = explorer.treeNode(databaseId);
|
const databaseNode = await explorer.waitForNode(databaseId);
|
||||||
await databaseNode.expand();
|
const collectionNode = await explorer.waitForContainerNode(databaseId, collectionId);
|
||||||
const collectionNode = explorer.treeNode(`${databaseId}/${collectionId}`);
|
|
||||||
|
|
||||||
await collectionNode.openContextMenu();
|
await collectionNode.openContextMenu();
|
||||||
await collectionNode.contextMenuItem("Delete Collection").click();
|
await collectionNode.contextMenuItem("Delete Collection").click();
|
||||||
await explorer.whilePanelOpen("Delete Collection", async (panel, okButton) => {
|
await explorer.whilePanelOpen(
|
||||||
|
"Delete Collection",
|
||||||
|
async (panel, okButton) => {
|
||||||
await panel.getByRole("textbox", { name: "Confirm by typing the collection id" }).fill(collectionId);
|
await panel.getByRole("textbox", { name: "Confirm by typing the collection id" }).fill(collectionId);
|
||||||
await okButton.click();
|
await okButton.click();
|
||||||
});
|
},
|
||||||
|
{ closeTimeout: 5 * 60 * 1000 },
|
||||||
|
);
|
||||||
await expect(collectionNode.element).not.toBeAttached();
|
await expect(collectionNode.element).not.toBeAttached();
|
||||||
|
|
||||||
await databaseNode.openContextMenu();
|
await databaseNode.openContextMenu();
|
||||||
await databaseNode.contextMenuItem("Delete Database").click();
|
await databaseNode.contextMenuItem("Delete Database").click();
|
||||||
await explorer.whilePanelOpen("Delete Database", async (panel, okButton) => {
|
await explorer.whilePanelOpen(
|
||||||
|
"Delete Database",
|
||||||
|
async (panel, okButton) => {
|
||||||
await panel.getByRole("textbox", { name: "Confirm by typing the Database id" }).fill(databaseId);
|
await panel.getByRole("textbox", { name: "Confirm by typing the Database id" }).fill(databaseId);
|
||||||
await okButton.click();
|
await okButton.click();
|
||||||
});
|
},
|
||||||
|
{ closeTimeout: 5 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
|
||||||
await expect(databaseNode.element).not.toBeAttached();
|
await expect(databaseNode.element).not.toBeAttached();
|
||||||
});
|
});
|
||||||
|
107
test/scripts/clean-test-accounts.ps1
Normal file
107
test/scripts/clean-test-accounts.ps1
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$false)][string]$ResourceGroup,
|
||||||
|
[Parameter(Mandatory=$false)][string]$Subscription,
|
||||||
|
[Parameter(Mandatory=$false)][string]$ResourcePrefix,
|
||||||
|
[Parameter(Mandatory=$false)][string]$DatabasePrefix = "t_"
|
||||||
|
)
|
||||||
|
|
||||||
|
Import-Module "Az.Accounts" -Scope Local
|
||||||
|
Import-Module "Az.Resources" -Scope Local
|
||||||
|
|
||||||
|
if (-not $Subscription) {
|
||||||
|
# Show the user the currently-selected subscription and ask if that's what they want to use
|
||||||
|
$currentSubscription = Get-AzContext | Select-Object -ExpandProperty Subscription
|
||||||
|
Write-Host "The currently-selected subscription is $($currentSubscription.Name) ($($currentSubscription.Id))."
|
||||||
|
$useCurrentSubscription = Read-Host "Do you want to use this subscription? (y/n)"
|
||||||
|
if ($useCurrentSubscription -eq "n") {
|
||||||
|
throw "Either specify a subscription using '-Subscription' or select a subscription using 'Select-AzSubscription' before running this script."
|
||||||
|
}
|
||||||
|
$Subscription = $currentSubscription.Id
|
||||||
|
}
|
||||||
|
|
||||||
|
$AzSubscription = (Get-AzSubscription -SubscriptionId $Subscription -ErrorAction SilentlyContinue | Select-Object -First 1) ?? (Get-AzSubscription -SubscriptionName $Subscription -ErrorAction SilentlyContinue | Select-Object -First 1)
|
||||||
|
if (-not $AzSubscription) {
|
||||||
|
throw "The subscription '$Subscription' could not be found."
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-AzContext $AzSubscription.Id | Out-Null
|
||||||
|
|
||||||
|
if (-not $ResourceGroup) {
|
||||||
|
# Check for the default resource group name
|
||||||
|
$DefaultResourceGroupName = $env:USERNAME + "-e2e-testing"
|
||||||
|
if (Get-AzResourceGroup -Name $DefaultResourceGroupName -ErrorAction SilentlyContinue) {
|
||||||
|
$ResourceGroup = $DefaultResourceGroupName
|
||||||
|
} else {
|
||||||
|
$ResourceGroup = Read-Host "Specify the name of the resource group to find the resources in."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$AzResourceGroup = Get-AzResourceGroup -Name $ResourceGroup -ErrorAction SilentlyContinue
|
||||||
|
if (-not $AzResourceGroup) {
|
||||||
|
throw "The resource group '$ResourceGroup' could not be found. You have to create the resource group manually before running this script."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $ResourcePrefix) {
|
||||||
|
$defaultResourcePrefix = $env:USERNAME + "-e2e-"
|
||||||
|
|
||||||
|
# Check for one of the default resources
|
||||||
|
$defaultResource = Get-AzResource -ResourceGroupName $AzResourceGroup.ResourceGroupName -ResourceName "$($defaultResourcePrefix)cassandra" -ResourceType "Microsoft.DocumentDB/databaseAccounts" -ErrorAction SilentlyContinue
|
||||||
|
if ($defaultResource) {
|
||||||
|
Write-Host "Found a resource with the default resource prefix ($defaultResourcePrefix). Configuring that prefix for E2E testing."
|
||||||
|
$ResourcePrefix = $defaultResourcePrefix
|
||||||
|
} else {
|
||||||
|
$ResourcePrefix = Read-Host "Specify the resource prefix used in the resource names."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Cleaning E2E Testing Resources"
|
||||||
|
Write-Host " Subscription: $($AzSubscription.Name) ($($AzSubscription.Id))"
|
||||||
|
Write-Host " Resource Group: $($AzResourceGroup.ResourceGroupName)"
|
||||||
|
Write-Host " Resource Prefix: $ResourcePrefix"
|
||||||
|
Write-Host
|
||||||
|
Write-Host "All databases with the prefix '$DatabasePrefix' will be deleted."
|
||||||
|
|
||||||
|
# Confirm the deletion
|
||||||
|
$confirm = Read-Host "Are you sure you want to delete these resources? (y/n)"
|
||||||
|
if ($confirm -ne "y") {
|
||||||
|
Write-Host "Aborting."
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
|
||||||
|
Get-AzResource -ResourceGroupName $AzResourceGroup.ResourceGroupName -ResourceType "Microsoft.DocumentDB/databaseAccounts" -ErrorAction SilentlyContinue | ForEach-Object {
|
||||||
|
$account = Get-AzCosmosDBAccount -ResourceGroupName $AzResourceGroup.ResourceGroupName -Name $_.Name -ErrorAction SilentlyContinue
|
||||||
|
if (-not $account) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ($account.Kind -eq "MongoDB") {
|
||||||
|
Write-Host " Cleaning Mongo Account: $($_.Name)"
|
||||||
|
Get-AzCosmosDBMongoDBDatabase -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName | Where-Object { $_.Name -like "$DatabasePrefix*" } | ForEach-Object {
|
||||||
|
Write-Host " Cleaning Database: $($_.Name)"
|
||||||
|
Remove-AzCosmosDBMongoDBDatabase -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName -Name $_.Name
|
||||||
|
}
|
||||||
|
} elseif ($account.Capabilities | Where-Object { $_.Name -eq "EnableCassandra" }) {
|
||||||
|
Write-Host " Cleaning Cassandra Account: $($_.Name)"
|
||||||
|
Get-AzCosmosDBCassandraKeyspace -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName | Where-Object { $_.Name -like "$DatabasePrefix*" } | ForEach-Object {
|
||||||
|
Write-Host " Cleaning Keyspace: $($_.Name)"
|
||||||
|
Remove-AzCosmosDBCassandraKeyspace -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName -Name $_.Name
|
||||||
|
}
|
||||||
|
} elseif ($account.Capabilities | Where-Object { $_.Name -eq "EnableGremlin" }) {
|
||||||
|
Write-Host " Cleaning Gremlin Account: $($_.Name)"
|
||||||
|
Get-AzCosmosDBGremlinDatabase -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName | Where-Object { $_.Name -like "$DatabasePrefix*" } | ForEach-Object {
|
||||||
|
Write-Host " Cleaning Database: $($_.Name)"
|
||||||
|
Remove-AzCosmosDBGremlinDatabase -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName -Name $_.Name
|
||||||
|
}
|
||||||
|
} elseif ($account.Capabilities | Where-Object { $_.Name -eq "EnableTable" }) {
|
||||||
|
Write-Host " Cleaning Table Account: $($_.Name)"
|
||||||
|
Get-AzCosmosDBTable -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName | Where-Object { $_.Name -like "$DatabasePrefix*" } | ForEach-Object {
|
||||||
|
Write-Host " Cleaning Table: $($_.Name)"
|
||||||
|
Remove-AzCosmosDBTable -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName -Name $_.Name
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host " Cleaning SQL Account: $($_.Name)"
|
||||||
|
Get-AzCosmosDBSqlDatabase -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName | Where-Object { $_.Name -like "$DatabasePrefix*" } | ForEach-Object {
|
||||||
|
Write-Host " Cleaning Database: $($_.Name)"
|
||||||
|
Remove-AzCosmosDBSqlDatabase -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName -Name $_.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,40 +1,51 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
import { DataExplorer, TestAccount, generateDatabaseNameWithTimestamp, generateUniqueName } from "../fx";
|
import { DataExplorer, TestAccount, generateUniqueName } from "../fx";
|
||||||
|
|
||||||
test("SQL database and container CRUD", async ({ page }) => {
|
test("SQL database and container CRUD", async ({ page }) => {
|
||||||
const databaseId = generateDatabaseNameWithTimestamp();
|
const databaseId = generateUniqueName("db");
|
||||||
const containerId = generateUniqueName("container");
|
const containerId = "testcontainer"; // A unique container name isn't needed because the database is unique
|
||||||
|
|
||||||
const explorer = await DataExplorer.open(page, TestAccount.SQL);
|
const explorer = await DataExplorer.open(page, TestAccount.SQL);
|
||||||
|
|
||||||
await explorer.globalCommandButton("New Container").click();
|
await explorer.globalCommandButton("New Container").click();
|
||||||
await explorer.whilePanelOpen("New Container", async (panel, okButton) => {
|
await explorer.whilePanelOpen(
|
||||||
|
"New Container",
|
||||||
|
async (panel, okButton) => {
|
||||||
await panel.getByPlaceholder("Type a new database id").fill(databaseId);
|
await panel.getByPlaceholder("Type a new database id").fill(databaseId);
|
||||||
await panel.getByRole("textbox", { name: "Container id, Example Container1" }).fill(containerId);
|
await panel.getByRole("textbox", { name: "Container id, Example Container1" }).fill(containerId);
|
||||||
await panel.getByRole("textbox", { name: "Partition key" }).fill("/pk");
|
await panel.getByRole("textbox", { name: "Partition key" }).fill("/pk");
|
||||||
await panel.getByLabel("Database max RU/s").fill("1000");
|
await panel.getByLabel("Database max RU/s").fill("1000");
|
||||||
await okButton.click();
|
await okButton.click();
|
||||||
});
|
},
|
||||||
|
{ closeTimeout: 5 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
|
||||||
const databaseNode = explorer.treeNode(databaseId);
|
const databaseNode = await explorer.waitForNode(databaseId);
|
||||||
await databaseNode.expand();
|
const containerNode = await explorer.waitForContainerNode(databaseId, containerId);
|
||||||
const containerNode = explorer.treeNode(`${databaseId}/${containerId}`);
|
|
||||||
|
|
||||||
await containerNode.openContextMenu();
|
await containerNode.openContextMenu();
|
||||||
await containerNode.contextMenuItem("Delete Container").click();
|
await containerNode.contextMenuItem("Delete Container").click();
|
||||||
await explorer.whilePanelOpen("Delete Container", async (panel, okButton) => {
|
await explorer.whilePanelOpen(
|
||||||
|
"Delete Container",
|
||||||
|
async (panel, okButton) => {
|
||||||
await panel.getByRole("textbox", { name: "Confirm by typing the container id" }).fill(containerId);
|
await panel.getByRole("textbox", { name: "Confirm by typing the container id" }).fill(containerId);
|
||||||
await okButton.click();
|
await okButton.click();
|
||||||
});
|
},
|
||||||
|
{ closeTimeout: 5 * 60 * 1000 },
|
||||||
|
);
|
||||||
await expect(containerNode.element).not.toBeAttached();
|
await expect(containerNode.element).not.toBeAttached();
|
||||||
|
|
||||||
await databaseNode.openContextMenu();
|
await databaseNode.openContextMenu();
|
||||||
await databaseNode.contextMenuItem("Delete Database").click();
|
await databaseNode.contextMenuItem("Delete Database").click();
|
||||||
await explorer.whilePanelOpen("Delete Database", async (panel, okButton) => {
|
await explorer.whilePanelOpen(
|
||||||
|
"Delete Database",
|
||||||
|
async (panel, okButton) => {
|
||||||
await panel.getByRole("textbox", { name: "Confirm by typing the database id" }).fill(databaseId);
|
await panel.getByRole("textbox", { name: "Confirm by typing the database id" }).fill(databaseId);
|
||||||
await okButton.click();
|
await okButton.click();
|
||||||
});
|
},
|
||||||
|
{ closeTimeout: 5 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
|
||||||
await expect(databaseNode.element).not.toBeAttached();
|
await expect(databaseNode.element).not.toBeAttached();
|
||||||
});
|
});
|
||||||
|
93
test/sql/query.spec.ts
Normal file
93
test/sql/query.spec.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { DataExplorer, Editor, QueryTab, TestAccount } from "../fx";
|
||||||
|
import { TestContainerContext, TestItem, createTestSQLContainer } from "../testData";
|
||||||
|
|
||||||
|
let context: TestContainerContext = null!;
|
||||||
|
let explorer: DataExplorer = null!;
|
||||||
|
let queryTab: QueryTab = null!;
|
||||||
|
let queryEditor: Editor = null!;
|
||||||
|
|
||||||
|
test.beforeAll("Create Test Database", async () => {
|
||||||
|
context = await createTestSQLContainer(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach("Open new query tab", async ({ page }) => {
|
||||||
|
// Open a query tab
|
||||||
|
explorer = await DataExplorer.open(page, TestAccount.SQL);
|
||||||
|
|
||||||
|
// Container nodes should be visible. The explorer auto-expands database nodes when they are first loaded.
|
||||||
|
const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id);
|
||||||
|
await containerNode.openContextMenu();
|
||||||
|
await containerNode.contextMenuItem("New SQL Query").click();
|
||||||
|
|
||||||
|
// Wait for the editor to load
|
||||||
|
queryTab = explorer.queryTab("tab0");
|
||||||
|
queryEditor = queryTab.editor();
|
||||||
|
await queryEditor.locator.waitFor({ timeout: 30 * 1000 });
|
||||||
|
await queryTab.executeCTA.waitFor();
|
||||||
|
await explorer.frame.getByTestId("NotificationConsole/ExpandCollapseButton").click();
|
||||||
|
await explorer.frame.getByTestId("NotificationConsole/Contents").waitFor();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll("Delete Test Database", async () => {
|
||||||
|
await context?.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Query results", async () => {
|
||||||
|
// Run the query and verify the results
|
||||||
|
await queryEditor.locator.click();
|
||||||
|
const executeQueryButton = explorer.commandBarButton("Execute Query");
|
||||||
|
await executeQueryButton.click();
|
||||||
|
await expect(queryTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 });
|
||||||
|
|
||||||
|
// Read the results
|
||||||
|
const resultText = await queryTab.resultsEditor.text();
|
||||||
|
expect(resultText).not.toBeNull();
|
||||||
|
const resultData: TestItem[] = JSON.parse(resultText!);
|
||||||
|
|
||||||
|
// Pick 3 random documents and assert them
|
||||||
|
const randomDocs = [0, 1, 2].map(() => resultData[Math.floor(Math.random() * resultData.length)]);
|
||||||
|
randomDocs.forEach((doc) => {
|
||||||
|
const matchingDoc = context?.testData.get(doc.id);
|
||||||
|
expect(matchingDoc).not.toBeNull();
|
||||||
|
expect(doc.randomData).toEqual(matchingDoc?.randomData);
|
||||||
|
expect(doc.partitionKey).toEqual(matchingDoc?.partitionKey);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Query stats", async () => {
|
||||||
|
// Run the query and verify the results
|
||||||
|
await queryEditor.locator.click();
|
||||||
|
const executeQueryButton = explorer.commandBarButton("Execute Query");
|
||||||
|
await executeQueryButton.click();
|
||||||
|
await expect(queryTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 });
|
||||||
|
|
||||||
|
// Open the query stats tab and validate some data there
|
||||||
|
queryTab.queryStatsTab.click();
|
||||||
|
await expect(queryTab.queryStatsList).toBeAttached();
|
||||||
|
const showingResultsCell = queryTab.queryStatsList.getByTestId("Row:Showing Results/Column:value");
|
||||||
|
await expect(showingResultsCell).toContainText(/\d+ - \d+/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Query errors", async () => {
|
||||||
|
test.skip(true, "Disabled due to an issue with error reporting in the backend.");
|
||||||
|
|
||||||
|
await queryEditor.locator.click();
|
||||||
|
await queryEditor.setText("SELECT\n glarb(c.id),\n blarg(c.id)\nFROM c");
|
||||||
|
|
||||||
|
// Run the query and verify the results
|
||||||
|
const executeQueryButton = explorer.commandBarButton("Execute Query");
|
||||||
|
await executeQueryButton.click();
|
||||||
|
|
||||||
|
await expect(queryTab.errorList).toBeAttached({ timeout: 60 * 1000 });
|
||||||
|
|
||||||
|
// Validating the squiggles requires a lot of digging through the Monaco model, OR a screenshot comparison.
|
||||||
|
// The screenshot ended up being fairly flaky, and a pain to maintain, so I decided not to include validation for the squiggles.
|
||||||
|
|
||||||
|
// Validate the errors are in the list
|
||||||
|
await expect(queryTab.errorList.getByTestId("Row:0/Column:code")).toHaveText("SC2005");
|
||||||
|
await expect(queryTab.errorList.getByTestId("Row:0/Column:location")).toHaveText("Line 2");
|
||||||
|
await expect(queryTab.errorList.getByTestId("Row:1/Column:code")).toHaveText("SC2005");
|
||||||
|
await expect(queryTab.errorList.getByTestId("Row:1/Column:location")).toHaveText("Line 3");
|
||||||
|
});
|
@ -19,7 +19,7 @@ test("SQL account using Resource token", async ({ page }) => {
|
|||||||
const account = await armClient.databaseAccounts.get(resourceGroupName, accountName);
|
const account = await armClient.databaseAccounts.get(resourceGroupName, accountName);
|
||||||
const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName);
|
const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName);
|
||||||
const dbId = generateUniqueName("db");
|
const dbId = generateUniqueName("db");
|
||||||
const collectionId = generateUniqueName("col");
|
const collectionId = "testcollection";
|
||||||
const client = new CosmosClient({
|
const client = new CosmosClient({
|
||||||
endpoint: account.documentEndpoint!,
|
endpoint: account.documentEndpoint!,
|
||||||
key: keys.primaryMasterKey,
|
key: keys.primaryMasterKey,
|
||||||
|
@ -3,29 +3,33 @@ import { expect, test } from "@playwright/test";
|
|||||||
import { DataExplorer, TestAccount, generateUniqueName } from "../fx";
|
import { DataExplorer, TestAccount, generateUniqueName } from "../fx";
|
||||||
|
|
||||||
test("Tables CRUD", async ({ page }) => {
|
test("Tables CRUD", async ({ page }) => {
|
||||||
const tableId = generateUniqueName("table");
|
const tableId = generateUniqueName("table"); // A unique table name IS needed because the database is shared when using Table Storage.
|
||||||
|
|
||||||
const explorer = await DataExplorer.open(page, TestAccount.Tables);
|
const explorer = await DataExplorer.open(page, TestAccount.Tables);
|
||||||
|
|
||||||
await explorer.globalCommandButton("New Table").click();
|
await explorer.globalCommandButton("New Table").click();
|
||||||
await explorer.whilePanelOpen("New Table", async (panel, okButton) => {
|
await explorer.whilePanelOpen(
|
||||||
|
"New Table",
|
||||||
|
async (panel, okButton) => {
|
||||||
await panel.getByRole("textbox", { name: "Table id, Example Table1" }).fill(tableId);
|
await panel.getByRole("textbox", { name: "Table id, Example Table1" }).fill(tableId);
|
||||||
await panel.getByLabel("Table Max RU/s").fill("1000");
|
await panel.getByLabel("Table Max RU/s").fill("1000");
|
||||||
await okButton.click();
|
await okButton.click();
|
||||||
});
|
},
|
||||||
|
{ closeTimeout: 5 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
|
||||||
const databaseNode = explorer.treeNode("TablesDB");
|
const tableNode = await explorer.waitForContainerNode("TablesDB", tableId);
|
||||||
await databaseNode.expand();
|
|
||||||
|
|
||||||
const tableNode = explorer.treeNode(`TablesDB/${tableId}`);
|
|
||||||
await expect(tableNode.element).toBeAttached();
|
|
||||||
|
|
||||||
await tableNode.openContextMenu();
|
await tableNode.openContextMenu();
|
||||||
await tableNode.contextMenuItem("Delete Table").click();
|
await tableNode.contextMenuItem("Delete Table").click();
|
||||||
await explorer.whilePanelOpen("Delete Table", async (panel, okButton) => {
|
await explorer.whilePanelOpen(
|
||||||
|
"Delete Table",
|
||||||
|
async (panel, okButton) => {
|
||||||
await panel.getByRole("textbox", { name: "Confirm by typing the table id" }).fill(tableId);
|
await panel.getByRole("textbox", { name: "Confirm by typing the table id" }).fill(tableId);
|
||||||
await okButton.click();
|
await okButton.click();
|
||||||
});
|
},
|
||||||
|
{ closeTimeout: 5 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
|
||||||
await expect(tableNode.element).not.toBeAttached();
|
await expect(tableNode.element).not.toBeAttached();
|
||||||
});
|
});
|
||||||
|
95
test/testData.ts
Normal file
95
test/testData.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { CosmosDBManagementClient } from "@azure/arm-cosmosdb";
|
||||||
|
import { BulkOperationType, Container, CosmosClient, Database, JSONObject } from "@azure/cosmos";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import {
|
||||||
|
TestAccount,
|
||||||
|
generateUniqueName,
|
||||||
|
getAccountName,
|
||||||
|
getAzureCLICredentials,
|
||||||
|
resourceGroupName,
|
||||||
|
subscriptionId,
|
||||||
|
} from "./fx";
|
||||||
|
|
||||||
|
export interface TestItem {
|
||||||
|
id: string;
|
||||||
|
partitionKey: string;
|
||||||
|
randomData: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const partitionCount = 4;
|
||||||
|
|
||||||
|
// If we increase this number, we need to split bulk creates into multiple batches.
|
||||||
|
// Bulk operations are limited to 100 items per partition.
|
||||||
|
const itemsPerPartition = 100;
|
||||||
|
|
||||||
|
function createTestItems(): TestItem[] {
|
||||||
|
const items: TestItem[] = [];
|
||||||
|
for (let i = 0; i < partitionCount; i++) {
|
||||||
|
for (let j = 0; j < itemsPerPartition; j++) {
|
||||||
|
const id = crypto.randomBytes(32).toString("base64");
|
||||||
|
items.push({
|
||||||
|
id,
|
||||||
|
partitionKey: `partition_${i}`,
|
||||||
|
randomData: crypto.randomBytes(32).toString("base64"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TestData: TestItem[] = createTestItems();
|
||||||
|
|
||||||
|
export class TestContainerContext {
|
||||||
|
constructor(
|
||||||
|
public armClient: CosmosDBManagementClient,
|
||||||
|
public client: CosmosClient,
|
||||||
|
public database: Database,
|
||||||
|
public container: Container,
|
||||||
|
public testData: Map<string, TestItem>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async dispose() {
|
||||||
|
await this.database.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTestSQLContainer(includeTestData?: boolean) {
|
||||||
|
const databaseId = generateUniqueName("db");
|
||||||
|
const containerId = "testcontainer"; // A unique container name isn't needed because the database is unique
|
||||||
|
const credentials = await getAzureCLICredentials();
|
||||||
|
const armClient = new CosmosDBManagementClient(credentials, subscriptionId);
|
||||||
|
const accountName = getAccountName(TestAccount.SQL);
|
||||||
|
const account = await armClient.databaseAccounts.get(resourceGroupName, accountName);
|
||||||
|
const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName);
|
||||||
|
const client = new CosmosClient({
|
||||||
|
endpoint: account.documentEndpoint!,
|
||||||
|
key: keys.primaryMasterKey,
|
||||||
|
});
|
||||||
|
const { database } = await client.databases.createIfNotExists({ id: databaseId });
|
||||||
|
try {
|
||||||
|
const { container } = await database.containers.createIfNotExists({
|
||||||
|
id: containerId,
|
||||||
|
partitionKey: "/partitionKey",
|
||||||
|
});
|
||||||
|
if (includeTestData) {
|
||||||
|
const batchCount = TestData.length / 100;
|
||||||
|
for (let i = 0; i < batchCount; i++) {
|
||||||
|
const batchItems = TestData.slice(i * 100, i * 100 + 100);
|
||||||
|
await container.items.bulk(
|
||||||
|
batchItems.map((item) => ({
|
||||||
|
operationType: BulkOperationType.Create,
|
||||||
|
resourceBody: item as unknown as JSONObject,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testDataMap = new Map<string, TestItem>();
|
||||||
|
TestData.forEach((item) => testDataMap.set(item.id, item));
|
||||||
|
|
||||||
|
return new TestContainerContext(armClient, client, database, container, testDataMap);
|
||||||
|
} catch (e) {
|
||||||
|
await database.delete();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user