Compare commits

...

39 Commits

Author SHA1 Message Date
Ashley Stanton-Nurse
cf8359c548 refmt 2024-10-02 19:01:07 +00:00
Ashley Stanton-Nurse
b03925abab pr feedback 2024-10-01 21:52:39 +00:00
Ashley Stanton-Nurse
53d3413c62 add new command bar behind a feature flag 2024-09-23 16:46:25 +00:00
Laurent Nguyen
7128133874 Only show throttling warning when throttling happened. (#1976) 2024-09-23 17:30:42 +02:00
Ashley Stanton-Nurse
053dc9d76b Add config files for Codespaces (#1975) 2024-09-20 08:28:03 -07:00
Laurent Nguyen
23b2e59560 Migrate Most Recent activity local storage to App State persistence (#1967)
* Rewrite MostRecentActivity to leverage AppStatePersistenceUtility.

* Fix format. Update type enum.

* Migrate Item enum to string enum

* Fix unit tests

* Fix build issue
2024-09-20 08:26:58 +02:00
sunghyunkang1111
869d81dfbc fix partitionkey value fetching (#1972)
* fix partitionkey value fetching

* fix partitionkey value fetching

* added unit test

* Added some unit tests

* move the constant
2024-09-19 13:09:09 -05:00
Laurent Nguyen
42a1c6c319 Move table column selection out of feature flag to MPAC. (#1973) 2024-09-19 07:18:03 +02:00
Asier Isayas
9f1cc4cd5c Force Mongo and Proxy users to switch to Mongo and Cassandra Proxy (#1971)
* Force Mongo and Cassandra users to the new Proxies

* npm run format

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-09-18 13:49:09 -04:00
Asier Isayas
78154bd976 Disable Bulk Delete API (#1968)
* disable bulk delete

* disable bulk delete

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-09-13 08:36:53 -04:00
Laurent Nguyen
91649d2f52 Migrate Copilot local persistence (toggle and prompt history) to new local storage infrastructure (#1948)
* Migrate copilot persistence to AppState

* Migrate persistence of toggle and history to new infra

* Save toggle value as boolean

* Fix compile bug

* Fix unit tests
2024-09-13 12:01:14 +02:00
vchske
d7647b2ecf Fixed issue with Tables API when selecting a row with the same row key in different partition keys (#1969) 2024-09-12 16:36:22 -07:00
Ashley Stanton-Nurse
2c7e788358 Replace RU limit banner by clarifying the error when RU limit is exceeded (#1966)
* allow DE to provide clearer error messages for certain conditions

* allow rendeering a "help" link for an error

* use TableCellLayout where possible

* remove RU Threshold banner, now that we have a clearer error

* refmt

* fix QueryError test

* change "RU Threshold" to "RU Limit"
2024-09-12 11:45:10 -07:00
Laurent Nguyen
fdbbbd7378 Better handling throttling error in bulk delete (#1954)
* Implement retry on throttling for nosql

* Clean up code

* Produce specific error for throttling error in mongoProxy bulk delete. Clean up code.

* Fix throttling doc url

* Fix mongo error wording

* Fix unit test

* Unit test cleanup

* Fix format

* Fix unit tests

* Fix format

* Fix unit test

* Fix format

* Improve comments

* Improve error message wording. Fix URL and add specific URL for Mongo and NoSql.

* Fix error messages. Add console errors.

* Clean up selection of various delete fct

* Fix error display
2024-09-11 17:11:41 +02:00
Asier Isayas
82bdeff158 Add new Portal Backend Sample Data API and remove Notifications API references (#1965)
* Fixed Sample Data logic and remove notifications references

* fixed undefined

* fixed unit tests

* fixed format test

* cleanup

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-09-11 08:07:50 -04:00
Laurent Nguyen
825a5d5257 Enable column selection and sorting in DocumentsTab (with persistence) with improvements (#1963)
* Reapply "Enable column selection and sorting in DocumentsTab (with persistence) (#1881)" (#1960)

This reverts commit fe9730206e.

* Fix logic bug: always include defaultQueryFields in query.

* Show resize column option outside of feature flag

* Improve prevention of no selected columns

* Add more unit tests

* Fix styling on table

* Update test snapshots

* Remove "sortable" property on table which makes the header cell focusable (user sorts by selecting menu item, not by clicking on cell)
2024-09-11 13:26:49 +02:00
vchske
d75553a94d Removing trailing ; from resource token which is incompatible with v2 tokens (#1962)
* Removing trailing ; from resource token which is incompatible with v2 tokens

* Adding check in case resourceToken is undefined

* Fixing unit tests
2024-09-09 12:04:16 -07:00
Laurent Nguyen
50c47a82d6 Allow slashes in persistence keys (#1961)
* Allow slashes in persistence keys

* Add unit tests
2024-09-09 19:12:55 +02:00
Laurent Nguyen
2c2f0c8d7b Disable bulkdelete for users point to old mongo proxy (#1964)
* Disable bulk delete if old mongo proxy

* Bug fix

* Fix unit tests

* Fix formatting
2024-09-09 11:13:52 -04:00
SATYA SB
cfc8196c4b [accessibility-3100032]:[Programmatic Access - Azure Cosmos DB - Data Explorer]: Close button does not have discernible text under 'Data Explorer' pane. (#1949) 2024-09-06 11:56:05 +05:30
Asier Isayas
87024f4bf4 use old backend for Mongo and Cassandra accounts depending on their IP addresses (#1959)
Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-09-05 16:25:53 -04:00
Laurent Nguyen
fe9730206e Revert "Enable column selection and sorting in DocumentsTab (with persistence) (#1881)" (#1960)
This reverts commit 7e95f5d8c8.
2024-09-05 21:44:33 +02:00
Laurent Nguyen
7e95f5d8c8 Enable column selection and sorting in DocumentsTab (with persistence) (#1881)
* Initial implementation of saving split value to local storage

* Make table columns generic (no more id and partition keys)

* Save column width

* Add column selection from right-click

* Implement new menu for column selection with search.

* Switch icons and search compare with lowercase.

* Search uses string includes instead of startsWith

* Only allow data fields that can be rendered (string and numbers) in column selection

* Accumulate properties rather than replace for column definitions

* Do not allow deselecting all columns

* Move table values under its own property

* Update choices of column when creating new or updating document

* Rework column selection UI

* Fix table size issue with some heuristics

* Fix heuristic for size update

* Don't allow unselecting last column

* Implement column sorting

* Fix format

* Fix format, update snapshots

* Add reset button to column selection and fix naming of openUploadItemsPanePane()

* Fix unit tests

* Fix unit test

* Persist column selection

* Persist column sorting

* Save columns definition (schema) along with selected columns.

* Merge branch 'master' into users/languy/save-documentstab-prefs

* Revert "Merge branch 'master' into users/languy/save-documentstab-prefs"

This reverts commit e5a82fd356.

* Disable column selection for Mongo. Remove extra refresh button

* Update test snapshots

* Remove unused function

* Fix table width

* Add background color to "..." button for column selection

* Label to indicate which field is a partition key in Column Selection Pane

* Update unit test snapshot

* Move column selection and sorting behind feature flag enableDocumentsTableColumnSelection

* Cleanup checkbox styles
2024-09-05 17:43:40 +02:00
Ashley Stanton-Nurse
1be221e106 fix rendering of global commands menu (#1953)
* fix rendering of global commands menu

* refmt
2024-09-05 08:33:39 -07:00
Asier Isayas
8e7a3db67e Point DE to new Mongo and Cassandra Proxies only and activate Cassandra Proxy in FF/MC (#1958)
Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-09-05 10:57:55 -04:00
Laurent Nguyen
07c0ead523 Improve SettingsPane (#1945)
* Use accordion in settingsPane

* Fix format

* Fix format for retry interval

* Fix unit tests

* Cosmetic changes

* Move info tips into accordion section

* Update snapshot
2024-09-05 11:51:32 +02:00
Laurent Nguyen
4296b5ae02 Add more default filters (#1955) 2024-09-05 07:16:48 +02:00
Ashley Stanton-Nurse
e8a5658799 Reduce extra spacing in the new tree and items tab (#1951)
* reduce layout row size and default font size

* icons for the tree

* refmt and update snapshots

* remove commented out code
2024-09-04 13:07:27 -07:00
vchske
b4973e8367 Fixing regex on allowedParentFrameOrigins to address XSS (#1956) 2024-09-04 11:35:32 -07:00
Asier Isayas
4b207f3fa6 Show portal networking banner for new backend (#1952)
* show portal networking banner for new backend

* fixed valid endpoints

* format

* fixed tests

* Fixed tests

* fix tests

* fixed tests

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-08-29 18:42:13 -04:00
sindhuba
c5b7f599b3 Add AAD Endpoints for Data Explorer in Portal (#1943)
* Fix API endpoint for CassandraProxy query API

* activate Mongo Proxy and Cassandra Proxy in Prod

* Add CP Prod endpoint

* Run npm format and tests

* Revert code

* fix bug that blocked local mongo proxy and cassandra proxy development

* Add prod endpoint

* fix pr check tests

* Remove prod

* Remove prod endpoint

* Remove dev endpoint

* Support data plane RBAC

* Support data plane RBAC

* Add additional changes for Portal RBAC functionality

* Remove unnecessary code

* Remove unnecessary code

* Add code to fix VCoreMongo/PG bug

* Address feedback

* Add more logs for RBAC feature

* Add more logs for RBAC features

* Add AAD endpoints for all environments

* Add AAD endpoints

* Run npm format

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-08-28 09:11:21 -07:00
Asier Isayas
6aeac542b1 Runtime Proxy API (#1950)
Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-08-28 09:04:49 -04:00
Ashley Stanton-Nurse
0d22d4ab4d change default splitter orientation when the setting has not been set (#1946) 2024-08-27 14:20:34 -07:00
vchske
0658448b54 Reinstating partition key fix with added check for nested partitions (#1947)
* Reinstating empty hiearchical partition key value fix

* Added use case for nested partitions

* Fix lint issue
2024-08-26 10:00:33 -07:00
Laurent Nguyen
833d677d20 Change persistence format for column width (#1944) 2024-08-22 17:00:49 +02:00
Laurent Nguyen
038142c180 Save and restore DocumentsTab state to local storage (#1919)
* Infrastructure to save app state

* Save filters

* Replace read/save methods with more generic ones

* Make datalist for filter unique per database/container combination

* Disable saving middle split position for now

* Fix unit tests

* Turn off confusing auto-complete from input box

* Disable tabStateData for now

* Save and restore split position

* Fix replace autocomplete="off" by removing id on Input tag

* Properly set allotment width

* Fix saved percentage

* Save splitter per collection

* Add error handling and telemetry

* Fix compiling issue

* Add ability to delete filter history. Bug fix when hitting Enter on filter input box.

* Replace delete filter modal with dropdown menu

* Add code to remove oldest record if max limit is reached in app state persistence

* Only save new splitter position on drag end (not onchange)

* Add unit tests

* Add Clear all in settings. Update snapshots

* Fix format

* Remove filter delete and keep filter history to a max. Reword clear button and message in settings pane.

* Fix setting button label

* Update test snapshots

* Reword Clear history button text

* Update unit test snapshot

* Enable Settings pane for Fabric, but turn off Rbac dial for Fabric.

* Change union type to enum

* Update src/Shared/AppStatePersistenceUtility.ts

Assert that path does not include slash char.

Co-authored-by: Ashley Stanton-Nurse <ashleyst@microsoft.com>

* Update src/Shared/AppStatePersistenceUtility.ts

Assert that path does not contain slash.

Co-authored-by: Ashley Stanton-Nurse <ashleyst@microsoft.com>

* Fix format

---------

Co-authored-by: Ashley Stanton-Nurse <ashleyst@microsoft.com>
2024-08-22 07:37:15 +02:00
Ashley Stanton-Nurse
94d3fcb30f disable query error tests due to backend issue (#1942) 2024-08-21 11:30:43 -07:00
Ashley Stanton-Nurse
d3722f2c99 Improve sidebar UI layout when narrow (#1938)
* improve how the sidebar reacts to being a smol lil' guy

* fix snapshots

* shrink minimum sizes to allow small screens to work in some way
2024-08-21 09:55:57 -07:00
Laurent Nguyen
5a5e155205 Implement bulk delete documents for Mongo (#1859)
* Implement bulk delete documents for Mongo

* Fix unit test

* Adding bulkdelete to new mongo apis

* Fix error message

* Fix typo

* Improve error message wording

* Fix format

* Fix format

* Put back old delete for older container with system partition key
2024-08-21 16:59:52 +02:00
105 changed files with 5567 additions and 4260 deletions

16
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm
# Install pre-reqs for gyp, and 'canvas' npm module
RUN apt-get update && \
apt-get install -y \
make \
gcc \
g++ \
python3-minimal \
libcairo2-dev \
libpango1.0-dev \
&& \
rm -rf /var/lib/apt/lists/*
# Install node-gyp to build native modules
RUN npm install -g node-gyp

View File

@@ -0,0 +1,32 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
{
"name": "Azure Cosmos DB Explorer",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"build": {
"dockerfile": "Dockerfile"
},
"onCreateCommand": ".devcontainer/oncreate",
"features": {
"ghcr.io/devcontainers/features/azure-cli:1": {
"version": "latest"
},
"ghcr.io/devcontainers/features/github-cli:1": {
"installDirectlyFromGitHubRelease": true,
"version": "latest"
},
"ghcr.io/devcontainers/features/sshd:1": {
"version": "latest"
}
}
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "yarn install",
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

4
.devcontainer/oncreate Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# Install packages once, to prime the node_modules directory.
npm ci

View File

@@ -1906,8 +1906,14 @@ input::-webkit-calendar-picker-indicator::after {
}
.nav-tabs-margin {
padding-top: 5px;
height: 32px;
background-color: #f2f2f2;
.nav-tabs {
display: flex;
align-items: flex-end;
height: 100%;
}
}
.navTabHeight {
@@ -2612,6 +2618,7 @@ a:link {
.tabPanesContainer {
display: flex;
flex-grow: 1;
flex-direction: column;
overflow: hidden;
}

56
package-lock.json generated
View File

@@ -2527,13 +2527,13 @@
}
},
"node_modules/@babel/preset-env/node_modules/babel-plugin-polyfill-corejs3": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.4.tgz",
"integrity": "sha512-25J6I8NGfa5YkCDogHRID3fVCadIR8/pGl1/spvCkzb6lVn6SR3ojpx9nOn9iEBcUsjY24AmdKm5khcfKdylcg==",
"version": "0.10.6",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz",
"integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==",
"dev": true,
"dependencies": {
"@babel/helper-define-polyfill-provider": "^0.6.1",
"core-js-compat": "^3.36.1"
"@babel/helper-define-polyfill-provider": "^0.6.2",
"core-js-compat": "^3.38.0"
},
"peerDependencies": {
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
@@ -2932,10 +2932,10 @@
}
},
"node_modules/@floating-ui/core": {
"version": "1.6.3",
"version": "1.6.2",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.3"
"@floating-ui/utils": "^0.2.0"
}
},
"node_modules/@floating-ui/devtools": {
@@ -2945,15 +2945,15 @@
}
},
"node_modules/@floating-ui/dom": {
"version": "1.6.6",
"version": "1.6.5",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.0.0",
"@floating-ui/utils": "^0.2.3"
"@floating-ui/utils": "^0.2.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.3",
"version": "0.2.2",
"license": "MIT"
},
"node_modules/@fluentui/date-time-utilities": {
@@ -3501,7 +3501,7 @@
"resolved": "https://registry.npmjs.org/@fluentui/react-hooks/-/react-hooks-8.8.10.tgz",
"integrity": "sha512-Xvnn6uKMsinMg/zo79KBNCDABnl0gpmArQYNQya9FCNRzvmHUCDvuQCqv4IKslvPvuC0Ya8mR2NORm2w0JoZiw==",
"dependencies": {
"@fluentui/react-window-provider": "^2.2.27",
"@fluentui/react-window-provider": "^2.2.28",
"@fluentui/set-version": "^8.2.23",
"@fluentui/utilities": "^8.15.13",
"tslib": "^2.1.0"
@@ -4426,9 +4426,9 @@
}
},
"node_modules/@fluentui/react-window-provider": {
"version": "2.2.27",
"resolved": "https://registry.npmjs.org/@fluentui/react-window-provider/-/react-window-provider-2.2.27.tgz",
"integrity": "sha512-Dg0G9bizjryV0Q/r0CPtCVTPa2II/EsT9E6JT3jPSALjQADDLlW4/+ZXbcEC7geZ/40+KpZDmhplvk/AJSFBKg==",
"version": "2.2.28",
"resolved": "https://registry.npmjs.org/@fluentui/react-window-provider/-/react-window-provider-2.2.28.tgz",
"integrity": "sha512-YdZ74HTaoDwlvLDzoBST80/17ExIl93tLJpTxnqK5jlJOAUVQ+mxLPF2HQEJq+SZr5IMXHsQ56w/KaZVRn72YA==",
"dependencies": {
"@fluentui/set-version": "^8.2.23",
"tslib": "^2.1.0"
@@ -4512,7 +4512,7 @@
"dependencies": {
"@fluentui/dom-utilities": "^2.3.7",
"@fluentui/merge-styles": "^8.6.12",
"@fluentui/react-window-provider": "^2.2.27",
"@fluentui/react-window-provider": "^2.2.28",
"@fluentui/set-version": "^8.2.23",
"tslib": "^2.1.0"
},
@@ -14966,9 +14966,9 @@
"license": "MIT"
},
"node_modules/browserslist": {
"version": "4.23.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.2.tgz",
"integrity": "sha512-qkqSyistMYdxAcw+CzbZwlBy8AGmS/eEWs+sEV5TnLRGDOL+C5M2EnH6tlZyg0YoAxGJAFKh61En9BR941GnHA==",
"version": "4.23.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz",
"integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==",
"funding": [
{
"type": "opencollective",
@@ -14984,9 +14984,9 @@
}
],
"dependencies": {
"caniuse-lite": "^1.0.30001640",
"electron-to-chromium": "^1.4.820",
"node-releases": "^2.0.14",
"caniuse-lite": "^1.0.30001646",
"electron-to-chromium": "^1.5.4",
"node-releases": "^2.0.18",
"update-browserslist-db": "^1.1.0"
},
"bin": {
@@ -15142,9 +15142,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001645",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001645.tgz",
"integrity": "sha512-GFtY2+qt91kzyMk6j48dJcwJVq5uTkk71XxE3RtScx7XWRLsO7bU44LOFkOZYR8w9YMS0UhPSYpN/6rAMImmLw==",
"version": "1.0.30001651",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz",
"integrity": "sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==",
"funding": [
{
"type": "opencollective",
@@ -16063,12 +16063,12 @@
"license": "MIT"
},
"node_modules/core-js-compat": {
"version": "3.37.1",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.37.1.tgz",
"integrity": "sha512-9TNiImhKvQqSUkOvk/mMRZzOANTiEVC7WaBNhHcKM7x+/5E1l5NvsysR19zuDQScE8k+kfQXWRN3AtS/eOSHpg==",
"version": "3.38.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.38.0.tgz",
"integrity": "sha512-75LAicdLa4OJVwFxFbQR3NdnZjNgX6ILpVcVzcC4T2smerB5lELMrJQQQoWV6TiuC/vlaFqgU2tKQx9w5s0e0A==",
"dev": true,
"dependencies": {
"browserslist": "^4.23.0"
"browserslist": "^4.23.3"
},
"funding": {
"type": "opencollective",

View File

@@ -134,6 +134,9 @@ export class BackendApi {
public static readonly GenerateToken: string = "GenerateToken";
public static readonly PortalSettings: string = "PortalSettings";
public static readonly AccountRestrictions: string = "AccountRestrictions";
public static readonly RuntimeProxy: string = "RuntimeProxy";
public static readonly DisallowedLocations: string = "DisallowedLocations";
public static readonly SampleData: string = "SampleData";
}
export class PortalBackendEndpoints {
@@ -183,6 +186,12 @@ export class CassandraProxyAPIs {
public static readonly connectionStringSchemaApi: string = "api/connectionstring/cassandra/schema";
}
export class AadEndpoints {
public static readonly Prod: string = "https://login.microsoftonline.com/";
public static readonly Fairfax: string = "https://login.microsoftonline.us/";
public static readonly Mooncake: string = "https://login.partner.microsoftonline.cn/";
}
export class Queries {
public static CustomPageOption: string = "custom";
public static UnlimitedPageOption: string = "unlimited";
@@ -284,6 +293,7 @@ export class HttpStatusCodes {
public static readonly Accepted: number = 202;
public static readonly NoContent: number = 204;
public static readonly NotModified: number = 304;
public static readonly BadRequest: number = 400;
public static readonly Unauthorized: number = 401;
public static readonly Forbidden: number = 403;
public static readonly NotFound: number = 404;
@@ -495,7 +505,7 @@ export class PriorityLevel {
public static readonly Default = "low";
}
export const QueryCopilotSampleDatabaseId = "CopilotSampleDb";
export const QueryCopilotSampleDatabaseId = "CopilotSampleDB";
export const QueryCopilotSampleContainerId = "SampleContainer";
export const QueryCopilotSampleContainerSchema = {

View File

@@ -3,15 +3,16 @@ import { getAuthorizationTokenUsingResourceTokens } from "Common/getAuthorizatio
import { AuthorizationToken } from "Contracts/FabricMessageTypes";
import { checkDatabaseResourceTokensValidity } from "Platform/Fabric/FabricUtil";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { useNewPortalBackendEndpoint } from "Utils/EndpointUtils";
import { AuthType } from "../AuthType";
import { PriorityLevel } from "../Common/Constants";
import { BackendApi, PriorityLevel } from "../Common/Constants";
import * as Logger from "../Common/Logger";
import { Platform, configContext } from "../ConfigContext";
import { userContext } from "../UserContext";
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
import * as PriorityBasedExecutionUtils from "../Utils/PriorityBasedExecutionUtils";
import { EmulatorMasterKey, HttpHeaders } from "./Constants";
import { getErrorMessage } from "./ErrorHandlingUtils";
import * as Logger from "../Common/Logger";
const _global = typeof self === "undefined" ? window : self;
@@ -123,6 +124,37 @@ export async function getTokenFromAuthService(
verb: string,
resourceType: string,
resourceId?: string,
): Promise<AuthorizationToken> {
if (!useNewPortalBackendEndpoint(BackendApi.RuntimeProxy)) {
return getTokenFromAuthService_ToBeDeprecated(verb, resourceType, resourceId);
}
try {
const host: string = configContext.PORTAL_BACKEND_ENDPOINT;
const response: Response = await _global.fetch(host + "/api/connectionstring/runtimeproxy/authorizationtokens", {
method: "POST",
headers: {
"content-type": "application/json",
"x-ms-encrypted-auth-token": userContext.accessToken,
},
body: JSON.stringify({
verb,
resourceType,
resourceId,
}),
});
const result: AuthorizationToken = await response.json();
return result;
} catch (error) {
logConsoleError(`Failed to get authorization headers for ${resourceType}: ${getErrorMessage(error)}`);
return Promise.reject(error);
}
}
export async function getTokenFromAuthService_ToBeDeprecated(
verb: string,
resourceType: string,
resourceId?: string,
): Promise<AuthorizationToken> {
try {
const host = configContext.BACKEND_ENDPOINT;

View File

@@ -1,3 +1,5 @@
import { PortalBackendEndpoints } from "Common/Constants";
import { updateConfigContext } from "ConfigContext";
import * as EnvironmentUtility from "./EnvironmentUtility";
describe("Environment Utility Test", () => {
@@ -11,4 +13,18 @@ describe("Environment Utility Test", () => {
const expectedResult = "test/";
expect(EnvironmentUtility.normalizeArmEndpoint(uri)).toEqual(expectedResult);
});
it("Detect environment is Mpac", () => {
updateConfigContext({
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Mpac,
});
expect(EnvironmentUtility.getEnvironment()).toBe(EnvironmentUtility.Environment.Mpac);
});
it("Detect environment is Development", () => {
updateConfigContext({
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Development,
});
expect(EnvironmentUtility.getEnvironment()).toBe(EnvironmentUtility.Environment.Development);
});
});

View File

@@ -1,6 +1,29 @@
import { PortalBackendEndpoints } from "Common/Constants";
import { configContext } from "ConfigContext";
export function normalizeArmEndpoint(uri: string): string {
if (uri && uri.slice(-1) !== "/") {
return `${uri}/`;
}
return uri;
}
export enum Environment {
Development = "Development",
Mpac = "MPAC",
Prod = "Prod",
Fairfax = "Fairfax",
Mooncake = "Mooncake",
}
export const getEnvironment = (): Environment => {
const environmentMap: { [key: string]: Environment } = {
[PortalBackendEndpoints.Development]: Environment.Development,
[PortalBackendEndpoints.Mpac]: Environment.Mpac,
[PortalBackendEndpoints.Prod]: Environment.Prod,
[PortalBackendEndpoints.Fairfax]: Environment.Fairfax,
[PortalBackendEndpoints.Mooncake]: Environment.Mooncake,
};
return environmentMap[configContext.PORTAL_BACKEND_ENDPOINT];
};

View File

@@ -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(
params: DataModels.CreateCollectionParams,
): Promise<DataModels.Collection> {
@@ -677,23 +720,22 @@ export function useMongoProxyEndpoint(api: string): boolean {
MongoProxyEndpoints.Local,
MongoProxyEndpoints.Mpac,
MongoProxyEndpoints.Prod,
// MongoProxyEndpoints.Fairfax,
MongoProxyEndpoints.Fairfax,
MongoProxyEndpoints.Mooncake,
];
let canAccessMongoProxy: boolean = userContext.databaseAccount.properties.publicNetworkAccess === "Enabled";
if (
configContext.MONGO_PROXY_ENDPOINT !== MongoProxyEndpoints.Local &&
userContext.databaseAccount.properties.ipRules?.length > 0
) {
canAccessMongoProxy = canAccessMongoProxy && configContext.MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED;
}
return (
canAccessMongoProxy &&
configContext.NEW_MONGO_APIS?.includes(api) &&
activeMongoProxyEndpoints.includes(configContext.MONGO_PROXY_ENDPOINT)
);
}
export class ThrottlingError extends Error {
constructor(message: string) {
super(message);
}
}
// TODO: This function throws most of the time except on Forbidden which is a bit strange
// It causes problems for TypeScript understanding the types
async function errorHandling(response: Response, action: string, params: unknown): Promise<void> {
@@ -703,6 +745,14 @@ async function errorHandling(response: Response, action: string, params: unknown
if (response.status === HttpStatusCodes.Forbidden) {
sendMessage({ type: MessageTypes.ForbiddenError, reason: errorMessage });
return;
} else if (
response.status === HttpStatusCodes.BadRequest &&
errorMessage.includes("Error=16500") &&
errorMessage.includes("RetryAfterMs=")
) {
// If throttling is happening, Cosmos DB will return a 400 with a body of:
// A write operation resulted in an error. Error=16500, RetryAfterMs=4, Details='Batch write error.
throw new ThrottlingError(errorMessage);
}
throw new Error(errorMessage);
}

View File

@@ -1,39 +0,0 @@
import { configContext, Platform } from "../ConfigContext";
import * as DataModels from "../Contracts/DataModels";
import * as ViewModels from "../Contracts/ViewModels";
import { userContext } from "../UserContext";
import { getAuthorizationHeader } from "../Utils/AuthorizationUtils";
const notificationsPath = () => {
switch (configContext.platform) {
case Platform.Hosted:
return "/api/guest/notifications";
case Platform.Portal:
return "/api/notifications";
default:
throw new Error(`Unknown platform: ${configContext.platform}`);
}
};
export const fetchPortalNotifications = async (): Promise<DataModels.Notification[]> => {
if (configContext.platform === Platform.Emulator || configContext.platform === Platform.Hosted) {
return [];
}
const { databaseAccount, resourceGroup, subscriptionId } = userContext;
const url = `${configContext.BACKEND_ENDPOINT}${notificationsPath()}?accountName=${
databaseAccount.name
}&subscriptionId=${subscriptionId}&resourceGroup=${resourceGroup}`;
const authorizationHeader: ViewModels.AuthorizationTokenHeaderMetadata = getAuthorizationHeader();
const headers = { [authorizationHeader.header]: authorizationHeader.token };
const response = await window.fetch(url, {
headers,
});
if (!response.ok) {
throw new Error(await response.text());
}
return (await response.json()) as DataModels.Notification[];
};

View File

@@ -0,0 +1,94 @@
import QueryError, { QueryErrorLocation, QueryErrorSeverity } from "Common/QueryError";
describe("QueryError.tryParse", () => {
const testErrorLocationResolver = ({ start, end }: { start: number; end: number }) =>
new QueryErrorLocation(
{ offset: start, lineNumber: start, column: start },
{ offset: end, lineNumber: end, column: end },
);
it("handles a string error", () => {
const error = "error";
const result = QueryError.tryParse(error, testErrorLocationResolver);
expect(result).toEqual([new QueryError("error", QueryErrorSeverity.Error)]);
});
it("handles an error object", () => {
const error = {
message: "error",
severity: "Warning",
location: { start: 0, end: 1 },
code: "code",
};
const result = QueryError.tryParse(error, testErrorLocationResolver);
expect(result).toEqual([
new QueryError(
"error",
QueryErrorSeverity.Warning,
"code",
new QueryErrorLocation({ offset: 0, lineNumber: 0, column: 0 }, { offset: 1, lineNumber: 1, column: 1 }),
),
]);
});
it("handles a JSON message without syntax errors", () => {
const innerError = {
code: "BadRequest",
message: "Your query is bad, and you should feel bad",
};
const message = JSON.stringify(innerError);
const outerError = {
code: "BadRequest",
message,
};
const result = QueryError.tryParse(outerError, testErrorLocationResolver);
expect(result).toEqual([
new QueryError("Your query is bad, and you should feel bad", QueryErrorSeverity.Error, "BadRequest"),
]);
});
// Imitate the value coming from the backend, which has the syntax errors serialized as JSON in the message.
it("handles single-nested error", () => {
const errors = [
{
message: "error1",
severity: "Warning",
location: { start: 0, end: 1 },
code: "code1",
},
{
message: "error2",
severity: "Error",
location: { start: 2, end: 3 },
code: "code2",
},
];
const innerError = {
code: "BadRequest",
message: "Your query is bad, and you should feel bad",
errors,
};
const message = JSON.stringify(innerError);
const outerError = {
code: "BadRequest",
message,
};
const result = QueryError.tryParse(outerError, testErrorLocationResolver);
expect(result).toEqual([
new QueryError(
"error1",
QueryErrorSeverity.Warning,
"code1",
new QueryErrorLocation({ offset: 0, lineNumber: 0, column: 0 }, { offset: 1, lineNumber: 1, column: 1 }),
),
new QueryError(
"error2",
QueryErrorSeverity.Error,
"code2",
new QueryErrorLocation({ offset: 2, lineNumber: 2, column: 2 }, { offset: 3, lineNumber: 3, column: 3 }),
),
]);
});
});

View File

@@ -1,5 +1,5 @@
import { getErrorMessage } from "Common/ErrorHandlingUtils";
import { monaco } from "Explorer/LazyMonaco";
import { getRUThreshold, ruThresholdEnabled } from "Shared/StorageUtility";
export enum QueryErrorSeverity {
Error = "Error",
@@ -97,13 +97,44 @@ export const createMonacoMarkersForQueryErrors = (errors: QueryError[]) => {
.filter((marker) => !!marker);
};
export interface ErrorEnrichment {
title?: string;
message: string;
learnMoreUrl?: string;
}
const REPLACEMENT_MESSAGES: Record<string, (original: string) => string> = {
OPERATION_RU_LIMIT_EXCEEDED: (original) => {
if (ruThresholdEnabled()) {
const threshold = getRUThreshold();
return `Query exceeded the Request Unit (RU) limit of ${threshold} RUs. You can change this limit in Data Explorer settings.`;
}
return original;
},
};
const HELP_LINKS: Record<string, string> = {
OPERATION_RU_LIMIT_EXCEEDED:
"https://learn.microsoft.com/en-us/azure/cosmos-db/data-explorer#configure-request-unit-threshold",
};
export default class QueryError {
message: string;
helpLink?: string;
constructor(
public message: string,
message: string,
public severity: QueryErrorSeverity,
public code?: string,
public location?: QueryErrorLocation,
) {}
helpLink?: string,
) {
// Automatically replace the message with a more Data Explorer-specific message if we have for this error code.
this.message = REPLACEMENT_MESSAGES[code] ? REPLACEMENT_MESSAGES[code](message) : message;
// Automatically set the help link if we have one for this error code.
this.helpLink = helpLink ?? HELP_LINKS[code];
}
getMonacoSeverity(): monaco.MarkerSeverity {
// It's very difficult to use the monaco.MarkerSeverity enum from here, so we'll just use the numbers directly.
@@ -135,7 +166,7 @@ export default class QueryError {
return errors;
}
const errorMessage = getErrorMessage(error as string | Error);
const errorMessage = error as string;
// Map some well known messages to richer errors
const knownError = knownErrors[errorMessage];
@@ -160,7 +191,9 @@ export default class QueryError {
}
const severity =
"severity" in error && typeof error.severity === "string" ? (error.severity as QueryErrorSeverity) : undefined;
"severity" in error && typeof error.severity === "string"
? (error.severity as QueryErrorSeverity)
: QueryErrorSeverity.Error;
const location =
"location" in error && typeof error.location === "object"
? locationResolver(error.location as { start: number; end: number })
@@ -173,16 +206,15 @@ export default class QueryError {
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") {
let message: string | undefined;
if (typeof error === "object" && "message" in error && typeof error.message === "string") {
message = error.message;
} else {
// Unsupported error format.
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.
@@ -196,12 +228,15 @@ export default class QueryError {
try {
parsed = JSON.parse(message);
} catch (e) {
// Not a query error.
return null;
// The message doesn't contain a nested error.
return [QueryError.read(error, locationResolver)];
}
if (typeof parsed === "object" && "errors" in parsed && Array.isArray(parsed.errors)) {
return parsed.errors.map((e) => QueryError.read(e, locationResolver)).filter((e) => e !== null);
if (typeof parsed === "object") {
if ("errors" in parsed && Array.isArray(parsed.errors)) {
return parsed.errors.map((e) => QueryError.read(e, locationResolver)).filter((e) => e !== null);
}
return [QueryError.read(parsed, locationResolver)];
}
return null;
}

View File

@@ -3,11 +3,12 @@ import * as React from "react";
export interface TooltipProps {
children: string;
className?: string;
}
export const InfoTooltip: React.FunctionComponent<TooltipProps> = ({ children }: TooltipProps) => {
export const InfoTooltip: React.FunctionComponent<TooltipProps> = ({ children, className }: TooltipProps) => {
return (
<span>
<span className={className}>
<TooltipHost content={children}>
<Icon iconName="Info" ariaLabel={children} className="panelInfoIcon" tabIndex={0} />
</TooltipHost>

View File

@@ -26,14 +26,23 @@ export const deleteDocument = async (collection: CollectionBase, documentId: Doc
}
};
export interface IBulkDeleteResult {
documentId: DocumentId;
requestCharge: number;
statusCode: number;
retryAfterMilliseconds?: number;
}
/**
* Bulk delete documents
* @param collection
* @param documentId
* @returns array of ids that were successfully deleted
* @returns array of results and status codes
*/
export const deleteDocuments = async (collection: CollectionBase, documentIds: DocumentId[]): Promise<DocumentId[]> => {
const nbDocuments = documentIds.length;
export const deleteDocuments = async (
collection: CollectionBase,
documentIds: DocumentId[],
): Promise<IBulkDeleteResult[]> => {
const clearMessage = logConsoleProgress(`Deleting ${documentIds.length} ${getEntityName(true)}`);
try {
const v2Container = await client().database(collection.databaseId).container(collection.id());
@@ -56,18 +65,17 @@ export const deleteDocuments = async (collection: CollectionBase, documentIds: D
operationType: BulkOperationType.Delete,
}));
const promise = v2Container.items.bulk(operations).then((bulkResult) => {
return documentIdsChunk.filter((_, index) => bulkResult[index].statusCode === 204);
const promise = v2Container.items.bulk(operations).then((bulkResults) => {
return bulkResults.map((bulkResult, index) => {
const documentId = documentIdsChunk[index];
return { ...bulkResult, documentId };
});
});
promiseArray.push(promise);
}
const allResult = await Promise.all(promiseArray);
const flatAllResult = Array.prototype.concat.apply([], allResult);
logConsoleInfo(
`Successfully deleted ${getEntityName(flatAllResult.length > 1)}: ${flatAllResult.length} out of ${nbDocuments}`,
);
// TODO: handle case result.length != nbDocuments
return flatAllResult;
} catch (error) {
handleError(

View File

@@ -49,14 +49,12 @@ export interface ConfigContext {
ARCADIA_ENDPOINT: string;
ARCADIA_LIVY_ENDPOINT_DNS_ZONE: string;
BACKEND_ENDPOINT?: string;
PORTAL_BACKEND_ENDPOINT?: string;
PORTAL_BACKEND_ENDPOINT: string;
NEW_BACKEND_APIS?: BackendApi[];
MONGO_BACKEND_ENDPOINT?: string;
MONGO_PROXY_ENDPOINT?: string;
MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED?: boolean;
MONGO_PROXY_ENDPOINT: string;
NEW_MONGO_APIS?: string[];
CASSANDRA_PROXY_ENDPOINT?: string;
CASSANDRA_PROXY_OUTBOUND_IPS_ALLOWLISTED: boolean;
CASSANDRA_PROXY_ENDPOINT: string;
NEW_CASSANDRA_APIS?: string[];
PROXY_PATH?: string;
JUNO_ENDPOINT: string;
@@ -87,7 +85,7 @@ let configContext: Readonly<ConfigContext> = {
`^https:\\/\\/.*\\.analysis-df\\.net$`,
`^https:\\/\\/.*\\.analysis-df\\.windows\\.net$`,
`^https:\\/\\/.*\\.azure-test\\.net$`,
`^https:\\/\\/cosmos-explorer-preview\\.azurewebsites\\.net`,
`^https:\\/\\/cosmos-explorer-preview\\.azurewebsites\\.net$`,
], // Webpack injects this at build time
gitSha: process.env.GIT_SHA,
hostedExplorerURL: "https://cosmos.azure.com/",
@@ -117,11 +115,10 @@ let configContext: Readonly<ConfigContext> = {
"deleteDocument",
"createCollectionWithProxy",
"legacyMongoShell",
// "bulkdelete",
],
MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED: false,
CASSANDRA_PROXY_ENDPOINT: CassandraProxyEndpoints.Prod,
NEW_CASSANDRA_APIS: ["postQuery", "createOrDelete", "getKeys", "getSchema"],
CASSANDRA_PROXY_OUTBOUND_IPS_ALLOWLISTED: false,
isTerminalEnabled: false,
isPhoenixEnabled: false,
};

View File

@@ -98,7 +98,6 @@ export interface Database extends TreeNode {
openAddCollection(database: Database, event: MouseEvent): void;
onSettingsClick: () => void;
loadOffer(): Promise<void>;
getPendingThroughputSplitNotification(): Promise<DataModels.Notification>;
}
export interface CollectionBase extends TreeNode {
@@ -191,8 +190,6 @@ export interface Collection extends CollectionBase {
onDragOver(source: Collection, event: { originalEvent: DragEvent }): void;
onDrop(source: Collection, event: { originalEvent: DragEvent }): void;
uploadFiles(fileList: FileList): Promise<{ data: UploadDetailsRecord[] }>;
getPendingThroughputSplitNotification(): Promise<DataModels.Notification>;
}
/**

View File

@@ -1,23 +1,14 @@
/**
* React component for Command button component.
*/
import Explorer from "Explorer/Explorer";
import { KeyboardAction } from "KeyboardShortcuts";
import * as React from "react";
import CollapseChevronDownIcon from "../../../../images/QueryBuilder/CollapseChevronDown_16x.png";
import { KeyCodes } from "../../../Common/Constants";
import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
import * as StringUtils from "../../../Utils/StringUtils";
/**
* Options for this component
*/
export interface CommandButtonComponentProps {
/**
* font icon name for the button
*/
iconName?: string;
/**
* image source for the button icon
*/
@@ -31,7 +22,7 @@ export interface CommandButtonComponentProps {
/**
* Click handler for command button click
*/
onCommandClick?: (e: React.SyntheticEvent | KeyboardEvent) => void;
onCommandClick?: (e: React.SyntheticEvent | KeyboardEvent, container: Explorer) => void;
/**
* Label for the button
@@ -120,157 +111,3 @@ export interface CommandButtonComponentProps {
*/
keyboardAction?: KeyboardAction;
}
export class CommandButtonComponent extends React.Component<CommandButtonComponentProps> {
private dropdownElt: HTMLElement;
private expandButtonElt: HTMLElement;
public componentDidUpdate(): void {
if (!this.dropdownElt || !this.expandButtonElt) {
return;
}
$(this.dropdownElt).offset({ left: $(this.expandButtonElt).offset().left });
}
private onKeyPress(event: React.KeyboardEvent): boolean {
if (event.keyCode === KeyCodes.Space || event.keyCode === KeyCodes.Enter) {
this.commandClickCallback && this.commandClickCallback(event);
event.stopPropagation();
return false;
}
return true;
}
private onLauncherKeyDown(event: React.KeyboardEvent<HTMLDivElement>): boolean {
if (event.keyCode === KeyCodes.DownArrow) {
$(this.dropdownElt).hide();
$(this.dropdownElt).show().focus();
event.stopPropagation();
return false;
}
if (event.keyCode === KeyCodes.UpArrow) {
$(this.dropdownElt).hide();
event.stopPropagation();
return false;
}
return true;
}
private getCommandButtonId(): string {
if (this.props.id) {
return this.props.id;
} else {
return `commandButton-${StringUtils.stripSpacesFromString(this.props.commandButtonLabel)}`;
}
}
public static renderButton(options: CommandButtonComponentProps, key?: string): JSX.Element {
return <CommandButtonComponent key={key} {...options} />;
}
private commandClickCallback(e: React.SyntheticEvent): void {
if (this.props.disabled) {
return;
}
// TODO Query component's parent, not document
const el = document.querySelector(".commandDropdownContainer") as HTMLElement;
if (el) {
el.style.display = "none";
}
this.props.onCommandClick(e);
TelemetryProcessor.trace(Action.SelectItem, ActionModifiers.Mark, {
commandButtonClicked: this.props.commandButtonLabel,
});
}
private renderChildren(): JSX.Element {
if (!this.props.children || this.props.children.length < 1) {
return <React.Fragment />;
}
return (
<div
className="commandExpand"
tabIndex={0}
ref={(ref: HTMLElement) => {
this.expandButtonElt = ref;
}}
onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>) => this.onLauncherKeyDown(e)}
>
<div className="commandDropdownLauncher">
<span className="partialSplitter" />
<span className="expandDropdown">
<img src={CollapseChevronDownIcon} />
</span>
</div>
<div
className="commandDropdownContainer"
ref={(ref: HTMLElement) => {
this.dropdownElt = ref;
}}
>
<div className="commandDropdown">
{this.props.children.map((c: CommandButtonComponentProps, index: number): JSX.Element => {
return CommandButtonComponent.renderButton(c, `${index}`);
})}
</div>
</div>
</div>
);
}
public static renderLabel(
props: CommandButtonComponentProps,
key?: string,
refct?: (input: HTMLElement) => void,
): JSX.Element {
if (!props.commandButtonLabel) {
return <React.Fragment />;
}
return (
<span className="commandLabel" key={key} ref={refct}>
{props.commandButtonLabel}
</span>
);
}
public render(): JSX.Element {
let mainClassName = "commandButtonComponent";
if (this.props.disabled) {
mainClassName += " commandDisabled";
}
if (this.props.isSelected) {
mainClassName += " selectedButton";
}
let contentClassName = "commandContent";
if (this.props.children && this.props.children.length > 0) {
contentClassName += " hasHiddenItems";
}
return (
<div className="commandButtonReact">
<span
className={mainClassName}
role="menuitem"
tabIndex={this.props.tabIndex}
onKeyPress={(e: React.KeyboardEvent<HTMLSpanElement>) => this.onKeyPress(e)}
title={this.props.tooltipText}
id={this.getCommandButtonId()}
aria-disabled={this.props.disabled}
aria-haspopup={this.props.hasPopup}
aria-label={this.props.ariaLabel}
onClick={(e: React.MouseEvent<HTMLSpanElement>) => this.commandClickCallback(e)}
>
<div className={contentClassName}>
<img className="commandIcon" src={this.props.iconSrc} alt={this.props.iconAlt} />
{CommandButtonComponent.renderLabel(this.props)}
</div>
</span>
{this.props.children && this.renderChildren()}
</div>
);
}
}

View File

@@ -35,7 +35,7 @@ export interface DialogState {
textFieldProps?: TextFieldProps,
primaryButtonDisabled?: boolean,
) => void;
showOkModalDialog: (title: string, subText: string) => void;
showOkModalDialog: (title: string, subText: string, linkProps?: LinkProps) => void;
}
export const useDialog: UseStore<DialogState> = create((set, get) => ({
@@ -83,7 +83,7 @@ export const useDialog: UseStore<DialogState> = create((set, get) => ({
textFieldProps,
primaryButtonDisabled,
}),
showOkModalDialog: (title: string, subText: string): void =>
showOkModalDialog: (title: string, subText: string, linkProps?: LinkProps): void =>
get().openDialog({
isModal: true,
title,
@@ -94,6 +94,7 @@ export const useDialog: UseStore<DialogState> = create((set, get) => ({
get().closeDialog();
},
onSecondaryButtonClick: undefined,
linkProps,
}),
}));

View File

@@ -0,0 +1,79 @@
import {
Button,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
DialogTrigger,
Field,
ProgressBar,
} from "@fluentui/react-components";
import * as React from "react";
interface ProgressModalDialogProps {
isOpen: boolean;
title: string;
message: string;
maxValue: number;
value: number;
dismissText: string;
onDismiss: () => void;
onCancel?: () => void;
/* mode drives the state of the action buttons
* inProgress: Show cancel button
* completed: Show close button
* aborting: Show cancel button, but disabled
* aborted: Show close button
*/
mode?: "inProgress" | "completed" | "aborting" | "aborted";
}
/**
* React component that renders a modal dialog with a progress bar.
*/
export const ProgressModalDialog: React.FC<ProgressModalDialogProps> = ({
isOpen,
title,
message,
maxValue,
value,
dismissText,
onCancel,
onDismiss,
children,
mode = "completed",
}) => (
<Dialog
open={isOpen}
onOpenChange={(event, data) => {
if (!data.open) {
onDismiss();
}
}}
>
<DialogSurface>
<DialogBody>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<Field validationMessage={message} validationState="none">
<ProgressBar max={maxValue} value={value} />
</Field>
{children}
</DialogContent>
<DialogActions>
{mode === "inProgress" || mode === "aborting" ? (
<Button appearance="secondary" onClick={onCancel} disabled={mode === "aborting"}>
{dismissText}
</Button>
) : (
<DialogTrigger disableButtonEnhancement>
<Button appearance="primary">Close</Button>
</DialogTrigger>
)}
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
);

View File

@@ -134,7 +134,6 @@ describe("SettingsComponent", () => {
readSettings: undefined,
onSettingsClick: undefined,
loadOffer: undefined,
getPendingThroughputSplitNotification: undefined,
} as ViewModels.Database;
newCollection.getDatabase = () => newDatabase;
newCollection.offer = ko.observable(undefined);

View File

@@ -7,6 +7,7 @@ import {
ContainerVectorPolicyComponent,
ContainerVectorPolicyComponentProps,
} from "Explorer/Controls/Settings/SettingsSubComponents/ContainerVectorPolicyComponent";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { useDatabases } from "Explorer/useDatabases";
import { isVectorSearchEnabled } from "Utils/CapabilityUtils";
import { isRunningOnPublicCloud } from "Utils/CloudUtils";
@@ -32,7 +33,6 @@ import {
PartitionKeyComponent,
PartitionKeyComponentProps,
} from "../../Controls/Settings/SettingsSubComponents/PartitionKeyComponent";
import { useCommandBar } from "../../Menus/CommandBar/CommandBarComponentAdapter";
import { SettingsTabV2 } from "../../Tabs/SettingsTabV2";
import "./SettingsComponent.less";
import { mongoIndexingPolicyAADError } from "./SettingsRenderUtils";
@@ -130,7 +130,6 @@ export interface SettingsComponentState {
conflictResolutionPolicyProcedureBaseline: string;
isConflictResolutionDirty: boolean;
initialNotification: DataModels.Notification;
selectedTab: SettingsV2TabTypes;
}
@@ -229,7 +228,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
conflictResolutionPolicyProcedureBaseline: undefined,
isConflictResolutionDirty: false,
initialNotification: undefined,
selectedTab: SettingsV2TabTypes.ScaleTab,
};
@@ -1052,7 +1050,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
onMaxAutoPilotThroughputChange: this.onMaxAutoPilotThroughputChange,
onScaleSaveableChange: this.onScaleSaveableChange,
onScaleDiscardableChange: this.onScaleDiscardableChange,
initialNotification: this.props.settingsTab.pendingNotification(),
throughputError: this.state.throughputError,
};

View File

@@ -1,18 +1,10 @@
import { shallow } from "enzyme";
import ko from "knockout";
import React from "react";
import * as Constants from "../../../../Common/Constants";
import * as DataModels from "../../../../Contracts/DataModels";
import { updateUserContext } from "../../../../UserContext";
import Explorer from "../../../Explorer";
import { throughputUnit } from "../SettingsRenderUtils";
import { collection } from "../TestUtils";
import { ScaleComponent, ScaleComponentProps } from "./ScaleComponent";
import { ThroughputInputAutoPilotV3Component } from "./ThroughputInputComponents/ThroughputInputAutoPilotV3Component";
describe("ScaleComponent", () => {
const targetThroughput = 6000;
const baseProps: ScaleComponentProps = {
collection: collection,
database: undefined,
@@ -36,39 +28,8 @@ describe("ScaleComponent", () => {
onScaleDiscardableChange: () => {
return;
},
initialNotification: {
description: `Throughput update for ${targetThroughput} ${throughputUnit}`,
} as DataModels.Notification,
};
it("renders with correct initial notification", () => {
let wrapper = shallow(<ScaleComponent {...baseProps} />);
expect(wrapper).toMatchSnapshot();
expect(wrapper.exists(ThroughputInputAutoPilotV3Component)).toEqual(true);
expect(wrapper.exists("#throughputApplyLongDelayMessage")).toEqual(true);
expect(wrapper.exists("#throughputApplyShortDelayMessage")).toEqual(false);
expect(wrapper.find("#throughputApplyLongDelayMessage").html()).toContain(`${targetThroughput}`);
const newCollection = { ...collection };
const maxThroughput = 5000;
newCollection.offer = ko.observable({
manualThroughput: undefined,
autoscaleMaxThroughput: maxThroughput,
minimumThroughput: 400,
id: "offer",
offerReplacePending: true,
});
const newProps = {
...baseProps,
initialNotification: undefined as DataModels.Notification,
collection: newCollection,
};
wrapper = shallow(<ScaleComponent {...newProps} />);
expect(wrapper.exists("#throughputApplyShortDelayMessage")).toEqual(true);
expect(wrapper.exists("#throughputApplyLongDelayMessage")).toEqual(false);
expect(wrapper.find("#throughputApplyShortDelayMessage").html()).toContain(`${maxThroughput}`);
});
it("autoScale disabled", () => {
const scaleComponent = new ScaleComponent(baseProps);
expect(scaleComponent.isAutoScaleEnabled()).toEqual(false);

View File

@@ -10,7 +10,6 @@ import * as AutoPilotUtils from "../../../../Utils/AutoPilotUtils";
import { isRunningOnNationalCloud } from "../../../../Utils/CloudUtils";
import {
getTextFieldStyles,
getThroughputApplyLongDelayMessage,
getThroughputApplyShortDelayMessage,
subComponentStackProps,
throughputUnit,
@@ -34,7 +33,6 @@ export interface ScaleComponentProps {
onMaxAutoPilotThroughputChange: (newThroughput: number) => void;
onScaleSaveableChange: (isScaleSaveable: boolean) => void;
onScaleDiscardableChange: (isScaleDiscardable: boolean) => void;
initialNotification: DataModels.Notification;
throughputError?: string;
}
@@ -102,10 +100,6 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
};
public getInitialNotificationElement = (): JSX.Element => {
if (this.props.initialNotification) {
return this.getLongDelayMessage();
}
if (this.offer?.offerReplacePending) {
const throughput = this.offer.manualThroughput || this.offer.autoscaleMaxThroughput;
return getThroughputApplyShortDelayMessage(
@@ -120,26 +114,6 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
return undefined;
};
public getLongDelayMessage = (): JSX.Element => {
const matches: string[] = this.props.initialNotification?.description.match(
`Throughput update for (.*) ${throughputUnit}`,
);
const throughput = this.props.throughputBaseline;
const targetThroughput: number = matches.length > 1 && Number(matches[1]);
if (targetThroughput) {
return getThroughputApplyLongDelayMessage(
this.props.wasAutopilotOriginallySet,
throughput,
throughputUnit,
this.databaseId,
this.collectionId,
targetThroughput,
);
}
return <></>;
};
private getThroughputInputComponent = (): JSX.Element => (
<ThroughputInputAutoPilotV3Component
databaseAccount={userContext?.databaseAccount}

View File

@@ -1,64 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ScaleComponent renders with correct initial notification 1`] = `
<Stack
tokens={
{
"childrenGap": 20,
}
}
>
<StyledMessageBar
messageBarType={5}
>
<Text
id="throughputApplyLongDelayMessage"
styles={
{
"root": {
"color": "windowtext",
"fontSize": 14,
},
}
}
>
A request to increase the throughput is currently in progress. This operation will take 1-3 business days to complete. View the latest status in Notifications.
<br />
Database: test, Container: test
, Current autoscale throughput: 100 - 1000 RU/s, Target autoscale throughput: 600 - 6000 RU/s
</Text>
</StyledMessageBar>
<Stack
tokens={
{
"childrenGap": 20,
}
}
>
<ThroughputInputAutoPilotV3Component
canExceedMaximumValue={true}
collectionName="test"
databaseName="test"
isAutoPilotSelected={false}
isEmulator={false}
isEnabled={true}
isFixed={false}
label="Throughput (6,000 - unlimited RU/s)"
maxAutoPilotThroughput={4000}
maxAutoPilotThroughputBaseline={4000}
maximum={1000000}
minimum={6000}
onAutoPilotSelected={[Function]}
onMaxAutoPilotThroughputChange={[Function]}
onScaleDiscardableChange={[Function]}
onScaleSaveableChange={[Function]}
onThroughputChange={[Function]}
spendAckChecked={false}
throughput={1000}
throughputBaseline={1000}
usageSizeInKB={100}
wasAutopilotOriginallySet={true}
/>
</Stack>
</Stack>
`;

View File

@@ -44,7 +44,6 @@ describe("SettingsUtils", () => {
readSettings: undefined,
onSettingsClick: undefined,
loadOffer: undefined,
getPendingThroughputSplitNotification: undefined,
} as ViewModels.Database;
};
newCollection.offer(undefined);

View File

@@ -17,7 +17,7 @@ export const useTreeStyles = makeStyles({
minWidth: "100%",
rowGap: "0px",
paddingTop: "0px",
[treeIconWidth]: "20px",
[treeIconWidth]: "16px",
[leafNodeSpacing]: "24px",
},
nodeIcon: {
@@ -25,12 +25,13 @@ export const useTreeStyles = makeStyles({
height: `var(${treeIconWidth})`,
},
treeItem: {},
nodeLabel: {},
nodeLabel: {
whiteSpace: "nowrap", // Don't wrap text, there will be a scrollbar.
},
treeItemLayout: {
fontSize: tokens.fontSizeBase300,
height: tokens.layoutRowHeight,
...cosmosShorthands.borderBottom(),
paddingLeft: `calc(var(${treeItemLevelToken}, 1) * ${tokens.spacingHorizontalXXL})`,
// Some sneaky CSS variables stuff to change the background color of the action button on hover.
[actionButtonBackground]: tokens.colorNeutralBackground1,

View File

@@ -107,7 +107,7 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
const onOpenChange = useCallback(
(_: TreeOpenChangeEvent, data: TreeOpenChangeData) => {
if (data.type === "Click" && !isBranch && node.onClick) {
if (data.type === "Click" && node.onClick) {
node.onClick();
}
if (!node.isExpanded && data.open && node.onExpanded) {
@@ -119,7 +119,7 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
node.onCollapsed?.();
}
},
[isBranch, node, setIsLoading],
[node, setIsLoading],
);
const onMenuOpenChange = useCallback(
@@ -149,15 +149,16 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
// We use the expandIcon slot to hold the node icon too.
// We only show a node icon for leaf nodes, even if a branch node has an iconSrc.
const expandIcon = isLoading ? (
<Spinner size="extra-tiny" />
) : !isBranch ? (
typeof node.iconSrc === "string" ? (
const treeIcon =
node.iconSrc === undefined ? undefined : typeof node.iconSrc === "string" ? (
<img src={node.iconSrc} className={treeStyles.nodeIcon} alt="" />
) : (
node.iconSrc
)
) : openItems.includes(treeNodeId) ? (
);
const expandIcon = isLoading ? (
<Spinner size="extra-tiny" />
) : !isBranch ? undefined : openItems.includes(treeNodeId) ? (
<ChevronDown20Regular data-test="TreeNode/CollapseIcon" />
) : (
<ChevronRight20Regular data-text="TreeNode/ExpandIcon" />
@@ -174,7 +175,6 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
<TreeItemLayout
className={mergeClasses(
treeStyles.treeItemLayout,
expandIcon ? undefined : treeStyles.treeItemLayoutNoIcon,
shouldShowAsSelected && treeStyles.selectedItem,
node.className && treeStyles[node.className],
)}
@@ -200,6 +200,7 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
),
}
}
iconBefore={treeIcon}
expandIcon={expandIcon}
>
<span className={treeStyles.nodeLabel}>{node.label}</span>

View File

@@ -10,16 +10,23 @@ exports[`TreeNodeComponent does not render children if the node is loading 1`] =
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -156,7 +163,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
"itemType": "branch",
"layoutRef": {
"current": <div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
>
<div
@@ -179,11 +186,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/>
</svg>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -208,7 +225,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="-1"
>
<div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
>
<div
@@ -231,18 +248,28 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/>
</svg>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
</div>
</div>
<div
class="fui-Tree rnv2ez3 ___jy13a00_lpffjy0 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
class="fui-Tree rnv2ez3 ___17a32do_7zrvj80 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
data-test="Tree:root"
role="tree"
>
@@ -256,7 +283,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="0"
>
<div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child1Label"
>
<div
@@ -279,11 +306,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/>
</svg>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child1Icon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child1Label
</span>
@@ -300,7 +337,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="-1"
>
<div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child2LoadingLabel"
>
<div
@@ -323,11 +360,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/>
</svg>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child2LoadingIcon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child2LoadingLabel
</span>
@@ -343,7 +390,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="-1"
>
<div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vrg09j fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_vz3p260 fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child3ExpandingLabel"
>
<div
@@ -363,11 +410,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</span>
</div>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child3ExpandingIcon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child3ExpandingLabel
</span>
@@ -383,16 +440,23 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
}
>
<div
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
className="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
>
<div
@@ -419,11 +483,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</svg>
</ChevronRight20Regular>
</div>
<div
aria-hidden={true}
className="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
</div>
<div
className="fui-TreeItemLayout__main rklbe47"
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -431,7 +505,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</div>
</TreeItemLayout>
<Tree
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
className="___17a32do_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
data-test="Tree:root"
>
<TreeProvider
@@ -499,7 +573,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
}
>
<div
className="fui-Tree rnv2ez3 ___jy13a00_lpffjy0 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
className="fui-Tree rnv2ez3 ___17a32do_7zrvj80 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
data-test="Tree:root"
role="tree"
>
@@ -587,7 +661,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
"itemType": "branch",
"layoutRef": {
"current": <div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child1Label"
>
<div
@@ -610,11 +684,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/>
</svg>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child1Icon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child1Label
</span>
@@ -639,7 +723,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="0"
>
<div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child1Label"
>
<div
@@ -662,11 +746,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/>
</svg>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child1Icon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child1Label
</span>
@@ -680,16 +774,23 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child1Label"
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child1Icon"
/>
}
>
<div
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
className="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child1Label"
>
<div
@@ -716,11 +817,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</svg>
</ChevronRight20Regular>
</div>
<div
aria-hidden={true}
className="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child1Icon"
/>
</div>
<div
className="fui-TreeItemLayout__main rklbe47"
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
child1Label
</span>
@@ -728,7 +839,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</div>
</TreeItemLayout>
<Tree
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
className="___17a32do_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
data-test="Tree:root/child1Label"
>
<TreeProvider
@@ -821,7 +932,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
"itemType": "branch",
"layoutRef": {
"current": <div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child2LoadingLabel"
>
<div
@@ -844,11 +955,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/>
</svg>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child2LoadingIcon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child2LoadingLabel
</span>
@@ -873,7 +994,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="-1"
>
<div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child2LoadingLabel"
>
<div
@@ -896,11 +1017,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/>
</svg>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child2LoadingIcon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child2LoadingLabel
</span>
@@ -914,16 +1045,23 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child2LoadingLabel"
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child2LoadingIcon"
/>
}
>
<div
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
className="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child2LoadingLabel"
>
<div
@@ -950,11 +1088,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</svg>
</ChevronRight20Regular>
</div>
<div
aria-hidden={true}
className="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child2LoadingIcon"
/>
</div>
<div
className="fui-TreeItemLayout__main rklbe47"
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
child2LoadingLabel
</span>
@@ -1039,7 +1187,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
"itemType": "leaf",
"layoutRef": {
"current": <div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vrg09j fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_vz3p260 fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child3ExpandingLabel"
>
<div
@@ -1059,11 +1207,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</span>
</div>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child3ExpandingIcon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child3ExpandingLabel
</span>
@@ -1087,7 +1245,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="-1"
>
<div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vrg09j fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_vz3p260 fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child3ExpandingLabel"
>
<div
@@ -1107,11 +1265,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</span>
</div>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child3ExpandingIcon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child3ExpandingLabel
</span>
@@ -1125,9 +1293,9 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child3ExpandingLabel"
expandIcon={
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
@@ -1136,12 +1304,12 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
}
>
<div
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vrg09j fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_vz3p260 fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child3ExpandingLabel"
>
<div
aria-hidden={true}
className="fui-TreeItemLayout__expandIcon rh4pu5o"
className="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
@@ -1153,7 +1321,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
className="fui-TreeItemLayout__main rklbe47"
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
child3ExpandingLabel
</span>
@@ -1184,9 +1352,9 @@ exports[`TreeNodeComponent renders a loading spinner if the node is loading: loa
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
@@ -1195,7 +1363,7 @@ exports[`TreeNodeComponent renders a loading spinner if the node is loading: loa
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -1213,16 +1381,23 @@ exports[`TreeNodeComponent renders a loading spinner if the node is loading: loa
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={
<Spinner
size="extra-tiny"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -1240,16 +1415,23 @@ exports[`TreeNodeComponent renders a node as expandable if it has empty, but def
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -1313,9 +1495,9 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
"className": "___1r8p62d_0000000 f1xg1ack f1e31b4d",
}
}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
@@ -1324,7 +1506,7 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -1363,9 +1545,9 @@ exports[`TreeNodeComponent renders a single node 1`] = `
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
@@ -1374,7 +1556,7 @@ exports[`TreeNodeComponent renders a single node 1`] = `
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -1392,9 +1574,9 @@ exports[`TreeNodeComponent renders an icon if the node has one 1`] = `
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
@@ -1403,7 +1585,7 @@ exports[`TreeNodeComponent renders an icon if the node has one 1`] = `
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -1421,22 +1603,29 @@ exports[`TreeNodeComponent renders selected parent node as selected if no descen
>
<TreeItemLayout
actions={false}
className="___kqkdor0_ihxn0o0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1nfm20t f1do9gdl"
className="___rq9vxg0_1ykn2d2 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1nfm20t f1do9gdl"
data-test="TreeNode:root"
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
</TreeItemLayout>
<Tree
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
className="___17a32do_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
data-test="Tree:root"
>
<TreeNodeComponent
@@ -1497,22 +1686,29 @@ exports[`TreeNodeComponent renders selected parent node as unselected if any des
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
</TreeItemLayout>
<Tree
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
className="___17a32do_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
data-test="Tree:root"
>
<TreeNodeComponent
@@ -1574,9 +1770,9 @@ exports[`TreeNodeComponent renders single selected leaf node as selected 1`] = `
>
<TreeItemLayout
actions={false}
className="___kqkdor0_ihxn0o0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1nfm20t f1do9gdl"
className="___rq9vxg0_1ykn2d2 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1nfm20t f1do9gdl"
data-test="TreeNode:root"
expandIcon={
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
@@ -1585,7 +1781,7 @@ exports[`TreeNodeComponent renders single selected leaf node as selected 1`] = `
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>

View File

@@ -1,9 +1,11 @@
import * as msal from "@azure/msal-browser";
import { Link } from "@fluentui/react/lib/Link";
import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility";
import { Environment, getEnvironment } from "Common/EnvironmentUtility";
import { sendMessage } from "Common/MessageHandler";
import { Platform, configContext } from "ConfigContext";
import { MessageTypes } from "Contracts/ExplorerContracts";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
import { getCopilotEnabled, isCopilotFeatureRegistered } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { IGalleryItem } from "Juno/JunoClient";
@@ -46,7 +48,6 @@ import { useTabs } from "../hooks/useTabs";
import "./ComponentRegisterer";
import { DialogProps, useDialog } from "./Controls/Dialog";
import { GalleryTab as GalleryTabKind } from "./Controls/NotebookGallery/GalleryViewerComponent";
import { useCommandBar } from "./Menus/CommandBar/CommandBarComponentAdapter";
import * as FileSystemUtil from "./Notebook/FileSystemUtil";
import { SnapshotRequest } from "./Notebook/NotebookComponent/types";
import { NotebookContentItem, NotebookContentItemType } from "./Notebook/NotebookContentItem";
@@ -1119,7 +1120,7 @@ export default class Explorer {
}
}
public openUploadItemsPanePane(): void {
public openUploadItemsPane(): void {
useSidePanel.getState().openSidePanel("Upload " + getUploadName(), <UploadItemsPane />);
}
public openExecuteSprocParamsPanel(storedProcedure: StoredProcedure): void {
@@ -1178,7 +1179,11 @@ export default class Explorer {
}
public async configureCopilot(): Promise<void> {
if (userContext.apiType !== "SQL" || !userContext.subscriptionId) {
if (
userContext.apiType !== "SQL" ||
!userContext.subscriptionId ||
![Environment.Development, Environment.Mpac, Environment.Prod].includes(getEnvironment())
) {
return;
}
const copilotEnabledPromise = getCopilotEnabled();

View File

@@ -4,83 +4,51 @@
* and update any knockout observables passed from the parent.
*/
import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react";
import {
createPlatformButtons,
createStaticCommandBarButtons,
} from "Explorer/Menus/CommandBar/CommandBarComponentButtonFactory";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { useNotebook } from "Explorer/Notebook/useNotebook";
import { KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
import { userContext } from "UserContext";
import * as React from "react";
import create, { UseStore } from "zustand";
import { ConnectionStatusType, PoolIdType } from "../../../Common/Constants";
import { StyleConstants } from "../../../Common/StyleConstants";
import { Platform, configContext } from "../../../ConfigContext";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import Explorer from "../../Explorer";
import { useSelectedNode } from "../../useSelectedNode";
import * as CommandBarComponentButtonFactory from "./CommandBarComponentButtonFactory";
import * as CommandBarUtil from "./CommandBarUtil";
interface Props {
container: Explorer;
}
export interface CommandBarStore {
contextButtons: CommandButtonComponentProps[];
setContextButtons: (contextButtons: CommandButtonComponentProps[]) => void;
isHidden: boolean;
setIsHidden: (isHidden: boolean) => void;
}
export const useCommandBar: UseStore<CommandBarStore> = create((set) => ({
contextButtons: [],
setContextButtons: (contextButtons: CommandButtonComponentProps[]) => set((state) => ({ ...state, contextButtons })),
isHidden: false,
setIsHidden: (isHidden: boolean) => set((state) => ({ ...state, isHidden })),
}));
export const CommandBar: React.FC<Props> = ({ container }: Props) => {
const selectedNodeState = useSelectedNode();
const buttons = useCommandBar((state) => state.contextButtons);
const contextButtons = useCommandBar((state) => state.contextButtons);
const isHidden = useCommandBar((state) => state.isHidden);
const backgroundColor = StyleConstants.BaseLight;
const setKeyboardHandlers = useKeyboardActionGroup(KeyboardActionGroup.COMMAND_BAR);
const staticButtons = createStaticCommandBarButtons(selectedNodeState);
const platformButtons = createPlatformButtons();
if (userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo") {
const buttons =
userContext.apiType === "Postgres"
? CommandBarComponentButtonFactory.createPostgreButtons(container)
: CommandBarComponentButtonFactory.createVCoreMongoButtons(container);
return (
<div className="commandBarContainer" style={{ display: isHidden ? "none" : "initial" }}>
<FluentCommandBar
ariaLabel="Use left and right arrow keys to navigate between commands"
items={CommandBarUtil.convertButton(buttons, backgroundColor)}
styles={{
root: { backgroundColor: backgroundColor },
}}
overflowButtonProps={{ ariaLabel: "More commands" }}
/>
</div>
);
}
const staticButtons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(container, selectedNodeState);
const contextButtons = (buttons || []).concat(
CommandBarComponentButtonFactory.createContextCommandBarButtons(container, selectedNodeState),
);
const controlButtons = CommandBarComponentButtonFactory.createControlCommandBarButtons(container);
const uiFabricStaticButtons = CommandBarUtil.convertButton(staticButtons, backgroundColor);
if (buttons && buttons.length > 0) {
const uiFabricStaticButtons = CommandBarUtil.convertButton(staticButtons, backgroundColor, container);
if (contextButtons?.length > 0) {
uiFabricStaticButtons.forEach((btn: ICommandBarItemProps) => (btn.iconOnly = true));
}
const uiFabricTabsButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton(contextButtons, backgroundColor);
const uiFabricTabsButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton(
contextButtons || [],
backgroundColor,
container,
);
if (uiFabricTabsButtons.length > 0) {
uiFabricStaticButtons.push(CommandBarUtil.createDivider("commandBarDivider"));
}
const uiFabricControlButtons = CommandBarUtil.convertButton(controlButtons, backgroundColor);
uiFabricControlButtons.forEach((btn: ICommandBarItemProps) => (btn.iconOnly = true));
const uiFabricPlatformButtons = CommandBarUtil.convertButton(platformButtons || [], backgroundColor, container);
uiFabricPlatformButtons.forEach((btn: ICommandBarItemProps) => (btn.iconOnly = true));
const connectionInfo = useNotebook((state) => state.connectionInfo);
@@ -88,7 +56,7 @@ export const CommandBar: React.FC<Props> = ({ container }: Props) => {
(useNotebook.getState().isPhoenixNotebooks || useNotebook.getState().isPhoenixFeatures) &&
connectionInfo?.status !== ConnectionStatusType.Connect
) {
uiFabricControlButtons.unshift(
uiFabricPlatformButtons.unshift(
CommandBarUtil.createConnectionStatus(container, PoolIdType.DefaultPoolId, "connectionStatus"),
);
}
@@ -107,8 +75,8 @@ export const CommandBar: React.FC<Props> = ({ container }: Props) => {
},
};
const allButtons = staticButtons.concat(contextButtons).concat(controlButtons);
const keyboardHandlers = CommandBarUtil.createKeyboardHandlers(allButtons);
const allButtons = staticButtons.concat(contextButtons).concat(platformButtons);
const keyboardHandlers = CommandBarUtil.createKeyboardHandlers(allButtons, container);
setKeyboardHandlers(keyboardHandlers);
return (
@@ -116,7 +84,7 @@ export const CommandBar: React.FC<Props> = ({ container }: Props) => {
<FluentCommandBar
ariaLabel="Use left and right arrow keys to navigate between commands"
items={uiFabricStaticButtons.concat(uiFabricTabsButtons)}
farItems={uiFabricControlButtons}
farItems={uiFabricPlatformButtons}
styles={rootStyle}
overflowButtonProps={{ ariaLabel: "More commands" }}
/>

View File

@@ -3,15 +3,12 @@ import { AuthType } from "../../../AuthType";
import { DatabaseAccount } from "../../../Contracts/DataModels";
import { CollectionBase } from "../../../Contracts/ViewModels";
import { updateUserContext } from "../../../UserContext";
import Explorer from "../../Explorer";
import { useNotebook } from "../../Notebook/useNotebook";
import { useDatabases } from "../../useDatabases";
import { useSelectedNode } from "../../useSelectedNode";
import * as CommandBarComponentButtonFactory from "./CommandBarComponentButtonFactory";
describe("CommandBarComponentButtonFactory tests", () => {
let mockExplorer: Explorer;
afterEach(() => useSelectedNode.getState().setSelectedNode(undefined));
describe("Enable Azure Synapse Link Button", () => {
@@ -19,7 +16,6 @@ describe("CommandBarComponentButtonFactory tests", () => {
const selectedNodeState = useSelectedNode.getState();
beforeAll(() => {
mockExplorer = {} as Explorer;
updateUserContext({
databaseAccount: {
properties: {
@@ -30,7 +26,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
});
it("Button should be visible", () => {
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(selectedNodeState);
const enableAzureSynapseLinkBtn = buttons.find(
(button) => button.commandButtonLabel === enableAzureSynapseLinkBtnLabel,
);
@@ -46,7 +42,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
} as DatabaseAccount,
});
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(selectedNodeState);
const enableAzureSynapseLinkBtn = buttons.find(
(button) => button.commandButtonLabel === enableAzureSynapseLinkBtnLabel,
);
@@ -62,7 +58,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
} as DatabaseAccount,
});
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(selectedNodeState);
const enableAzureSynapseLinkBtn = buttons.find(
(button) => button.commandButtonLabel === enableAzureSynapseLinkBtnLabel,
);
@@ -75,7 +71,6 @@ describe("CommandBarComponentButtonFactory tests", () => {
const selectedNodeState = useSelectedNode.getState();
beforeAll(() => {
mockExplorer = {} as Explorer;
updateUserContext({
databaseAccount: {
properties: {
@@ -108,7 +103,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
},
} as DatabaseAccount,
});
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(selectedNodeState);
const openCassandraShellBtn = buttons.find((button) => button.commandButtonLabel === openCassandraShellBtnLabel);
expect(openCassandraShellBtn).toBeUndefined();
});
@@ -118,13 +113,13 @@ describe("CommandBarComponentButtonFactory tests", () => {
portalEnv: "mooncake",
});
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(selectedNodeState);
const openCassandraShellBtn = buttons.find((button) => button.commandButtonLabel === openCassandraShellBtnLabel);
expect(openCassandraShellBtn).toBeUndefined();
});
it("Notebooks is not enabled and is unavailable - button should be shown and disabled", () => {
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(selectedNodeState);
const openCassandraShellBtn = buttons.find((button) => button.commandButtonLabel === openCassandraShellBtnLabel);
expect(openCassandraShellBtn).toBeUndefined();
});
@@ -134,12 +129,8 @@ describe("CommandBarComponentButtonFactory tests", () => {
const openPostgresShellButtonLabel = "Open PSQL shell";
const openVCoreMongoShellButtonLabel = "Open MongoDB (vCore) shell";
beforeAll(() => {
mockExplorer = {} as Explorer;
});
it("creates Postgres shell button", () => {
const buttons = CommandBarComponentButtonFactory.createPostgreButtons(mockExplorer);
const buttons = CommandBarComponentButtonFactory.createPostgreButtons();
const openPostgresShellButton = buttons.find(
(button) => button.commandButtonLabel === openPostgresShellButtonLabel,
);
@@ -147,7 +138,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
});
it("creates vCore Mongo shell button", () => {
const buttons = CommandBarComponentButtonFactory.createVCoreMongoButtons(mockExplorer);
const buttons = CommandBarComponentButtonFactory.createVCoreMongoButtons();
const openVCoreMongoShellButton = buttons.find(
(button) => button.commandButtonLabel === openVCoreMongoShellButtonLabel,
);
@@ -162,8 +153,6 @@ describe("CommandBarComponentButtonFactory tests", () => {
const selectedNodeState = useSelectedNode.getState();
beforeAll(() => {
mockExplorer = {} as Explorer;
updateUserContext({
authType: AuthType.ResourceToken,
});
@@ -175,7 +164,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
kind: "DocumentDB",
} as DatabaseAccount,
});
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(selectedNodeState);
expect(buttons.length).toBe(2);
expect(buttons[0].commandButtonLabel).toBe("New SQL Query");
expect(buttons[0].disabled).toBe(false);

View File

@@ -21,7 +21,6 @@ import { userContext } from "../../../UserContext";
import { isRunningOnNationalCloud } from "../../../Utils/CloudUtils";
import { useSidePanel } from "../../../hooks/useSidePanel";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import Explorer from "../../Explorer";
import { useNotebook } from "../../Notebook/useNotebook";
import { OpenFullScreen } from "../../OpenFullScreen";
import { BrowseQueriesPane } from "../../Panes/BrowseQueriesPane/BrowseQueriesPane";
@@ -32,19 +31,20 @@ import { SelectedNodeState, useSelectedNode } from "../../useSelectedNode";
let counter = 0;
export function createStaticCommandBarButtons(
container: Explorer,
selectedNodeState: SelectedNodeState,
): CommandButtonComponentProps[] {
export function createStaticCommandBarButtons(selectedNodeState: SelectedNodeState): CommandButtonComponentProps[] {
if (userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo") {
return userContext.apiType === "Postgres" ? createPostgreButtons() : createVCoreMongoButtons();
}
if (userContext.authType === AuthType.ResourceToken) {
return createStaticCommandBarButtonsForResourceToken(container, selectedNodeState);
return createStaticCommandBarButtonsForResourceToken(selectedNodeState);
}
const buttons: CommandButtonComponentProps[] = [];
// Avoid starting with a divider
const addDivider = () => {
if (buttons.length > 0) {
if (buttons.length > 0 && !buttons[buttons.length - 1].isDivider) {
buttons.push(createDivider());
}
};
@@ -54,7 +54,7 @@ export function createStaticCommandBarButtons(
userContext.apiType !== "Tables" &&
userContext.apiType !== "Cassandra"
) {
const addSynapseLink = createOpenSynapseLinkDialogButton(container);
const addSynapseLink = createOpenSynapseLinkDialogButton();
if (addSynapseLink) {
addDivider();
buttons.push(addSynapseLink);
@@ -67,9 +67,9 @@ export function createStaticCommandBarButtons(
const aadTokenUpdated = useDataPlaneRbac((state) => state.aadTokenUpdated);
useEffect(() => {
const buttonProps = createLoginForEntraIDButton(container);
const buttonProps = createLoginForEntraIDButton();
setLoginButtonProps(buttonProps);
}, [dataPlaneRbacEnabled, aadTokenUpdated, container]);
}, [dataPlaneRbacEnabled, aadTokenUpdated]);
if (loginButtonProps) {
addDivider();
@@ -87,8 +87,8 @@ export function createStaticCommandBarButtons(
}
if (isQuerySupported && selectedNodeState.findSelectedCollection() && configContext.platform !== Platform.Fabric) {
const openQueryBtn = createOpenQueryButton(container);
openQueryBtn.children = [createOpenQueryButton(container), createOpenQueryFromDiskButton()];
const openQueryBtn = createOpenQueryButton();
openQueryBtn.children = [createOpenQueryButton(), createOpenQueryFromDiskButton()];
buttons.push(openQueryBtn);
}
@@ -103,6 +103,7 @@ export function createStaticCommandBarButtons(
selectedCollection && selectedCollection.onNewStoredProcedureClick(selectedCollection);
},
commandButtonLabel: label,
tooltipText: userContext.features.commandBarV2 ? "New..." : label,
ariaLabel: label,
hasPopup: true,
disabled:
@@ -115,21 +116,12 @@ export function createStaticCommandBarButtons(
}
}
return buttons;
}
export function createContextCommandBarButtons(
container: Explorer,
selectedNodeState: SelectedNodeState,
): CommandButtonComponentProps[] {
const buttons: CommandButtonComponentProps[] = [];
if (!selectedNodeState.isDatabaseNodeOrNoneSelected() && userContext.apiType === "Mongo") {
const label = useNotebook.getState().isShellEnabled ? "Open Mongo Shell" : "New Shell";
const newMongoShellBtn: CommandButtonComponentProps = {
iconSrc: HostedTerminalIcon,
iconAlt: label,
onCommandClick: () => {
onCommandClick: (_, container) => {
const selectedCollection: ViewModels.Collection = selectedNodeState.findSelectedCollection();
if (useNotebook.getState().isShellEnabled) {
container.openNotebookTerminal(ViewModels.TerminalKind.Mongo);
@@ -141,6 +133,7 @@ export function createContextCommandBarButtons(
ariaLabel: label,
hasPopup: true,
};
addDivider();
buttons.push(newMongoShellBtn);
}
@@ -153,36 +146,34 @@ export function createContextCommandBarButtons(
const newCassandraShellButton: CommandButtonComponentProps = {
iconSrc: HostedTerminalIcon,
iconAlt: label,
onCommandClick: () => {
onCommandClick: (_, container) => {
container.openNotebookTerminal(ViewModels.TerminalKind.Cassandra);
},
commandButtonLabel: label,
ariaLabel: label,
hasPopup: true,
};
addDivider();
buttons.push(newCassandraShellButton);
}
return buttons;
}
export function createControlCommandBarButtons(container: Explorer): CommandButtonComponentProps[] {
const buttons: CommandButtonComponentProps[] =
configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly
? []
: [
{
iconSrc: SettingsIcon,
iconAlt: "Settings",
onCommandClick: () =>
useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={container} />),
commandButtonLabel: undefined,
ariaLabel: "Settings",
tooltipText: "Settings",
hasPopup: true,
disabled: false,
},
];
export function createPlatformButtons(): CommandButtonComponentProps[] {
const buttons: CommandButtonComponentProps[] = [
{
iconSrc: SettingsIcon,
iconAlt: "Settings",
onCommandClick: (_, container) =>
useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={container} />),
commandButtonLabel: undefined,
ariaLabel: "Settings",
tooltipText: "Settings",
hasPopup: true,
disabled: false,
},
];
const showOpenFullScreen =
configContext.platform === Platform.Portal && !isRunningOnNationalCloud() && userContext.apiType !== "Gremlin";
@@ -211,7 +202,7 @@ export function createControlCommandBarButtons(container: Explorer): CommandButt
const feedbackButtonOptions: CommandButtonComponentProps = {
iconSrc: FeedbackIcon,
iconAlt: label,
onCommandClick: () => container.openCESCVAFeedbackBlade(),
onCommandClick: (_, container) => container.openCESCVAFeedbackBlade(),
commandButtonLabel: undefined,
ariaLabel: label,
tooltipText: label,
@@ -243,7 +234,7 @@ function areScriptsSupported(): boolean {
);
}
function createOpenSynapseLinkDialogButton(container: Explorer): CommandButtonComponentProps {
function createOpenSynapseLinkDialogButton(): CommandButtonComponentProps {
if (configContext.platform === Platform.Emulator) {
return undefined;
}
@@ -261,7 +252,7 @@ function createOpenSynapseLinkDialogButton(container: Explorer): CommandButtonCo
return {
iconSrc: SynapseIcon,
iconAlt: label,
onCommandClick: () => container.openEnableSynapseLinkDialog(),
onCommandClick: (_, container) => container.openEnableSynapseLinkDialog(),
commandButtonLabel: label,
hasPopup: false,
disabled:
@@ -270,12 +261,12 @@ function createOpenSynapseLinkDialogButton(container: Explorer): CommandButtonCo
};
}
function createLoginForEntraIDButton(container: Explorer): CommandButtonComponentProps {
function createLoginForEntraIDButton(): CommandButtonComponentProps {
if (configContext.platform !== Platform.Portal) {
return undefined;
}
const handleCommandClick = async () => {
const handleCommandClick: CommandButtonComponentProps["onCommandClick"] = async (_, container) => {
await container.openLoginForEntraIDPopUp();
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: true });
};
@@ -402,13 +393,14 @@ export function createScriptCommandButtons(selectedNodeState: SelectedNodeState)
return buttons;
}
function createOpenQueryButton(container: Explorer): CommandButtonComponentProps {
function createOpenQueryButton(): CommandButtonComponentProps {
const label = "Open Query";
return {
iconSrc: BrowseQueriesIcon,
iconAlt: label,
tooltipText: userContext.features.commandBarV2 ? "Open Query..." : "Open Query",
keyboardAction: KeyboardAction.OPEN_QUERY,
onCommandClick: () =>
onCommandClick: (_, container) =>
useSidePanel.getState().openSidePanel("Open Saved Queries", <BrowseQueriesPane explorer={container} />),
commandButtonLabel: label,
ariaLabel: label,
@@ -431,10 +423,7 @@ function createOpenQueryFromDiskButton(): CommandButtonComponentProps {
};
}
function createOpenTerminalButtonByKind(
container: Explorer,
terminalKind: ViewModels.TerminalKind,
): CommandButtonComponentProps {
function createOpenTerminalButtonByKind(terminalKind: ViewModels.TerminalKind): CommandButtonComponentProps {
const terminalFriendlyName = (): string => {
switch (terminalKind) {
case ViewModels.TerminalKind.Cassandra:
@@ -457,7 +446,7 @@ function createOpenTerminalButtonByKind(
return {
iconSrc: HostedTerminalIcon,
iconAlt: label,
onCommandClick: () => {
onCommandClick: (_, container) => {
if (useNotebook.getState().isNotebookEnabled) {
container.openNotebookTerminal(terminalKind);
}
@@ -471,11 +460,10 @@ function createOpenTerminalButtonByKind(
}
function createStaticCommandBarButtonsForResourceToken(
container: Explorer,
selectedNodeState: SelectedNodeState,
): CommandButtonComponentProps[] {
const newSqlQueryBtn = createNewSQLQueryButton(selectedNodeState);
const openQueryBtn = createOpenQueryButton(container);
const openQueryBtn = createOpenQueryButton();
const resourceTokenCollection: ViewModels.CollectionBase = useDatabases.getState().resourceTokenCollection;
const isResourceTokenCollectionNodeSelected: boolean =
@@ -488,20 +476,20 @@ function createStaticCommandBarButtonsForResourceToken(
openQueryBtn.disabled = !isResourceTokenCollectionNodeSelected;
if (!openQueryBtn.disabled) {
openQueryBtn.children = [createOpenQueryButton(container), createOpenQueryFromDiskButton()];
openQueryBtn.children = [createOpenQueryButton(), createOpenQueryFromDiskButton()];
}
return [newSqlQueryBtn, openQueryBtn];
}
export function createPostgreButtons(container: Explorer): CommandButtonComponentProps[] {
const openPostgreShellBtn = createOpenTerminalButtonByKind(container, ViewModels.TerminalKind.Postgres);
export function createPostgreButtons(): CommandButtonComponentProps[] {
const openPostgreShellBtn = createOpenTerminalButtonByKind(ViewModels.TerminalKind.Postgres);
return [openPostgreShellBtn];
}
export function createVCoreMongoButtons(container: Explorer): CommandButtonComponentProps[] {
const openVCoreMongoTerminalButton = createOpenTerminalButtonByKind(container, ViewModels.TerminalKind.VCoreMongo);
export function createVCoreMongoButtons(): CommandButtonComponentProps[] {
const openVCoreMongoTerminalButton = createOpenTerminalButtonByKind(ViewModels.TerminalKind.VCoreMongo);
return [openVCoreMongoTerminalButton];
}

View File

@@ -1,8 +1,10 @@
import { ICommandBarItemProps } from "@fluentui/react";
import Explorer from "Explorer/Explorer";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import * as CommandBarUtil from "./CommandBarUtil";
describe("CommandBarUtil tests", () => {
const mockExplorer = {} as Explorer;
const createButton = (): CommandButtonComponentProps => {
return {
iconSrc: "icon",
@@ -22,7 +24,7 @@ describe("CommandBarUtil tests", () => {
const btn = createButton();
const backgroundColor = "backgroundColor";
const converteds = CommandBarUtil.convertButton([btn], backgroundColor);
const converteds = CommandBarUtil.convertButton([btn], backgroundColor, mockExplorer);
expect(converteds.length).toBe(1);
const converted = converteds[0];
expect(converted.split).toBe(undefined);
@@ -46,7 +48,7 @@ describe("CommandBarUtil tests", () => {
btn.children.push(child);
}
const converteds = CommandBarUtil.convertButton([btn], "backgroundColor");
const converteds = CommandBarUtil.convertButton([btn], "backgroundColor", mockExplorer);
expect(converteds.length).toBe(1);
const converted = converteds[0];
expect(converted.split).toBe(true);
@@ -62,7 +64,7 @@ describe("CommandBarUtil tests", () => {
btns.push(createButton());
}
const converteds = CommandBarUtil.convertButton(btns, "backgroundColor");
const converteds = CommandBarUtil.convertButton(btns, "backgroundColor", mockExplorer);
const uniqueKeys = converteds
.map((btn: ICommandBarItemProps) => btn.key)
.filter((value: string, index: number, self: string[]) => self.indexOf(value) === index);
@@ -74,10 +76,10 @@ describe("CommandBarUtil tests", () => {
const backgroundColor = "backgroundColor";
btn.commandButtonLabel = undefined;
let converted = CommandBarUtil.convertButton([btn], backgroundColor)[0];
let converted = CommandBarUtil.convertButton([btn], backgroundColor, mockExplorer)[0];
expect(converted.text).toEqual(btn.tooltipText);
converted = CommandBarUtil.convertButton([btn], backgroundColor)[0];
converted = CommandBarUtil.convertButton([btn], backgroundColor, mockExplorer)[0];
delete btn.commandButtonLabel;
expect(converted.text).toEqual(btn.tooltipText);
});

View File

@@ -25,7 +25,11 @@ import { MemoryTracker } from "./MemoryTrackerComponent";
* Convert our NavbarButtonConfig to UI Fabric buttons
* @param btns
*/
export const convertButton = (btns: CommandButtonComponentProps[], backgroundColor: string): ICommandBarItemProps[] => {
export const convertButton = (
btns: CommandButtonComponentProps[],
backgroundColor: string,
container: Explorer,
): ICommandBarItemProps[] => {
const buttonHeightPx =
configContext.platform == Platform.Fabric
? StyleConstants.FabricCommandBarButtonHeight
@@ -54,15 +58,14 @@ export const convertButton = (btns: CommandButtonComponentProps[], backgroundCol
iconProps: {
style: {
width: StyleConstants.CommandBarIconWidth, // 16
alignSelf: btn.iconName ? "baseline" : undefined,
alignSelf: undefined,
filter: getFilter(btn.disabled),
},
imageProps: btn.iconSrc ? { src: btn.iconSrc, alt: btn.iconAlt } : undefined,
iconName: btn.iconName,
},
onClick: btn.onCommandClick
? (ev?: React.MouseEvent<HTMLElement, MouseEvent> | React.KeyboardEvent<HTMLElement>) => {
btn.onCommandClick(ev);
btn.onCommandClick(ev, container);
let copilotEnabled = false;
if (useQueryCopilot.getState().copilotEnabled && useQueryCopilot.getState().copilotUserDBEnabled) {
copilotEnabled = useQueryCopilot.getState().copilotEnabledforExecution;
@@ -135,7 +138,7 @@ export const convertButton = (btns: CommandButtonComponentProps[], backgroundCol
result.split = true;
result.subMenuProps = {
items: convertButton(btn.children, backgroundColor),
items: convertButton(btn.children, backgroundColor, container),
styles: {
list: {
// TODO Figure out how to do it the proper way with subComponentStyles.
@@ -186,7 +189,7 @@ export const convertButton = (btns: CommandButtonComponentProps[], backgroundCol
option?: IDropdownOption,
index?: number,
): void => {
btn.children[index].onCommandClick(event);
btn.children[index].onCommandClick(event, container);
TelemetryProcessor.trace(Action.ClickCommandBarButton, ActionModifiers.Mark, { label: option.text });
};
@@ -237,14 +240,17 @@ export const createConnectionStatus = (container: Explorer, poolId: PoolIdType,
};
};
export function createKeyboardHandlers(allButtons: CommandButtonComponentProps[]): KeyboardHandlerMap {
export function createKeyboardHandlers(
allButtons: CommandButtonComponentProps[],
container: Explorer,
): KeyboardHandlerMap {
const handlers: KeyboardHandlerMap = {};
function createHandlers(buttons: CommandButtonComponentProps[]) {
buttons.forEach((button) => {
if (!button.disabled && button.keyboardAction) {
handlers[button.keyboardAction] = (e) => {
button.onCommandClick(e);
button.onCommandClick(e, container);
// If the handler is bound, it means the button is visible and enabled, so we should prevent the default action
return true;

View File

@@ -0,0 +1,16 @@
import { CommandButtonComponentProps } from "Explorer/Controls/CommandButton/CommandButtonComponent";
import create, { UseStore } from "zustand";
export interface CommandBarStore {
contextButtons: CommandButtonComponentProps[];
setContextButtons: (contextButtons: CommandButtonComponentProps[]) => void;
isHidden: boolean;
setIsHidden: (isHidden: boolean) => void;
}
export const useCommandBar: UseStore<CommandBarStore> = create((set) => ({
contextButtons: [],
setContextButtons: (contextButtons: CommandButtonComponentProps[]) => set((state) => ({ ...state, contextButtons })),
isHidden: false,
setIsHidden: (isHidden: boolean) => set((state) => ({ ...state, isHidden })),
}));

View File

@@ -0,0 +1,159 @@
import {
makeStyles,
Menu,
MenuButton,
MenuItem,
MenuList,
MenuPopover,
MenuTrigger,
Toolbar,
ToolbarButton,
ToolbarDivider,
ToolbarGroup,
Tooltip,
} from "@fluentui/react-components";
import { CommandButtonComponentProps } from "Explorer/Controls/CommandButton/CommandButtonComponent";
import Explorer from "Explorer/Explorer";
import {
createPlatformButtons,
createStaticCommandBarButtons,
} from "Explorer/Menus/CommandBar/CommandBarComponentButtonFactory";
import { createKeyboardHandlers } from "Explorer/Menus/CommandBar/CommandBarUtil";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { CosmosFluentProvider, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil";
import { useSelectedNode } from "Explorer/useSelectedNode";
import { KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
import React, { MouseEventHandler } from "react";
const useToolbarStyles = makeStyles({
toolbar: {
height: tokens.layoutRowHeight,
justifyContent: "space-between", // Ensures that the two toolbar groups are at opposite ends of the toolbar
...cosmosShorthands.borderBottom(),
},
toolbarGroup: {
display: "flex",
},
});
export interface CommandBarV2Props {
explorer: Explorer;
}
export const CommandBarV2: React.FC<CommandBarV2Props> = ({ explorer }: CommandBarV2Props) => {
const styles = useToolbarStyles();
const selectedNodeState = useSelectedNode();
const contextButtons = useCommandBar((state) => state.contextButtons);
const isHidden = useCommandBar((state) => state.isHidden);
const setKeyboardHandlers = useKeyboardActionGroup(KeyboardActionGroup.COMMAND_BAR);
const staticButtons = createStaticCommandBarButtons(selectedNodeState);
const platformButtons = createPlatformButtons();
if (isHidden) {
setKeyboardHandlers({});
return null;
}
const allButtons = staticButtons.concat(contextButtons).concat(platformButtons);
const keyboardHandlers = createKeyboardHandlers(allButtons, explorer);
setKeyboardHandlers(keyboardHandlers);
return (
<CosmosFluentProvider>
<Toolbar className={styles.toolbar}>
<ToolbarGroup role="presentation" className={styles.toolbarGroup}>
{staticButtons.map((button, index) =>
renderButton(explorer, button, `static-${index}`, contextButtons?.length > 0),
)}
{staticButtons.length > 0 && contextButtons?.length > 0 && <ToolbarDivider />}
{contextButtons.map((button, index) => renderButton(explorer, button, `context-${index}`, false))}
</ToolbarGroup>
<ToolbarGroup role="presentation">
{platformButtons.map((button, index) => renderButton(explorer, button, `platform-${index}`, true))}
</ToolbarGroup>
</Toolbar>
</CosmosFluentProvider>
);
};
// This allows us to migrate individual buttons over to using a JSX.Element for the icon, without requiring us to change them all at once.
function renderIcon(iconSrcOrElement: string | JSX.Element, alt?: string): JSX.Element {
if (typeof iconSrcOrElement === "string") {
return <img src={iconSrcOrElement} alt={alt} />;
}
return iconSrcOrElement;
}
function renderButton(
explorer: Explorer,
btn: CommandButtonComponentProps,
key: string,
iconOnly: boolean,
): JSX.Element {
if (btn.isDivider) {
return <ToolbarDivider key={key} />;
}
const hasChildren = !!btn.children && btn.children.length > 0;
const label = btn.commandButtonLabel || btn.tooltipText;
const tooltip = btn.tooltipText || (iconOnly ? label : undefined);
const onClick: MouseEventHandler | undefined =
btn.onCommandClick && !hasChildren ? (e) => btn.onCommandClick(e, explorer) : undefined;
// We don't know which element will be the top-level element, so just slap a key on all of 'em
let button = hasChildren ? (
<MenuButton key={key} appearance="subtle" aria-label={btn.ariaLabel} icon={renderIcon(btn.iconSrc, btn.iconAlt)}>
{!iconOnly && label}
</MenuButton>
) : (
<ToolbarButton key={key} aria-label={btn.ariaLabel} onClick={onClick} icon={renderIcon(btn.iconSrc, btn.iconAlt)}>
{!iconOnly && label}
</ToolbarButton>
);
if (tooltip) {
button = (
<Tooltip key={key} content={tooltip} relationship="description" withArrow>
{button}
</Tooltip>
);
}
if (hasChildren) {
button = (
<Menu key={key}>
<MenuTrigger disableButtonEnhancement>{button}</MenuTrigger>
<MenuPopover>
<MenuList>{btn.children.map((child, index) => renderMenuItem(explorer, child, index.toString()))}</MenuList>
</MenuPopover>
</Menu>
);
}
return button;
}
function renderMenuItem(explorer: Explorer, btn: CommandButtonComponentProps, key: string): JSX.Element {
const hasChildren = !!btn.children && btn.children.length > 0;
const onClick: MouseEventHandler | undefined = btn.onCommandClick
? (e) => btn.onCommandClick(e, explorer)
: undefined;
const item = (
<MenuItem key={key} onClick={onClick} icon={renderIcon(btn.iconSrc, btn.iconAlt)}>
{btn.commandButtonLabel || btn.tooltipText}
</MenuItem>
);
if (hasChildren) {
return (
<Menu>
<MenuTrigger disableButtonEnhancement>{item}</MenuTrigger>
<MenuPopover>
<MenuList>{btn.children.map((child, index) => renderMenuItem(explorer, child, index.toString()))}</MenuList>
</MenuPopover>
</Menu>
);
}
return item;
}

View File

@@ -1,13 +1,13 @@
import { clear, collectionWasOpened, getItems, Type } from "Explorer/MostRecentActivity/MostRecentActivity";
import { observable } from "knockout";
import { mostRecentActivity } from "./MostRecentActivity";
describe("MostRecentActivity", () => {
const accountId = "some account";
const accountName = "some account";
beforeEach(() => mostRecentActivity.clear(accountId));
beforeEach(() => clear(accountName));
it("Has no items at first", () => {
expect(mostRecentActivity.getItems(accountId)).toStrictEqual([]);
expect(getItems(accountName)).toStrictEqual([]);
});
it("Can record collections being opened", () => {
@@ -18,9 +18,9 @@ describe("MostRecentActivity", () => {
databaseId,
};
mostRecentActivity.collectionWasOpened(accountId, collection);
collectionWasOpened(accountName, collection);
const activity = mostRecentActivity.getItems(accountId);
const activity = getItems(accountName);
expect(activity).toEqual([
expect.objectContaining({
collectionId,
@@ -29,58 +29,24 @@ describe("MostRecentActivity", () => {
]);
});
it("Can record notebooks being opened", () => {
const name = "some notebook";
const path = "some path";
const notebook = { name, path };
it("Does not store duplicate entries", () => {
const collectionId = "some collection";
const databaseId = "some database";
const collection = {
id: observable(collectionId),
databaseId,
};
mostRecentActivity.notebookWasItemOpened(accountId, notebook);
collectionWasOpened(accountName, collection);
collectionWasOpened(accountName, collection);
const activity = mostRecentActivity.getItems(accountId);
expect(activity).toEqual([expect.objectContaining(notebook)]);
});
it("Filters out duplicates", () => {
const name = "some notebook";
const path = "some path";
const notebook = { name, path };
const sameNotebook = { name, path };
mostRecentActivity.notebookWasItemOpened(accountId, notebook);
mostRecentActivity.notebookWasItemOpened(accountId, sameNotebook);
const activity = mostRecentActivity.getItems(accountId);
expect(activity.length).toEqual(1);
expect(activity).toEqual([expect.objectContaining(notebook)]);
});
it("Allows for multiple accounts", () => {
const name = "some notebook";
const path = "some path";
const notebook = { name, path };
const anotherNotebook = { name: "Another " + name, path };
const anotherAccountId = "Another " + accountId;
mostRecentActivity.notebookWasItemOpened(accountId, notebook);
mostRecentActivity.notebookWasItemOpened(anotherAccountId, anotherNotebook);
expect(mostRecentActivity.getItems(accountId)).toEqual([expect.objectContaining(notebook)]);
expect(mostRecentActivity.getItems(anotherAccountId)).toEqual([expect.objectContaining(anotherNotebook)]);
});
it("Can store multiple distinct elements, in FIFO order", () => {
const name = "some notebook";
const path = "some path";
const first = { name, path };
const second = { name: "Another " + name, path };
const third = { name, path: "Another " + path };
mostRecentActivity.notebookWasItemOpened(accountId, first);
mostRecentActivity.notebookWasItemOpened(accountId, second);
mostRecentActivity.notebookWasItemOpened(accountId, third);
const activity = mostRecentActivity.getItems(accountId);
expect(activity).toEqual([third, second, first].map(expect.objectContaining));
const activity = getItems(accountName);
expect(activity).toEqual([
expect.objectContaining({
type: Type.OpenCollection,
collectionId,
databaseId,
}),
]);
});
});

View File

@@ -1,10 +1,10 @@
import { AppStateComponentNames, deleteState, loadState, saveState } from "Shared/AppStatePersistenceUtility";
import { CollectionBase } from "../../Contracts/ViewModels";
import { StorageKey, LocalStorageUtility } from "../../Shared/StorageUtility";
import { NotebookContentItem } from "../Notebook/NotebookContentItem";
import { LocalStorageUtility, StorageKey } from "../../Shared/StorageUtility";
export enum Type {
OpenCollection,
OpenNotebook,
OpenCollection = "OpenCollection",
OpenNotebook = "OpenNotebook",
}
export interface OpenNotebookItem {
@@ -21,158 +21,174 @@ export interface OpenCollectionItem {
type Item = OpenNotebookItem | OpenCollectionItem;
// Update schemaVersion if you are going to change this interface
interface StoredData {
schemaVersion: string;
itemsMap: { [accountId: string]: Item[] }; // FIFO
}
const itemsMaxNumber: number = 5;
/**
* Stores most recent activity
* Migrate old data to new AppState
*/
class MostRecentActivity {
private static readonly schemaVersion: string = "2";
private static itemsMaxNumber: number = 5;
private storedData: StoredData;
constructor() {
// Retrieve from local storage
if (LocalStorageUtility.hasItem(StorageKey.MostRecentActivity)) {
const rawData = LocalStorageUtility.getEntryString(StorageKey.MostRecentActivity);
if (!rawData) {
this.storedData = MostRecentActivity.createEmptyData();
} else {
try {
this.storedData = JSON.parse(rawData);
} catch (e) {
console.error("Unable to parse stored most recent activity. Use empty data:", rawData);
this.storedData = MostRecentActivity.createEmptyData();
}
// If version doesn't match or schema broke, nuke it!
if (
!this.storedData.hasOwnProperty("schemaVersion") ||
this.storedData["schemaVersion"] !== MostRecentActivity.schemaVersion
) {
LocalStorageUtility.removeEntry(StorageKey.MostRecentActivity);
this.storedData = MostRecentActivity.createEmptyData();
}
const migrateOldData = () => {
if (LocalStorageUtility.hasItem(StorageKey.MostRecentActivity)) {
const oldDataSchemaVersion: string = "2";
const rawData = LocalStorageUtility.getEntryString(StorageKey.MostRecentActivity);
if (rawData) {
const oldData = JSON.parse(rawData);
if (oldData.schemaVersion === oldDataSchemaVersion) {
const itemsMap: Record<string, Item[]> = oldData.itemsMap;
Object.keys(itemsMap).forEach((accountId: string) => {
const accountName = accountId.split("/").pop();
if (accountName) {
saveState(
{
componentName: AppStateComponentNames.MostRecentActivity,
globalAccountName: accountName,
},
itemsMap[accountId].map((item) => {
if ((item.type as unknown as number) === 0) {
item.type = Type.OpenCollection;
} else if ((item.type as unknown as number) === 1) {
item.type = Type.OpenNotebook;
}
return item;
}),
);
}
});
}
} else {
this.storedData = MostRecentActivity.createEmptyData();
}
for (let p in this.storedData.itemsMap) {
this.cleanupItems(p);
// Remove old data
LocalStorageUtility.removeEntry(StorageKey.MostRecentActivity);
}
};
const addItem = (accountName: string, newItem: Item): void => {
// When debugging, accountId is "undefined": most recent activity cannot be saved by account. Uncomment to disable.
// if (!accountId) {
// return;
// }
let items =
(loadState({
componentName: AppStateComponentNames.MostRecentActivity,
globalAccountName: accountName,
}) as Item[]) || [];
// Remove duplicate
items = removeDuplicate(newItem, items);
items.unshift(newItem);
items = cleanupItems(items, accountName);
saveState(
{
componentName: AppStateComponentNames.MostRecentActivity,
globalAccountName: accountName,
},
items,
);
};
export const getItems = (accountName: string): Item[] => {
if (!accountName) {
return [];
}
return (
(loadState({
componentName: AppStateComponentNames.MostRecentActivity,
globalAccountName: accountName,
}) as Item[]) || []
);
};
export const collectionWasOpened = (
accountName: string,
{ id, databaseId }: Pick<CollectionBase, "id" | "databaseId">,
) => {
if (accountName === undefined) {
return;
}
const collectionId = id();
addItem(accountName, {
type: Type.OpenCollection,
databaseId,
collectionId,
});
};
export const clear = (accountName: string): void => {
if (!accountName) {
return;
}
deleteState({
componentName: AppStateComponentNames.MostRecentActivity,
globalAccountName: accountName,
});
};
// Sort object by key
const sortObjectKeys = (unordered: Record<string, unknown>): Record<string, unknown> => {
return Object.keys(unordered)
.sort()
.reduce((obj: Record<string, unknown>, key: string) => {
obj[key] = unordered[key];
return obj;
}, {});
};
/**
* Find items by doing strict comparison and remove from array if duplicate is found.
* Modifies the array.
* @param item
* @param itemsArray
* @returns new array
*/
const removeDuplicate = (item: Item, itemsArray: Item[]): Item[] => {
if (!itemsArray) {
return itemsArray;
}
const result: Item[] = [...itemsArray];
let index = -1;
for (let i = 0; i < result.length; i++) {
const currentItem = result[i];
if (
JSON.stringify(sortObjectKeys(currentItem as unknown as Record<string, unknown>)) ===
JSON.stringify(sortObjectKeys(item as unknown as Record<string, unknown>))
) {
index = i;
break;
}
this.saveToLocalStorage();
}
private static createEmptyData(): StoredData {
return {
schemaVersion: MostRecentActivity.schemaVersion,
itemsMap: {},
};
if (index !== -1) {
result.splice(index, 1);
}
private static isEmpty(object: any) {
return Object.keys(object).length === 0 && object.constructor === Object;
return result;
};
/**
* Remove unknown types
* Limit items to max number
* Modifies the array.
*/
const cleanupItems = (items: Item[], accountName: string): Item[] => {
if (accountName === undefined) {
return [];
}
private saveToLocalStorage() {
if (MostRecentActivity.isEmpty(this.storedData.itemsMap)) {
if (LocalStorageUtility.hasItem(StorageKey.MostRecentActivity)) {
LocalStorageUtility.removeEntry(StorageKey.MostRecentActivity);
}
// Don't save if empty
return;
}
LocalStorageUtility.setEntryString(StorageKey.MostRecentActivity, JSON.stringify(this.storedData));
}
private addItem(accountId: string, newItem: Item): void {
// When debugging, accountId is "undefined": most recent activity cannot be saved by account. Uncomment to disable.
// if (!accountId) {
// return;
// }
// Remove duplicate
MostRecentActivity.removeDuplicate(newItem, this.storedData.itemsMap[accountId]);
this.storedData.itemsMap[accountId] = this.storedData.itemsMap[accountId] || [];
this.storedData.itemsMap[accountId].unshift(newItem);
this.cleanupItems(accountId);
this.saveToLocalStorage();
}
public getItems(accountId: string): Item[] {
return this.storedData.itemsMap[accountId] || [];
}
public collectionWasOpened(accountId: string, { id, databaseId }: Pick<CollectionBase, "id" | "databaseId">) {
const collectionId = id();
this.addItem(accountId, {
type: Type.OpenCollection,
databaseId,
collectionId,
const itemsArray = items.filter((item) => item.type in Type).slice(0, itemsMaxNumber);
if (itemsArray.length === 0) {
deleteState({
componentName: AppStateComponentNames.MostRecentActivity,
globalAccountName: accountName,
});
}
return itemsArray;
};
public notebookWasItemOpened(accountId: string, { name, path }: Pick<NotebookContentItem, "name" | "path">) {
this.addItem(accountId, {
type: Type.OpenNotebook,
name,
path,
});
}
public clear(accountId: string): void {
delete this.storedData.itemsMap[accountId];
this.saveToLocalStorage();
}
/**
* Find items by doing strict comparison and remove from array if duplicate is found
* @param item
*/
private static removeDuplicate(item: Item, itemsArray: Item[]): void {
if (!itemsArray) {
return;
}
let index = -1;
for (let i = 0; i < itemsArray.length; i++) {
const currentItem = itemsArray[i];
if (JSON.stringify(currentItem) === JSON.stringify(item)) {
index = i;
break;
}
}
if (index !== -1) {
itemsArray.splice(index, 1);
}
}
/**
* Remove unknown types
* Limit items to max number
*/
private cleanupItems(accountId: string): void {
if (!this.storedData.itemsMap.hasOwnProperty(accountId)) {
return;
}
const itemsArray = this.storedData.itemsMap[accountId]
.filter((item) => item.type in Type)
.slice(0, MostRecentActivity.itemsMaxNumber);
if (itemsArray.length === 0) {
delete this.storedData.itemsMap[accountId];
} else {
this.storedData.itemsMap[accountId] = itemsArray;
}
}
}
export const mostRecentActivity = new MostRecentActivity();
migrateOldData();

View File

@@ -1,5 +1,6 @@
import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility";
import { PhoenixClient } from "Phoenix/PhoenixClient";
import { useNewPortalBackendEndpoint } from "Utils/EndpointUtils";
import { cloneDeep } from "lodash";
import create, { UseStore } from "zustand";
import { AuthType } from "../../AuthType";
@@ -127,7 +128,9 @@ export const useNotebook: UseStore<NotebookState> = create((set, get) => ({
userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo"
? databaseAccount?.location
: databaseAccount?.properties?.writeLocations?.[0]?.locationName.toLowerCase();
const disallowedLocationsUri = `${configContext.BACKEND_ENDPOINT}/api/disallowedLocations`;
const disallowedLocationsUri: string = useNewPortalBackendEndpoint(Constants.BackendApi.DisallowedLocations)
? `${configContext.PORTAL_BACKEND_ENDPOINT}/api/disallowedlocations`
: `${configContext.BACKEND_ENDPOINT}/api/disallowedLocations`;
const authorizationHeader = getAuthorizationHeader();
try {
const response = await fetch(disallowedLocationsUri, {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,156 @@
import {
Button,
Checkbox,
CheckboxOnChangeData,
InputOnChangeData,
makeStyles,
SearchBox,
SearchBoxChangeEvent,
Text,
} from "@fluentui/react-components";
import { configContext } from "ConfigContext";
import { ColumnDefinition } from "Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent";
import { CosmosFluentProvider, getPlatformTheme } from "Explorer/Theme/ThemeUtil";
import React from "react";
import { useSidePanel } from "../../../hooks/useSidePanel";
const useColumnSelectionStyles = makeStyles({
paneContainer: {
height: "100%",
display: "flex",
},
searchBox: {
width: "100%",
},
checkboxContainer: {
display: "flex",
flexDirection: "column",
flex: 1,
},
checkboxLabel: {
padding: "4px 8px",
marginBottom: "0px",
},
});
export interface TableColumnSelectionPaneProps {
columnDefinitions: ColumnDefinition[];
selectedColumnIds: string[];
onSelectionChange: (newSelectedColumnIds: string[]) => void;
defaultSelection: string[];
}
export const TableColumnSelectionPane: React.FC<TableColumnSelectionPaneProps> = ({
columnDefinitions,
selectedColumnIds,
onSelectionChange,
defaultSelection,
}: TableColumnSelectionPaneProps): JSX.Element => {
const closeSidePanel = useSidePanel((state) => state.closeSidePanel);
const originalSelectedColumnIds = React.useMemo(() => selectedColumnIds, []);
const [columnSearchText, setColumnSearchText] = React.useState<string>("");
const [newSelectedColumnIds, setNewSelectedColumnIds] = React.useState<string[]>(originalSelectedColumnIds);
const styles = useColumnSelectionStyles();
const selectedColumnIdsSet = new Set(newSelectedColumnIds);
const onCheckedValueChange = (id: string, checkedData?: CheckboxOnChangeData): void => {
const checked = checkedData?.checked;
if (checked === "mixed" || checked === undefined) {
return;
}
if (checked) {
selectedColumnIdsSet.add(id);
} else {
/* selectedColumnIds may contain ids that are not in columnDefinitions, because the selected
* ids may have been loaded from persistence, but don't exist in the current retrieved documents.
*/
if (
Array.from(selectedColumnIdsSet).filter((id) => columnDefinitions.find((def) => def.id === id) !== undefined)
.length === 1 &&
selectedColumnIdsSet.has(id)
) {
// Don't allow unchecking the last column
return;
}
selectedColumnIdsSet.delete(id);
}
setNewSelectedColumnIds([...selectedColumnIdsSet]);
};
const onSave = (): void => {
onSelectionChange(newSelectedColumnIds);
closeSidePanel();
};
const onSearchChange: (event: SearchBoxChangeEvent, data: InputOnChangeData) => void = (_, data) =>
// eslint-disable-next-line react/prop-types
setColumnSearchText(data.value);
const theme = getPlatformTheme(configContext.platform);
// Filter and move partition keys to the top
const columnDefinitionList = columnDefinitions
.filter((def) => !columnSearchText || def.label.toLowerCase().includes(columnSearchText.toLowerCase()))
.sort((a, b) => {
const ID = "id";
// "id" always at the top, then partition keys, then everything else sorted
if (a.id === ID) {
return b.id === ID ? 0 : -1;
} else if (b.id === ID) {
return a.id === ID ? 0 : 1;
} else if (a.isPartitionKey && !b.isPartitionKey) {
return -1;
} else if (b.isPartitionKey && !a.isPartitionKey) {
return 1;
} else {
return a.label.localeCompare(b.label);
}
});
return (
<div className={styles.paneContainer}>
<CosmosFluentProvider>
<div className="panelFormWrapper">
<div className="panelMainContent" style={{ display: "flex", flexDirection: "column" }}>
<Text>Select which columns to display in your view of items in your container.</Text>
<div /* Wrap <SearchBox> to avoid margin-bottom set by panelMainContent css */>
<SearchBox
className={styles.searchBox}
value={columnSearchText}
onChange={onSearchChange}
placeholder="Search fields"
/>
</div>
<div className={styles.checkboxContainer}>
{columnDefinitionList.map((columnDefinition) => (
<Checkbox
style={{ marginBottom: 0 }}
key={columnDefinition.id}
label={{
className: styles.checkboxLabel,
children: `${columnDefinition.label}${columnDefinition.isPartitionKey ? " (partition key)" : ""}`,
}}
checked={selectedColumnIdsSet.has(columnDefinition.id)}
onChange={(_, data) => onCheckedValueChange(columnDefinition.id, data)}
/>
))}
</div>
<Button appearance="secondary" size="small" onClick={() => setNewSelectedColumnIds(defaultSelection)}>
Reset
</Button>
</div>
<div className="panelFooter" style={{ display: "flex", gap: theme.spacingHorizontalS }}>
<Button appearance="primary" onClick={onSave}>
Save
</Button>
<Button appearance="secondary" onClick={closeSidePanel}>
Cancel
</Button>
</div>
</div>
</CosmosFluentProvider>
</div>
);
};

View File

@@ -171,7 +171,7 @@ export const QueryCopilotCarousel: React.FC<QueryCopilotCarouselProps> = ({
the query builder.
</Text>
<Text style={{ fontSize: 13, fontWeight: 600, marginTop: 24 }}>Database Id</Text>
<Text style={{ fontSize: 13 }}>CopilotSampleDb</Text>
<Text style={{ fontSize: 13 }}>CopilotSampleDB</Text>
<Text style={{ fontSize: 13, fontWeight: 600, marginTop: 16 }}>Database throughput (autoscale)</Text>
<Text style={{ fontSize: 13 }}>Autoscale</Text>
<Text style={{ fontSize: 13, fontWeight: 600, marginTop: 16 }}>Database Max RU/s</Text>

View File

@@ -28,6 +28,8 @@ import {
SuggestedPrompt,
getSampleDatabaseSuggestedPrompts,
getSuggestedPrompts,
readPromptHistory,
savePromptHistory,
} from "Explorer/QueryCopilot/QueryCopilotUtilities";
import { SubmitFeedback, allocatePhoenixContainer } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { GenerateSQLQueryResponse, QueryCopilotProps } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
@@ -136,9 +138,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
};
const isSampleCopilotActive = useSelectedNode.getState().isQueryCopilotCollectionSelected();
const cachedHistoriesString = localStorage.getItem(`${userContext.databaseAccount?.id}-queryCopilotHistories`);
const cachedHistories = cachedHistoriesString?.split("|");
const [histories, setHistories] = useState<string[]>(cachedHistories || []);
const [histories, setHistories] = useState<string[]>(() => readPromptHistory(userContext.databaseAccount));
const suggestedPrompts: SuggestedPrompt[] = isSampleCopilotActive
? getSampleDatabaseSuggestedPrompts()
: getSuggestedPrompts();
@@ -172,7 +172,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
const newHistories = [formattedUserPrompt, ...updatedHistories.slice(0, 2)];
setHistories(newHistories);
localStorage.setItem(`${userContext.databaseAccount.id}-queryCopilotHistories`, newHistories.join("|"));
savePromptHistory(userContext.databaseAccount, newHistories);
};
const resetMessageStates = (): void => {

View File

@@ -1,10 +1,39 @@
import { shallow } from "enzyme";
import { CopilotSubComponentNames } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import React from "react";
import { AppStateComponentNames, StorePath } from "Shared/AppStatePersistenceUtility";
import { updateUserContext } from "UserContext";
import Explorer from "../Explorer";
import { QueryCopilotTab } from "./QueryCopilotTab";
describe("Query copilot tab snapshot test", () => {
it("should render with initial input", () => {
updateUserContext({
databaseAccount: {
name: "name",
properties: undefined,
id: "",
location: "",
type: "",
kind: "",
},
});
const loadState = (path: StorePath) => {
if (
path.componentName === AppStateComponentNames.QueryCopilot &&
path.subComponentName === CopilotSubComponentNames.toggleStatus
) {
return { enabled: true };
} else {
return undefined;
}
};
jest.mock("Shared/AppStatePersistenceUtility", () => ({
loadState,
}));
const wrapper = shallow(<QueryCopilotTab explorer={new Explorer()} />);
expect(wrapper).toMatchSnapshot();
});

View File

@@ -3,9 +3,10 @@ import { Stack } from "@fluentui/react";
import { QueryCopilotSampleContainerId, QueryCopilotSampleDatabaseId } from "Common/Constants";
import { CommandButtonComponentProps } from "Explorer/Controls/CommandButton/CommandButtonComponent";
import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { SaveQueryPane } from "Explorer/Panes/SaveQueryPane/SaveQueryPane";
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
import { readCopilotToggleStatus, saveCopilotToggleStatus } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import { OnExecuteQueryClick } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { QueryCopilotProps } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
import { QueryCopilotResults } from "Explorer/QueryCopilot/Shared/QueryCopilotResults";
@@ -18,18 +19,13 @@ import SplitterLayout from "react-splitter-layout";
import QueryCommandIcon from "../../../images/CopilotCommand.svg";
import ExecuteQueryIcon from "../../../images/ExecuteQuery.svg";
import SaveQueryIcon from "../../../images/save-cosmos.svg";
import * as StringUtility from "../../Shared/StringUtility";
export const QueryCopilotTab: React.FC<QueryCopilotProps> = ({ explorer }: QueryCopilotProps): JSX.Element => {
const { query, setQuery, selectedQuery, setSelectedQuery, isGeneratingQuery } = useQueryCopilot();
const cachedCopilotToggleStatus: string = localStorage.getItem(
`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`,
const [copilotActive, setCopilotActive] = useState<boolean>(() =>
readCopilotToggleStatus(userContext.databaseAccount),
);
const copilotInitialActive: boolean = cachedCopilotToggleStatus
? StringUtility.toBoolean(cachedCopilotToggleStatus)
: true;
const [copilotActive, setCopilotActive] = useState<boolean>(copilotInitialActive);
const [tabActive, setTabActive] = useState<boolean>(true);
const getCommandbarButtons = (): CommandButtonComponentProps[] => {
@@ -88,7 +84,7 @@ export const QueryCopilotTab: React.FC<QueryCopilotProps> = ({ explorer }: Query
const toggleCopilot = (toggle: boolean) => {
setCopilotActive(toggle);
localStorage.setItem(`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`, toggle.toString());
saveCopilotToggleStatus(userContext.databaseAccount, toggle);
};
return (

View File

@@ -90,7 +90,7 @@ describe("QueryCopilotUtilities", () => {
// Mock the items.query method to return the mockResult
(
sampleDataClient().database("CopilotSampleDb").container("SampleContainer").items.query as jest.Mock
sampleDataClient().database("CopilotSampleDB").container("SampleContainer").items.query as jest.Mock
).mockReturnValue(mockResult);
const result = querySampleDocuments(query, options);
@@ -119,10 +119,10 @@ describe("QueryCopilotUtilities", () => {
const result = await readSampleDocument(documentId);
expect(sampleDataClient).toHaveBeenCalled();
expect(sampleDataClient().database).toHaveBeenCalledWith("CopilotSampleDb");
expect(sampleDataClient().database("CopilotSampleDb").container).toHaveBeenCalledWith("SampleContainer");
expect(sampleDataClient().database).toHaveBeenCalledWith("CopilotSampleDB");
expect(sampleDataClient().database("CopilotSampleDB").container).toHaveBeenCalledWith("SampleContainer");
expect(
sampleDataClient().database("CopilotSampleDb").container("SampleContainer").item("DocumentId", undefined).read,
sampleDataClient().database("CopilotSampleDB").container("SampleContainer").item("DocumentId", undefined).read,
).toHaveBeenCalled();
expect(result).toEqual(expectedResponse);
});
@@ -144,10 +144,10 @@ describe("QueryCopilotUtilities", () => {
await expect(readSampleDocument(documentId)).rejects.toStrictEqual(errorMock);
expect(sampleDataClient).toHaveBeenCalled();
expect(sampleDataClient().database).toHaveBeenCalledWith("CopilotSampleDb");
expect(sampleDataClient().database("CopilotSampleDb").container).toHaveBeenCalledWith("SampleContainer");
expect(sampleDataClient().database).toHaveBeenCalledWith("CopilotSampleDB");
expect(sampleDataClient().database("CopilotSampleDB").container).toHaveBeenCalledWith("SampleContainer");
expect(
sampleDataClient().database("CopilotSampleDb").container("SampleContainer").item("DocumentId", undefined).read,
sampleDataClient().database("CopilotSampleDB").container("SampleContainer").item("DocumentId", undefined).read,
).toHaveBeenCalled();
expect(handleError).toHaveBeenCalledWith(errorMock, "ReadDocument", expect.any(String));
});

View File

@@ -4,8 +4,11 @@ import { handleError } from "Common/ErrorHandlingUtils";
import { sampleDataClient } from "Common/SampleDataClient";
import { getPartitionKeyValue } from "Common/dataAccess/getPartitionKeyValue";
import { getCommonQueryOptions } from "Common/dataAccess/queryDocuments";
import { DatabaseAccount } from "Contracts/DataModels";
import DocumentId from "Explorer/Tree/DocumentId";
import { AppStateComponentNames, loadState, saveState } from "Shared/AppStatePersistenceUtility";
import { logConsoleProgress } from "Utils/NotificationConsoleUtils";
import * as StringUtility from "../../Shared/StringUtility";
export interface SuggestedPrompt {
id: number;
@@ -54,3 +57,110 @@ export const getSuggestedPrompts = (): SuggestedPrompt[] => {
{ id: 3, text: "Find the oldest item added to my collection" },
];
};
// Prompt history persistence
export enum CopilotSubComponentNames {
promptHistory = "PromptHistory",
toggleStatus = "ToggleStatus",
}
const getLegacyHistoryKey = (databaseAccount: DatabaseAccount): string =>
`${databaseAccount?.id}-queryCopilotHistories`;
const getLegacyToggleStatusKey = (databaseAccount: DatabaseAccount): string =>
`${databaseAccount?.id}-queryCopilotToggleStatus`;
// Migration only needs to run once
let hasMigrated = false;
// Migrate old prompt history to new format
export const migrateCopilotPersistence = (databaseAccount: DatabaseAccount): void => {
if (hasMigrated) {
return;
}
let key = getLegacyHistoryKey(databaseAccount);
let item = localStorage.getItem(key);
if (item !== undefined && item !== null) {
const historyItems = item.split("|");
saveState(
{
componentName: AppStateComponentNames.QueryCopilot,
subComponentName: CopilotSubComponentNames.promptHistory,
globalAccountName: databaseAccount.name,
databaseName: undefined,
containerName: undefined,
},
historyItems,
);
localStorage.removeItem(key);
}
key = getLegacyToggleStatusKey(databaseAccount);
item = localStorage.getItem(key);
if (item !== undefined && item !== null) {
saveState(
{
componentName: AppStateComponentNames.QueryCopilot,
subComponentName: CopilotSubComponentNames.toggleStatus,
globalAccountName: databaseAccount.name,
databaseName: undefined,
containerName: undefined,
},
StringUtility.toBoolean(item),
);
localStorage.removeItem(key);
}
hasMigrated = true;
};
export const readPromptHistory = (databaseAccount: DatabaseAccount): string[] => {
migrateCopilotPersistence(databaseAccount);
return (
(loadState({
componentName: AppStateComponentNames.QueryCopilot,
subComponentName: CopilotSubComponentNames.promptHistory,
globalAccountName: databaseAccount.name,
databaseName: undefined,
containerName: undefined,
}) as string[]) || []
);
};
export const savePromptHistory = (databaseAccount: DatabaseAccount, historyItems: string[]): void => {
saveState(
{
componentName: AppStateComponentNames.QueryCopilot,
subComponentName: CopilotSubComponentNames.promptHistory,
globalAccountName: databaseAccount.name,
databaseName: undefined,
containerName: undefined,
},
historyItems,
);
};
export const readCopilotToggleStatus = (databaseAccount: DatabaseAccount): boolean => {
migrateCopilotPersistence(databaseAccount);
return !!loadState({
componentName: AppStateComponentNames.QueryCopilot,
subComponentName: CopilotSubComponentNames.toggleStatus,
globalAccountName: databaseAccount.name,
databaseName: undefined,
containerName: undefined,
}) as boolean;
};
export const saveCopilotToggleStatus = (databaseAccount: DatabaseAccount, status: boolean): void => {
saveState(
{
componentName: AppStateComponentNames.QueryCopilot,
subComponentName: CopilotSubComponentNames.toggleStatus,
globalAccountName: databaseAccount.name,
databaseName: undefined,
containerName: undefined,
},
status,
);
};

View File

@@ -26,7 +26,7 @@ import {
import { AuthorizationTokenHeaderMetadata, QueryResults } from "Contracts/ViewModels";
import { useDialog } from "Explorer/Controls/Dialog";
import Explorer from "Explorer/Explorer";
import { querySampleDocuments } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import { querySampleDocuments, readCopilotToggleStatus } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import { FeedbackParams, GenerateSQLQueryResponse } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import { traceFailure, traceStart, traceSuccess } from "Shared/Telemetry/TelemetryProcessor";
@@ -36,7 +36,6 @@ import { useNewPortalBackendEndpoint } from "Utils/EndpointUtils";
import { queryPagesUntilContentPresent } from "Utils/QueryUtils";
import { QueryCopilotState, useQueryCopilot } from "hooks/useQueryCopilot";
import { useTabs } from "hooks/useTabs";
import * as StringUtility from "../../../Shared/StringUtility";
async function fetchWithTimeout(
url: string,
@@ -361,9 +360,7 @@ export const QueryDocumentsPerPage = async (
correlationId: useQueryCopilot.getState().correlationId,
});
} catch (error) {
const isCopilotActive = StringUtility.toBoolean(
localStorage.getItem(`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`),
);
const isCopilotActive = readCopilotToggleStatus(userContext.databaseAccount);
const errorMessage = getErrorMessage(error);
traceFailure(Action.ExecuteQueryGeneratedFromQueryCopilot, {
correlationId: useQueryCopilot.getState().correlationId,

View File

@@ -17,38 +17,6 @@ exports[`Query copilot tab snapshot test should render with initial input 1`] =
}
}
>
<QueryCopilotPromptbar
containerId="SampleContainer"
databaseId="CopilotSampleDb"
explorer={
Explorer {
"_isInitializingNotebooks": false,
"isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {
"armResourceId": undefined,
"retryOptions": {
"maxTimeout": 5000,
"minTimeout": 5000,
"retries": 3,
},
},
"provideFeedbackEmail": [Function],
"queriesClient": QueriesClient {
"container": [Circular],
},
"refreshNotebookList": [Function],
"resourceTree": ResourceTreeAdapter {
"container": [Circular],
"copyNotebook": [Function],
"parameters": [Function],
},
}
}
toggleCopilot={[Function]}
/>
<Stack
className="tabPaneContentContainer"
>

View File

@@ -1,6 +1,7 @@
import {
Button,
Menu,
MenuButton,
MenuButtonProps,
MenuItem,
MenuList,
@@ -25,7 +26,7 @@ import { getCollectionName, getDatabaseName } from "Utils/APITypeUtils";
import { Allotment, AllotmentHandle } from "allotment";
import { useSidePanel } from "hooks/useSidePanel";
import { debounce } from "lodash";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
const useSidebarStyles = makeStyles({
sidebar: {
@@ -60,6 +61,7 @@ const useSidebarStyles = makeStyles({
alignItems: "center",
justifyItems: "center",
width: "100%",
containerType: "size", // Use this container for "@container" queries below this.
...cosmosShorthands.borderBottom(),
},
loadingProgressBar: {
@@ -83,6 +85,18 @@ const useSidebarStyles = makeStyles({
},
},
},
globalCommandsMenuButton: {
display: "inline-flex",
"@container (min-width: 250px)": {
display: "none",
},
},
globalCommandsSplitButton: {
display: "none",
"@container (min-width: 250px)": {
display: "flex",
},
},
});
interface GlobalCommandsProps {
@@ -99,6 +113,12 @@ interface GlobalCommand {
const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
const styles = useSidebarStyles();
// Since we have two buttons in the DOM (one for small screens and one for larger screens), we wrap the entire thing in a div.
// However, that messes with the Menu positioning, so we need to get a reference to the 'div' to pass to the Menu.
// We can't use a ref though, because it would be set after the Menu is rendered, so we use a state value to force a re-render.
const [globalCommandButton, setGlobalCommandButton] = useState<HTMLElement | null>(null);
const actions = useMemo<GlobalCommand[]>(() => {
if (
configContext.platform === Platform.Fabric ||
@@ -168,16 +188,22 @@ const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
{primaryAction.label}
</Button>
) : (
<Menu positioning="below-end">
<Menu positioning={{ target: globalCommandButton, position: "below", align: "end" }}>
<MenuTrigger disableButtonEnhancement>
{(triggerProps: MenuButtonProps) => (
<SplitButton
menuButton={{ ...triggerProps, "aria-label": "More commands" }}
primaryActionButton={{ onClick: onPrimaryActionClick }}
icon={primaryAction.icon}
>
{primaryAction.label}
</SplitButton>
<div ref={setGlobalCommandButton}>
<SplitButton
menuButton={{ ...triggerProps, "aria-label": "More commands" }}
primaryActionButton={{ onClick: onPrimaryActionClick }}
className={styles.globalCommandsSplitButton}
icon={primaryAction.icon}
>
{primaryAction.label}
</SplitButton>
<MenuButton {...triggerProps} icon={primaryAction.icon} className={styles.globalCommandsMenuButton}>
New...
</MenuButton>
</div>
)}
</MenuTrigger>
<MenuPopover>
@@ -199,7 +225,7 @@ interface SidebarProps {
explorer: Explorer;
}
const CollapseThreshold = 50;
const CollapseThreshold = 140;
export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
const styles = useSidebarStyles();
@@ -260,7 +286,7 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
{/* Collections Tree - Start */}
{hasSidebar && (
// When collapsed, we force the pane to 24 pixels wide and make it non-resizable.
<Allotment.Pane minSize={24} preferredSize={300}>
<Allotment.Pane minSize={24} preferredSize={250}>
<CosmosFluentProvider className={mergeClasses(styles.sidebar)}>
<div className={styles.sidebarContainer}>
{loading && (
@@ -314,7 +340,7 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
</CosmosFluentProvider>
</Allotment.Pane>
)}
<Allotment.Pane minSize={800}>
<Allotment.Pane minSize={200}>
<Tabs explorer={explorer} />
</Allotment.Pane>
</Allotment>

View File

@@ -114,7 +114,7 @@ export class SplashScreen extends React.Component<SplashScreenProps> {
}
private clearMostRecent = (): void => {
MostRecentActivity.mostRecentActivity.clear(userContext.databaseAccount?.id);
MostRecentActivity.clear(userContext.databaseAccount?.name);
this.setState({});
};
@@ -498,7 +498,7 @@ export class SplashScreen extends React.Component<SplashScreenProps> {
}
private createRecentItems(): SplashScreenItem[] {
return MostRecentActivity.mostRecentActivity.getItems(userContext.databaseAccount?.id).map((activity) => {
return MostRecentActivity.getItems(userContext.databaseAccount?.name).map((activity) => {
switch (activity.type) {
default: {
const unknownActivity: never = activity;

View File

@@ -155,6 +155,7 @@ export const htmlAttributeNames = {
dataTableContentTypeAttr: "contentType_attr",
dataTableSnapshotAttr: "snapshot_attr",
dataTableRowKeyAttr: "rowKey_attr",
dataTablePartitionKeyAttr: "partKey_attr",
dataTableMessageIdAttr: "messageId_attr",
dataTableHeaderIndex: "data-column-index",
};

View File

@@ -193,6 +193,9 @@ function getServerData(sSource: any, aoData: any, fnCallback: any, oSettings: an
* from UI elements.
*/
function bindClientId(nRow: Node, aData: Entities.ITableEntity) {
if (aData.PartitionKey && aData.PartitionKey._) {
$(nRow).attr(Constants.htmlAttributeNames.dataTablePartitionKeyAttr, aData.PartitionKey._);
}
$(nRow).attr(Constants.htmlAttributeNames.dataTableRowKeyAttr, aData.RowKey._);
return nRow;
}
@@ -205,6 +208,10 @@ function selectionChanged(element: any, valueAccessor: any, allBindings: any, vi
selected &&
selected.forEach((b: Entities.ITableEntity) => {
var sel = DataTableOperations.getRowSelector([
{
key: Constants.htmlAttributeNames.dataTablePartitionKeyAttr,
value: b.PartitionKey && b.PartitionKey._ && b.PartitionKey._.toString(),
},
{
key: Constants.htmlAttributeNames.dataTableRowKeyAttr,
value: b.RowKey && b.RowKey._ && b.RowKey._.toString(),
@@ -370,8 +377,9 @@ function updateSelectionStatus(oSettings: any): void {
for (var i = 0; i < $dataTableRows.length; i++) {
var $row: JQuery = $dataTableRows.eq(i);
var rowKey: string = $row.attr(Constants.htmlAttributeNames.dataTableRowKeyAttr);
var partitionKey: string = $row.attr(Constants.htmlAttributeNames.dataTablePartitionKeyAttr);
var table = tableEntityListViewModelMap[oSettings.ajax].tableViewModel;
if (table.isItemSelected(table.getTableEntityKeys(rowKey))) {
if (table.isItemSelected(table.getTableEntityKeys(rowKey, partitionKey))) {
$row.attr("tabindex", "0");
}
}

View File

@@ -56,7 +56,10 @@ export default class DataTableOperationManager {
// Simply select the first item in this case.
var lastSelectedItemIndex = lastSelectedItem
? this._tableEntityListViewModel.getItemIndexFromCurrentPage(
this._tableEntityListViewModel.getTableEntityKeys(lastSelectedItem.RowKey._),
this._tableEntityListViewModel.getTableEntityKeys(
lastSelectedItem.RowKey._,
lastSelectedItem.PartitionKey && lastSelectedItem.PartitionKey._,
),
)
: -1;
var nextIndex: number = isUpArrowKey ? lastSelectedItemIndex - 1 : lastSelectedItemIndex + 1;
@@ -147,13 +150,14 @@ export default class DataTableOperationManager {
private getEntityIdentity($elem: JQuery<Element>): Entities.ITableEntityIdentity {
return {
RowKey: $elem.attr(Constants.htmlAttributeNames.dataTableRowKeyAttr),
PartitionKey: $elem.attr(Constants.htmlAttributeNames.dataTablePartitionKeyAttr),
};
}
private updateLastSelectedItem($elem: JQuery<Element>, isShiftSelect: boolean) {
var entityIdentity: Entities.ITableEntityIdentity = this.getEntityIdentity($elem);
var entity = this._tableEntityListViewModel.getItemFromCurrentPage(
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.RowKey),
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.PartitionKey, entityIdentity.RowKey),
);
this._tableEntityListViewModel.lastSelectedItem = entity;
@@ -168,7 +172,7 @@ export default class DataTableOperationManager {
var entityIdentity: Entities.ITableEntityIdentity = this.getEntityIdentity($elem);
this._tableEntityListViewModel.clearSelection();
this.addToSelection(entityIdentity.RowKey);
this.addToSelection(entityIdentity.RowKey, entityIdentity.PartitionKey);
}
}
@@ -190,11 +194,11 @@ export default class DataTableOperationManager {
if (
!this._tableEntityListViewModel.isItemSelected(
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.RowKey),
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.PartitionKey, entityIdentity.RowKey),
)
) {
// Adding item not previously in selection
this.addToSelection(entityIdentity.RowKey);
this.addToSelection(entityIdentity.RowKey, entityIdentity.PartitionKey);
} else {
koSelected.remove((item: Entities.ITableEntity) => item.RowKey._ === entityIdentity.RowKey);
}
@@ -212,10 +216,10 @@ export default class DataTableOperationManager {
if (anchorItem) {
var entityIdentity: Entities.ITableEntityIdentity = this.getEntityIdentity($elem);
var elementIndex = this._tableEntityListViewModel.getItemIndexFromAllPages(
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.RowKey),
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.PartitionKey, entityIdentity.RowKey),
);
var anchorIndex = this._tableEntityListViewModel.getItemIndexFromAllPages(
this._tableEntityListViewModel.getTableEntityKeys(anchorItem.RowKey._),
this._tableEntityListViewModel.getTableEntityKeys(anchorItem.PartitionKey._, anchorItem.RowKey._),
);
var startIndex = Math.min(elementIndex, anchorIndex);
@@ -234,24 +238,25 @@ export default class DataTableOperationManager {
if (
!this._tableEntityListViewModel.isItemSelected(
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.RowKey),
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.PartitionKey, entityIdentity.RowKey),
)
) {
if (this._tableEntityListViewModel.selected().length) {
this._tableEntityListViewModel.clearSelection();
}
this.addToSelection(entityIdentity.RowKey);
this.addToSelection(entityIdentity.RowKey, entityIdentity.PartitionKey);
}
}
private addToSelection(rowKey: string) {
private addToSelection(rowKey: string, partitionKey?: string) {
var selectedEntity: Entities.ITableEntity = this._tableEntityListViewModel.getItemFromCurrentPage(
this._tableEntityListViewModel.getTableEntityKeys(rowKey),
this._tableEntityListViewModel.getTableEntityKeys(rowKey, partitionKey),
);
if (selectedEntity != null) {
this._tableEntityListViewModel.selected.push(selectedEntity);
}
console.log(this._tableEntityListViewModel.selected().length);
}
// Selecting first row if the selection is empty.
@@ -269,7 +274,7 @@ export default class DataTableOperationManager {
// Clear last selection: lastSelectedItem and lastSelectedAnchorItem
this._tableEntityListViewModel.clearLastSelected();
this.addToSelection(firstEntity.RowKey._);
this.addToSelection(firstEntity.RowKey._, firstEntity.PartitionKey && firstEntity.PartitionKey._);
// Update last selection
this._tableEntityListViewModel.lastSelectedItem = firstEntity;

View File

@@ -128,8 +128,14 @@ export default class TableEntityListViewModel extends DataTableViewModel {
this.sqlQuery = ko.observable<string>("SELECT * FROM c");
}
public getTableEntityKeys(rowKey: string): Entities.IProperty[] {
return [{ key: Constants.EntityKeyNames.RowKey, value: rowKey }];
public getTableEntityKeys(rowKey: string, partitionKey: string): Entities.IProperty[] {
const properties: Entities.IProperty[] = [{ key: Constants.EntityKeyNames.RowKey, value: rowKey }];
if (partitionKey) {
properties.push({ key: Constants.EntityKeyNames.PartitionKey, value: partitionKey });
}
return properties;
}
public reloadTable(useSetting: boolean = true, resetHeaders: boolean = true): DataTables.Api<Element> {
@@ -261,7 +267,8 @@ export default class TableEntityListViewModel extends DataTableViewModel {
}
var oldEntityIndex: number = _.findIndex(
this.cache.data,
(data: Entities.ITableEntity) => data.RowKey._ === entity.RowKey._,
(data: Entities.ITableEntity) =>
data.RowKey._ === entity.RowKey._ && data.PartitionKey._ === entity.PartitionKey._,
);
this.cache.data.splice(oldEntityIndex, 1, entity);
@@ -285,7 +292,7 @@ export default class TableEntityListViewModel extends DataTableViewModel {
entities.forEach((entity: Entities.ITableEntity) => {
var cachedIndex: number = _.findIndex(
this.cache.data,
(e: Entities.ITableEntity) => e.RowKey._ === entity.RowKey._,
(e: Entities.ITableEntity) => e.RowKey._ === entity.RowKey._ && e.PartitionKey._ === entity.PartitionKey._,
);
if (cachedIndex >= 0) {
this.cache.data.splice(cachedIndex, 1);
@@ -393,6 +400,16 @@ export default class TableEntityListViewModel extends DataTableViewModel {
});
}
// Override as Tables can have the same Row key in different Partition keys
/**
* @override
*/
public getItemFromCurrentPage(itemKeys: Entities.IProperty[]): Entities.ITableEntity {
return _.find(this.items(), (item: Entities.ITableEntity) => {
return this.matchesKeys(item, itemKeys);
});
}
private prefetchAndRender(
tableQuery: Entities.ITableQuery,
tablePageStartIndex: number,

View File

@@ -36,4 +36,5 @@ export interface ITableQuery {
export interface ITableEntityIdentity {
RowKey: string;
PartitionKey?: string;
}

View File

@@ -753,17 +753,11 @@ export class CassandraAPIDataClient extends TableDataClient {
CassandraProxyEndpoints.Development,
CassandraProxyEndpoints.Mpac,
CassandraProxyEndpoints.Prod,
CassandraProxyEndpoints.Fairfax,
CassandraProxyEndpoints.Mooncake,
];
let canAccessCassandraProxy: boolean = userContext.databaseAccount.properties.publicNetworkAccess === "Enabled";
if (
configContext.CASSANDRA_PROXY_ENDPOINT !== CassandraProxyEndpoints.Development &&
userContext.databaseAccount.properties.ipRules?.length > 0
) {
canAccessCassandraProxy = canAccessCassandraProxy && configContext.CASSANDRA_PROXY_OUTBOUND_IPS_ALLOWLISTED;
}
return (
canAccessCassandraProxy &&
configContext.NEW_CASSANDRA_APIS?.includes(api) &&
activeCassandraProxyEndpoints.includes(configContext.CASSANDRA_PROXY_ENDPOINT)
);

View File

@@ -0,0 +1,113 @@
// Definitions of State data
import { ColumnDefinition } from "Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent";
import {
AppStateComponentNames,
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 = AppStateComponentNames.DocumentsTab;
export enum SubComponentName {
ColumnSizes = "ColumnSizes",
FilterHistory = "FilterHistory",
MainTabDivider = "MainTabDivider",
ColumnsSelection = "ColumnsSelection",
ColumnSort = "ColumnSort",
}
export type ColumnSizesMap = { [columnId: string]: WidthDefinition };
export type FilterHistory = string[];
export type WidthDefinition = { widthPx: number };
export type TabDivider = { leftPaneWidthPercent: number };
export type ColumnsSelection = { selectedColumnIds: string[]; columnDefinitions: ColumnDefinition[] };
export type ColumnSort = { columnId: string; direction: "ascending" | "descending" };
/**
*
* @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(),
});
};

View File

@@ -1,8 +1,11 @@
import { FeedResponse, ItemDefinition, Resource } from "@azure/cosmos";
import { waitFor } from "@testing-library/react";
import { deleteDocuments } from "Common/dataAccess/deleteDocument";
import { Platform, updateConfigContext } from "ConfigContext";
import { useDialog } from "Explorer/Controls/Dialog";
import { EditorReactProps } from "Explorer/Controls/Editor/EditorReact";
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { ProgressModalDialog } from "Explorer/Controls/ProgressModalDialog";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import {
ButtonsDependencies,
DELETE_BUTTON_ID,
@@ -13,6 +16,7 @@ import {
SAVE_BUTTON_ID,
UPDATE_BUTTON_ID,
UPLOAD_BUTTON_ID,
addStringsNoDuplicate,
buildQuery,
getDiscardExistingDocumentChangesButtonState,
getDiscardNewDocumentChangesButtonState,
@@ -64,12 +68,14 @@ jest.mock("Explorer/Controls/Editor/EditorReact", () => ({
EditorReact: (props: EditorReactProps) => <>{props.content}</>,
}));
const mockDialogState = {
showOkCancelModalDialog: jest.fn((title: string, subText: string, okLabel: string, onOk: () => void) => onOk()),
showOkModalDialog: () => {},
};
jest.mock("Explorer/Controls/Dialog", () => ({
useDialog: {
getState: jest.fn(() => ({
showOkCancelModalDialog: (title: string, subText: string, okLabel: string, onOk: () => void) => onOk(),
showOkModalDialog: () => {},
})),
getState: jest.fn(() => mockDialogState),
},
}));
@@ -79,6 +85,10 @@ jest.mock("Common/dataAccess/deleteDocument", () => ({
),
}));
jest.mock("Explorer/Controls/ProgressModalDialog", () => ({
ProgressModalDialog: jest.fn(() => <></>),
}));
async function waitForComponentToPaint<P = unknown>(wrapper: ReactWrapper<P> | ShallowWrapper<P>, amount = 0) {
let newWrapper;
await act(async () => {
@@ -91,7 +101,13 @@ async function waitForComponentToPaint<P = unknown>(wrapper: ReactWrapper<P> | S
describe("Documents tab (noSql API)", () => {
describe("buildQuery", () => {
it("should generate the right select query for SQL API", () => {
expect(buildQuery(false, "")).toContain("select");
expect(
buildQuery(false, "", ["pk"], {
paths: ["pk"],
kind: "Hash",
version: 2,
}),
).toContain("select");
});
});
@@ -339,7 +355,10 @@ describe("Documents tab (noSql API)", () => {
const createMockProps = (): IDocumentsTabComponentProps => ({
isPreferredApiMongoDB: false,
documentIds: [],
collection: undefined,
collection: {
id: ko.observable<string>("collectionId"),
databaseId: "databaseId",
} as ViewModels.CollectionBase,
partitionKey: { kind: "Hash", paths: ["/foo"], version: 2 },
onLoadStartKey: 0,
tabTitle: "",
@@ -380,7 +399,7 @@ describe("Documents tab (noSql API)", () => {
.findWhere((node) => node.text() === "Edit Filter")
.at(0)
.simulate("click");
expect(wrapper.find("#filterInput").exists()).toBeTruthy();
expect(wrapper.find("Input.filterInput").exists()).toBeTruthy();
});
});
@@ -442,7 +461,7 @@ describe("Documents tab (noSql API)", () => {
useCommandBar
.getState()
.contextButtons.find((button) => button.id === NEW_DOCUMENT_BUTTON_ID)
.onCommandClick(undefined);
.onCommandClick(undefined, undefined);
});
expect(wrapper.findWhere((node) => node.text().includes("replace_with_new_document_id")).exists()).toBeTruthy();
});
@@ -452,14 +471,36 @@ describe("Documents tab (noSql API)", () => {
useCommandBar
.getState()
.contextButtons.find((button) => button.id === NEW_DOCUMENT_BUTTON_ID)
.onCommandClick(undefined);
.onCommandClick(undefined, undefined);
});
expect(useCommandBar.getState().contextButtons.find((button) => button.id === SAVE_BUTTON_ID)).toBeDefined();
expect(useCommandBar.getState().contextButtons.find((button) => button.id === DISCARD_BUTTON_ID)).toBeDefined();
});
it("clicking Delete Document asks for confirmation", () => {
it("clicking Delete Document asks for confirmation", async () => {
act(async () => {
await useCommandBar
.getState()
.contextButtons.find((button) => button.id === DELETE_BUTTON_ID)
.onCommandClick(undefined, undefined);
});
expect(useDialog.getState().showOkCancelModalDialog).toHaveBeenCalled();
});
it("clicking Delete Document for NoSql shows progress dialog", () => {
act(() => {
useCommandBar
.getState()
.contextButtons.find((button) => button.id === DELETE_BUTTON_ID)
.onCommandClick(undefined, undefined);
});
expect(ProgressModalDialog).toHaveBeenCalled();
});
it("clicking Delete Document eventually calls delete client api", () => {
const mockDeleteDocuments = deleteDocuments as jest.Mock;
mockDeleteDocuments.mockClear();
@@ -467,10 +508,21 @@ describe("Documents tab (noSql API)", () => {
useCommandBar
.getState()
.contextButtons.find((button) => button.id === DELETE_BUTTON_ID)
.onCommandClick(undefined);
.onCommandClick(undefined, undefined);
});
expect(mockDeleteDocuments).toHaveBeenCalled();
// The implementation uses setTimeout, so wait for it to finish
waitFor(() => expect(mockDeleteDocuments).toHaveBeenCalled());
});
});
});
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"]);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import { deleteDocument } from "Common/MongoProxyClient";
import { deleteDocuments } from "Common/MongoProxyClient";
import { Platform, updateConfigContext } from "ConfigContext";
import { EditorReactProps } from "Explorer/Controls/Editor/EditorReact";
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import {
DELETE_BUTTON_ID,
DISCARD_BUTTON_ID,
@@ -49,7 +49,9 @@ jest.mock("Common/MongoProxyClient", () => ({
id: "id1",
}),
),
deleteDocument: jest.fn(() => Promise.resolve()),
deleteDocuments: jest.fn(() => Promise.resolve({ deleteCount: 0, isAcknowledged: true })),
ThrottlingError: Error,
useMongoProxyEndpoint: jest.fn(() => true),
}));
jest.mock("Explorer/Controls/Editor/EditorReact", () => ({
@@ -161,7 +163,7 @@ describe("Documents tab (Mongo API)", () => {
useCommandBar
.getState()
.contextButtons.find((button) => button.id === NEW_DOCUMENT_BUTTON_ID)
.onCommandClick(undefined);
.onCommandClick(undefined, undefined);
});
expect(wrapper.findWhere((node) => node.text().includes("replace_with_new_document_id")).exists()).toBeTruthy();
});
@@ -171,25 +173,25 @@ describe("Documents tab (Mongo API)", () => {
useCommandBar
.getState()
.contextButtons.find((button) => button.id === NEW_DOCUMENT_BUTTON_ID)
.onCommandClick(undefined);
.onCommandClick(undefined, undefined);
});
expect(useCommandBar.getState().contextButtons.find((button) => button.id === SAVE_BUTTON_ID)).toBeDefined();
expect(useCommandBar.getState().contextButtons.find((button) => button.id === DISCARD_BUTTON_ID)).toBeDefined();
});
it("clicking Delete Document asks for confirmation", () => {
const mockDeleteDocument = deleteDocument as jest.Mock;
mockDeleteDocument.mockClear();
it("clicking Delete Document eventually calls delete client api", () => {
const mockDeleteDocuments = deleteDocuments as jest.Mock;
mockDeleteDocuments.mockClear();
act(() => {
useCommandBar
.getState()
.contextButtons.find((button) => button.id === DELETE_BUTTON_ID)
.onCommandClick(undefined);
.onCommandClick(undefined, undefined);
});
expect(mockDeleteDocument).toHaveBeenCalled();
expect(mockDeleteDocuments).toHaveBeenCalled();
});
});
});

View File

@@ -1,6 +1,7 @@
import { TableRowId } from "@fluentui/react-components";
import { mount } from "enzyme";
import React from "react";
import * as ViewModels from "../../../Contracts/ViewModels";
import { DocumentsTableComponent, IDocumentsTableComponentProps } from "./DocumentsTableComponent";
const PARTITION_KEY_HEADER = "partitionKey";
@@ -20,11 +21,19 @@ describe("DocumentsTableComponent", () => {
height: 0,
width: 0,
},
columnHeaders: {
idHeader: ID_HEADER,
partitionKeyHeaders: [PARTITION_KEY_HEADER],
columnDefinitions: [
{ id: ID_HEADER, label: "ID", isPartitionKey: false },
{ id: PARTITION_KEY_HEADER, label: "Partition Key", isPartitionKey: true },
],
isRowSelectionDisabled: false,
collection: {
databaseId: "db",
id: ((): string => "coll") as ko.Observable<string>,
} as ViewModels.CollectionBase,
onRefreshTable: (): void => {
throw new Error("Function not implemented.");
},
isSelectionDisabled: false,
selectedColumnIds: [],
});
it("should render documents and partition keys in header", () => {
@@ -35,7 +44,7 @@ describe("DocumentsTableComponent", () => {
it("should not render selection column when isSelectionDisabled is true", () => {
const props: IDocumentsTableComponentProps = createMockProps();
props.isSelectionDisabled = true;
props.isRowSelectionDisabled = true;
const wrapper = mount(<DocumentsTableComponent {...props} />);
expect(wrapper).toMatchSnapshot();
});

View File

@@ -1,52 +1,86 @@
import {
Button,
Menu,
MenuDivider,
MenuItem,
MenuList,
MenuPopover,
MenuTrigger,
TableRowData as RowStateBase,
SortDirection,
Table,
TableBody,
TableCell,
TableCellLayout,
TableColumnDefinition,
TableColumnId,
TableColumnSizingOptions,
TableHeader,
TableHeaderCell,
TableRow,
TableRowId,
TableSelectionCell,
createTableColumn,
tokens,
useArrowNavigationGroup,
useTableColumnSizing_unstable,
useTableFeatures,
useTableSelection,
useTableSort,
} from "@fluentui/react-components";
import {
ArrowClockwise16Regular,
ArrowResetRegular,
DeleteRegular,
EditRegular,
MoreHorizontalRegular,
TableResizeColumnRegular,
TextSortAscendingRegular,
TextSortDescendingRegular,
} from "@fluentui/react-icons";
import { NormalizedEventKey } from "Common/Constants";
import { Environment, getEnvironment } from "Common/EnvironmentUtility";
import { TableColumnSelectionPane } from "Explorer/Panes/TableColumnSelectionPane/TableColumnSelectionPane";
import {
ColumnSizesMap,
ColumnSort,
deleteSubComponentState,
readSubComponentState,
saveSubComponentState,
SubComponentName,
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
import { INITIAL_SELECTED_ROW_INDEX, useDocumentsTabStyles } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2";
import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
import { LayoutConstants } from "Explorer/Theme/ThemeUtil";
import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils";
import { useSidePanel } from "hooks/useSidePanel";
import React, { useCallback, useMemo } from "react";
import { FixedSizeList as List, ListChildComponentProps } from "react-window";
import * as ViewModels from "../../../Contracts/ViewModels";
export type DocumentsTableComponentItem = {
id: string;
} & Record<string, string>;
} & Record<string, string | number>;
export type ColumnHeaders = {
idHeader: string;
partitionKeyHeaders: string[];
export type ColumnDefinition = {
id: string;
label: string;
isPartitionKey: boolean;
};
export interface IDocumentsTableComponentProps {
onRefreshTable: () => void;
items: DocumentsTableComponentItem[];
onItemClicked: (index: number) => void;
onSelectedRowsChange: (selectedItemsIndices: Set<TableRowId>) => void;
selectedRows: Set<TableRowId>;
size: { height: number; width: number };
columnHeaders: ColumnHeaders;
selectedColumnIds: string[];
columnDefinitions: ColumnDefinition[];
style?: React.CSSProperties;
isSelectionDisabled?: boolean;
isRowSelectionDisabled?: boolean;
collection: ViewModels.CollectionBase;
onColumnSelectionChange?: (newSelectedColumnIds: string[]) => void;
defaultColumnSelection?: string[];
isColumnSelectionDisabled?: boolean;
}
interface TableRowData extends RowStateBase<DocumentsTableComponentItem> {
@@ -59,72 +93,204 @@ interface ReactWindowRenderFnProps extends ListChildComponentProps {
data: TableRowData[];
}
const COLUMNS_MENU_NAME = "columnsMenu";
const defaultSize = {
idealWidth: 200,
minWidth: 50,
};
export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> = ({
onRefreshTable,
items,
onSelectedRowsChange,
selectedRows,
style,
size,
columnHeaders,
isSelectionDisabled,
selectedColumnIds,
columnDefinitions,
isRowSelectionDisabled: isSelectionDisabled,
collection,
onColumnSelectionChange,
defaultColumnSelection,
isColumnSelectionDisabled,
}: IDocumentsTableComponentProps) => {
const styles = useDocumentsTabStyles();
const initialSizingOptions: TableColumnSizingOptions = {
id: {
idealWidth: 280,
minWidth: 50,
},
};
columnHeaders.partitionKeyHeaders.forEach((pkHeader) => {
initialSizingOptions[pkHeader] = {
idealWidth: 200,
minWidth: 50,
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(() => {
const columnSizesMap: ColumnSizesMap = readSubComponentState(SubComponentName.ColumnSizes, collection, {});
const columnSizesPx: TableColumnSizingOptions = {};
selectedColumnIds.forEach((columnId) => {
if (
!columnSizesMap ||
!columnSizesMap[columnId] ||
columnSizesMap[columnId].widthPx === undefined ||
isNaN(columnSizesMap[columnId].widthPx)
) {
columnSizesPx[columnId] = defaultSize;
} else {
columnSizesPx[columnId] = {
idealWidth: columnSizesMap[columnId].widthPx,
minWidth: 50,
};
}
});
return columnSizesPx;
});
const [sortState, setSortState] = React.useState<{
sortDirection: "ascending" | "descending";
sortColumn: TableColumnId | undefined;
}>(() => {
const sort = readSubComponentState<ColumnSort>(SubComponentName.ColumnSort, collection, undefined);
if (!sort) {
return {
sortDirection: undefined,
sortColumn: undefined,
};
}
return {
sortDirection: sort.direction,
sortColumn: sort.columnId,
};
});
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(initialSizingOptions);
const onColumnResize = React.useCallback((_, { columnId, width }: { columnId: string; width: number }) => {
setColumnSizingOptions((state) => {
const newSizingOptions = {
...state,
[columnId]: {
...state[columnId],
idealWidth: width,
},
};
const onColumnResize = React.useCallback((_, { columnId, width }) => {
setColumnSizingOptions((state) => ({
...state,
[columnId]: {
...state[columnId],
idealWidth: width,
},
}));
const persistentSizes = Object.keys(newSizingOptions).reduce((acc, key) => {
acc[key] = {
widthPx: newSizingOptions[key].idealWidth,
};
return acc;
}, {} as ColumnSizesMap);
saveSubComponentState<ColumnSizesMap>(SubComponentName.ColumnSizes, collection, persistentSizes, true);
return newSizingOptions;
});
}, []);
// const restoreFocusTargetAttribute = useRestoreFocusTarget();
const onSortClick = (event: React.SyntheticEvent, columnId: string, direction: SortDirection) => {
setColumnSort(event, columnId, direction);
if (columnId === undefined || direction === undefined) {
deleteSubComponentState(SubComponentName.ColumnSort, collection);
return;
}
saveSubComponentState<ColumnSort>(SubComponentName.ColumnSort, collection, { columnId, direction });
};
// Columns must be a static object and cannot change on re-renders otherwise React will complain about too many refreshes
const columns: TableColumnDefinition<DocumentsTableComponentItem>[] = useMemo(
() =>
[
createTableColumn<DocumentsTableComponentItem>({
columnId: "id",
compare: (a, b) => a.id.localeCompare(b.id),
renderHeaderCell: () => columnHeaders.idHeader,
columnDefinitions
.filter((column) => selectedColumnIds.includes(column.id))
.map((column) => ({
columnId: column.id,
compare: (a, b) => {
if (typeof a[column.id] === "string") {
return (a[column.id] as string).localeCompare(b[column.id] as string);
} else if (typeof a[column.id] === "number") {
return (a[column.id] as number) - (b[column.id] as number);
} else {
// Should not happen
return 0;
}
},
renderHeaderCell: () => (
<>
<span title={column.label}>{column.label}</span>
<Menu>
<MenuTrigger disableButtonEnhancement>
<Button
// {...restoreFocusTargetAttribute}
appearance="transparent"
aria-label="Select column"
size="small"
icon={<MoreHorizontalRegular />}
style={{ position: "absolute", right: 0, backgroundColor: tokens.colorNeutralBackground1 }}
/>
</MenuTrigger>
<MenuPopover>
<MenuList>
<MenuItem key="refresh" icon={<ArrowClockwise16Regular />} onClick={onRefreshTable}>
Refresh
</MenuItem>
{[Environment.Development, Environment.Mpac].includes(getEnvironment()) && (
<>
<MenuItem
icon={<TextSortAscendingRegular />}
onClick={(e) => onSortClick(e, column.id, "ascending")}
>
Sort ascending
</MenuItem>
<MenuItem
icon={<TextSortDescendingRegular />}
onClick={(e) => onSortClick(e, column.id, "descending")}
>
Sort descending
</MenuItem>
<MenuItem icon={<ArrowResetRegular />} onClick={(e) => onSortClick(e, undefined, undefined)}>
Reset sorting
</MenuItem>
{!isColumnSelectionDisabled && (
<MenuItem key="editcolumns" icon={<EditRegular />} onClick={openColumnSelectionPane}>
Edit columns
</MenuItem>
)}
<MenuDivider />
</>
)}
<MenuItem
key="keyboardresize"
icon={<TableResizeColumnRegular />}
onClick={columnSizing.enableKeyboardMode(column.id)}
>
Resize with left/right arrow keys
</MenuItem>
{[Environment.Development, Environment.Mpac].includes(getEnvironment()) &&
!isColumnSelectionDisabled && (
<MenuItem
key="remove"
icon={<DeleteRegular />}
onClick={() => {
// Remove column id from selectedColumnIds
const index = selectedColumnIds.indexOf(column.id);
if (index === -1) {
return;
}
const newSelectedColumnIds = [...selectedColumnIds];
newSelectedColumnIds.splice(index, 1);
onColumnSelectionChange(newSelectedColumnIds);
}}
>
Remove column
</MenuItem>
)}
</MenuList>
</MenuPopover>
</Menu>
</>
),
renderCell: (item) => (
<TableCellLayout truncate title={item.id}>
{item.id}
<TableCellLayout truncate title={`${item[column.id]}`}>
{item[column.id]}
</TableCellLayout>
),
}),
].concat(
columnHeaders.partitionKeyHeaders.map((pkHeader) =>
createTableColumn<DocumentsTableComponentItem>({
columnId: pkHeader,
compare: (a, b) => a[pkHeader].localeCompare(b[pkHeader]),
// Show Refresh button on last column
renderHeaderCell: () => <span title={pkHeader}>{pkHeader}</span>,
renderCell: (item) => (
<TableCellLayout truncate title={item[pkHeader]}>
{item[pkHeader]}
</TableCellLayout>
),
}),
),
),
[columnHeaders],
})),
[columnDefinitions, onColumnSelectionChange, selectedColumnIds],
);
const [selectionStartIndex, setSelectionStartIndex] = React.useState<number>(INITIAL_SELECTED_ROW_INDEX);
@@ -214,6 +380,7 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
columnSizing_unstable: columnSizing,
tableRef,
selection: { allRowsSelected, someRowsSelected, toggleAllRows, toggleRow, isRowSelected },
sort: { getSortDirection, setColumnSort, sort },
} = useTableFeatures(
{
columns,
@@ -227,25 +394,36 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
// eslint-disable-next-line react/prop-types
onSelectionChange: (e, data) => onSelectedRowsChange(data.selectedItems),
}),
useTableSort({
sortState,
onSortChange: (e, nextSortState) => setSortState(nextSortState),
}),
],
);
const rows: TableRowData[] = getRows((row) => {
const selected = isRowSelected(row.rowId);
return {
...row,
onClick: (e: React.MouseEvent) => toggleRow(e, row.rowId),
onKeyDown: (e: React.KeyboardEvent) => {
if (e.key === " ") {
e.preventDefault();
toggleRow(e, row.rowId);
}
},
selected,
appearance: selected ? ("brand" as const) : ("none" as const),
};
const headerSortProps = (columnId: TableColumnId) => ({
// onClick: (e: React.MouseEvent) => toggleColumnSort(e, columnId),
sortDirection: getSortDirection(columnId),
});
const rows: TableRowData[] = sort(
getRows((row) => {
const selected = isRowSelected(row.rowId);
return {
...row,
onClick: (e: React.MouseEvent) => toggleRow(e, row.rowId),
onKeyDown: (e: React.KeyboardEvent) => {
if (e.key === " ") {
e.preventDefault();
toggleRow(e, row.rowId);
}
},
selected,
appearance: selected ? ("brand" as const) : ("none" as const),
};
}),
);
const toggleAllKeydown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === " ") {
@@ -271,39 +449,53 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
...style,
};
const checkedValues: { [COLUMNS_MENU_NAME]: string[] } = {
[COLUMNS_MENU_NAME]: [],
};
columnDefinitions.forEach(
(columnDefinition) =>
selectedColumnIds.includes(columnDefinition.id) && checkedValues[COLUMNS_MENU_NAME].push(columnDefinition.id),
);
const openColumnSelectionPane = (): void => {
useSidePanel
.getState()
.openSidePanel(
"Select columns",
<TableColumnSelectionPane
selectedColumnIds={selectedColumnIds}
columnDefinitions={columnDefinitions}
onSelectionChange={onColumnSelectionChange}
defaultSelection={defaultColumnSelection}
/>,
);
};
return (
<Table noNativeElements {...tableProps}>
<TableHeader>
<TableHeader className={styles.tableHeader}>
<TableRow className={styles.tableRow} style={{ width: size ? size.width - 15 : "100%" }}>
{!isSelectionDisabled && (
<TableSelectionCell
key="selectcell"
checked={allRowsSelected ? true : someRowsSelected ? "mixed" : false}
onClick={toggleAllRows}
onKeyDown={toggleAllKeydown}
checkboxIndicator={{ "aria-label": "Select all rows " }}
/>
)}
{columns.map((column /* index */) => (
<Menu openOnContext key={column.columnId}>
<MenuTrigger>
<TableHeaderCell
className={styles.tableCell}
key={column.columnId}
{...columnSizing.getTableHeaderCellProps(column.columnId)}
>
{column.renderHeaderCell()}
</TableHeaderCell>
</MenuTrigger>
<MenuPopover>
<MenuList>
<MenuItem onClick={columnSizing.enableKeyboardMode(column.columnId)}>
Keyboard Column Resizing
</MenuItem>
</MenuList>
</MenuPopover>
</Menu>
{columns.map((column) => (
<TableHeaderCell
className={styles.tableCell}
key={column.columnId}
{...columnSizing.getTableHeaderCellProps(column.columnId)}
{...headerSortProps(column.columnId)}
>
{column.renderHeaderCell()}
</TableHeaderCell>
))}
</TableRow>
<div className={styles.tableHeaderFiller}></div>
</TableHeader>
<TableBody>
<List

View File

@@ -1,3 +1,5 @@
import { useEffect, useRef } from "react";
/**
* Utility class to help with selection.
* This emulates File Explorer selection behavior.
@@ -90,3 +92,12 @@ export const selectionHelper = (
}
}
};
// To get previous values of a state in useEffect
export const usePrevious = <T>(value: T): T | undefined => {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
});
return ref.current;
};

View File

@@ -38,9 +38,11 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
}
}
>
<Allotment>
<Allotment
onDragEnd={[Function]}
>
<Allotment.Pane
minSize={175}
minSize={55}
preferredSize="35%"
>
<div
@@ -53,52 +55,61 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
}
>
<div
className="___77lcry0_0000000 f10pi13n"
className="___9o87uj0_0000000 ffefeo0"
>
<div
className="___1rwkz4r_0000000 f1euv43f f1l8gmrm f1e31b4d f150nix6 fy6ml6n f19g0ac"
style={
{
"height": "100%",
"width": "calc(100% + -11px)",
}
}
>
<Button
appearance="transparent"
aria-label="Refresh"
icon={<ArrowClockwise16Filled />}
onClick={[Function]}
onKeyDown={[Function]}
size="small"
style={
<DocumentsTableComponent
collection={
{
"color": undefined,
"databaseId": "databaseId",
"id": [Function],
}
}
columnDefinitions={
[
{
"id": "id",
"isPartitionKey": false,
"label": "id",
},
]
}
defaultColumnSelection={
[
"id",
]
}
isColumnSelectionDisabled={false}
isRowSelectionDisabled={true}
items={[]}
onColumnSelectionChange={[Function]}
onItemClicked={[Function]}
onRefreshTable={[Function]}
onSelectedRowsChange={[Function]}
selectedColumnIds={
[
"id",
]
}
selectedRows={
Set {
0,
}
}
/>
</div>
</div>
<div
className="___9o87uj0_0000000 ffefeo0"
>
<DocumentsTableComponent
columnHeaders={
{
"idHeader": "id",
"partitionKeyHeaders": [],
}
}
isSelectionDisabled={true}
items={[]}
onItemClicked={[Function]}
onSelectedRowsChange={[Function]}
selectedRows={
Set {
0,
}
}
/>
</div>
</div>
</Allotment.Pane>
<Allotment.Pane
minSize={300}
preferredSize="65%"
minSize={30}
>
<div
style={

View File

@@ -152,7 +152,6 @@ export default class NotebookTabV2 extends NotebookTabBase {
ariaLabel: saveLabel,
children: saveButtonChildren.length && [
{
iconName: "Save",
onCommandClick: () => this.notebookComponentAdapter.notebookSave(),
commandButtonLabel: saveLabel,
hasPopup: false,

View File

@@ -12,7 +12,7 @@ import {
createTableColumn,
tokens,
} from "@fluentui/react-components";
import { ErrorCircleFilled, MoreHorizontalRegular, WarningFilled } from "@fluentui/react-icons";
import { ErrorCircleFilled, MoreHorizontalRegular, QuestionRegular, WarningFilled } from "@fluentui/react-icons";
import QueryError, { QueryErrorSeverity, compareSeverity } from "Common/QueryError";
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
import { useNotificationConsole } from "hooks/useNotificationConsole";
@@ -34,25 +34,32 @@ export const ErrorList: React.FC<{ errors: QueryError[] }> = ({ errors }) => {
createTableColumn<QueryError>({
columnId: "code",
compare: (item1, item2) => item1.code.localeCompare(item2.code),
renderHeaderCell: () => null,
renderCell: (item) => item.code,
renderHeaderCell: () => "Code",
renderCell: (item) => <TableCellLayout truncate>{item.code}</TableCellLayout>,
}),
createTableColumn<QueryError>({
columnId: "severity",
compare: (item1, item2) => compareSeverity(item1.severity, item2.severity),
renderHeaderCell: () => null,
renderCell: (item) => <TableCellLayout media={severityIcons[item.severity]}>{item.severity}</TableCellLayout>,
renderHeaderCell: () => "Severity",
renderCell: (item) => (
<TableCellLayout truncate 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>",
renderCell: (item) => (
<TableCellLayout truncate>
{item.location
? item.location.start.lineNumber
? `Line ${item.location.start.lineNumber}`
: "<unknown>"
: "<no location>"}
</TableCellLayout>
),
}),
createTableColumn<QueryError>({
columnId: "message",
@@ -60,8 +67,20 @@ export const ErrorList: React.FC<{ errors: QueryError[] }> = ({ errors }) => {
renderHeaderCell: () => "Message",
renderCell: (item) => (
<div className={styles.errorListMessageCell}>
<div className={styles.errorListMessage}>{item.message}</div>
<div>
<div className={styles.errorListMessage} title={item.message}>
{item.message}
</div>
<div className={styles.errorListMessageActions}>
{item.helpLink && (
<Button
as="a"
aria-label="Help"
appearance="subtle"
icon={<QuestionRegular />}
href={item.helpLink}
target="_blank"
/>
)}
<Button
aria-label="Details"
appearance="subtle"
@@ -76,9 +95,9 @@ export const ErrorList: React.FC<{ errors: QueryError[] }> = ({ errors }) => {
const columnSizingOptions: TableColumnSizingOptions = {
code: {
minWidth: 75,
idealWidth: 75,
defaultWidth: 75,
minWidth: 90,
idealWidth: 90,
defaultWidth: 90,
},
severity: {
minWidth: 100,

View File

@@ -2,12 +2,14 @@ import { fireEvent, render } from "@testing-library/react";
import { CollectionTabKind } from "Contracts/ViewModels";
import { CopilotProvider } from "Explorer/QueryCopilot/QueryCopilotContext";
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
import { CopilotSubComponentNames } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import {
IQueryTabComponentProps,
QueryTabComponent,
QueryTabCopilotComponent,
} from "Explorer/Tabs/QueryTab/QueryTabComponent";
import TabsBase from "Explorer/Tabs/TabsBase";
import { AppStateComponentNames, StorePath } from "Shared/AppStatePersistenceUtility";
import { updateUserContext, userContext } from "UserContext";
import { mount } from "enzyme";
import { useQueryCopilot } from "hooks/useQueryCopilot";
@@ -16,6 +18,24 @@ import React from "react";
jest.mock("Explorer/Controls/Editor/EditorReact");
const loadState = (path: StorePath) => {
if (
path.componentName === AppStateComponentNames.QueryCopilot &&
path.subComponentName === CopilotSubComponentNames.toggleStatus
) {
return true;
} else {
return undefined;
}
};
jest.mock("Shared/AppStatePersistenceUtility", () => ({
loadState,
AppStateComponentNames: {
QueryCopilot: "QueryCopilot",
},
}));
describe("QueryTabComponent", () => {
const mockStore = useQueryCopilot.getState();
beforeEach(() => {
@@ -32,7 +52,7 @@ describe("QueryTabComponent", () => {
},
});
const propsMock: Readonly<IQueryTabComponentProps> = {
collection: { databaseId: "CopilotSampleDb" },
collection: { databaseId: "CopilotSampleDB" },
onTabAccessor: () => jest.fn(),
isExecutionError: false,
tabId: "mockTabId",
@@ -50,6 +70,17 @@ describe("QueryTabComponent", () => {
});
it("copilot should be enabled by default when tab is active", () => {
updateUserContext({
databaseAccount: {
name: "name",
properties: undefined,
id: "",
location: "",
type: "",
kind: "",
},
});
useQueryCopilot.getState().setCopilotEnabled(true);
useQueryCopilot.getState().setCopilotUserDBEnabled(true);
const activeTab = new TabsBase({

View File

@@ -6,9 +6,11 @@ import { SplitterDirection } from "Common/Splitter";
import { Platform, configContext } from "ConfigContext";
import { useDialog } from "Explorer/Controls/Dialog";
import { monaco } from "Explorer/LazyMonaco";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { QueryCopilotFeedbackModal } from "Explorer/QueryCopilot/Modal/QueryCopilotFeedbackModal";
import { useCopilotStore } from "Explorer/QueryCopilot/QueryCopilotContext";
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
import { readCopilotToggleStatus, saveCopilotToggleStatus } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import { OnExecuteQueryClick, QueryDocumentsPerPage } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { QueryCopilotSidebar } from "Explorer/QueryCopilot/V2/Sidebar/QueryCopilotSidebar";
import { QueryResultSection } from "Explorer/Tabs/QueryTab/QueryResultSection";
@@ -46,7 +48,6 @@ import { queryDocuments } from "../../../Common/dataAccess/queryDocuments";
import { queryDocumentsPage } from "../../../Common/dataAccess/queryDocumentsPage";
import * as DataModels from "../../../Contracts/DataModels";
import * as ViewModels from "../../../Contracts/ViewModels";
import * as StringUtility from "../../../Shared/StringUtility";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
import { userContext } from "../../../UserContext";
import * as QueryUtils from "../../../Utils/QueryUtils";
@@ -54,7 +55,6 @@ import { useSidePanel } from "../../../hooks/useSidePanel";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import { EditorReact } from "../../Controls/Editor/EditorReact";
import Explorer from "../../Explorer";
import { useCommandBar } from "../../Menus/CommandBar/CommandBarComponentAdapter";
import { BrowseQueriesPane } from "../../Panes/BrowseQueriesPane/BrowseQueriesPane";
import { SaveQueryPane } from "../../Panes/SaveQueryPane/SaveQueryPane";
import TabsBase from "../TabsBase";
@@ -209,13 +209,7 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
private _queryCopilotActive(): boolean {
if (this.props.copilotEnabled) {
const cachedCopilotToggleStatus: string = localStorage.getItem(
`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`,
);
const copilotInitialActive: boolean = cachedCopilotToggleStatus
? StringUtility.toBoolean(cachedCopilotToggleStatus)
: true;
return copilotInitialActive;
return readCopilotToggleStatus(userContext.databaseAccount);
}
return false;
}
@@ -584,7 +578,7 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
private _toggleCopilot = (active: boolean) => {
this.setState({ copilotActive: active });
useQueryCopilot.getState().setCopilotEnabledforExecution(active);
localStorage.setItem(`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`, active.toString());
saveCopilotToggleStatus(userContext.databaseAccount, active);
TelemetryProcessor.traceSuccess(active ? Action.ActivateQueryCopilot : Action.DeactivateQueryCopilot, {
databaseName: this.props.collection.databaseId,

View File

@@ -72,6 +72,11 @@ export const useQueryTabStyles = makeStyles({
metricsGridButtons: {
...cosmosShorthands.borderTop(),
},
errorListTableCell: {
textOverflow: "ellipsis",
whiteSpace: "nowrap",
overflow: "hidden",
},
errorListMessageCell: {
display: "flex",
flexDirection: "row",
@@ -80,5 +85,12 @@ export const useQueryTabStyles = makeStyles({
},
errorListMessage: {
flexGrow: 1,
textOverflow: "ellipsis",
whiteSpace: "nowrap",
overflow: "hidden",
},
errorListMessageActions: {
display: "flex",
flexDirection: "row",
},
});

View File

@@ -1,5 +1,6 @@
import { Resource, StoredProcedureDefinition } from "@azure/cosmos";
import { Pivot, PivotItem } from "@fluentui/react";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { KeyboardAction } from "KeyboardShortcuts";
import React from "react";
import ExecuteQueryIcon from "../../../../images/ExecuteQuery.svg";
@@ -15,7 +16,6 @@ import { useTabs } from "../../../hooks/useTabs";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import { EditorReact } from "../../Controls/Editor/EditorReact";
import Explorer from "../../Explorer";
import { useCommandBar } from "../../Menus/CommandBar/CommandBarComponentAdapter";
import StoredProcedure from "../../Tree/StoredProcedure";
import { useSelectedNode } from "../../useSelectedNode";
import ScriptTabBase from "../ScriptTabBase";

View File

@@ -1,12 +1,13 @@
import { IMessageBarStyles, Link, MessageBar, MessageBarButton, MessageBarType } from "@fluentui/react";
import { IMessageBarStyles, MessageBar, MessageBarButton, MessageBarType } from "@fluentui/react";
import { CassandraProxyEndpoints, MongoProxyEndpoints } from "Common/Constants";
import { sendMessage } from "Common/MessageHandler";
import { Platform, configContext, updateConfigContext } from "ConfigContext";
import { configContext } from "ConfigContext";
import { IpRule } from "Contracts/DataModels";
import { MessageTypes } from "Contracts/ExplorerContracts";
import { CollectionTabKind } from "Contracts/ViewModels";
import Explorer from "Explorer/Explorer";
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { CommandBarV2 } from "Explorer/Menus/CommandBarV2/CommandBarV2";
import { QueryCopilotTab } from "Explorer/QueryCopilot/QueryCopilotTab";
import { SplashScreen } from "Explorer/SplashScreen/SplashScreen";
import { ConnectTab } from "Explorer/Tabs/ConnectTab";
@@ -16,7 +17,6 @@ import { VcoreMongoConnectTab } from "Explorer/Tabs/VCoreMongoConnectTab";
import { VcoreMongoQuickstartTab } from "Explorer/Tabs/VCoreMongoQuickstartTab";
import { LayoutConstants } from "Explorer/Theme/ThemeUtil";
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
import { hasRUThresholdBeenConfigured } from "Shared/StorageUtility";
import { userContext } from "UserContext";
import { CassandraProxyOutboundIPs, MongoProxyOutboundIPs, PortalBackendIPs } from "Utils/EndpointUtils";
import { useTeachingBubble } from "hooks/useTeachingBubble";
@@ -37,9 +37,6 @@ interface TabsProps {
export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
const { openedTabs, openedReactTabs, activeTab, activeReactTab, networkSettingsWarning } = useTabs();
const [showRUThresholdMessageBar, setShowRUThresholdMessageBar] = useState<boolean>(
userContext.apiType === "SQL" && configContext.platform !== Platform.Fabric && !hasRUThresholdBeenConfigured(),
);
const [
showMongoAndCassandraProxiesNetworkSettingsWarningState,
setShowMongoAndCassandraProxiesNetworkSettingsWarningState,
@@ -87,30 +84,6 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
{networkSettingsWarning}
</MessageBar>
)}
{showRUThresholdMessageBar && (
<MessageBar
messageBarType={MessageBarType.info}
onDismiss={() => {
setShowRUThresholdMessageBar(false);
}}
styles={{
...defaultMessageBarStyles,
innerText: {
fontWeight: "bold",
},
}}
>
{`To prevent queries from using excessive RUs, Data Explorer has a 5,000 RU default limit. To modify or remove
the limit, go to the Settings cog on the right and find "RU Threshold".`}
<Link
className="underlinedLink"
href="https://review.learn.microsoft.com/en-us/azure/cosmos-db/data-explorer?branch=main#configure-request-unit-threshold"
target="_blank"
>
Learn More
</Link>
</MessageBar>
)}
{showMongoAndCassandraProxiesNetworkSettingsWarningState && (
<MessageBar
messageBarType={MessageBarType.warning}
@@ -119,7 +92,7 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
setShowMongoAndCassandraProxiesNetworkSettingsWarningState(false);
}}
>
{`We are moving our middleware to new infrastructure. To avoid future issues with Data Explorer access, please
{`We have migrated our middleware to new infrastructure. To avoid issues with Data Explorer access, please
re-enable "Allow access from Azure Portal" on the Networking blade for your account.`}
</MessageBar>
)}
@@ -134,6 +107,7 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
</ul>
</div>
<div className="tabPanesContainer">
{userContext.features.commandBarV2 && <CommandBarV2 explorer={explorer} />}
{activeReactTab !== undefined && getReactTabContent(activeReactTab, explorer)}
{openedTabs.map((tab) => (
<TabPane key={tab.tabId} tab={tab} active={activeTab === tab} />
@@ -398,12 +372,6 @@ const showMongoAndCassandraProxiesNetworkSettingsWarning = (): boolean => {
ipAddressesFromIPRules.includes(mongoProxyOutboundIP),
);
if (ipRulesIncludeMongoProxy) {
updateConfigContext({
MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED: true,
});
}
return !ipRulesIncludeMongoProxy;
} else if (userContext.apiType === "Cassandra") {
const isProdOrMpacCassandraProxyEndpoint: boolean = [
@@ -422,12 +390,6 @@ const showMongoAndCassandraProxiesNetworkSettingsWarning = (): boolean => {
(cassandraProxyOutboundIP: string) => ipAddressesFromIPRules.includes(cassandraProxyOutboundIP),
);
if (ipRulesIncludeCassandraProxy) {
updateConfigContext({
CASSANDRA_PROXY_OUTBOUND_IPS_ALLOWLISTED: true,
});
}
return !ipRulesIncludeCassandraProxy;
}
}

View File

@@ -1,8 +1,8 @@
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { KeyboardActionGroup, clearKeyboardActionGroup } from "KeyboardShortcuts";
import * as ko from "knockout";
import * as Constants from "../../Common/Constants";
import * as ThemeUtility from "../../Common/ThemeUtility";
import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
@@ -10,7 +10,6 @@ import { useNotificationConsole } from "../../hooks/useNotificationConsole";
import { useTabs } from "../../hooks/useTabs";
import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent";
import Explorer from "../Explorer";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import { WaitsForTemplateViewModel } from "../WaitsForTemplateViewModel";
import { useSelectedNode } from "../useSelectedNode";
// TODO: Use specific actions for logging telemetry data
@@ -28,7 +27,6 @@ export default class TabsBase extends WaitsForTemplateViewModel {
public tabPath: ko.Observable<string>;
public isExecutionError = ko.observable(false);
public isExecuting = ko.observable(false);
public pendingNotification?: ko.Observable<DataModels.Notification>;
protected _theme: string;
public onLoadStartKey: number;
@@ -45,7 +43,6 @@ export default class TabsBase extends WaitsForTemplateViewModel {
this.tabPath =
this.collection &&
ko.observable<string>(`${this.collection.databaseId}>${this.collection.id()}>${options.title}`);
this.pendingNotification = ko.observable<DataModels.Notification>(undefined);
this.onLoadStartKey = options.onLoadStartKey;
this.closeTabButton = {
enabled: ko.computed<boolean>(() => {

View File

@@ -1,5 +1,6 @@
import { TriggerDefinition } from "@azure/cosmos";
import { Dropdown, IDropdownOption, Label, TextField } from "@fluentui/react";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { KeyboardAction } from "KeyboardShortcuts";
import React, { Component } from "react";
import DiscardIcon from "../../../images/discard.svg";
@@ -14,7 +15,6 @@ import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { SqlTriggerResource } from "../../Utils/arm/generatedClients/cosmos/types";
import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent";
import { EditorReact } from "../Controls/Editor/EditorReact";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import TriggerTab from "./TriggerTab";
const triggerTypeOptions: IDropdownOption[] = [

View File

@@ -1,5 +1,6 @@
import { UserDefinedFunctionDefinition } from "@azure/cosmos";
import { Label, TextField } from "@fluentui/react";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { KeyboardAction } from "KeyboardShortcuts";
import React, { Component } from "react";
import DiscardIcon from "../../../images/discard.svg";
@@ -13,7 +14,6 @@ import { Action } from "../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent";
import { EditorReact } from "../Controls/Editor/EditorReact";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import UserDefinedFunctionTab from "./UserDefinedFunctionTab";
interface IUserDefinedFunctionTabContentState {

View File

@@ -16,7 +16,7 @@ import React from "react";
import { appThemeFabricTealBrandRamp } from "../../Platform/Fabric/FabricTheme";
export const LayoutConstants = {
rowHeight: 36,
rowHeight: 32,
};
// Our CosmosFluentProvider has the same props as a FluentProvider.
@@ -91,15 +91,30 @@ const appThemePortalBrandRamp: BrandVariants = {
160: "#CDD8EF",
};
const cosmosThemeElements = {
layoutRowHeight: `${LayoutConstants.rowHeight}px`,
export enum LayoutSize {
Compact,
// TODO: Cozy and Roomy layouts.
}
interface CosmosThemeElements {
layoutRowHeight: string;
}
export type CosmosTheme = Theme & CosmosThemeElements;
const sizeMappings: Record<LayoutSize, Partial<Theme> & CosmosThemeElements> = {
[LayoutSize.Compact]: {
layoutRowHeight: "32px",
fontSizeBase300: "13px",
},
};
const cosmosTheme = {
sidebarMinimumWidth: "200px",
sidebarInitialWidth: "300px",
};
export type CosmosTheme = Theme & typeof cosmosThemeElements;
export const tokens = themeToTokensObject({ ...webLightTheme, ...cosmosThemeElements });
export const tokens = themeToTokensObject({ ...webLightTheme, ...cosmosTheme, ...sizeMappings[LayoutSize.Compact] });
export const cosmosShorthands = {
border: () => shorthands.border("1px", "solid", tokens.colorNeutralStroke2),
@@ -117,6 +132,7 @@ export function getPlatformTheme(platform: Platform): CosmosTheme {
return {
...baseTheme,
...cosmosThemeElements,
...cosmosTheme,
...sizeMappings[LayoutSize.Compact], // TODO: Allow for different layout sizes.
};
}

View File

@@ -1,12 +1,11 @@
import { Resource, StoredProcedureDefinition, TriggerDefinition, UserDefinedFunctionDefinition } from "@azure/cosmos";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { useNotebook } from "Explorer/Notebook/useNotebook";
import { DocumentsTabV2 } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2";
import * as ko from "knockout";
import * as _ from "underscore";
import * as Constants from "../../Common/Constants";
import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils";
import * as Logger from "../../Common/Logger";
import { fetchPortalNotifications } from "../../Common/PortalNotifications";
import { bulkCreateDocument } from "../../Common/dataAccess/bulkCreateDocument";
import { createDocument } from "../../Common/dataAccess/createDocument";
import { getCollectionUsageSizeInKB } from "../../Common/dataAccess/getCollectionDataUsageSize";
@@ -25,7 +24,6 @@ import { logConsoleInfo } from "../../Utils/NotificationConsoleUtils";
import { SqlTriggerResource } from "../../Utils/arm/generatedClients/cosmos/types";
import { useTabs } from "../../hooks/useTabs";
import Explorer from "../Explorer";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import { CassandraAPIDataClient, CassandraTableKey, CassandraTableKeys } from "../Tables/TableDataClient";
import ConflictsTab from "../Tabs/ConflictsTab";
import GraphTab from "../Tabs/GraphTab";
@@ -1020,41 +1018,6 @@ export default class Collection implements ViewModels.Collection {
this.uploadFiles(event.originalEvent.dataTransfer.files);
}
public async getPendingThroughputSplitNotification(): Promise<DataModels.Notification> {
if (!this.container) {
return undefined;
}
try {
const notifications: DataModels.Notification[] = await fetchPortalNotifications();
if (!notifications || notifications.length === 0) {
return undefined;
}
return _.find(notifications, (notification: DataModels.Notification) => {
const throughputUpdateRegExp: RegExp = new RegExp("Throughput update (.*) in progress");
return (
notification.kind === "message" &&
notification.collectionName === this.id() &&
notification.description &&
throughputUpdateRegExp.test(notification.description)
);
});
} catch (error) {
Logger.logError(
JSON.stringify({
error: getErrorMessage(error),
accountName: userContext?.databaseAccount,
databaseName: this.databaseId,
collectionName: this.id(),
}),
"Settings tree node",
);
return undefined;
}
}
public async uploadFiles(files: FileList): Promise<{ data: UploadDetailsRecord[] }> {
const data = await Promise.all(Array.from(files).map((file) => this.uploadFile(file)));

View File

@@ -4,8 +4,6 @@ import * as _ from "underscore";
import { AuthType } from "../../AuthType";
import * as Constants from "../../Common/Constants";
import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils";
import * as Logger from "../../Common/Logger";
import { fetchPortalNotifications } from "../../Common/PortalNotifications";
import { readCollections, readCollectionsWithPagination } from "../../Common/dataAccess/readCollections";
import { readDatabaseOffer } from "../../Common/dataAccess/readDatabaseOffer";
import * as DataModels from "../../Contracts/DataModels";
@@ -76,7 +74,6 @@ export default class Database implements ViewModels.Database {
await useDatabases.getState().loadAllOffers();
}
const pendingNotificationsPromise: Promise<DataModels.Notification> = this.getPendingThroughputSplitNotification();
const tabKind = ViewModels.CollectionTabKind.DatabaseSettingsV2;
const matchingTabs = useTabs.getState().getTabs(tabKind, (tab) => tab.node?.id() === this.id());
let settingsTab = matchingTabs?.[0] as DatabaseSettingsTabV2;
@@ -87,53 +84,39 @@ export default class Database implements ViewModels.Database {
dataExplorerArea: Constants.Areas.Tab,
tabTitle: "Scale",
});
pendingNotificationsPromise.then(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(data: any) => {
const pendingNotification: DataModels.Notification = data?.[0];
const tabOptions: ViewModels.TabOptions = {
tabKind,
title: "Scale",
tabPath: "",
node: this,
rid: this.rid,
database: this,
onLoadStartKey: startKey,
};
settingsTab = new DatabaseSettingsTabV2(tabOptions);
settingsTab.pendingNotification(pendingNotification);
useTabs.getState().activateNewTab(settingsTab);
},
(error) => {
const errorMessage = getErrorMessage(error);
TelemetryProcessor.traceFailure(
Action.Tab,
{
databaseName: this.id(),
collectionName: this.id(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle: "Scale",
error: errorMessage,
errorStack: getErrorStack(error),
},
startKey,
);
logConsoleError(`Error while fetching database settings for database ${this.id()}: ${errorMessage}`);
throw error;
},
);
try {
const tabOptions: ViewModels.TabOptions = {
tabKind,
title: "Scale",
tabPath: "",
node: this,
rid: this.rid,
database: this,
onLoadStartKey: startKey,
};
settingsTab = new DatabaseSettingsTabV2(tabOptions);
useTabs.getState().activateNewTab(settingsTab);
} catch (error) {
const errorMessage = getErrorMessage(error);
TelemetryProcessor.traceFailure(
Action.Tab,
{
databaseName: this.id(),
collectionName: this.id(),
dataExplorerArea: Constants.Areas.Tab,
tabTitle: "Scale",
error: errorMessage,
errorStack: getErrorStack(error),
},
startKey,
);
logConsoleError(`Error while fetching database settings for database ${this.id()}: ${errorMessage}`);
throw error;
}
} else {
pendingNotificationsPromise.then(
(pendingNotification: DataModels.Notification) => {
settingsTab.pendingNotification(pendingNotification);
useTabs.getState().activateTab(settingsTab);
},
() => {
settingsTab.pendingNotification(undefined);
useTabs.getState().activateTab(settingsTab);
},
);
useTabs.getState().activateTab(settingsTab);
}
};
@@ -260,42 +243,6 @@ export default class Database implements ViewModels.Database {
}
}
public async getPendingThroughputSplitNotification(): Promise<DataModels.Notification> {
if (!this.container) {
return undefined;
}
try {
const notifications: DataModels.Notification[] = await fetchPortalNotifications();
if (!notifications || notifications.length === 0) {
return undefined;
}
return _.find(notifications, (notification: DataModels.Notification) => {
const throughputUpdateRegExp = new RegExp("Throughput update (.*) in progress");
return (
notification.kind === "message" &&
!notification.collectionName &&
notification.databaseName === this.id() &&
notification.description &&
throughputUpdateRegExp.test(notification.description)
);
});
} catch (error) {
Logger.logError(
JSON.stringify({
error: getErrorMessage(error),
accountName: userContext?.databaseAccount,
databaseName: this.id(),
collectionName: this.id(),
}),
"Settings tree node",
);
return undefined;
}
}
private getDeltaCollections(updatedCollectionsList: DataModels.Collection[]): {
toAdd: DataModels.Collection[];
toDelete: Collection[];

View File

@@ -1,4 +1,6 @@
import { TreeNodeMenuItem } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { collectionWasOpened } from "Explorer/MostRecentActivity/MostRecentActivity";
import { shouldShowScriptNodes } from "Explorer/Tree/treeNodeUtil";
import { getItemName } from "Utils/APITypeUtils";
import * as ko from "knockout";
@@ -27,8 +29,6 @@ import * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFacto
import { useDialog } from "../Controls/Dialog";
import { LegacyTreeComponent, LegacyTreeNode } from "../Controls/TreeComponent/LegacyTreeComponent";
import Explorer from "../Explorer";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import { mostRecentActivity } from "../MostRecentActivity/MostRecentActivity";
import { NotebookContentItem, NotebookContentItemType } from "../Notebook/NotebookContentItem";
import { NotebookUtil } from "../Notebook/NotebookUtil";
import { useNotebook } from "../Notebook/useNotebook";
@@ -229,7 +229,7 @@ export class ResourceTreeAdapter implements ReactAdapter {
onClick: () => {
collection.openTab();
// push to most recent
mostRecentActivity.collectionWasOpened(userContext.databaseAccount?.id, collection);
collectionWasOpened(userContext.databaseAccount?.name, collection);
},
isSelected: () =>
useSelectedNode

View File

@@ -1,4 +1,4 @@
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import TabsBase from "Explorer/Tabs/TabsBase";
import { useSelectedNode } from "Explorer/useSelectedNode";
import { useTabs } from "hooks/useTabs";

View File

@@ -30,6 +30,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "standardCollection",
@@ -69,6 +72,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "conflictsCollection",
@@ -92,6 +98,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "standardDb",
@@ -102,6 +111,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
{
"children": [
{
"iconSrc": <SettingsRegular
fontSize={16}
/>,
"id": "",
"isSelected": [Function],
"label": "Scale",
@@ -133,6 +145,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "sampleItemsCollection",
@@ -156,6 +171,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "sharedDatabase",
@@ -246,6 +264,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "schemaCollection",
@@ -274,6 +295,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "giganticDatabase",
@@ -345,6 +369,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "standardCollection",
@@ -415,6 +442,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "conflictsCollection",
@@ -438,6 +468,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "standardDb",
@@ -448,6 +481,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
{
"children": [
{
"iconSrc": <SettingsRegular
fontSize={16}
/>,
"id": "",
"isSelected": [Function],
"label": "Scale",
@@ -510,6 +546,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "sampleItemsCollection",
@@ -533,6 +572,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "sharedDatabase",
@@ -654,6 +696,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "schemaCollection",
@@ -682,6 +727,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "giganticDatabase",
@@ -706,6 +754,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"onClick": [Function],
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "standardCollection",
@@ -724,6 +775,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"onClick": [Function],
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "conflictsCollection",
@@ -747,6 +801,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "standardDb",
@@ -766,6 +823,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"onClick": [Function],
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "sampleItemsCollection",
@@ -789,6 +849,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "sharedDatabase",
@@ -808,6 +871,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"onClick": [Function],
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "schemaCollection",
@@ -836,6 +902,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "giganticDatabase",
@@ -976,6 +1045,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "standardCollection",
@@ -1076,6 +1148,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "conflictsCollection",
@@ -1099,6 +1174,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "standardDb",
@@ -1109,6 +1187,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
{
"children": [
{
"iconSrc": <SettingsRegular
fontSize={16}
/>,
"id": "",
"isSelected": [Function],
"label": "Scale",
@@ -1201,6 +1282,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "sampleItemsCollection",
@@ -1224,6 +1308,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "sharedDatabase",
@@ -1375,6 +1462,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "schemaCollection",
@@ -1403,6 +1493,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "giganticDatabase",
@@ -1543,6 +1636,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "standardCollection",
@@ -1638,6 +1734,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "conflictsCollection",
@@ -1661,6 +1760,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "standardDb",
@@ -1671,6 +1773,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
{
"children": [
{
"iconSrc": <SettingsRegular
fontSize={16}
/>,
"id": "",
"isSelected": [Function],
"label": "Scale",
@@ -1763,6 +1868,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "sampleItemsCollection",
@@ -1786,6 +1894,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "sharedDatabase",
@@ -1937,6 +2048,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
"styleClass": "deleteCollectionMenuItem",
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "schemaCollection",
@@ -1965,6 +2079,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
"styleClass": "deleteDatabaseMenuItem",
},
],
"iconSrc": <DatabaseRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "giganticDatabase",
@@ -1986,6 +2103,9 @@ exports[`createResourceTokenTreeNodes creates the expected tree nodes 1`] = `
},
],
"className": "collectionNode",
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": true,
"isSelected": [Function],
"label": "testCollection",
@@ -2021,6 +2141,9 @@ exports[`createSampleDataTreeNodes creates the expected tree nodes 1`] = `
"onClick": [Function],
},
],
"iconSrc": <DocumentMultipleRegular
fontSize={16}
/>,
"isExpanded": false,
"isSelected": [Function],
"label": "testCollection",

View File

@@ -2,7 +2,7 @@ import { CapabilityNames } from "Common/Constants";
import { Platform, updateConfigContext } from "ConfigContext";
import { TreeNode } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import Explorer from "Explorer/Explorer";
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { useNotebook } from "Explorer/Notebook/useNotebook";
import { DeleteDatabaseConfirmationPanel } from "Explorer/Panes/DeleteDatabaseConfirmationPanel";
import TabsBase from "Explorer/Tabs/TabsBase";

View File

@@ -1,4 +1,7 @@
import { DatabaseRegular, DocumentMultipleRegular, SettingsRegular } from "@fluentui/react-icons";
import { TreeNode } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { collectionWasOpened } from "Explorer/MostRecentActivity/MostRecentActivity";
import TabsBase from "Explorer/Tabs/TabsBase";
import StoredProcedure from "Explorer/Tree/StoredProcedure";
import Trigger from "Explorer/Tree/Trigger";
@@ -7,6 +10,7 @@ import { useDatabases } from "Explorer/useDatabases";
import { getItemName } from "Utils/APITypeUtils";
import { isServerlessAccount } from "Utils/CapabilityUtils";
import { useTabs } from "hooks/useTabs";
import React from "react";
import { isPublicInternetAccessAllowed } from "../../Common/DatabaseAccountUtility";
import { Platform, configContext } from "../../ConfigContext";
import * as DataModels from "../../Contracts/DataModels";
@@ -14,8 +18,6 @@ import * as ViewModels from "../../Contracts/ViewModels";
import { userContext } from "../../UserContext";
import * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFactory";
import Explorer from "../Explorer";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import { mostRecentActivity } from "../MostRecentActivity/MostRecentActivity";
import { useNotebook } from "../Notebook/useNotebook";
import { useSelectedNode } from "../useSelectedNode";
@@ -25,6 +27,10 @@ export const shouldShowScriptNodes = (): boolean => {
);
};
const TreeDatabaseIcon = <DatabaseRegular fontSize={16} />;
const TreeSettingsIcon = <SettingsRegular fontSize={16} />;
const TreeCollectionIcon = <DocumentMultipleRegular fontSize={16} />;
export const createSampleDataTreeNodes = (sampleDataResourceTokenCollection: ViewModels.CollectionBase): TreeNode[] => {
const updatedSampleTree: TreeNode = {
label: sampleDataResourceTokenCollection.databaseId,
@@ -36,6 +42,7 @@ export const createSampleDataTreeNodes = (sampleDataResourceTokenCollection: Vie
isExpanded: false,
className: "collectionNode",
contextMenu: ResourceTreeContextMenuButtonFactory.createSampleCollectionContextMenuButton(),
iconSrc: TreeCollectionIcon,
onClick: () => {
useSelectedNode.getState().setSelectedNode(sampleDataResourceTokenCollection);
useCommandBar.getState().setContextButtons([]);
@@ -91,7 +98,7 @@ export const createResourceTokenTreeNodes = (collection: ViewModels.CollectionBa
onClick: () => {
collection.onDocumentDBDocumentsClick();
// push to most recent
mostRecentActivity.collectionWasOpened(userContext.databaseAccount?.id, collection);
collectionWasOpened(userContext.databaseAccount?.name, collection);
},
isSelected: () =>
useSelectedNode
@@ -104,6 +111,7 @@ export const createResourceTokenTreeNodes = (collection: ViewModels.CollectionBa
isExpanded: true,
children,
className: "collectionNode",
iconSrc: TreeCollectionIcon,
onClick: () => {
// Rewritten version of expandCollapseCollection
useSelectedNode.getState().setSelectedNode(collection);
@@ -133,6 +141,7 @@ export const createDatabaseTreeNodes = (
databaseNode.children.push({
id: database.isSampleDB ? "sampleScaleSettings" : "",
label: "Scale",
iconSrc: TreeSettingsIcon,
isSelected: () =>
useSelectedNode
.getState()
@@ -169,6 +178,7 @@ export const createDatabaseTreeNodes = (
children: [],
isSelected: () => useSelectedNode.getState().isDataNodeSelected(database.id()),
contextMenu: ResourceTreeContextMenuButtonFactory.createDatabaseContextMenu(container, database.id()),
iconSrc: TreeDatabaseIcon,
onExpanded: async () => {
useSelectedNode.getState().setSelectedNode(database);
if (!databaseNode.children || databaseNode.children?.length === 0) {
@@ -219,11 +229,12 @@ export const buildCollectionNode = (
children: children,
className: "collectionNode",
contextMenu: ResourceTreeContextMenuButtonFactory.createCollectionContextMenuButton(container, collection),
iconSrc: TreeCollectionIcon,
onClick: () => {
useSelectedNode.getState().setSelectedNode(collection);
collection.openTab();
// push to most recent
mostRecentActivity.collectionWasOpened(userContext.databaseAccount?.id, collection);
collectionWasOpened(userContext.databaseAccount?.name, collection);
},
onExpanded: async () => {
// Rewritten version of expandCollapseCollection
@@ -271,7 +282,7 @@ const buildCollectionNodeChildren = (
onClick: () => {
collection.openTab();
// push to most recent
mostRecentActivity.collectionWasOpened(userContext.databaseAccount?.id, collection);
collectionWasOpened(userContext.databaseAccount?.name, collection);
},
isSelected: () =>
useSelectedNode

View File

@@ -20,9 +20,11 @@ import "../externals/jquery.typeahead.min.css";
import "../externals/jquery.typeahead.min.js";
// Image Dependencies
import { Platform } from "ConfigContext";
import { CommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { QueryCopilotCarousel } from "Explorer/QueryCopilot/CopilotCarousel";
import { SidebarContainer } from "Explorer/Sidebar";
import { KeyboardShortcutRoot } from "KeyboardShortcuts";
import { userContext } from "UserContext";
import "allotment/dist/style.css";
import "../images/CosmosDB_rgb_ui_lighttheme.ico";
import hdeConnectImage from "../images/HdeConnectCosmosDB.svg";
@@ -48,7 +50,6 @@ import "./Explorer/Controls/Notebook/NotebookTerminalComponent.less";
import "./Explorer/Controls/TreeComponent/treeComponent.less";
import "./Explorer/Graph/GraphExplorerComponent/graphExplorer.less";
import "./Explorer/Menus/CommandBar/CommandBarComponent.less";
import { CommandBar } from "./Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import "./Explorer/Menus/CommandBar/ConnectionStatusComponent.less";
import "./Explorer/Menus/CommandBar/MemoryTrackerComponent.less";
import "./Explorer/Menus/NotificationConsole/NotificationConsole.less";
@@ -86,7 +87,7 @@ const App: React.FunctionComponent = () => {
<div id="divExplorer" className="flexContainer hideOverflows">
<div id="freeTierTeachingBubble"> </div>
{/* Main Command Bar - Start */}
<CommandBar container={explorer} />
{!userContext.features.commandBarV2 && <CommandBar container={explorer} />}
{/* Collections Tree and Tabs - Begin */}
<SidebarContainer explorer={explorer} />
{/* Collections Tree and Tabs - End */}

View File

@@ -52,7 +52,7 @@ export const isAccountRestrictedForConnectionStringLogin = async (connectionStri
const headers = new Headers();
headers.append(HttpHeaders.connectionString, connectionString);
const backendEndpoint: string = useNewPortalBackendEndpoint(BackendApi.PortalSettings)
const backendEndpoint: string = useNewPortalBackendEndpoint(BackendApi.AccountRestrictions)
? configContext.PORTAL_BACKEND_ENDPOINT
: configContext.BACKEND_ENDPOINT;

View File

@@ -1,22 +1,18 @@
import * as React from "react";
import { CommandButtonComponent } from "../../../Explorer/Controls/CommandButton/CommandButtonComponent";
import FeedbackIcon from "../../../../images/Feedback.svg";
const onClick = () => {
window.open("https://aka.ms/cosmosdbfeedback?subject=Cosmos%20DB%20Hosted%20Data%20Explorer%20Feedback");
};
export const FeedbackCommandButton: React.FunctionComponent = () => {
return (
<div className="feedbackConnectSettingIcons">
<CommandButtonComponent
id="commandbutton-feedback"
iconSrc={FeedbackIcon}
iconAlt="feeback button"
onCommandClick={() =>
window.open("https://aka.ms/cosmosdbfeedback?subject=Cosmos%20DB%20Hosted%20Data%20Explorer%20Feedback")
}
ariaLabel="feeback button"
tooltipText="Send feedback"
hasPopup={true}
disabled={false}
/>
<div className="commandButtonReact">
<a href="#" title="Send feedback" aria-haspopup="dialog" onClick={onClick}>
<img src={FeedbackIcon} alt="Send feedback" />
</a>
</div>
</div>
);
};

View File

@@ -11,7 +11,7 @@ describe("parseResourceTokenConnectionString", () => {
collectionId: "fakeCollectionId",
databaseId: "fakeDatabaseId",
partitionKey: undefined,
resourceToken: "type=resource&ver=1&sig=2dIP+CdIfT1ScwHWdv5GGw==;fakeToken;",
resourceToken: "type=resource&ver=1&sig=2dIP+CdIfT1ScwHWdv5GGw==;fakeToken",
});
});
@@ -25,7 +25,7 @@ describe("parseResourceTokenConnectionString", () => {
collectionId: "fakeCollectionId",
databaseId: "fakeDatabaseId",
partitionKey: "fakePartitionKey",
resourceToken: "type=resource&ver=1&sig=2dIP+CdIfT1ScwHWdv5GGw==;fakeToken;",
resourceToken: "type=resource&ver=1&sig=2dIP+CdIfT1ScwHWdv5GGw==;fakeToken",
});
});
});

View File

@@ -30,6 +30,10 @@ export function parseResourceTokenConnectionString(connectionString: string): Pa
}
});
if (resourceToken && resourceToken.endsWith(";")) {
resourceToken = resourceToken.substring(0, resourceToken.length - 1);
}
return {
accountEndpoint,
collectionId,

View File

@@ -38,6 +38,7 @@ export type Features = {
readonly copilotChatFixedMonacoEditorHeight: boolean;
readonly enablePriorityBasedExecution: boolean;
readonly disableConnectionStringLogin: boolean;
readonly commandBarV2: boolean;
// can be set via both flight and feature flag
autoscaleDefault: boolean;
@@ -108,6 +109,7 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear
copilotChatFixedMonacoEditorHeight: "true" === get("copilotchatfixedmonacoeditorheight"),
enablePriorityBasedExecution: "true" === get("enableprioritybasedexecution"),
disableConnectionStringLogin: "true" === get("disableconnectionstringlogin"),
commandBarV2: "true" === get("commandbarv2"),
};
}

View File

@@ -0,0 +1,200 @@
import {
AppStateComponentNames,
createKeyFromPath,
deleteState,
loadState,
MAX_ENTRY_NB,
PATH_SEPARATOR,
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: AppStateComponentNames.DocumentsTab,
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);
});
it("should handle components that include special characters", () => {
const storePath = {
componentName: AppStateComponentNames.DocumentsTab,
subComponentName: 'd"e"f',
globalAccountName: "g:hi{j",
databaseName: "a/b/c",
containerName: "https://blahblah.document.azure.com:443/",
};
const key = createKeyFromPath(storePath);
const segments = key.split(PATH_SEPARATOR);
expect(segments.length).toEqual(6); // There should be 5 segments
expect(segments[0]).toBe("");
const expectSubstringsInValue = (value: string, subStrings: string[]): boolean =>
subStrings.every((subString) => value.includes(subString));
expect(expectSubstringsInValue(segments[2], ["d", "e", "f"])).toBe(true);
expect(expectSubstringsInValue(segments[3], ["g", "hi", "j"])).toBe(true);
expect(expectSubstringsInValue(segments[4], ["a", "b", "c"])).toBe(true);
expect(expectSubstringsInValue(segments[5], ["https", "blahblah", "document", "com", "443"])).toBe(true);
});
});
});

View File

@@ -0,0 +1,114 @@
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
// The component name whose state is being saved. Component name must not include special characters.
export enum AppStateComponentNames {
DocumentsTab = "DocumentsTab",
MostRecentActivity = "MostRecentActivity",
QueryCopilot = "QueryCopilot",
}
export const PATH_SEPARATOR = "/"; // export for testing purposes
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;
}
// Export for testing purposes
export type StorePath = {
componentName: AppStateComponentNames;
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);
};
export const hasState = (path: StorePath): boolean => {
return loadState(path) !== undefined;
};
// 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 => {
let key = `${PATH_SEPARATOR}${encodeURIComponent(path.componentName)}`; // ComponentName is always there
orderedPathSegments.forEach((segment) => {
const segmentValue = path[segment as keyof StorePath];
key += `${PATH_SEPARATOR}${segmentValue !== undefined ? encodeURIComponent(segmentValue) : ""}`;
});
return key;
};
/**
* Remove the entire app state key from local storage
*/
export const deleteAllStates = (): void => {
LocalStorageUtility.removeEntry(StorageKey.AppState);
};

View File

@@ -20,3 +20,14 @@ export const setEntryNumber = (key: StorageKey, value: number): void =>
export const setEntryBoolean = (key: StorageKey, value: boolean): void =>
localStorage.setItem(StorageKey[key], value.toString());
export const setEntryObject = (key: StorageKey, value: unknown): void => {
localStorage.setItem(StorageKey[key], JSON.stringify(value));
};
export const getEntryObject = <T>(key: StorageKey): T | null => {
const item = localStorage.getItem(StorageKey[key]);
if (item) {
return JSON.parse(item) as T;
}
return null;
};

View File

@@ -24,12 +24,14 @@ export enum StorageKey {
MaxDegreeOfParellism,
IsGraphAutoVizDisabled,
TenantId,
MostRecentActivity,
MostRecentActivity, // deprecated
SetPartitionKeyUndefined,
GalleryCalloutDismissed,
VisitedAccounts,
PriorityLevel,
DocumentsTabPrefs,
DefaultQueryResultsView,
AppState,
}
export const hasRUThresholdBeenConfigured = (): boolean => {
@@ -56,10 +58,10 @@ export const getRUThreshold = (): number => {
export const getDefaultQueryResultsView = (): SplitterDirection => {
const defaultQueryResultsViewRaw = LocalStorageUtility.getEntryString(StorageKey.DefaultQueryResultsView);
if (defaultQueryResultsViewRaw === SplitterDirection.Horizontal) {
return SplitterDirection.Horizontal;
if (defaultQueryResultsViewRaw === SplitterDirection.Vertical) {
return SplitterDirection.Vertical;
}
return SplitterDirection.Vertical;
return SplitterDirection.Horizontal;
};
export const DefaultRUThreshold = 5000;

View File

@@ -139,6 +139,9 @@ export enum Action {
QueryEdited,
ExecuteQueryGeneratedFromQueryCopilot,
DeleteDocuments,
ReadPersistedTabState,
SavePersistedTabState,
DeletePersistedTabState,
}
export const ActionModifiers = {

View File

@@ -52,7 +52,11 @@ export const defaultAllowedArmEndpoints: ReadonlyArray<string> = [
"https://management.chinacloudapi.cn",
];
export const allowedAadEndpoints: ReadonlyArray<string> = ["https://login.microsoftonline.com/"];
export const allowedAadEndpoints: ReadonlyArray<string> = [
"https://login.microsoftonline.com/",
"https://login.microsoftonline.us/",
"https://login.partner.microsoftonline.cn/",
];
export const defaultAllowedBackendEndpoints: ReadonlyArray<string> = [
"https://main.documentdb.ext.azure.com",
@@ -74,6 +78,13 @@ export const PortalBackendIPs: { [key: string]: string[] } = {
//usnat: ["7.28.202.68"],
};
export const PortalBackendOutboundIPs: { [key: string]: string[] } = {
[PortalBackendEndpoints.Mpac]: ["13.91.105.215", "4.210.172.107"],
[PortalBackendEndpoints.Prod]: ["13.88.56.148", "40.91.218.243"],
[PortalBackendEndpoints.Fairfax]: ["52.247.163.6", "52.244.134.181"],
[PortalBackendEndpoints.Mooncake]: ["163.228.137.6", "143.64.170.142"],
};
export const MongoProxyOutboundIPs: { [key: string]: string[] } = {
[MongoProxyEndpoints.Mpac]: ["20.245.81.54", "40.118.23.126"],
[MongoProxyEndpoints.Prod]: ["40.80.152.199", "13.95.130.121"],
@@ -164,7 +175,28 @@ export function useNewPortalBackendEndpoint(backendApi: string): boolean {
PortalBackendEndpoints.Mpac,
PortalBackendEndpoints.Prod,
],
[BackendApi.AccountRestrictions]: [PortalBackendEndpoints.Development, PortalBackendEndpoints.Mpac],
[BackendApi.AccountRestrictions]: [
PortalBackendEndpoints.Development,
PortalBackendEndpoints.Mpac,
PortalBackendEndpoints.Prod,
],
[BackendApi.RuntimeProxy]: [
PortalBackendEndpoints.Development,
PortalBackendEndpoints.Mpac,
PortalBackendEndpoints.Prod,
],
[BackendApi.DisallowedLocations]: [
PortalBackendEndpoints.Development,
PortalBackendEndpoints.Mpac,
PortalBackendEndpoints.Prod,
PortalBackendEndpoints.Fairfax,
PortalBackendEndpoints.Mooncake,
],
[BackendApi.SampleData]: [
PortalBackendEndpoints.Development,
PortalBackendEndpoints.Mpac,
PortalBackendEndpoints.Prod,
],
};
if (!newBackendApiEnvironmentMap[backendApi] || !configContext.PORTAL_BACKEND_ENDPOINT) {

View File

@@ -1,20 +1,15 @@
import { MongoProxyEndpoints, PortalBackendEndpoints } from "Common/Constants";
import { resetConfigContext, updateConfigContext } from "ConfigContext";
import { DatabaseAccount, IpRule } from "Contracts/DataModels";
import { updateUserContext } from "UserContext";
import { PortalBackendIPs } from "Utils/EndpointUtils";
import { MongoProxyOutboundIPs, PortalBackendIPs, PortalBackendOutboundIPs } from "Utils/EndpointUtils";
import { getNetworkSettingsWarningMessage } from "./NetworkUtility";
describe("NetworkUtility tests", () => {
describe("getNetworkSettingsWarningMessage", () => {
const legacyBackendEndpoint: string = "https://main.documentdb.ext.azure.com";
const publicAccessMessagePart = "Please enable public access to proceed";
const accessMessagePart = "Please allow access from Azure Portal to proceed";
// validEnpoints are a subset of those from Utils/EndpointValidation/PortalBackendIPs
const validEndpoints = [
"https://main.documentdb.ext.azure.com",
"https://main.documentdb.ext.azure.cn",
"https://main.documentdb.ext.azure.us",
];
let warningMessageResult: string;
const warningMessageFunc = (msg: string) => (warningMessageResult = msg);
@@ -52,52 +47,59 @@ describe("NetworkUtility tests", () => {
expect(warningMessageResult).toContain(publicAccessMessagePart);
});
it(`should return no message when the appropriate ip rules are added to mongo/cassandra account per endpoint`, () => {
validEndpoints.forEach(async (endpoint) => {
updateUserContext({
databaseAccount: {
kind: "MongoDB",
properties: {
ipRules: PortalBackendIPs[endpoint].map((ip: string) => ({ ipAddressOrRange: ip }) as IpRule),
publicNetworkAccess: "Enabled",
},
} as DatabaseAccount,
});
updateConfigContext({
BACKEND_ENDPOINT: endpoint,
});
let asyncWarningMessageResult: string;
const asyncWarningMessageFunc = (msg: string) => (asyncWarningMessageResult = msg);
await getNetworkSettingsWarningMessage(asyncWarningMessageFunc);
expect(asyncWarningMessageResult).toBeUndefined();
it(`should return no message when the appropriate ip rules are added to mongo/cassandra account per endpoint`, async () => {
const portalBackendOutboundIPsWithLegacyIPs: string[] = [
...PortalBackendOutboundIPs[PortalBackendEndpoints.Mpac],
...PortalBackendOutboundIPs[PortalBackendEndpoints.Prod],
...MongoProxyOutboundIPs[MongoProxyEndpoints.Mpac],
...MongoProxyOutboundIPs[MongoProxyEndpoints.Prod],
...PortalBackendIPs["https://main.documentdb.ext.azure.com"],
];
updateUserContext({
databaseAccount: {
kind: "MongoDB",
properties: {
ipRules: portalBackendOutboundIPsWithLegacyIPs.map((ip: string) => ({ ipAddressOrRange: ip }) as IpRule),
publicNetworkAccess: "Enabled",
},
} as DatabaseAccount,
});
updateConfigContext({
BACKEND_ENDPOINT: legacyBackendEndpoint,
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Mpac,
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Mpac,
});
let asyncWarningMessageResult: string;
const asyncWarningMessageFunc = (msg: string) => (asyncWarningMessageResult = msg);
await getNetworkSettingsWarningMessage(asyncWarningMessageFunc);
expect(asyncWarningMessageResult).toBeUndefined();
});
it("should return accessMessage when incorrent ip rule is added to mongo/cassandra account per endpoint", () => {
validEndpoints.forEach(async (endpoint) => {
updateUserContext({
databaseAccount: {
kind: "MongoDB",
properties: {
ipRules: [{ ipAddressOrRange: "1.1.1.1" }],
publicNetworkAccess: "Enabled",
},
} as DatabaseAccount,
});
updateConfigContext({
BACKEND_ENDPOINT: endpoint,
});
let asyncWarningMessageResult: string;
const asyncWarningMessageFunc = (msg: string) => (asyncWarningMessageResult = msg);
await getNetworkSettingsWarningMessage(asyncWarningMessageFunc);
expect(asyncWarningMessageResult).toContain(accessMessagePart);
it("should return accessMessage when incorrent ip rule is added to mongo/cassandra account per endpoint", async () => {
updateUserContext({
databaseAccount: {
kind: "MongoDB",
properties: {
ipRules: [{ ipAddressOrRange: "1.1.1.1" }],
publicNetworkAccess: "Enabled",
},
} as DatabaseAccount,
});
updateConfigContext({
BACKEND_ENDPOINT: legacyBackendEndpoint,
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Mpac,
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Mpac,
});
let asyncWarningMessageResult: string;
const asyncWarningMessageFunc = (msg: string) => (asyncWarningMessageResult = msg);
await getNetworkSettingsWarningMessage(asyncWarningMessageFunc);
expect(asyncWarningMessageResult).toContain(accessMessagePart);
});
// Postgres and vcore mongo account checks basically pass through to CheckFirewallRules so those

View File

@@ -1,7 +1,13 @@
import { CassandraProxyEndpoints, MongoProxyEndpoints, PortalBackendEndpoints } from "Common/Constants";
import { configContext } from "ConfigContext";
import { checkFirewallRules } from "Explorer/Tabs/Shared/CheckFirewallRules";
import { userContext } from "UserContext";
import { PortalBackendIPs } from "Utils/EndpointUtils";
import {
CassandraProxyOutboundIPs,
MongoProxyOutboundIPs,
PortalBackendIPs,
PortalBackendOutboundIPs,
} from "Utils/EndpointUtils";
export const getNetworkSettingsWarningMessage = async (
setStateFunc: (warningMessage: string) => void,
@@ -45,18 +51,53 @@ export const getNetworkSettingsWarningMessage = async (
const ipRules = accountProperties.ipRules;
// public network access is NOT set to "All networks"
if (ipRules?.length > 0) {
if (userContext.apiType === "Cassandra" || userContext.apiType === "Mongo") {
const portalIPs = PortalBackendIPs[configContext.BACKEND_ENDPOINT];
let numberOfMatches = 0;
ipRules.forEach((ipRule) => {
if (portalIPs.indexOf(ipRule.ipAddressOrRange) !== -1) {
numberOfMatches++;
}
});
const isProdOrMpacPortalBackendEndpoint: boolean = [
PortalBackendEndpoints.Mpac,
PortalBackendEndpoints.Prod,
].includes(configContext.PORTAL_BACKEND_ENDPOINT);
const portalBackendOutboundIPs: string[] = isProdOrMpacPortalBackendEndpoint
? [
...PortalBackendOutboundIPs[PortalBackendEndpoints.Mpac],
...PortalBackendOutboundIPs[PortalBackendEndpoints.Prod],
]
: PortalBackendOutboundIPs[configContext.PORTAL_BACKEND_ENDPOINT];
let portalIPs: string[] = [...portalBackendOutboundIPs, ...PortalBackendIPs[configContext.BACKEND_ENDPOINT]];
if (numberOfMatches !== portalIPs.length) {
setStateFunc(accessMessage);
if (userContext.apiType === "Mongo") {
const isProdOrMpacMongoProxyEndpoint: boolean = [MongoProxyEndpoints.Mpac, MongoProxyEndpoints.Prod].includes(
configContext.MONGO_PROXY_ENDPOINT,
);
const mongoProxyOutboundIPs: string[] = isProdOrMpacMongoProxyEndpoint
? [...MongoProxyOutboundIPs[MongoProxyEndpoints.Mpac], ...MongoProxyOutboundIPs[MongoProxyEndpoints.Prod]]
: MongoProxyOutboundIPs[configContext.MONGO_PROXY_ENDPOINT];
portalIPs = [...portalIPs, ...mongoProxyOutboundIPs];
} else if (userContext.apiType === "Cassandra") {
const isProdOrMpacCassandraProxyEndpoint: boolean = [
CassandraProxyEndpoints.Mpac,
CassandraProxyEndpoints.Prod,
].includes(configContext.CASSANDRA_PROXY_ENDPOINT);
const cassandraProxyOutboundIPs: string[] = isProdOrMpacCassandraProxyEndpoint
? [
...CassandraProxyOutboundIPs[CassandraProxyEndpoints.Mpac],
...CassandraProxyOutboundIPs[CassandraProxyEndpoints.Prod],
]
: CassandraProxyOutboundIPs[configContext.CASSANDRA_PROXY_ENDPOINT];
portalIPs = [...portalIPs, ...cassandraProxyOutboundIPs];
}
let numberOfMatches = 0;
ipRules.forEach((ipRule) => {
if (portalIPs.indexOf(ipRule.ipAddressOrRange) !== -1) {
numberOfMatches++;
}
});
if (numberOfMatches !== portalIPs.length) {
setStateFunc(accessMessage);
}
}
}

Some files were not shown because too many files have changed in this diff Show More