Compare commits

..

47 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
Laurent Nguyen
2226169a71 Remove database context menu if Fabric and readonly. (#1939)
Co-authored-by: Laurent Nguyen <languye@microsoft.com>
2024-08-20 16:54:58 +02:00
SATYA SB
6f35fb5526 [accessibility-2819223]:Bug 2819223: [Keyboard navigation - Cosmos DB Query Copilot - Copilot]: The suggestions of 'Copilot search' edit field are not accessible with keyboard. (#1893)
Co-authored-by: Satyapriya Bai <v-satybai@microsoft.com>
2024-08-19 10:34:49 +05:30
Ashley Stanton-Nurse
805a4ae168 Error rendering improvements (#1887) 2024-08-15 13:29:57 -07:00
Asier Isayas
cc89691da3 Activate Mongo Proxy in Prod (#1936)
* activate mongo proxy in mpac

* activate mongo proxy in mpac

* activate mongo proxy in prod

* fixed parition key unit test

* remove three part partition key value test

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-08-14 14:15:11 -04:00
sunghyunkang1111
24860a6842 revert extract partition key (#1935) 2024-08-14 02:54:20 -05:00
Asier Isayas
bf6b362610 Activate Mongo Proxy in MPAC (#1934)
* activate mongo proxy in mpac

* activate mongo proxy in mpac

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-08-13 16:34:34 -04:00
sunghyunkang1111
baca7922b4 move nps survey open dialog call to after explorer initialization (#1932) 2024-08-13 14:16:20 -05:00
Ashley Stanton-Nurse
b59ba20ed0 fix #1929 by using flex instead of grid to lay out the tabs view (#1930) 2024-08-13 11:19:24 -07:00
132 changed files with 7970 additions and 5512 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

@@ -174,7 +174,7 @@ module.exports = {
}, },
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
transformIgnorePatterns: ["/node_modules/", "/externals/"], transformIgnorePatterns: ["/node_modules/(?!@fluentui/react-icons)", "/externals/"],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined, // unmockedModulePathPatterns: undefined,

View File

@@ -1906,8 +1906,14 @@ input::-webkit-calendar-picker-indicator::after {
} }
.nav-tabs-margin { .nav-tabs-margin {
padding-top: 5px; height: 32px;
background-color: #f2f2f2; background-color: #f2f2f2;
.nav-tabs {
display: flex;
align-items: flex-end;
height: 100%;
}
} }
.navTabHeight { .navTabHeight {
@@ -2352,8 +2358,8 @@ a:link {
.tabsManagerContainer { .tabsManagerContainer {
height: 100%; height: 100%;
display: grid; display: flex;
grid-template-rows: 36px 36px 1fr; flex-direction: column;
min-width: 0; // This prevents it to grow past the parent's width if its content is too wide min-width: 0; // This prevents it to grow past the parent's width if its content is too wide
} }
@@ -2610,9 +2616,9 @@ a:link {
} }
.tabPanesContainer { .tabPanesContainer {
grid-row: span 2; // Fill the remaining space
display: flex; display: flex;
height: 100%; flex-grow: 1;
flex-direction: column;
overflow: hidden; overflow: hidden;
} }

938
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,6 @@ export default defineConfig({
reporter: process.env.CI ? "blob" : "html", reporter: process.env.CI ? "blob" : "html",
timeout: 10 * 60 * 1000, timeout: 10 * 60 * 1000,
use: { use: {
actionTimeout: 5 * 60 * 1000,
trace: "off", trace: "off",
video: "off", video: "off",
screenshot: "on", screenshot: "on",
@@ -23,7 +22,8 @@ export default defineConfig({
}, },
expect: { expect: {
timeout: 5 * 60 * 1000, // Many of our expectations take a little longer than the default 5 seconds.
timeout: 15 * 1000,
}, },
projects: [ projects: [

View File

@@ -134,6 +134,9 @@ export class BackendApi {
public static readonly GenerateToken: string = "GenerateToken"; public static readonly GenerateToken: string = "GenerateToken";
public static readonly PortalSettings: string = "PortalSettings"; public static readonly PortalSettings: string = "PortalSettings";
public static readonly AccountRestrictions: string = "AccountRestrictions"; 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 { export class PortalBackendEndpoints {
@@ -183,6 +186,12 @@ export class CassandraProxyAPIs {
public static readonly connectionStringSchemaApi: string = "api/connectionstring/cassandra/schema"; 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 { export class Queries {
public static CustomPageOption: string = "custom"; public static CustomPageOption: string = "custom";
public static UnlimitedPageOption: string = "unlimited"; public static UnlimitedPageOption: string = "unlimited";
@@ -284,6 +293,7 @@ export class HttpStatusCodes {
public static readonly Accepted: number = 202; public static readonly Accepted: number = 202;
public static readonly NoContent: number = 204; public static readonly NoContent: number = 204;
public static readonly NotModified: number = 304; public static readonly NotModified: number = 304;
public static readonly BadRequest: number = 400;
public static readonly Unauthorized: number = 401; public static readonly Unauthorized: number = 401;
public static readonly Forbidden: number = 403; public static readonly Forbidden: number = 403;
public static readonly NotFound: number = 404; public static readonly NotFound: number = 404;
@@ -495,7 +505,7 @@ export class PriorityLevel {
public static readonly Default = "low"; public static readonly Default = "low";
} }
export const QueryCopilotSampleDatabaseId = "CopilotSampleDb"; export const QueryCopilotSampleDatabaseId = "CopilotSampleDB";
export const QueryCopilotSampleContainerId = "SampleContainer"; export const QueryCopilotSampleContainerId = "SampleContainer";
export const QueryCopilotSampleContainerSchema = { export const QueryCopilotSampleContainerSchema = {

View File

@@ -3,15 +3,16 @@ import { getAuthorizationTokenUsingResourceTokens } from "Common/getAuthorizatio
import { AuthorizationToken } from "Contracts/FabricMessageTypes"; import { AuthorizationToken } from "Contracts/FabricMessageTypes";
import { checkDatabaseResourceTokensValidity } from "Platform/Fabric/FabricUtil"; import { checkDatabaseResourceTokensValidity } from "Platform/Fabric/FabricUtil";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility"; import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { useNewPortalBackendEndpoint } from "Utils/EndpointUtils";
import { AuthType } from "../AuthType"; 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 { Platform, configContext } from "../ConfigContext";
import { userContext } from "../UserContext"; import { userContext } from "../UserContext";
import { logConsoleError } from "../Utils/NotificationConsoleUtils"; import { logConsoleError } from "../Utils/NotificationConsoleUtils";
import * as PriorityBasedExecutionUtils from "../Utils/PriorityBasedExecutionUtils"; import * as PriorityBasedExecutionUtils from "../Utils/PriorityBasedExecutionUtils";
import { EmulatorMasterKey, HttpHeaders } from "./Constants"; import { EmulatorMasterKey, HttpHeaders } from "./Constants";
import { getErrorMessage } from "./ErrorHandlingUtils"; import { getErrorMessage } from "./ErrorHandlingUtils";
import * as Logger from "../Common/Logger";
const _global = typeof self === "undefined" ? window : self; const _global = typeof self === "undefined" ? window : self;
@@ -123,6 +124,37 @@ export async function getTokenFromAuthService(
verb: string, verb: string,
resourceType: string, resourceType: string,
resourceId?: 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> { ): Promise<AuthorizationToken> {
try { try {
const host = configContext.BACKEND_ENDPOINT; 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"; import * as EnvironmentUtility from "./EnvironmentUtility";
describe("Environment Utility Test", () => { describe("Environment Utility Test", () => {
@@ -11,4 +13,18 @@ describe("Environment Utility Test", () => {
const expectedResult = "test/"; const expectedResult = "test/";
expect(EnvironmentUtility.normalizeArmEndpoint(uri)).toEqual(expectedResult); 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 { export function normalizeArmEndpoint(uri: string): string {
if (uri && uri.slice(-1) !== "/") { if (uri && uri.slice(-1) !== "/") {
return `${uri}/`; return `${uri}/`;
} }
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

@@ -53,7 +53,8 @@ const replaceKnownError = (errorMessage: string): string => {
return "Partition key paths must contain only valid characters and not contain a trailing slash or wildcard character."; return "Partition key paths must contain only valid characters and not contain a trailing slash or wildcard character.";
} else if ( } else if (
errorMessage?.indexOf("The user aborted a request") >= 0 || errorMessage?.indexOf("The user aborted a request") >= 0 ||
errorMessage?.indexOf("The operation was aborted") >= 0 errorMessage?.indexOf("The operation was aborted") >= 0 ||
errorMessage === "signal is aborted without reason"
) { ) {
return "User aborted query."; return "User aborted query.";
} }

View File

@@ -70,7 +70,6 @@ export function sendMessage(data: any): void {
} }
export function sendReadyMessage(): void { export function sendReadyMessage(): void {
console.log("SENDING READY MESSAGE");
_sendMessage({ _sendMessage({
signature: "pcIframe", signature: "pcIframe",
kind: "ready", kind: "ready",

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( export function createMongoCollectionWithProxy(
params: DataModels.CreateCollectionParams, params: DataModels.CreateCollectionParams,
): Promise<DataModels.Collection> { ): Promise<DataModels.Collection> {
@@ -678,22 +721,21 @@ export function useMongoProxyEndpoint(api: string): boolean {
MongoProxyEndpoints.Mpac, MongoProxyEndpoints.Mpac,
MongoProxyEndpoints.Prod, 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 ( return (
canAccessMongoProxy &&
configContext.NEW_MONGO_APIS?.includes(api) && configContext.NEW_MONGO_APIS?.includes(api) &&
activeMongoProxyEndpoints.includes(configContext.MONGO_PROXY_ENDPOINT) 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 // TODO: This function throws most of the time except on Forbidden which is a bit strange
// It causes problems for TypeScript understanding the types // It causes problems for TypeScript understanding the types
async function errorHandling(response: Response, action: string, params: unknown): Promise<void> { 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) { if (response.status === HttpStatusCodes.Forbidden) {
sendMessage({ type: MessageTypes.ForbiddenError, reason: errorMessage }); sendMessage({ type: MessageTypes.ForbiddenError, reason: errorMessage });
return; 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); 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 }),
),
]);
});
});

247
src/Common/QueryError.ts Normal file
View File

@@ -0,0 +1,247 @@
import { monaco } from "Explorer/LazyMonaco";
import { getRUThreshold, ruThresholdEnabled } from "Shared/StorageUtility";
export enum QueryErrorSeverity {
Error = "Error",
Warning = "Warning",
}
export class QueryErrorLocation {
constructor(
public start: ErrorPosition,
public end: ErrorPosition,
) {}
}
export class ErrorPosition {
constructor(
public offset: number,
public lineNumber?: number,
public column?: number,
) {}
}
// Maps severities to numbers for sorting.
const severityMap: Record<QueryErrorSeverity, number> = {
Error: 1,
Warning: 0,
};
export function compareSeverity(left: QueryErrorSeverity, right: QueryErrorSeverity): number {
return severityMap[left] - severityMap[right];
}
export function createMonacoErrorLocationResolver(
editor: monaco.editor.IStandaloneCodeEditor,
selection?: monaco.Selection,
): (location: { start: number; end: number }) => QueryErrorLocation {
return ({ start, end }) => {
// Start and end are absolute offsets (character index) in the document.
// But we need line numbers and columns for the monaco editor.
// To get those, we use the editor's model to convert the offsets to positions.
const model = editor.getModel();
if (!model) {
return new QueryErrorLocation(new ErrorPosition(start), new ErrorPosition(end));
}
// If the error was found in a selection, adjust the start and end positions to be relative to the document.
if (selection) {
// Get the character index of the start of the selection.
const selectionStartOffset = model.getOffsetAt(selection.getStartPosition());
// Adjust the start and end positions to be relative to the document.
start = selectionStartOffset + start;
end = selectionStartOffset + end;
// Now, when we resolve the positions, they will be relative to the document and appear in the correct location.
}
const startPos = model.getPositionAt(start);
const endPos = model.getPositionAt(end);
return new QueryErrorLocation(
new ErrorPosition(start, startPos.lineNumber, startPos.column),
new ErrorPosition(end, endPos.lineNumber, endPos.column),
);
};
}
export const createMonacoMarkersForQueryErrors = (errors: QueryError[]) => {
if (!errors) {
return [];
}
return errors
.map((error): monaco.editor.IMarkerData => {
// Validate that we have what we need to make a marker
if (
error.location === undefined ||
error.location.start === undefined ||
error.location.end === undefined ||
error.location.start.lineNumber === undefined ||
error.location.end.lineNumber === undefined ||
error.location.start.column === undefined ||
error.location.end.column === undefined
) {
return null;
}
return {
message: error.message,
severity: error.getMonacoSeverity(),
startLineNumber: error.location.start.lineNumber,
startColumn: error.location.start.column,
endLineNumber: error.location.end.lineNumber,
endColumn: error.location.end.column,
};
})
.filter((marker) => !!marker);
};
export 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(
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.
// See: https://microsoft.github.io/monaco-editor/typedoc/enums/MarkerSeverity.html
switch (this.severity) {
case QueryErrorSeverity.Error:
return 8;
case QueryErrorSeverity.Warning:
return 4;
default:
return 2; // Info
}
}
/** Attempts to parse a query error from a string or object.
*
* @param error The error to parse.
* @returns An array of query errors if the error could be parsed, or null otherwise.
*/
static tryParse(
error: unknown,
locationResolver?: (location: { start: number; end: number }) => QueryErrorLocation,
): QueryError[] {
locationResolver =
locationResolver ||
(({ start, end }) => new QueryErrorLocation(new ErrorPosition(start), new ErrorPosition(end)));
const errors = QueryError.tryParseObject(error, locationResolver);
if (errors !== null) {
return errors;
}
const errorMessage = error as string;
// Map some well known messages to richer errors
const knownError = knownErrors[errorMessage];
if (knownError) {
return [knownError];
} else {
return [new QueryError(errorMessage, QueryErrorSeverity.Error)];
}
}
static read(
error: unknown,
locationResolver: (location: { start: number; end: number }) => QueryErrorLocation,
): QueryError | null {
if (typeof error !== "object" || error === null) {
return null;
}
const message = "message" in error && typeof error.message === "string" ? error.message : undefined;
if (!message) {
return null; // Invalid error (no message).
}
const severity =
"severity" in error && typeof error.severity === "string"
? (error.severity as QueryErrorSeverity)
: QueryErrorSeverity.Error;
const location =
"location" in error && typeof error.location === "object"
? locationResolver(error.location as { start: number; end: number })
: undefined;
const code = "code" in error && typeof error.code === "string" ? error.code : undefined;
return new QueryError(message, severity, code, location);
}
private static tryParseObject(
error: unknown,
locationResolver: (location: { start: number; end: number }) => QueryErrorLocation,
): QueryError[] | null {
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.
if (message.startsWith("Message: ")) {
// Reassigning this to 'error' restores the original type of 'error', which is 'unknown'.
// So we use a separate variable to avoid this.
message = message.substring("Message: ".length);
}
const lines = message.split("\n");
message = lines[0].trim();
let parsed: unknown;
try {
parsed = JSON.parse(message);
} catch (e) {
// The message doesn't contain a nested error.
return [QueryError.read(error, locationResolver)];
}
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;
}
}
const knownErrors: Record<string, QueryError> = {
"User aborted query.": new QueryError("User aborted query.", QueryErrorSeverity.Warning),
};

View File

@@ -3,11 +3,12 @@ import * as React from "react";
export interface TooltipProps { export interface TooltipProps {
children: string; children: string;
className?: string;
} }
export const InfoTooltip: React.FunctionComponent<TooltipProps> = ({ children }: TooltipProps) => { export const InfoTooltip: React.FunctionComponent<TooltipProps> = ({ children, className }: TooltipProps) => {
return ( return (
<span> <span className={className}>
<TooltipHost content={children}> <TooltipHost content={children}>
<Icon iconName="Info" ariaLabel={children} className="panelInfoIcon" tabIndex={0} /> <Icon iconName="Info" ariaLabel={children} className="panelInfoIcon" tabIndex={0} />
</TooltipHost> </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 * Bulk delete documents
* @param collection * @param collection
* @param documentId * @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[]> => { export const deleteDocuments = async (
const nbDocuments = documentIds.length; collection: CollectionBase,
documentIds: DocumentId[],
): Promise<IBulkDeleteResult[]> => {
const clearMessage = logConsoleProgress(`Deleting ${documentIds.length} ${getEntityName(true)}`); const clearMessage = logConsoleProgress(`Deleting ${documentIds.length} ${getEntityName(true)}`);
try { try {
const v2Container = await client().database(collection.databaseId).container(collection.id()); 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, operationType: BulkOperationType.Delete,
})); }));
const promise = v2Container.items.bulk(operations).then((bulkResult) => { const promise = v2Container.items.bulk(operations).then((bulkResults) => {
return documentIdsChunk.filter((_, index) => bulkResult[index].statusCode === 204); return bulkResults.map((bulkResult, index) => {
const documentId = documentIdsChunk[index];
return { ...bulkResult, documentId };
});
}); });
promiseArray.push(promise); promiseArray.push(promise);
} }
const allResult = await Promise.all(promiseArray); const allResult = await Promise.all(promiseArray);
const flatAllResult = Array.prototype.concat.apply([], allResult); 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; return flatAllResult;
} catch (error) { } catch (error) {
handleError( handleError(

View File

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

View File

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

View File

@@ -41,6 +41,10 @@ export interface DatabaseContextMenuButtonParams {
* New resource tree (in ReactJS) * New resource tree (in ReactJS)
*/ */
export const createDatabaseContextMenu = (container: Explorer, databaseId: string): TreeNodeMenuItem[] => { export const createDatabaseContextMenu = (container: Explorer, databaseId: string): TreeNodeMenuItem[] => {
if (configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly) {
return undefined;
}
const items: TreeNodeMenuItem[] = [ const items: TreeNodeMenuItem[] = [
{ {
iconSrc: AddCollectionIcon, iconSrc: AddCollectionIcon,

View File

@@ -1,23 +1,14 @@
/** /**
* React component for Command button component. * React component for Command button component.
*/ */
import Explorer from "Explorer/Explorer";
import { KeyboardAction } from "KeyboardShortcuts"; import { KeyboardAction } from "KeyboardShortcuts";
import * as React from "react"; 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 * Options for this component
*/ */
export interface CommandButtonComponentProps { export interface CommandButtonComponentProps {
/**
* font icon name for the button
*/
iconName?: string;
/** /**
* image source for the button icon * image source for the button icon
*/ */
@@ -31,7 +22,7 @@ export interface CommandButtonComponentProps {
/** /**
* Click handler for command button click * Click handler for command button click
*/ */
onCommandClick?: (e: React.SyntheticEvent | KeyboardEvent) => void; onCommandClick?: (e: React.SyntheticEvent | KeyboardEvent, container: Explorer) => void;
/** /**
* Label for the button * Label for the button
@@ -120,157 +111,3 @@ export interface CommandButtonComponentProps {
*/ */
keyboardAction?: KeyboardAction; 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, textFieldProps?: TextFieldProps,
primaryButtonDisabled?: boolean, primaryButtonDisabled?: boolean,
) => void; ) => void;
showOkModalDialog: (title: string, subText: string) => void; showOkModalDialog: (title: string, subText: string, linkProps?: LinkProps) => void;
} }
export const useDialog: UseStore<DialogState> = create((set, get) => ({ export const useDialog: UseStore<DialogState> = create((set, get) => ({
@@ -83,7 +83,7 @@ export const useDialog: UseStore<DialogState> = create((set, get) => ({
textFieldProps, textFieldProps,
primaryButtonDisabled, primaryButtonDisabled,
}), }),
showOkModalDialog: (title: string, subText: string): void => showOkModalDialog: (title: string, subText: string, linkProps?: LinkProps): void =>
get().openDialog({ get().openDialog({
isModal: true, isModal: true,
title, title,
@@ -94,6 +94,7 @@ export const useDialog: UseStore<DialogState> = create((set, get) => ({
get().closeDialog(); get().closeDialog();
}, },
onSecondaryButtonClick: undefined, onSecondaryButtonClick: undefined,
linkProps,
}), }),
})); }));

View File

@@ -3,6 +3,37 @@ import * as React from "react";
import { loadMonaco, monaco } from "../../LazyMonaco"; import { loadMonaco, monaco } from "../../LazyMonaco";
// import "./EditorReact.less"; // import "./EditorReact.less";
// In development, add a function to window to allow us to get the editor instance for a given element
if (process.env.NODE_ENV === "development") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const win = window as any;
win._monaco_getEditorForElement =
win._monaco_getEditorForElement ||
((element: HTMLElement) => {
const editorId = element.dataset["monacoEditorId"];
if (!editorId || !win.__monaco_editors || typeof win.__monaco_editors !== "object") {
return null;
}
return win.__monaco_editors[editorId];
});
win._monaco_getEditorContentForElement =
win._monaco_getEditorContentForElement ||
((element: HTMLElement) => {
const editor = win._monaco_getEditorForElement(element);
return editor ? editor.getValue() : null;
});
win._monaco_setEditorContentForElement =
win._monaco_setEditorContentForElement ||
((element: HTMLElement, text: string) => {
const editor = win._monaco_getEditorForElement(element);
if (editor) {
editor.setValue(text);
}
});
}
interface EditorReactStates { interface EditorReactStates {
showEditor: boolean; showEditor: boolean;
} }
@@ -11,7 +42,7 @@ export interface EditorReactProps {
content: string; content: string;
isReadOnly: boolean; isReadOnly: boolean;
ariaLabel: string; // Sets what will be read to the user to define the control ariaLabel: string; // Sets what will be read to the user to define the control
onContentSelected?: (selectedContent: string) => void; // Called when text is selected onContentSelected?: (selectedContent: string, selection: monaco.Selection) => void; // Called when text is selected
onContentChanged?: (newContent: string) => void; // Called when text is changed onContentChanged?: (newContent: string) => void; // Called when text is changed
theme?: string; // Monaco editor theme theme?: string; // Monaco editor theme
wordWrap?: monaco.editor.IEditorOptions["wordWrap"]; wordWrap?: monaco.editor.IEditorOptions["wordWrap"];
@@ -25,6 +56,7 @@ export interface EditorReactProps {
className?: string; className?: string;
spinnerClassName?: string; spinnerClassName?: string;
modelMarkers?: monaco.editor.IMarkerData[];
enableWordWrapContextMenuItem?: boolean; // Enable/Disable "Word Wrap" context menu item enableWordWrapContextMenuItem?: boolean; // Enable/Disable "Word Wrap" context menu item
onWordWrapChanged?: (wordWrap: "on" | "off") => void; // Called when word wrap is changed onWordWrapChanged?: (wordWrap: "on" | "off") => void; // Called when word wrap is changed
} }
@@ -32,10 +64,25 @@ export interface EditorReactProps {
export class EditorReact extends React.Component<EditorReactProps, EditorReactStates> { export class EditorReact extends React.Component<EditorReactProps, EditorReactStates> {
private static readonly VIEWING_OPTIONS_GROUP_ID = "viewingoptions"; // Group ID for the context menu group private static readonly VIEWING_OPTIONS_GROUP_ID = "viewingoptions"; // Group ID for the context menu group
private rootNode: HTMLElement; private rootNode: HTMLElement;
private editor: monaco.editor.IStandaloneCodeEditor; public editor: monaco.editor.IStandaloneCodeEditor;
private selectionListener: monaco.IDisposable; private selectionListener: monaco.IDisposable;
monacoApi: {
private monacoEditorOptionsWordWrap: monaco.editor.EditorOption; default: typeof monaco;
Emitter: typeof monaco.Emitter;
MarkerTag: typeof monaco.MarkerTag;
MarkerSeverity: typeof monaco.MarkerSeverity;
CancellationTokenSource: typeof monaco.CancellationTokenSource;
Uri: typeof monaco.Uri;
KeyCode: typeof monaco.KeyCode;
KeyMod: typeof monaco.KeyMod;
Position: typeof monaco.Position;
Range: typeof monaco.Range;
Selection: typeof monaco.Selection;
SelectionDirection: typeof monaco.SelectionDirection;
Token: typeof monaco.Token;
editor: typeof monaco.editor;
languages: typeof monaco.languages;
};
public constructor(props: EditorReactProps) { public constructor(props: EditorReactProps) {
super(props); super(props);
@@ -64,7 +111,7 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
if (this.props.content !== existingContent) { if (this.props.content !== existingContent) {
if (this.props.isReadOnly) { if (this.props.isReadOnly) {
this.editor.setValue(this.props.content); this.editor.setValue(this.props.content || ""); // Monaco throws an error if you set the value to undefined.
} else { } else {
this.editor.pushUndoStop(); this.editor.pushUndoStop();
this.editor.executeEdits("", [ this.editor.executeEdits("", [
@@ -75,6 +122,8 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
]); ]);
} }
} }
this.monacoApi.editor.setModelMarkers(this.editor.getModel(), "owner", this.props.modelMarkers || []);
} }
public componentWillUnmount(): void { public componentWillUnmount(): void {
@@ -88,6 +137,7 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
<Spinner size={SpinnerSize.large} className={this.props.spinnerClassName || "spinner"} /> <Spinner size={SpinnerSize.large} className={this.props.spinnerClassName || "spinner"} />
)} )}
<div <div
data-test="EditorReact/Host/Unloaded"
className={this.props.className || "jsonEditor"} className={this.props.className || "jsonEditor"}
style={this.props.monacoContainerStyles} style={this.props.monacoContainerStyles}
ref={(elt: HTMLElement) => this.setRef(elt)} ref={(elt: HTMLElement) => this.setRef(elt)}
@@ -98,6 +148,18 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
protected configureEditor(editor: monaco.editor.IStandaloneCodeEditor) { protected configureEditor(editor: monaco.editor.IStandaloneCodeEditor) {
this.editor = editor; this.editor = editor;
this.rootNode.dataset["test"] = "EditorReact/Host/Loaded";
// In development, we want to be able to access the editor instance from the console
if (process.env.NODE_ENV === "development") {
this.rootNode.dataset["monacoEditorId"] = this.editor.getId();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const win = window as any;
win["__monaco_editors"] = win["__monaco_editors"] || {};
win["__monaco_editors"][this.editor.getId()] = this.editor;
}
if (!this.props.isReadOnly && this.props.onContentChanged) { if (!this.props.isReadOnly && this.props.onContentChanged) {
// Hooking the model's onDidChangeContent event because of some event ordering issues. // Hooking the model's onDidChangeContent event because of some event ordering issues.
// If a single user input causes BOTH the editor content to change AND the cursor selection to change (which is likely), // If a single user input causes BOTH the editor content to change AND the cursor selection to change (which is likely),
@@ -115,7 +177,7 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
this.selectionListener = this.editor.onDidChangeCursorSelection( this.selectionListener = this.editor.onDidChangeCursorSelection(
(event: monaco.editor.ICursorSelectionChangedEvent) => { (event: monaco.editor.ICursorSelectionChangedEvent) => {
const selectedContent: string = this.editor.getModel().getValueInRange(event.selection); const selectedContent: string = this.editor.getModel().getValueInRange(event.selection);
this.props.onContentSelected(selectedContent); this.props.onContentSelected(selectedContent, event.selection);
}, },
); );
} }
@@ -130,7 +192,7 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
// Method that will be executed when the action is triggered. // Method that will be executed when the action is triggered.
// @param editor The editor instance is passed in as a convenience // @param editor The editor instance is passed in as a convenience
run: (ed) => { run: (ed) => {
const newOption = ed.getOption(this.monacoEditorOptionsWordWrap) === "on" ? "off" : "on"; const newOption = ed.getOption(this.monacoApi.editor.EditorOption.wordWrap) === "on" ? "off" : "on";
ed.updateOptions({ wordWrap: newOption }); ed.updateOptions({ wordWrap: newOption });
this.props.onWordWrapChanged(newOption); this.props.onWordWrapChanged(newOption);
}, },
@@ -156,16 +218,14 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
lineDecorationsWidth: this.props.lineDecorationsWidth, lineDecorationsWidth: this.props.lineDecorationsWidth,
minimap: this.props.minimap, minimap: this.props.minimap,
scrollBeyondLastLine: this.props.scrollBeyondLastLine, scrollBeyondLastLine: this.props.scrollBeyondLastLine,
fixedOverflowWidgets: true,
}; };
this.rootNode.innerHTML = ""; this.rootNode.innerHTML = "";
const lazymonaco = await loadMonaco(); this.monacoApi = await loadMonaco();
// We can only get this constant after loading monaco lazily
this.monacoEditorOptionsWordWrap = lazymonaco.editor.EditorOption.wordWrap;
try { try {
createCallback(lazymonaco?.editor?.create(this.rootNode, options)); createCallback(this.monacoApi.editor.create(this.rootNode, options));
} catch (error) { } catch (error) {
// This could happen if the parent node suddenly disappears during create() // This could happen if the parent node suddenly disappears during create()
console.error("Unable to create EditorReact", error); console.error("Unable to create EditorReact", error);

View File

@@ -0,0 +1,37 @@
import { ProgressBar, makeStyles } from "@fluentui/react-components";
import React from "react";
const useStyles = makeStyles({
indeterminateProgressBarRoot: {
"@media screen and (prefers-reduced-motion: reduce)": {
animationIterationCount: "infinite",
animationDuration: "3s",
animationName: {
"0%": {
opacity: ".2", // matches indeterminate bar width
},
"50%": {
opacity: "1",
},
"100%": {
opacity: ".2",
},
},
},
},
indeterminateProgressBarBar: {
"@media screen and (prefers-reduced-motion: reduce)": {
maxWidth: "100%",
},
},
});
export const IndeterminateProgressBar: React.FC = () => {
const styles = useStyles();
return (
<ProgressBar
bar={{ className: styles.indeterminateProgressBarBar }}
className={styles.indeterminateProgressBarRoot}
/>
);
};

View File

@@ -0,0 +1,68 @@
import { Button, MessageBar, MessageBarActions, MessageBarBody } from "@fluentui/react-components";
import { DismissRegular } from "@fluentui/react-icons";
import React, { useState } from "react";
export enum MessageBannerState {
/** The banner should be visible if the triggering conditions are met. */
Allowed = "allowed",
/** The banner has been dismissed by the user and will not be shown until the component is recreated, even if the visibility condition is true. */
Dismissed = "dismissed",
/** The banner has been supressed by the user and will not be shown at all, even if the visibility condition is true. */
Suppressed = "suppressed",
}
export type MessageBannerProps = {
/** A CSS class for the root MessageBar component */
className: string;
/** A unique ID for the message that will be used to store it's dismiss/suppress state across sessions. */
messageId: string;
/** The current visibility state for the banner IGNORING the user's dimiss/suppress preference
*
* If this value is true but the user has dismissed the banner, the banner will NOT be shown.
*/
visible: boolean;
};
/** A component that shows a message banner which can be dismissed by the user.
*
* In the future, this can also support persisting the dismissed state in local storage without requiring changes to all the components that use it.
*
* A message banner can be in three "states":
* - Allowed: The banner should be visible if the triggering conditions are met.
* - Dismissed: The banner has been dismissed by the user and will not be shown until the component is recreated, even if the visibility condition is true.
* - Suppressed: The banner has been supressed by the user and will not be shown at all, even if the visibility condition is true.
*
* The "Dismissed" state represents the user clicking the "x" in the banner to dismiss it.
* The "Suppressed" state represents the user clicking "Don't show this again".
*/
export const MessageBanner: React.FC<MessageBannerProps> = ({ visible, className, children }) => {
const [state, setState] = useState<MessageBannerState>(MessageBannerState.Allowed);
if (state !== MessageBannerState.Allowed) {
return null;
}
if (!visible) {
return null;
}
return (
<MessageBar className={className}>
<MessageBarBody>{children}</MessageBarBody>
<MessageBarActions
containerAction={
<Button
aria-label="dismiss"
appearance="transparent"
icon={<DismissRegular />}
onClick={() => setState(MessageBannerState.Dismissed)}
/>
}
></MessageBarActions>
</MessageBar>
);
};

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, readSettings: undefined,
onSettingsClick: undefined, onSettingsClick: undefined,
loadOffer: undefined, loadOffer: undefined,
getPendingThroughputSplitNotification: undefined,
} as ViewModels.Database; } as ViewModels.Database;
newCollection.getDatabase = () => newDatabase; newCollection.getDatabase = () => newDatabase;
newCollection.offer = ko.observable(undefined); newCollection.offer = ko.observable(undefined);

View File

@@ -7,6 +7,7 @@ import {
ContainerVectorPolicyComponent, ContainerVectorPolicyComponent,
ContainerVectorPolicyComponentProps, ContainerVectorPolicyComponentProps,
} from "Explorer/Controls/Settings/SettingsSubComponents/ContainerVectorPolicyComponent"; } from "Explorer/Controls/Settings/SettingsSubComponents/ContainerVectorPolicyComponent";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { useDatabases } from "Explorer/useDatabases"; import { useDatabases } from "Explorer/useDatabases";
import { isVectorSearchEnabled } from "Utils/CapabilityUtils"; import { isVectorSearchEnabled } from "Utils/CapabilityUtils";
import { isRunningOnPublicCloud } from "Utils/CloudUtils"; import { isRunningOnPublicCloud } from "Utils/CloudUtils";
@@ -32,7 +33,6 @@ import {
PartitionKeyComponent, PartitionKeyComponent,
PartitionKeyComponentProps, PartitionKeyComponentProps,
} from "../../Controls/Settings/SettingsSubComponents/PartitionKeyComponent"; } from "../../Controls/Settings/SettingsSubComponents/PartitionKeyComponent";
import { useCommandBar } from "../../Menus/CommandBar/CommandBarComponentAdapter";
import { SettingsTabV2 } from "../../Tabs/SettingsTabV2"; import { SettingsTabV2 } from "../../Tabs/SettingsTabV2";
import "./SettingsComponent.less"; import "./SettingsComponent.less";
import { mongoIndexingPolicyAADError } from "./SettingsRenderUtils"; import { mongoIndexingPolicyAADError } from "./SettingsRenderUtils";
@@ -130,7 +130,6 @@ export interface SettingsComponentState {
conflictResolutionPolicyProcedureBaseline: string; conflictResolutionPolicyProcedureBaseline: string;
isConflictResolutionDirty: boolean; isConflictResolutionDirty: boolean;
initialNotification: DataModels.Notification;
selectedTab: SettingsV2TabTypes; selectedTab: SettingsV2TabTypes;
} }
@@ -229,7 +228,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
conflictResolutionPolicyProcedureBaseline: undefined, conflictResolutionPolicyProcedureBaseline: undefined,
isConflictResolutionDirty: false, isConflictResolutionDirty: false,
initialNotification: undefined,
selectedTab: SettingsV2TabTypes.ScaleTab, selectedTab: SettingsV2TabTypes.ScaleTab,
}; };
@@ -1052,7 +1050,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
onMaxAutoPilotThroughputChange: this.onMaxAutoPilotThroughputChange, onMaxAutoPilotThroughputChange: this.onMaxAutoPilotThroughputChange,
onScaleSaveableChange: this.onScaleSaveableChange, onScaleSaveableChange: this.onScaleSaveableChange,
onScaleDiscardableChange: this.onScaleDiscardableChange, onScaleDiscardableChange: this.onScaleDiscardableChange,
initialNotification: this.props.settingsTab.pendingNotification(),
throughputError: this.state.throughputError, 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 Constants from "../../../../Common/Constants";
import * as DataModels from "../../../../Contracts/DataModels";
import { updateUserContext } from "../../../../UserContext"; import { updateUserContext } from "../../../../UserContext";
import Explorer from "../../../Explorer"; import Explorer from "../../../Explorer";
import { throughputUnit } from "../SettingsRenderUtils";
import { collection } from "../TestUtils"; import { collection } from "../TestUtils";
import { ScaleComponent, ScaleComponentProps } from "./ScaleComponent"; import { ScaleComponent, ScaleComponentProps } from "./ScaleComponent";
import { ThroughputInputAutoPilotV3Component } from "./ThroughputInputComponents/ThroughputInputAutoPilotV3Component";
describe("ScaleComponent", () => { describe("ScaleComponent", () => {
const targetThroughput = 6000;
const baseProps: ScaleComponentProps = { const baseProps: ScaleComponentProps = {
collection: collection, collection: collection,
database: undefined, database: undefined,
@@ -36,39 +28,8 @@ describe("ScaleComponent", () => {
onScaleDiscardableChange: () => { onScaleDiscardableChange: () => {
return; 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", () => { it("autoScale disabled", () => {
const scaleComponent = new ScaleComponent(baseProps); const scaleComponent = new ScaleComponent(baseProps);
expect(scaleComponent.isAutoScaleEnabled()).toEqual(false); expect(scaleComponent.isAutoScaleEnabled()).toEqual(false);

View File

@@ -10,7 +10,6 @@ import * as AutoPilotUtils from "../../../../Utils/AutoPilotUtils";
import { isRunningOnNationalCloud } from "../../../../Utils/CloudUtils"; import { isRunningOnNationalCloud } from "../../../../Utils/CloudUtils";
import { import {
getTextFieldStyles, getTextFieldStyles,
getThroughputApplyLongDelayMessage,
getThroughputApplyShortDelayMessage, getThroughputApplyShortDelayMessage,
subComponentStackProps, subComponentStackProps,
throughputUnit, throughputUnit,
@@ -34,7 +33,6 @@ export interface ScaleComponentProps {
onMaxAutoPilotThroughputChange: (newThroughput: number) => void; onMaxAutoPilotThroughputChange: (newThroughput: number) => void;
onScaleSaveableChange: (isScaleSaveable: boolean) => void; onScaleSaveableChange: (isScaleSaveable: boolean) => void;
onScaleDiscardableChange: (isScaleDiscardable: boolean) => void; onScaleDiscardableChange: (isScaleDiscardable: boolean) => void;
initialNotification: DataModels.Notification;
throughputError?: string; throughputError?: string;
} }
@@ -102,10 +100,6 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
}; };
public getInitialNotificationElement = (): JSX.Element => { public getInitialNotificationElement = (): JSX.Element => {
if (this.props.initialNotification) {
return this.getLongDelayMessage();
}
if (this.offer?.offerReplacePending) { if (this.offer?.offerReplacePending) {
const throughput = this.offer.manualThroughput || this.offer.autoscaleMaxThroughput; const throughput = this.offer.manualThroughput || this.offer.autoscaleMaxThroughput;
return getThroughputApplyShortDelayMessage( return getThroughputApplyShortDelayMessage(
@@ -120,26 +114,6 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
return undefined; 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 => ( private getThroughputInputComponent = (): JSX.Element => (
<ThroughputInputAutoPilotV3Component <ThroughputInputAutoPilotV3Component
databaseAccount={userContext?.databaseAccount} 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, readSettings: undefined,
onSettingsClick: undefined, onSettingsClick: undefined,
loadOffer: undefined, loadOffer: undefined,
getPendingThroughputSplitNotification: undefined,
} as ViewModels.Database; } as ViewModels.Database;
}; };
newCollection.offer(undefined); newCollection.offer(undefined);

View File

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

View File

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

View File

@@ -10,12 +10,23 @@ exports[`TreeNodeComponent does not render children if the node is loading 1`] =
> >
<TreeItemLayout <TreeItemLayout
actions={false} 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" data-test="TreeNode:root"
expandIcon={<ChevronRight20Regular />} expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
}
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
rootLabel rootLabel
</span> </span>
@@ -133,6 +144,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg <svg
aria-hidden="true" aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0" class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor" fill="currentColor"
height="20" height="20"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -151,7 +163,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
"itemType": "branch", "itemType": "branch",
"layoutRef": { "layoutRef": {
"current": <div "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" data-test="TreeNode:root"
> >
<div <div
@@ -161,6 +173,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg <svg
aria-hidden="true" aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0" class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor" fill="currentColor"
height="20" height="20"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -173,11 +186,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/> />
</svg> </svg>
</div> </div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
</div>
<div <div
class="fui-TreeItemLayout__main rklbe47" class="fui-TreeItemLayout__main rklbe47"
> >
<span <span
class="" class="___1h29e9h_0000000 fz5stix"
> >
rootLabel rootLabel
</span> </span>
@@ -202,7 +225,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="-1" tabindex="-1"
> >
<div <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" data-test="TreeNode:root"
> >
<div <div
@@ -212,6 +235,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg <svg
aria-hidden="true" aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0" class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor" fill="currentColor"
height="20" height="20"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -224,18 +248,29 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/> />
</svg> </svg>
</div> </div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
</div>
<div <div
class="fui-TreeItemLayout__main rklbe47" class="fui-TreeItemLayout__main rklbe47"
> >
<span <span
class="" class="___1h29e9h_0000000 fz5stix"
> >
rootLabel rootLabel
</span> </span>
</div> </div>
</div> </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" role="tree"
> >
<div <div
@@ -248,7 +283,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="0" tabindex="0"
> >
<div <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" data-test="TreeNode:root/child1Label"
> >
<div <div
@@ -258,6 +293,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg <svg
aria-hidden="true" aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0" class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor" fill="currentColor"
height="20" height="20"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -270,11 +306,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/> />
</svg> </svg>
</div> </div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child1Icon"
/>
</div>
<div <div
class="fui-TreeItemLayout__main rklbe47" class="fui-TreeItemLayout__main rklbe47"
> >
<span <span
class="" class="___1h29e9h_0000000 fz5stix"
> >
child1Label child1Label
</span> </span>
@@ -291,7 +337,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="-1" tabindex="-1"
> >
<div <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" data-test="TreeNode:root/child2LoadingLabel"
> >
<div <div
@@ -301,6 +347,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg <svg
aria-hidden="true" aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0" class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor" fill="currentColor"
height="20" height="20"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -313,11 +360,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/> />
</svg> </svg>
</div> </div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child2LoadingIcon"
/>
</div>
<div <div
class="fui-TreeItemLayout__main rklbe47" class="fui-TreeItemLayout__main rklbe47"
> >
<span <span
class="" class="___1h29e9h_0000000 fz5stix"
> >
child2LoadingLabel child2LoadingLabel
</span> </span>
@@ -333,7 +390,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="-1" tabindex="-1"
> >
<div <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" data-test="TreeNode:root/child3ExpandingLabel"
> >
<div <div
@@ -353,11 +410,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</span> </span>
</div> </div>
</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 <div
class="fui-TreeItemLayout__main rklbe47" class="fui-TreeItemLayout__main rklbe47"
> >
<span <span
class="" class="___1h29e9h_0000000 fz5stix"
> >
child3ExpandingLabel child3ExpandingLabel
</span> </span>
@@ -373,22 +440,36 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
> >
<TreeItemLayout <TreeItemLayout
actions={false} 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" data-test="TreeNode:root"
expandIcon={<ChevronRight20Regular />} expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
}
> >
<div <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" data-test="TreeNode:root"
> >
<div <div
aria-hidden={true} aria-hidden={true}
className="fui-TreeItemLayout__expandIcon rh4pu5o" className="fui-TreeItemLayout__expandIcon rh4pu5o"
> >
<ChevronRight20Regular> <ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
>
<svg <svg
aria-hidden={true} aria-hidden={true}
className="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0" className="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor" fill="currentColor"
height="20" height="20"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -402,11 +483,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</svg> </svg>
</ChevronRight20Regular> </ChevronRight20Regular>
</div> </div>
<div
aria-hidden={true}
className="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
</div>
<div <div
className="fui-TreeItemLayout__main rklbe47" className="fui-TreeItemLayout__main rklbe47"
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
rootLabel rootLabel
</span> </span>
@@ -414,7 +505,8 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</div> </div>
</TreeItemLayout> </TreeItemLayout>
<Tree <Tree
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n" className="___17a32do_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
data-test="Tree:root"
> >
<TreeProvider <TreeProvider
value={ value={
@@ -481,7 +573,8 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
} }
> >
<div <div
className="fui-Tree rnv2ez3 ___jy13a00_lpffjy0 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n" className="fui-Tree rnv2ez3 ___17a32do_7zrvj80 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
data-test="Tree:root"
role="tree" role="tree"
> >
<TreeNodeComponent <TreeNodeComponent
@@ -549,6 +642,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg <svg
aria-hidden="true" aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0" class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor" fill="currentColor"
height="20" height="20"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -567,7 +661,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
"itemType": "branch", "itemType": "branch",
"layoutRef": { "layoutRef": {
"current": <div "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" data-test="TreeNode:root/child1Label"
> >
<div <div
@@ -577,6 +671,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg <svg
aria-hidden="true" aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0" class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor" fill="currentColor"
height="20" height="20"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -589,11 +684,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/> />
</svg> </svg>
</div> </div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child1Icon"
/>
</div>
<div <div
class="fui-TreeItemLayout__main rklbe47" class="fui-TreeItemLayout__main rklbe47"
> >
<span <span
class="" class="___1h29e9h_0000000 fz5stix"
> >
child1Label child1Label
</span> </span>
@@ -618,7 +723,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="0" tabindex="0"
> >
<div <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" data-test="TreeNode:root/child1Label"
> >
<div <div
@@ -628,6 +733,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg <svg
aria-hidden="true" aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0" class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor" fill="currentColor"
height="20" height="20"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -640,11 +746,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/> />
</svg> </svg>
</div> </div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child1Icon"
/>
</div>
<div <div
class="fui-TreeItemLayout__main rklbe47" class="fui-TreeItemLayout__main rklbe47"
> >
<span <span
class="" class="___1h29e9h_0000000 fz5stix"
> >
child1Label child1Label
</span> </span>
@@ -658,22 +774,36 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
> >
<TreeItemLayout <TreeItemLayout
actions={false} 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" data-test="TreeNode:root/child1Label"
expandIcon={<ChevronRight20Regular />} expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child1Icon"
/>
}
> >
<div <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" data-test="TreeNode:root/child1Label"
> >
<div <div
aria-hidden={true} aria-hidden={true}
className="fui-TreeItemLayout__expandIcon rh4pu5o" className="fui-TreeItemLayout__expandIcon rh4pu5o"
> >
<ChevronRight20Regular> <ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
>
<svg <svg
aria-hidden={true} aria-hidden={true}
className="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0" className="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor" fill="currentColor"
height="20" height="20"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -687,11 +817,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</svg> </svg>
</ChevronRight20Regular> </ChevronRight20Regular>
</div> </div>
<div
aria-hidden={true}
className="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child1Icon"
/>
</div>
<div <div
className="fui-TreeItemLayout__main rklbe47" className="fui-TreeItemLayout__main rklbe47"
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
child1Label child1Label
</span> </span>
@@ -699,7 +839,8 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</div> </div>
</TreeItemLayout> </TreeItemLayout>
<Tree <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 <TreeProvider
value={ value={
@@ -772,6 +913,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg <svg
aria-hidden="true" aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0" class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor" fill="currentColor"
height="20" height="20"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -790,7 +932,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
"itemType": "branch", "itemType": "branch",
"layoutRef": { "layoutRef": {
"current": <div "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" data-test="TreeNode:root/child2LoadingLabel"
> >
<div <div
@@ -800,6 +942,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg <svg
aria-hidden="true" aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0" class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor" fill="currentColor"
height="20" height="20"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -812,11 +955,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/> />
</svg> </svg>
</div> </div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child2LoadingIcon"
/>
</div>
<div <div
class="fui-TreeItemLayout__main rklbe47" class="fui-TreeItemLayout__main rklbe47"
> >
<span <span
class="" class="___1h29e9h_0000000 fz5stix"
> >
child2LoadingLabel child2LoadingLabel
</span> </span>
@@ -841,7 +994,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="-1" tabindex="-1"
> >
<div <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" data-test="TreeNode:root/child2LoadingLabel"
> >
<div <div
@@ -851,6 +1004,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg <svg
aria-hidden="true" aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0" class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor" fill="currentColor"
height="20" height="20"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -863,11 +1017,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/> />
</svg> </svg>
</div> </div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child2LoadingIcon"
/>
</div>
<div <div
class="fui-TreeItemLayout__main rklbe47" class="fui-TreeItemLayout__main rklbe47"
> >
<span <span
class="" class="___1h29e9h_0000000 fz5stix"
> >
child2LoadingLabel child2LoadingLabel
</span> </span>
@@ -881,22 +1045,36 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
> >
<TreeItemLayout <TreeItemLayout
actions={false} 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" data-test="TreeNode:root/child2LoadingLabel"
expandIcon={<ChevronRight20Regular />} expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child2LoadingIcon"
/>
}
> >
<div <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" data-test="TreeNode:root/child2LoadingLabel"
> >
<div <div
aria-hidden={true} aria-hidden={true}
className="fui-TreeItemLayout__expandIcon rh4pu5o" className="fui-TreeItemLayout__expandIcon rh4pu5o"
> >
<ChevronRight20Regular> <ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
>
<svg <svg
aria-hidden={true} aria-hidden={true}
className="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0" className="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor" fill="currentColor"
height="20" height="20"
viewBox="0 0 20 20" viewBox="0 0 20 20"
@@ -910,11 +1088,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</svg> </svg>
</ChevronRight20Regular> </ChevronRight20Regular>
</div> </div>
<div
aria-hidden={true}
className="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child2LoadingIcon"
/>
</div>
<div <div
className="fui-TreeItemLayout__main rklbe47" className="fui-TreeItemLayout__main rklbe47"
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
child2LoadingLabel child2LoadingLabel
</span> </span>
@@ -999,7 +1187,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
"itemType": "leaf", "itemType": "leaf",
"layoutRef": { "layoutRef": {
"current": <div "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" data-test="TreeNode:root/child3ExpandingLabel"
> >
<div <div
@@ -1019,11 +1207,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</span> </span>
</div> </div>
</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 <div
class="fui-TreeItemLayout__main rklbe47" class="fui-TreeItemLayout__main rklbe47"
> >
<span <span
class="" class="___1h29e9h_0000000 fz5stix"
> >
child3ExpandingLabel child3ExpandingLabel
</span> </span>
@@ -1047,7 +1245,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="-1" tabindex="-1"
> >
<div <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" data-test="TreeNode:root/child3ExpandingLabel"
> >
<div <div
@@ -1067,11 +1265,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</span> </span>
</div> </div>
</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 <div
class="fui-TreeItemLayout__main rklbe47" class="fui-TreeItemLayout__main rklbe47"
> >
<span <span
class="" class="___1h29e9h_0000000 fz5stix"
> >
child3ExpandingLabel child3ExpandingLabel
</span> </span>
@@ -1085,9 +1293,9 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
> >
<TreeItemLayout <TreeItemLayout
actions={false} 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" data-test="TreeNode:root/child3ExpandingLabel"
expandIcon={ iconBefore={
<img <img
alt="" alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b" className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
@@ -1096,12 +1304,12 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
} }
> >
<div <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" data-test="TreeNode:root/child3ExpandingLabel"
> >
<div <div
aria-hidden={true} aria-hidden={true}
className="fui-TreeItemLayout__expandIcon rh4pu5o" className="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
> >
<img <img
alt="" alt=""
@@ -1113,7 +1321,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
className="fui-TreeItemLayout__main rklbe47" className="fui-TreeItemLayout__main rklbe47"
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
child3ExpandingLabel child3ExpandingLabel
</span> </span>
@@ -1144,9 +1352,9 @@ exports[`TreeNodeComponent renders a loading spinner if the node is loading: loa
> >
<TreeItemLayout <TreeItemLayout
actions={false} 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" data-test="TreeNode:root"
expandIcon={ iconBefore={
<img <img
alt="" alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b" className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
@@ -1155,7 +1363,7 @@ exports[`TreeNodeComponent renders a loading spinner if the node is loading: loa
} }
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
rootLabel rootLabel
</span> </span>
@@ -1173,16 +1381,23 @@ exports[`TreeNodeComponent renders a loading spinner if the node is loading: loa
> >
<TreeItemLayout <TreeItemLayout
actions={false} 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" data-test="TreeNode:root"
expandIcon={ expandIcon={
<Spinner <Spinner
size="extra-tiny" size="extra-tiny"
/> />
} }
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
}
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
rootLabel rootLabel
</span> </span>
@@ -1200,12 +1415,23 @@ exports[`TreeNodeComponent renders a node as expandable if it has empty, but def
> >
<TreeItemLayout <TreeItemLayout
actions={false} 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" data-test="TreeNode:root"
expandIcon={<ChevronRight20Regular />} expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
}
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
rootLabel rootLabel
</span> </span>
@@ -1269,9 +1495,9 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
"className": "___1r8p62d_0000000 f1xg1ack f1e31b4d", "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" data-test="TreeNode:root"
expandIcon={ iconBefore={
<img <img
alt="" alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b" className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
@@ -1280,7 +1506,7 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
} }
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
rootLabel rootLabel
</span> </span>
@@ -1319,9 +1545,9 @@ exports[`TreeNodeComponent renders a single node 1`] = `
> >
<TreeItemLayout <TreeItemLayout
actions={false} 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" data-test="TreeNode:root"
expandIcon={ iconBefore={
<img <img
alt="" alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b" className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
@@ -1330,7 +1556,7 @@ exports[`TreeNodeComponent renders a single node 1`] = `
} }
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
rootLabel rootLabel
</span> </span>
@@ -1348,9 +1574,9 @@ exports[`TreeNodeComponent renders an icon if the node has one 1`] = `
> >
<TreeItemLayout <TreeItemLayout
actions={false} 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" data-test="TreeNode:root"
expandIcon={ iconBefore={
<img <img
alt="" alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b" className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
@@ -1359,7 +1585,7 @@ exports[`TreeNodeComponent renders an icon if the node has one 1`] = `
} }
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
rootLabel rootLabel
</span> </span>
@@ -1377,18 +1603,30 @@ exports[`TreeNodeComponent renders selected parent node as selected if no descen
> >
<TreeItemLayout <TreeItemLayout
actions={false} 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" data-test="TreeNode:root"
expandIcon={<ChevronRight20Regular />} expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
}
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
rootLabel rootLabel
</span> </span>
</TreeItemLayout> </TreeItemLayout>
<Tree <Tree
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n" className="___17a32do_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
data-test="Tree:root"
> >
<TreeNodeComponent <TreeNodeComponent
key="child1Label" key="child1Label"
@@ -1448,18 +1686,30 @@ exports[`TreeNodeComponent renders selected parent node as unselected if any des
> >
<TreeItemLayout <TreeItemLayout
actions={false} 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" data-test="TreeNode:root"
expandIcon={<ChevronRight20Regular />} expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
}
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
rootLabel rootLabel
</span> </span>
</TreeItemLayout> </TreeItemLayout>
<Tree <Tree
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n" className="___17a32do_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
data-test="Tree:root"
> >
<TreeNodeComponent <TreeNodeComponent
key="child1Label" key="child1Label"
@@ -1520,9 +1770,9 @@ exports[`TreeNodeComponent renders single selected leaf node as selected 1`] = `
> >
<TreeItemLayout <TreeItemLayout
actions={false} 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" data-test="TreeNode:root"
expandIcon={ iconBefore={
<img <img
alt="" alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b" className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
@@ -1531,7 +1781,7 @@ exports[`TreeNodeComponent renders single selected leaf node as selected 1`] = `
} }
> >
<span <span
className="" className="___1h29e9h_0000000 fz5stix"
> >
rootLabel rootLabel
</span> </span>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -131,6 +131,7 @@ export class NotificationConsoleComponent extends React.Component<
</div> </div>
<div <div
className="expandCollapseButton" className="expandCollapseButton"
data-test="NotificationConsole/ExpandCollapseButton"
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-label={"console button" + (this.props.isConsoleExpanded ? " expanded" : " collapsed")} aria-label={"console button" + (this.props.isConsoleExpanded ? " expanded" : " collapsed")}
@@ -147,7 +148,7 @@ export class NotificationConsoleComponent extends React.Component<
height={this.props.isConsoleExpanded ? "auto" : 0} height={this.props.isConsoleExpanded ? "auto" : 0}
onAnimationEnd={this.onConsoleWasExpanded} onAnimationEnd={this.onConsoleWasExpanded}
> >
<div className="notificationConsoleContents"> <div data-test="NotificationConsole/Contents" className="notificationConsoleContents">
<div className="notificationConsoleControls"> <div className="notificationConsoleControls">
<Dropdown <Dropdown
label="Filter:" label="Filter:"

View File

@@ -74,6 +74,7 @@ exports[`NotificationConsoleComponent renders the console 1`] = `
aria-expanded={true} aria-expanded={true}
aria-label="console button collapsed" aria-label="console button collapsed"
className="expandCollapseButton" className="expandCollapseButton"
data-test="NotificationConsole/ExpandCollapseButton"
role="button" role="button"
tabIndex={0} tabIndex={0}
> >
@@ -109,6 +110,7 @@ exports[`NotificationConsoleComponent renders the console 1`] = `
> >
<div <div
className="notificationConsoleContents" className="notificationConsoleContents"
data-test="NotificationConsole/Contents"
> >
<div <div
className="notificationConsoleControls" className="notificationConsoleControls"
@@ -245,6 +247,7 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
aria-expanded={true} aria-expanded={true}
aria-label="console button collapsed" aria-label="console button collapsed"
className="expandCollapseButton" className="expandCollapseButton"
data-test="NotificationConsole/ExpandCollapseButton"
role="button" role="button"
tabIndex={0} tabIndex={0}
> >
@@ -280,6 +283,7 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
> >
<div <div
className="notificationConsoleContents" className="notificationConsoleContents"
data-test="NotificationConsole/Contents"
> >
<div <div
className="notificationConsoleControls" className="notificationConsoleControls"

View File

@@ -1,13 +1,13 @@
import { clear, collectionWasOpened, getItems, Type } from "Explorer/MostRecentActivity/MostRecentActivity";
import { observable } from "knockout"; import { observable } from "knockout";
import { mostRecentActivity } from "./MostRecentActivity";
describe("MostRecentActivity", () => { describe("MostRecentActivity", () => {
const accountId = "some account"; const accountName = "some account";
beforeEach(() => mostRecentActivity.clear(accountId)); beforeEach(() => clear(accountName));
it("Has no items at first", () => { it("Has no items at first", () => {
expect(mostRecentActivity.getItems(accountId)).toStrictEqual([]); expect(getItems(accountName)).toStrictEqual([]);
}); });
it("Can record collections being opened", () => { it("Can record collections being opened", () => {
@@ -18,9 +18,9 @@ describe("MostRecentActivity", () => {
databaseId, databaseId,
}; };
mostRecentActivity.collectionWasOpened(accountId, collection); collectionWasOpened(accountName, collection);
const activity = mostRecentActivity.getItems(accountId); const activity = getItems(accountName);
expect(activity).toEqual([ expect(activity).toEqual([
expect.objectContaining({ expect.objectContaining({
collectionId, collectionId,
@@ -29,58 +29,24 @@ describe("MostRecentActivity", () => {
]); ]);
}); });
it("Can record notebooks being opened", () => { it("Does not store duplicate entries", () => {
const name = "some notebook"; const collectionId = "some collection";
const path = "some path"; const databaseId = "some database";
const notebook = { name, path }; const collection = {
id: observable(collectionId),
databaseId,
};
mostRecentActivity.notebookWasItemOpened(accountId, notebook); collectionWasOpened(accountName, collection);
collectionWasOpened(accountName, collection);
const activity = mostRecentActivity.getItems(accountId); const activity = getItems(accountName);
expect(activity).toEqual([expect.objectContaining(notebook)]); expect(activity).toEqual([
}); expect.objectContaining({
type: Type.OpenCollection,
it("Filters out duplicates", () => { collectionId,
const name = "some notebook"; databaseId,
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));
}); });
}); });

View File

@@ -1,10 +1,10 @@
import { AppStateComponentNames, deleteState, loadState, saveState } from "Shared/AppStatePersistenceUtility";
import { CollectionBase } from "../../Contracts/ViewModels"; import { CollectionBase } from "../../Contracts/ViewModels";
import { StorageKey, LocalStorageUtility } from "../../Shared/StorageUtility"; import { LocalStorageUtility, StorageKey } from "../../Shared/StorageUtility";
import { NotebookContentItem } from "../Notebook/NotebookContentItem";
export enum Type { export enum Type {
OpenCollection, OpenCollection = "OpenCollection",
OpenNotebook, OpenNotebook = "OpenNotebook",
} }
export interface OpenNotebookItem { export interface OpenNotebookItem {
@@ -21,158 +21,174 @@ export interface OpenCollectionItem {
type Item = OpenNotebookItem | OpenCollectionItem; type Item = OpenNotebookItem | OpenCollectionItem;
// Update schemaVersion if you are going to change this interface const itemsMaxNumber: number = 5;
interface StoredData {
schemaVersion: string;
itemsMap: { [accountId: string]: Item[] }; // FIFO
}
/** /**
* Stores most recent activity * Migrate old data to new AppState
*/ */
class MostRecentActivity { const migrateOldData = () => {
private static readonly schemaVersion: string = "2"; if (LocalStorageUtility.hasItem(StorageKey.MostRecentActivity)) {
private static itemsMaxNumber: number = 5; const oldDataSchemaVersion: string = "2";
private storedData: StoredData; const rawData = LocalStorageUtility.getEntryString(StorageKey.MostRecentActivity);
constructor() { if (rawData) {
// Retrieve from local storage const oldData = JSON.parse(rawData);
if (LocalStorageUtility.hasItem(StorageKey.MostRecentActivity)) { if (oldData.schemaVersion === oldDataSchemaVersion) {
const rawData = LocalStorageUtility.getEntryString(StorageKey.MostRecentActivity); const itemsMap: Record<string, Item[]> = oldData.itemsMap;
Object.keys(itemsMap).forEach((accountId: string) => {
if (!rawData) { const accountName = accountId.split("/").pop();
this.storedData = MostRecentActivity.createEmptyData(); if (accountName) {
} else { saveState(
try { {
this.storedData = JSON.parse(rawData); componentName: AppStateComponentNames.MostRecentActivity,
} catch (e) { globalAccountName: accountName,
console.error("Unable to parse stored most recent activity. Use empty data:", rawData); },
this.storedData = MostRecentActivity.createEmptyData(); itemsMap[accountId].map((item) => {
} if ((item.type as unknown as number) === 0) {
item.type = Type.OpenCollection;
// If version doesn't match or schema broke, nuke it! } else if ((item.type as unknown as number) === 1) {
if ( item.type = Type.OpenNotebook;
!this.storedData.hasOwnProperty("schemaVersion") || }
this.storedData["schemaVersion"] !== MostRecentActivity.schemaVersion return item;
) { }),
LocalStorageUtility.removeEntry(StorageKey.MostRecentActivity); );
this.storedData = MostRecentActivity.createEmptyData(); }
} });
} }
} else {
this.storedData = MostRecentActivity.createEmptyData();
} }
for (let p in this.storedData.itemsMap) { // Remove old data
this.cleanupItems(p); 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 { if (index !== -1) {
return { result.splice(index, 1);
schemaVersion: MostRecentActivity.schemaVersion,
itemsMap: {},
};
} }
private static isEmpty(object: any) { return result;
return Object.keys(object).length === 0 && object.constructor === Object; };
/**
* Remove unknown types
* Limit items to max number
* Modifies the array.
*/
const cleanupItems = (items: Item[], accountName: string): Item[] => {
if (accountName === undefined) {
return [];
} }
private saveToLocalStorage() { const itemsArray = items.filter((item) => item.type in Type).slice(0, itemsMaxNumber);
if (MostRecentActivity.isEmpty(this.storedData.itemsMap)) { if (itemsArray.length === 0) {
if (LocalStorageUtility.hasItem(StorageKey.MostRecentActivity)) { deleteState({
LocalStorageUtility.removeEntry(StorageKey.MostRecentActivity); componentName: AppStateComponentNames.MostRecentActivity,
} globalAccountName: accountName,
// 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,
}); });
} }
return itemsArray;
};
public notebookWasItemOpened(accountId: string, { name, path }: Pick<NotebookContentItem, "name" | "path">) { migrateOldData();
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();

View File

@@ -1,5 +1,6 @@
import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility"; import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility";
import { PhoenixClient } from "Phoenix/PhoenixClient"; import { PhoenixClient } from "Phoenix/PhoenixClient";
import { useNewPortalBackendEndpoint } from "Utils/EndpointUtils";
import { cloneDeep } from "lodash"; import { cloneDeep } from "lodash";
import create, { UseStore } from "zustand"; import create, { UseStore } from "zustand";
import { AuthType } from "../../AuthType"; import { AuthType } from "../../AuthType";
@@ -127,7 +128,9 @@ export const useNotebook: UseStore<NotebookState> = create((set, get) => ({
userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo" userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo"
? databaseAccount?.location ? databaseAccount?.location
: databaseAccount?.properties?.writeLocations?.[0]?.locationName.toLowerCase(); : 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(); const authorizationHeader = getAuthorizationHeader();
try { try {
const response = await fetch(disallowedLocationsUri, { 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. the query builder.
</Text> </Text>
<Text style={{ fontSize: 13, fontWeight: 600, marginTop: 24 }}>Database Id</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, fontWeight: 600, marginTop: 16 }}>Database throughput (autoscale)</Text>
<Text style={{ fontSize: 13 }}>Autoscale</Text> <Text style={{ fontSize: 13 }}>Autoscale</Text>
<Text style={{ fontSize: 13, fontWeight: 600, marginTop: 16 }}>Database Max RU/s</Text> <Text style={{ fontSize: 13, fontWeight: 600, marginTop: 16 }}>Database Max RU/s</Text>

View File

@@ -1,4 +1,5 @@
import { MinimalQueryIterator } from "Common/IteratorUtilities"; import { MinimalQueryIterator } from "Common/IteratorUtilities";
import QueryError from "Common/QueryError";
import { QueryResults } from "Contracts/ViewModels"; import { QueryResults } from "Contracts/ViewModels";
import { CopilotMessage } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces"; import { CopilotMessage } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
import { guid } from "Explorer/Tables/Utilities"; import { guid } from "Explorer/Tables/Utilities";
@@ -28,7 +29,7 @@ const CopilotProvider = ({ children }: { children: React.ReactNode }): JSX.Eleme
showSamplePrompts: false, showSamplePrompts: false,
queryIterator: undefined, queryIterator: undefined,
queryResults: undefined, queryResults: undefined,
errorMessage: "", errors: [],
isSamplePromptsOpen: false, isSamplePromptsOpen: false,
showPromptTeachingBubble: true, showPromptTeachingBubble: true,
showDeletePopup: false, showDeletePopup: false,
@@ -64,7 +65,7 @@ const CopilotProvider = ({ children }: { children: React.ReactNode }): JSX.Eleme
setShowSamplePrompts: (showSamplePrompts: boolean) => set({ showSamplePrompts }), setShowSamplePrompts: (showSamplePrompts: boolean) => set({ showSamplePrompts }),
setQueryIterator: (queryIterator: MinimalQueryIterator | undefined) => set({ queryIterator }), setQueryIterator: (queryIterator: MinimalQueryIterator | undefined) => set({ queryIterator }),
setQueryResults: (queryResults: QueryResults | undefined) => set({ queryResults }), setQueryResults: (queryResults: QueryResults | undefined) => set({ queryResults }),
setErrorMessage: (errorMessage: string) => set({ errorMessage }), setErrors: (errors: QueryError[]) => set({ errors }),
setIsSamplePromptsOpen: (isSamplePromptsOpen: boolean) => set({ isSamplePromptsOpen }), setIsSamplePromptsOpen: (isSamplePromptsOpen: boolean) => set({ isSamplePromptsOpen }),
setShowPromptTeachingBubble: (showPromptTeachingBubble: boolean) => set({ showPromptTeachingBubble }), setShowPromptTeachingBubble: (showPromptTeachingBubble: boolean) => set({ showPromptTeachingBubble }),
setShowDeletePopup: (showDeletePopup: boolean) => set({ showDeletePopup }), setShowDeletePopup: (showDeletePopup: boolean) => set({ showDeletePopup }),

View File

@@ -18,8 +18,9 @@ import {
Text, Text,
TextField, TextField,
} from "@fluentui/react"; } from "@fluentui/react";
import { HttpStatusCodes } from "Common/Constants"; import { HttpStatusCodes, NormalizedEventKey } from "Common/Constants";
import { handleError } from "Common/ErrorHandlingUtils"; import { handleError } from "Common/ErrorHandlingUtils";
import QueryError, { QueryErrorSeverity } from "Common/QueryError";
import { createUri } from "Common/UrlUtility"; import { createUri } from "Common/UrlUtility";
import { CopyPopup } from "Explorer/QueryCopilot/Popup/CopyPopup"; import { CopyPopup } from "Explorer/QueryCopilot/Popup/CopyPopup";
import { DeletePopup } from "Explorer/QueryCopilot/Popup/DeletePopup"; import { DeletePopup } from "Explorer/QueryCopilot/Popup/DeletePopup";
@@ -27,6 +28,8 @@ import {
SuggestedPrompt, SuggestedPrompt,
getSampleDatabaseSuggestedPrompts, getSampleDatabaseSuggestedPrompts,
getSuggestedPrompts, getSuggestedPrompts,
readPromptHistory,
savePromptHistory,
} from "Explorer/QueryCopilot/QueryCopilotUtilities"; } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import { SubmitFeedback, allocatePhoenixContainer } from "Explorer/QueryCopilot/Shared/QueryCopilotClient"; import { SubmitFeedback, allocatePhoenixContainer } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { GenerateSQLQueryResponse, QueryCopilotProps } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces"; import { GenerateSQLQueryResponse, QueryCopilotProps } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
@@ -34,7 +37,7 @@ import { SamplePrompts, SamplePromptsProps } from "Explorer/QueryCopilot/Shared/
import { Action } from "Shared/Telemetry/TelemetryConstants"; import { Action } from "Shared/Telemetry/TelemetryConstants";
import { userContext } from "UserContext"; import { userContext } from "UserContext";
import { useQueryCopilot } from "hooks/useQueryCopilot"; import { useQueryCopilot } from "hooks/useQueryCopilot";
import React, { useRef, useState } from "react"; import React, { useMemo, useRef, useState } from "react";
import HintIcon from "../../../images/Hint.svg"; import HintIcon from "../../../images/Hint.svg";
import RecentIcon from "../../../images/Recent.svg"; import RecentIcon from "../../../images/Recent.svg";
import errorIcon from "../../../images/close-black.svg"; import errorIcon from "../../../images/close-black.svg";
@@ -70,6 +73,8 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
}: QueryCopilotPromptProps): JSX.Element => { }: QueryCopilotPromptProps): JSX.Element => {
const [copilotTeachingBubbleVisible, setCopilotTeachingBubbleVisible] = useState<boolean>(false); const [copilotTeachingBubbleVisible, setCopilotTeachingBubbleVisible] = useState<boolean>(false);
const inputEdited = useRef(false); const inputEdited = useRef(false);
const itemRefs = useRef([]);
const searchInputRef = useRef(null);
const { const {
openFeedbackModal, openFeedbackModal,
hideFeedbackModalForLikedQueries, hideFeedbackModalForLikedQueries,
@@ -105,10 +110,10 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
setShowErrorMessageBar, setShowErrorMessageBar,
setGeneratedQueryComments, setGeneratedQueryComments,
setQueryResults, setQueryResults,
setErrorMessage, setErrors,
errorMessage, errors,
} = useCopilotStore(); } = useCopilotStore();
const [focusedIndex, setFocusedIndex] = useState(-1);
const sampleProps: SamplePromptsProps = { const sampleProps: SamplePromptsProps = {
isSamplePromptsOpen: isSamplePromptsOpen, isSamplePromptsOpen: isSamplePromptsOpen,
setIsSamplePromptsOpen: setIsSamplePromptsOpen, setIsSamplePromptsOpen: setIsSamplePromptsOpen,
@@ -133,14 +138,13 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
}; };
const isSampleCopilotActive = useSelectedNode.getState().isQueryCopilotCollectionSelected(); const isSampleCopilotActive = useSelectedNode.getState().isQueryCopilotCollectionSelected();
const cachedHistoriesString = localStorage.getItem(`${userContext.databaseAccount?.id}-queryCopilotHistories`); const [histories, setHistories] = useState<string[]>(() => readPromptHistory(userContext.databaseAccount));
const cachedHistories = cachedHistoriesString?.split("|");
const [histories, setHistories] = useState<string[]>(cachedHistories || []);
const suggestedPrompts: SuggestedPrompt[] = isSampleCopilotActive const suggestedPrompts: SuggestedPrompt[] = isSampleCopilotActive
? getSampleDatabaseSuggestedPrompts() ? getSampleDatabaseSuggestedPrompts()
: getSuggestedPrompts(); : getSuggestedPrompts();
const [filteredHistories, setFilteredHistories] = useState<string[]>(histories); const [filteredHistories, setFilteredHistories] = useState<string[]>(histories);
const [filteredSuggestedPrompts, setFilteredSuggestedPrompts] = useState<SuggestedPrompt[]>(suggestedPrompts); const [filteredSuggestedPrompts, setFilteredSuggestedPrompts] = useState<SuggestedPrompt[]>(suggestedPrompts);
const { UpArrow, DownArrow, Enter } = NormalizedEventKey;
const handleUserPromptChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleUserPromptChange = (event: React.ChangeEvent<HTMLInputElement>) => {
inputEdited.current = true; inputEdited.current = true;
@@ -168,7 +172,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
const newHistories = [formattedUserPrompt, ...updatedHistories.slice(0, 2)]; const newHistories = [formattedUserPrompt, ...updatedHistories.slice(0, 2)];
setHistories(newHistories); setHistories(newHistories);
localStorage.setItem(`${userContext.databaseAccount.id}-queryCopilotHistories`, newHistories.join("|")); savePromptHistory(userContext.databaseAccount, newHistories);
}; };
const resetMessageStates = (): void => { const resetMessageStates = (): void => {
@@ -179,7 +183,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
const resetQueryResults = (): void => { const resetQueryResults = (): void => {
setQueryResults(null); setQueryResults(null);
setErrorMessage(""); setErrors([]);
}; };
const generateSQLQuery = async (): Promise<void> => { const generateSQLQuery = async (): Promise<void> => {
@@ -243,7 +247,12 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
handleError(JSON.stringify(generateSQLQueryResponse), "copilotTooManyRequestError"); handleError(JSON.stringify(generateSQLQueryResponse), "copilotTooManyRequestError");
useTabs.getState().setIsQueryErrorThrown(true); useTabs.getState().setIsQueryErrorThrown(true);
setShowErrorMessageBar(true); setShowErrorMessageBar(true);
setErrorMessage("Ratelimit exceeded 5 per 1 minute. Please try again after sometime"); setErrors([
new QueryError(
"Ratelimit exceeded 5 per 1 minute. Please try again after sometime",
QueryErrorSeverity.Error,
),
]);
TelemetryProcessor.traceFailure(Action.QueryGenerationFromCopilotPrompt, { TelemetryProcessor.traceFailure(Action.QueryGenerationFromCopilotPrompt, {
databaseName: databaseId, databaseName: databaseId,
collectionId: containerId, collectionId: containerId,
@@ -301,7 +310,38 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
return "Content is updated"; return "Content is updated";
} }
}; };
const openSamplePrompts = () => {
inputEdited.current = true;
setShowSamplePrompts(true);
};
const totalSuggestions = useMemo(
() => [...filteredSuggestedPrompts, ...filteredHistories],
[filteredSuggestedPrompts, filteredHistories],
);
const handleKeyDownForInput = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === DownArrow) {
setFocusedIndex(0);
itemRefs.current[0]?.current?.focus();
} else if (event.key === Enter && userPrompt) {
inputEdited.current = true;
startGenerateQueryProcess();
}
};
const handleKeyDownForItem = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === UpArrow && focusedIndex > 0) {
itemRefs.current[focusedIndex - 1].current?.focus();
setFocusedIndex((prevIndex) => prevIndex - 1);
} else if (event.key === DownArrow && focusedIndex < totalSuggestions.length - 1) {
itemRefs.current[focusedIndex + 1].current?.focus();
setFocusedIndex((prevIndex) => prevIndex + 1);
}
};
React.useEffect(() => {
itemRefs.current = totalSuggestions.map(() => React.createRef());
}, [totalSuggestions]);
React.useEffect(() => { React.useEffect(() => {
useTabs.getState().setIsQueryErrorThrown(false); useTabs.getState().setIsQueryErrorThrown(false);
}, []); }, []);
@@ -331,23 +371,14 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
id="naturalLanguageInput" id="naturalLanguageInput"
value={userPrompt} value={userPrompt}
onChange={handleUserPromptChange} onChange={handleUserPromptChange}
onClick={() => { onClick={openSamplePrompts}
inputEdited.current = true; onFocus={() => setShowSamplePrompts(true)}
setShowSamplePrompts(true); elementRef={searchInputRef}
}} onKeyDown={handleKeyDownForInput}
onKeyDown={(e) => {
if (e.key === "Enter" && userPrompt) {
inputEdited.current = true;
startGenerateQueryProcess();
}
}}
style={{ lineHeight: 30 }} style={{ lineHeight: 30 }}
styles={{ styles={{
root: { width: "100%" }, root: { width: "100%" },
suffix: { suffix: { background: "none", padding: 0 },
background: "none",
padding: 0,
},
fieldGroup: { fieldGroup: {
borderRadius: 4, borderRadius: 4,
borderColor: "#D1D1D1", borderColor: "#D1D1D1",
@@ -360,7 +391,8 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
}, },
}} }}
disabled={isGeneratingQuery} disabled={isGeneratingQuery}
autoComplete="off" autoComplete="list"
aria-expanded={showSamplePrompts}
placeholder="Ask a question in natural language and well generate the query for you." placeholder="Ask a question in natural language and well generate the query for you."
aria-labelledby="copilot-textfield-label" aria-labelledby="copilot-textfield-label"
onRenderSuffix={() => { onRenderSuffix={() => {
@@ -432,6 +464,8 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
setShowSamplePrompts(false); setShowSamplePrompts(false);
inputEdited.current = true; inputEdited.current = true;
}} }}
elementRef={itemRefs.current[i]}
onKeyDown={handleKeyDownForItem}
onRenderIcon={() => <Image src={RecentIcon} styles={{ root: { overflow: "unset" } }} />} onRenderIcon={() => <Image src={RecentIcon} styles={{ root: { overflow: "unset" } }} />}
styles={promptStyles} styles={promptStyles}
> >
@@ -454,14 +488,16 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
> >
Suggested Prompts Suggested Prompts
</Text> </Text>
{filteredSuggestedPrompts.map((prompt) => ( {filteredSuggestedPrompts.map((prompt, index) => (
<DefaultButton <DefaultButton
key={prompt.id} key={prompt.id}
elementRef={itemRefs.current[filteredHistories.length + index]}
onClick={() => { onClick={() => {
setUserPrompt(prompt.text); setUserPrompt(prompt.text);
setShowSamplePrompts(false); setShowSamplePrompts(false);
inputEdited.current = true; inputEdited.current = true;
}} }}
onKeyDown={handleKeyDownForItem}
onRenderIcon={() => <Image src={HintIcon} />} onRenderIcon={() => <Image src={HintIcon} />}
styles={promptStyles} styles={promptStyles}
> >
@@ -514,7 +550,9 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
</Link> </Link>
{showErrorMessageBar && ( {showErrorMessageBar && (
<MessageBar messageBarType={MessageBarType.error}> <MessageBar messageBarType={MessageBarType.error}>
{errorMessage ? errorMessage : "We ran into an error and were not able to execute query."} {errors.length > 0
? errors[0].message
: "We ran into an error and were not able to execute query."}
</MessageBar> </MessageBar>
)} )}
{showInvalidQueryMessageBar && ( {showInvalidQueryMessageBar && (

View File

@@ -1,10 +1,39 @@
import { shallow } from "enzyme"; import { shallow } from "enzyme";
import { CopilotSubComponentNames } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import React from "react"; import React from "react";
import { AppStateComponentNames, StorePath } from "Shared/AppStatePersistenceUtility";
import { updateUserContext } from "UserContext";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
import { QueryCopilotTab } from "./QueryCopilotTab"; import { QueryCopilotTab } from "./QueryCopilotTab";
describe("Query copilot tab snapshot test", () => { describe("Query copilot tab snapshot test", () => {
it("should render with initial input", () => { 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()} />); const wrapper = shallow(<QueryCopilotTab explorer={new Explorer()} />);
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });

View File

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

View File

@@ -90,7 +90,7 @@ describe("QueryCopilotUtilities", () => {
// Mock the items.query method to return the mockResult // 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); ).mockReturnValue(mockResult);
const result = querySampleDocuments(query, options); const result = querySampleDocuments(query, options);
@@ -119,10 +119,10 @@ describe("QueryCopilotUtilities", () => {
const result = await readSampleDocument(documentId); const result = await readSampleDocument(documentId);
expect(sampleDataClient).toHaveBeenCalled(); expect(sampleDataClient).toHaveBeenCalled();
expect(sampleDataClient().database).toHaveBeenCalledWith("CopilotSampleDb"); expect(sampleDataClient().database).toHaveBeenCalledWith("CopilotSampleDB");
expect(sampleDataClient().database("CopilotSampleDb").container).toHaveBeenCalledWith("SampleContainer"); expect(sampleDataClient().database("CopilotSampleDB").container).toHaveBeenCalledWith("SampleContainer");
expect( expect(
sampleDataClient().database("CopilotSampleDb").container("SampleContainer").item("DocumentId", undefined).read, sampleDataClient().database("CopilotSampleDB").container("SampleContainer").item("DocumentId", undefined).read,
).toHaveBeenCalled(); ).toHaveBeenCalled();
expect(result).toEqual(expectedResponse); expect(result).toEqual(expectedResponse);
}); });
@@ -144,10 +144,10 @@ describe("QueryCopilotUtilities", () => {
await expect(readSampleDocument(documentId)).rejects.toStrictEqual(errorMock); await expect(readSampleDocument(documentId)).rejects.toStrictEqual(errorMock);
expect(sampleDataClient).toHaveBeenCalled(); expect(sampleDataClient).toHaveBeenCalled();
expect(sampleDataClient().database).toHaveBeenCalledWith("CopilotSampleDb"); expect(sampleDataClient().database).toHaveBeenCalledWith("CopilotSampleDB");
expect(sampleDataClient().database("CopilotSampleDb").container).toHaveBeenCalledWith("SampleContainer"); expect(sampleDataClient().database("CopilotSampleDB").container).toHaveBeenCalledWith("SampleContainer");
expect( expect(
sampleDataClient().database("CopilotSampleDb").container("SampleContainer").item("DocumentId", undefined).read, sampleDataClient().database("CopilotSampleDB").container("SampleContainer").item("DocumentId", undefined).read,
).toHaveBeenCalled(); ).toHaveBeenCalled();
expect(handleError).toHaveBeenCalledWith(errorMock, "ReadDocument", expect.any(String)); 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 { sampleDataClient } from "Common/SampleDataClient";
import { getPartitionKeyValue } from "Common/dataAccess/getPartitionKeyValue"; import { getPartitionKeyValue } from "Common/dataAccess/getPartitionKeyValue";
import { getCommonQueryOptions } from "Common/dataAccess/queryDocuments"; import { getCommonQueryOptions } from "Common/dataAccess/queryDocuments";
import { DatabaseAccount } from "Contracts/DataModels";
import DocumentId from "Explorer/Tree/DocumentId"; import DocumentId from "Explorer/Tree/DocumentId";
import { AppStateComponentNames, loadState, saveState } from "Shared/AppStatePersistenceUtility";
import { logConsoleProgress } from "Utils/NotificationConsoleUtils"; import { logConsoleProgress } from "Utils/NotificationConsoleUtils";
import * as StringUtility from "../../Shared/StringUtility";
export interface SuggestedPrompt { export interface SuggestedPrompt {
id: number; id: number;
@@ -54,3 +57,110 @@ export const getSuggestedPrompts = (): SuggestedPrompt[] => {
{ id: 3, text: "Find the oldest item added to my collection" }, { 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

@@ -13,6 +13,7 @@ import {
import { getErrorMessage, getErrorStack, handleError } from "Common/ErrorHandlingUtils"; import { getErrorMessage, getErrorStack, handleError } from "Common/ErrorHandlingUtils";
import { shouldEnableCrossPartitionKey } from "Common/HeadersUtility"; import { shouldEnableCrossPartitionKey } from "Common/HeadersUtility";
import { MinimalQueryIterator } from "Common/IteratorUtilities"; import { MinimalQueryIterator } from "Common/IteratorUtilities";
import QueryError from "Common/QueryError";
import { createUri } from "Common/UrlUtility"; import { createUri } from "Common/UrlUtility";
import { queryDocumentsPage } from "Common/dataAccess/queryDocumentsPage"; import { queryDocumentsPage } from "Common/dataAccess/queryDocumentsPage";
import { configContext } from "ConfigContext"; import { configContext } from "ConfigContext";
@@ -25,7 +26,7 @@ import {
import { AuthorizationTokenHeaderMetadata, QueryResults } from "Contracts/ViewModels"; import { AuthorizationTokenHeaderMetadata, QueryResults } from "Contracts/ViewModels";
import { useDialog } from "Explorer/Controls/Dialog"; import { useDialog } from "Explorer/Controls/Dialog";
import Explorer from "Explorer/Explorer"; 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 { FeedbackParams, GenerateSQLQueryResponse } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
import { Action } from "Shared/Telemetry/TelemetryConstants"; import { Action } from "Shared/Telemetry/TelemetryConstants";
import { traceFailure, traceStart, traceSuccess } from "Shared/Telemetry/TelemetryProcessor"; import { traceFailure, traceStart, traceSuccess } from "Shared/Telemetry/TelemetryProcessor";
@@ -35,7 +36,6 @@ import { useNewPortalBackendEndpoint } from "Utils/EndpointUtils";
import { queryPagesUntilContentPresent } from "Utils/QueryUtils"; import { queryPagesUntilContentPresent } from "Utils/QueryUtils";
import { QueryCopilotState, useQueryCopilot } from "hooks/useQueryCopilot"; import { QueryCopilotState, useQueryCopilot } from "hooks/useQueryCopilot";
import { useTabs } from "hooks/useTabs"; import { useTabs } from "hooks/useTabs";
import * as StringUtility from "../../../Shared/StringUtility";
async function fetchWithTimeout( async function fetchWithTimeout(
url: string, url: string,
@@ -354,24 +354,23 @@ export const QueryDocumentsPerPage = async (
); );
useQueryCopilot.getState().setQueryResults(queryResults); useQueryCopilot.getState().setQueryResults(queryResults);
useQueryCopilot.getState().setErrorMessage(""); useQueryCopilot.getState().setErrors([]);
useQueryCopilot.getState().setShowErrorMessageBar(false); useQueryCopilot.getState().setShowErrorMessageBar(false);
traceSuccess(Action.ExecuteQueryGeneratedFromQueryCopilot, { traceSuccess(Action.ExecuteQueryGeneratedFromQueryCopilot, {
correlationId: useQueryCopilot.getState().correlationId, correlationId: useQueryCopilot.getState().correlationId,
}); });
} catch (error) { } catch (error) {
const isCopilotActive = StringUtility.toBoolean( const isCopilotActive = readCopilotToggleStatus(userContext.databaseAccount);
localStorage.getItem(`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`),
);
const errorMessage = getErrorMessage(error); const errorMessage = getErrorMessage(error);
traceFailure(Action.ExecuteQueryGeneratedFromQueryCopilot, { traceFailure(Action.ExecuteQueryGeneratedFromQueryCopilot, {
correlationId: useQueryCopilot.getState().correlationId, correlationId: useQueryCopilot.getState().correlationId,
errorMessage: errorMessage, errorMessage,
}); });
handleError(errorMessage, "executeQueryCopilotTab"); handleError(errorMessage, "executeQueryCopilotTab");
useTabs.getState().setIsQueryErrorThrown(true); useTabs.getState().setIsQueryErrorThrown(true);
if (isCopilotActive) { if (isCopilotActive) {
useQueryCopilot.getState().setErrorMessage(errorMessage); const queryErrors = QueryError.tryParse(error);
useQueryCopilot.getState().setErrors(queryErrors);
useQueryCopilot.getState().setShowErrorMessageBar(true); useQueryCopilot.getState().setShowErrorMessageBar(true);
} }
} finally { } finally {

View File

@@ -8,7 +8,7 @@ export const QueryCopilotResults: React.FC = (): JSX.Element => {
<QueryResultSection <QueryResultSection
isMongoDB={false} isMongoDB={false}
queryEditorContent={useQueryCopilot.getState().selectedQuery || useQueryCopilot.getState().query} queryEditorContent={useQueryCopilot.getState().selectedQuery || useQueryCopilot.getState().query}
error={useQueryCopilot.getState().errorMessage} errors={useQueryCopilot.getState().errors}
queryResults={useQueryCopilot.getState().queryResults} queryResults={useQueryCopilot.getState().queryResults}
isExecuting={useQueryCopilot.getState().isExecuting} isExecuting={useQueryCopilot.getState().isExecuting}
executeQueryDocumentsPage={(firstItemIndex: number) => executeQueryDocumentsPage={(firstItemIndex: number) =>

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 <Stack
className="tabPaneContentContainer" className="tabPaneContentContainer"
> >

View File

@@ -1,6 +1,7 @@
import { import {
Button, Button,
Menu, Menu,
MenuButton,
MenuButtonProps, MenuButtonProps,
MenuItem, MenuItem,
MenuList, MenuList,
@@ -25,7 +26,7 @@ import { getCollectionName, getDatabaseName } from "Utils/APITypeUtils";
import { Allotment, AllotmentHandle } from "allotment"; import { Allotment, AllotmentHandle } from "allotment";
import { useSidePanel } from "hooks/useSidePanel"; import { useSidePanel } from "hooks/useSidePanel";
import { debounce } from "lodash"; import { debounce } from "lodash";
import React, { useCallback, useEffect, useMemo, useRef } from "react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
const useSidebarStyles = makeStyles({ const useSidebarStyles = makeStyles({
sidebar: { sidebar: {
@@ -60,6 +61,7 @@ const useSidebarStyles = makeStyles({
alignItems: "center", alignItems: "center",
justifyItems: "center", justifyItems: "center",
width: "100%", width: "100%",
containerType: "size", // Use this container for "@container" queries below this.
...cosmosShorthands.borderBottom(), ...cosmosShorthands.borderBottom(),
}, },
loadingProgressBar: { loadingProgressBar: {
@@ -83,6 +85,18 @@ const useSidebarStyles = makeStyles({
}, },
}, },
}, },
globalCommandsMenuButton: {
display: "inline-flex",
"@container (min-width: 250px)": {
display: "none",
},
},
globalCommandsSplitButton: {
display: "none",
"@container (min-width: 250px)": {
display: "flex",
},
},
}); });
interface GlobalCommandsProps { interface GlobalCommandsProps {
@@ -99,6 +113,12 @@ interface GlobalCommand {
const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => { const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
const styles = useSidebarStyles(); 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[]>(() => { const actions = useMemo<GlobalCommand[]>(() => {
if ( if (
configContext.platform === Platform.Fabric || configContext.platform === Platform.Fabric ||
@@ -168,16 +188,22 @@ const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
{primaryAction.label} {primaryAction.label}
</Button> </Button>
) : ( ) : (
<Menu positioning="below-end"> <Menu positioning={{ target: globalCommandButton, position: "below", align: "end" }}>
<MenuTrigger disableButtonEnhancement> <MenuTrigger disableButtonEnhancement>
{(triggerProps: MenuButtonProps) => ( {(triggerProps: MenuButtonProps) => (
<SplitButton <div ref={setGlobalCommandButton}>
menuButton={{ ...triggerProps, "aria-label": "More commands" }} <SplitButton
primaryActionButton={{ onClick: onPrimaryActionClick }} menuButton={{ ...triggerProps, "aria-label": "More commands" }}
icon={primaryAction.icon} primaryActionButton={{ onClick: onPrimaryActionClick }}
> className={styles.globalCommandsSplitButton}
{primaryAction.label} icon={primaryAction.icon}
</SplitButton> >
{primaryAction.label}
</SplitButton>
<MenuButton {...triggerProps} icon={primaryAction.icon} className={styles.globalCommandsMenuButton}>
New...
</MenuButton>
</div>
)} )}
</MenuTrigger> </MenuTrigger>
<MenuPopover> <MenuPopover>
@@ -199,7 +225,7 @@ interface SidebarProps {
explorer: Explorer; explorer: Explorer;
} }
const CollapseThreshold = 50; const CollapseThreshold = 140;
export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => { export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
const styles = useSidebarStyles(); const styles = useSidebarStyles();
@@ -260,7 +286,7 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
{/* Collections Tree - Start */} {/* Collections Tree - Start */}
{hasSidebar && ( {hasSidebar && (
// When collapsed, we force the pane to 24 pixels wide and make it non-resizable. // 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)}> <CosmosFluentProvider className={mergeClasses(styles.sidebar)}>
<div className={styles.sidebarContainer}> <div className={styles.sidebarContainer}>
{loading && ( {loading && (
@@ -274,6 +300,7 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
<div className={styles.floatingControls}> <div className={styles.floatingControls}>
<button <button
type="button" type="button"
data-test="Sidebar/RefreshButton"
className={styles.floatingControlButton} className={styles.floatingControlButton}
disabled={loading} disabled={loading}
title="Refresh" title="Refresh"
@@ -313,7 +340,7 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
</CosmosFluentProvider> </CosmosFluentProvider>
</Allotment.Pane> </Allotment.Pane>
)} )}
<Allotment.Pane minSize={800}> <Allotment.Pane minSize={200}>
<Tabs explorer={explorer} /> <Tabs explorer={explorer} />
</Allotment.Pane> </Allotment.Pane>
</Allotment> </Allotment>

View File

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

View File

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

View File

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

View File

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

View File

@@ -128,8 +128,14 @@ export default class TableEntityListViewModel extends DataTableViewModel {
this.sqlQuery = ko.observable<string>("SELECT * FROM c"); this.sqlQuery = ko.observable<string>("SELECT * FROM c");
} }
public getTableEntityKeys(rowKey: string): Entities.IProperty[] { public getTableEntityKeys(rowKey: string, partitionKey: string): Entities.IProperty[] {
return [{ key: Constants.EntityKeyNames.RowKey, value: rowKey }]; 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> { 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( var oldEntityIndex: number = _.findIndex(
this.cache.data, 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); this.cache.data.splice(oldEntityIndex, 1, entity);
@@ -285,7 +292,7 @@ export default class TableEntityListViewModel extends DataTableViewModel {
entities.forEach((entity: Entities.ITableEntity) => { entities.forEach((entity: Entities.ITableEntity) => {
var cachedIndex: number = _.findIndex( var cachedIndex: number = _.findIndex(
this.cache.data, this.cache.data,
(e: Entities.ITableEntity) => e.RowKey._ === entity.RowKey._, (e: Entities.ITableEntity) => e.RowKey._ === entity.RowKey._ && e.PartitionKey._ === entity.PartitionKey._,
); );
if (cachedIndex >= 0) { if (cachedIndex >= 0) {
this.cache.data.splice(cachedIndex, 1); 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( private prefetchAndRender(
tableQuery: Entities.ITableQuery, tableQuery: Entities.ITableQuery,
tablePageStartIndex: number, tablePageStartIndex: number,

View File

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

View File

@@ -753,17 +753,11 @@ export class CassandraAPIDataClient extends TableDataClient {
CassandraProxyEndpoints.Development, CassandraProxyEndpoints.Development,
CassandraProxyEndpoints.Mpac, CassandraProxyEndpoints.Mpac,
CassandraProxyEndpoints.Prod, 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 ( return (
canAccessCassandraProxy &&
configContext.NEW_CASSANDRA_APIS?.includes(api) && configContext.NEW_CASSANDRA_APIS?.includes(api) &&
activeCassandraProxyEndpoints.includes(configContext.CASSANDRA_PROXY_ENDPOINT) 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 { FeedResponse, ItemDefinition, Resource } from "@azure/cosmos";
import { waitFor } from "@testing-library/react";
import { deleteDocuments } from "Common/dataAccess/deleteDocument"; import { deleteDocuments } from "Common/dataAccess/deleteDocument";
import { Platform, updateConfigContext } from "ConfigContext"; import { Platform, updateConfigContext } from "ConfigContext";
import { useDialog } from "Explorer/Controls/Dialog";
import { EditorReactProps } from "Explorer/Controls/Editor/EditorReact"; 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 { import {
ButtonsDependencies, ButtonsDependencies,
DELETE_BUTTON_ID, DELETE_BUTTON_ID,
@@ -13,6 +16,7 @@ import {
SAVE_BUTTON_ID, SAVE_BUTTON_ID,
UPDATE_BUTTON_ID, UPDATE_BUTTON_ID,
UPLOAD_BUTTON_ID, UPLOAD_BUTTON_ID,
addStringsNoDuplicate,
buildQuery, buildQuery,
getDiscardExistingDocumentChangesButtonState, getDiscardExistingDocumentChangesButtonState,
getDiscardNewDocumentChangesButtonState, getDiscardNewDocumentChangesButtonState,
@@ -64,12 +68,14 @@ jest.mock("Explorer/Controls/Editor/EditorReact", () => ({
EditorReact: (props: EditorReactProps) => <>{props.content}</>, 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", () => ({ jest.mock("Explorer/Controls/Dialog", () => ({
useDialog: { useDialog: {
getState: jest.fn(() => ({ getState: jest.fn(() => mockDialogState),
showOkCancelModalDialog: (title: string, subText: string, okLabel: string, onOk: () => void) => onOk(),
showOkModalDialog: () => {},
})),
}, },
})); }));
@@ -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) { async function waitForComponentToPaint<P = unknown>(wrapper: ReactWrapper<P> | ShallowWrapper<P>, amount = 0) {
let newWrapper; let newWrapper;
await act(async () => { await act(async () => {
@@ -91,7 +101,13 @@ async function waitForComponentToPaint<P = unknown>(wrapper: ReactWrapper<P> | S
describe("Documents tab (noSql API)", () => { describe("Documents tab (noSql API)", () => {
describe("buildQuery", () => { describe("buildQuery", () => {
it("should generate the right select query for SQL API", () => { 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 => ({ const createMockProps = (): IDocumentsTabComponentProps => ({
isPreferredApiMongoDB: false, isPreferredApiMongoDB: false,
documentIds: [], documentIds: [],
collection: undefined, collection: {
id: ko.observable<string>("collectionId"),
databaseId: "databaseId",
} as ViewModels.CollectionBase,
partitionKey: { kind: "Hash", paths: ["/foo"], version: 2 }, partitionKey: { kind: "Hash", paths: ["/foo"], version: 2 },
onLoadStartKey: 0, onLoadStartKey: 0,
tabTitle: "", tabTitle: "",
@@ -380,7 +399,7 @@ describe("Documents tab (noSql API)", () => {
.findWhere((node) => node.text() === "Edit Filter") .findWhere((node) => node.text() === "Edit Filter")
.at(0) .at(0)
.simulate("click"); .simulate("click");
expect(wrapper.find("#filterInput").exists()).toBeTruthy(); expect(wrapper.find("Input.filterInput").exists()).toBeTruthy();
}); });
}); });
@@ -442,7 +461,7 @@ describe("Documents tab (noSql API)", () => {
useCommandBar useCommandBar
.getState() .getState()
.contextButtons.find((button) => button.id === NEW_DOCUMENT_BUTTON_ID) .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(); expect(wrapper.findWhere((node) => node.text().includes("replace_with_new_document_id")).exists()).toBeTruthy();
}); });
@@ -452,14 +471,36 @@ describe("Documents tab (noSql API)", () => {
useCommandBar useCommandBar
.getState() .getState()
.contextButtons.find((button) => button.id === NEW_DOCUMENT_BUTTON_ID) .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 === SAVE_BUTTON_ID)).toBeDefined();
expect(useCommandBar.getState().contextButtons.find((button) => button.id === DISCARD_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; const mockDeleteDocuments = deleteDocuments as jest.Mock;
mockDeleteDocuments.mockClear(); mockDeleteDocuments.mockClear();
@@ -467,10 +508,21 @@ describe("Documents tab (noSql API)", () => {
useCommandBar useCommandBar
.getState() .getState()
.contextButtons.find((button) => button.id === DELETE_BUTTON_ID) .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 { Platform, updateConfigContext } from "ConfigContext";
import { EditorReactProps } from "Explorer/Controls/Editor/EditorReact"; import { EditorReactProps } from "Explorer/Controls/Editor/EditorReact";
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter"; import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { import {
DELETE_BUTTON_ID, DELETE_BUTTON_ID,
DISCARD_BUTTON_ID, DISCARD_BUTTON_ID,
@@ -49,7 +49,9 @@ jest.mock("Common/MongoProxyClient", () => ({
id: "id1", 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", () => ({ jest.mock("Explorer/Controls/Editor/EditorReact", () => ({
@@ -161,7 +163,7 @@ describe("Documents tab (Mongo API)", () => {
useCommandBar useCommandBar
.getState() .getState()
.contextButtons.find((button) => button.id === NEW_DOCUMENT_BUTTON_ID) .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(); expect(wrapper.findWhere((node) => node.text().includes("replace_with_new_document_id")).exists()).toBeTruthy();
}); });
@@ -171,25 +173,25 @@ describe("Documents tab (Mongo API)", () => {
useCommandBar useCommandBar
.getState() .getState()
.contextButtons.find((button) => button.id === NEW_DOCUMENT_BUTTON_ID) .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 === SAVE_BUTTON_ID)).toBeDefined();
expect(useCommandBar.getState().contextButtons.find((button) => button.id === DISCARD_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 eventually calls delete client api", () => {
const mockDeleteDocument = deleteDocument as jest.Mock; const mockDeleteDocuments = deleteDocuments as jest.Mock;
mockDeleteDocument.mockClear(); mockDeleteDocuments.mockClear();
act(() => { act(() => {
useCommandBar useCommandBar
.getState() .getState()
.contextButtons.find((button) => button.id === DELETE_BUTTON_ID) .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 { TableRowId } from "@fluentui/react-components";
import { mount } from "enzyme"; import { mount } from "enzyme";
import React from "react"; import React from "react";
import * as ViewModels from "../../../Contracts/ViewModels";
import { DocumentsTableComponent, IDocumentsTableComponentProps } from "./DocumentsTableComponent"; import { DocumentsTableComponent, IDocumentsTableComponentProps } from "./DocumentsTableComponent";
const PARTITION_KEY_HEADER = "partitionKey"; const PARTITION_KEY_HEADER = "partitionKey";
@@ -20,11 +21,19 @@ describe("DocumentsTableComponent", () => {
height: 0, height: 0,
width: 0, width: 0,
}, },
columnHeaders: { columnDefinitions: [
idHeader: ID_HEADER, { id: ID_HEADER, label: "ID", isPartitionKey: false },
partitionKeyHeaders: [PARTITION_KEY_HEADER], { 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", () => { 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", () => { it("should not render selection column when isSelectionDisabled is true", () => {
const props: IDocumentsTableComponentProps = createMockProps(); const props: IDocumentsTableComponentProps = createMockProps();
props.isSelectionDisabled = true; props.isRowSelectionDisabled = true;
const wrapper = mount(<DocumentsTableComponent {...props} />); const wrapper = mount(<DocumentsTableComponent {...props} />);
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });

View File

@@ -1,52 +1,86 @@
import { import {
Button,
Menu, Menu,
MenuDivider,
MenuItem, MenuItem,
MenuList, MenuList,
MenuPopover, MenuPopover,
MenuTrigger, MenuTrigger,
TableRowData as RowStateBase, TableRowData as RowStateBase,
SortDirection,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableCellLayout, TableCellLayout,
TableColumnDefinition, TableColumnDefinition,
TableColumnId,
TableColumnSizingOptions, TableColumnSizingOptions,
TableHeader, TableHeader,
TableHeaderCell, TableHeaderCell,
TableRow, TableRow,
TableRowId, TableRowId,
TableSelectionCell, TableSelectionCell,
createTableColumn, tokens,
useArrowNavigationGroup, useArrowNavigationGroup,
useTableColumnSizing_unstable, useTableColumnSizing_unstable,
useTableFeatures, useTableFeatures,
useTableSelection, useTableSelection,
useTableSort,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import {
ArrowClockwise16Regular,
ArrowResetRegular,
DeleteRegular,
EditRegular,
MoreHorizontalRegular,
TableResizeColumnRegular,
TextSortAscendingRegular,
TextSortDescendingRegular,
} from "@fluentui/react-icons";
import { NormalizedEventKey } from "Common/Constants"; 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 { INITIAL_SELECTED_ROW_INDEX, useDocumentsTabStyles } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2";
import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper"; import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
import { LayoutConstants } from "Explorer/Theme/ThemeUtil"; import { LayoutConstants } from "Explorer/Theme/ThemeUtil";
import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils"; import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils";
import { useSidePanel } from "hooks/useSidePanel";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { FixedSizeList as List, ListChildComponentProps } from "react-window"; import { FixedSizeList as List, ListChildComponentProps } from "react-window";
import * as ViewModels from "../../../Contracts/ViewModels";
export type DocumentsTableComponentItem = { export type DocumentsTableComponentItem = {
id: string; id: string;
} & Record<string, string>; } & Record<string, string | number>;
export type ColumnHeaders = { export type ColumnDefinition = {
idHeader: string; id: string;
partitionKeyHeaders: string[]; label: string;
isPartitionKey: boolean;
}; };
export interface IDocumentsTableComponentProps { export interface IDocumentsTableComponentProps {
onRefreshTable: () => void;
items: DocumentsTableComponentItem[]; items: DocumentsTableComponentItem[];
onItemClicked: (index: number) => void; onItemClicked: (index: number) => void;
onSelectedRowsChange: (selectedItemsIndices: Set<TableRowId>) => void; onSelectedRowsChange: (selectedItemsIndices: Set<TableRowId>) => void;
selectedRows: Set<TableRowId>; selectedRows: Set<TableRowId>;
size: { height: number; width: number }; size: { height: number; width: number };
columnHeaders: ColumnHeaders; selectedColumnIds: string[];
columnDefinitions: ColumnDefinition[];
style?: React.CSSProperties; style?: React.CSSProperties;
isSelectionDisabled?: boolean; isRowSelectionDisabled?: boolean;
collection: ViewModels.CollectionBase;
onColumnSelectionChange?: (newSelectedColumnIds: string[]) => void;
defaultColumnSelection?: string[];
isColumnSelectionDisabled?: boolean;
} }
interface TableRowData extends RowStateBase<DocumentsTableComponentItem> { interface TableRowData extends RowStateBase<DocumentsTableComponentItem> {
@@ -59,72 +93,204 @@ interface ReactWindowRenderFnProps extends ListChildComponentProps {
data: TableRowData[]; data: TableRowData[];
} }
const COLUMNS_MENU_NAME = "columnsMenu";
const defaultSize = {
idealWidth: 200,
minWidth: 50,
};
export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> = ({ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> = ({
onRefreshTable,
items, items,
onSelectedRowsChange, onSelectedRowsChange,
selectedRows, selectedRows,
style, style,
size, size,
columnHeaders, selectedColumnIds,
isSelectionDisabled, columnDefinitions,
isRowSelectionDisabled: isSelectionDisabled,
collection,
onColumnSelectionChange,
defaultColumnSelection,
isColumnSelectionDisabled,
}: IDocumentsTableComponentProps) => { }: IDocumentsTableComponentProps) => {
const styles = useDocumentsTabStyles(); const styles = useDocumentsTabStyles();
const initialSizingOptions: TableColumnSizingOptions = { const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(() => {
id: { const columnSizesMap: ColumnSizesMap = readSubComponentState(SubComponentName.ColumnSizes, collection, {});
idealWidth: 280, const columnSizesPx: TableColumnSizingOptions = {};
minWidth: 50, selectedColumnIds.forEach((columnId) => {
}, if (
}; !columnSizesMap ||
columnHeaders.partitionKeyHeaders.forEach((pkHeader) => { !columnSizesMap[columnId] ||
initialSizingOptions[pkHeader] = { columnSizesMap[columnId].widthPx === undefined ||
idealWidth: 200, isNaN(columnSizesMap[columnId].widthPx)
minWidth: 50, ) {
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 }) => { const persistentSizes = Object.keys(newSizingOptions).reduce((acc, key) => {
setColumnSizingOptions((state) => ({ acc[key] = {
...state, widthPx: newSizingOptions[key].idealWidth,
[columnId]: { };
...state[columnId], return acc;
idealWidth: width, }, {} 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 // 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( const columns: TableColumnDefinition<DocumentsTableComponentItem>[] = useMemo(
() => () =>
[ columnDefinitions
createTableColumn<DocumentsTableComponentItem>({ .filter((column) => selectedColumnIds.includes(column.id))
columnId: "id", .map((column) => ({
compare: (a, b) => a.id.localeCompare(b.id), columnId: column.id,
renderHeaderCell: () => columnHeaders.idHeader, 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) => ( renderCell: (item) => (
<TableCellLayout truncate title={item.id}> <TableCellLayout truncate title={`${item[column.id]}`}>
{item.id} {item[column.id]}
</TableCellLayout> </TableCellLayout>
), ),
}), })),
].concat( [columnDefinitions, onColumnSelectionChange, selectedColumnIds],
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],
); );
const [selectionStartIndex, setSelectionStartIndex] = React.useState<number>(INITIAL_SELECTED_ROW_INDEX); const [selectionStartIndex, setSelectionStartIndex] = React.useState<number>(INITIAL_SELECTED_ROW_INDEX);
@@ -214,6 +380,7 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
columnSizing_unstable: columnSizing, columnSizing_unstable: columnSizing,
tableRef, tableRef,
selection: { allRowsSelected, someRowsSelected, toggleAllRows, toggleRow, isRowSelected }, selection: { allRowsSelected, someRowsSelected, toggleAllRows, toggleRow, isRowSelected },
sort: { getSortDirection, setColumnSort, sort },
} = useTableFeatures( } = useTableFeatures(
{ {
columns, columns,
@@ -227,25 +394,36 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
// eslint-disable-next-line react/prop-types // eslint-disable-next-line react/prop-types
onSelectionChange: (e, data) => onSelectedRowsChange(data.selectedItems), onSelectionChange: (e, data) => onSelectedRowsChange(data.selectedItems),
}), }),
useTableSort({
sortState,
onSortChange: (e, nextSortState) => setSortState(nextSortState),
}),
], ],
); );
const rows: TableRowData[] = getRows((row) => { const headerSortProps = (columnId: TableColumnId) => ({
const selected = isRowSelected(row.rowId); // onClick: (e: React.MouseEvent) => toggleColumnSort(e, columnId),
return { sortDirection: getSortDirection(columnId),
...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 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( const toggleAllKeydown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => { (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === " ") { if (e.key === " ") {
@@ -271,39 +449,53 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
...style, ...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 ( return (
<Table noNativeElements {...tableProps}> <Table noNativeElements {...tableProps}>
<TableHeader> <TableHeader className={styles.tableHeader}>
<TableRow className={styles.tableRow} style={{ width: size ? size.width - 15 : "100%" }}> <TableRow className={styles.tableRow} style={{ width: size ? size.width - 15 : "100%" }}>
{!isSelectionDisabled && ( {!isSelectionDisabled && (
<TableSelectionCell <TableSelectionCell
key="selectcell"
checked={allRowsSelected ? true : someRowsSelected ? "mixed" : false} checked={allRowsSelected ? true : someRowsSelected ? "mixed" : false}
onClick={toggleAllRows} onClick={toggleAllRows}
onKeyDown={toggleAllKeydown} onKeyDown={toggleAllKeydown}
checkboxIndicator={{ "aria-label": "Select all rows " }} checkboxIndicator={{ "aria-label": "Select all rows " }}
/> />
)} )}
{columns.map((column /* index */) => ( {columns.map((column) => (
<Menu openOnContext key={column.columnId}> <TableHeaderCell
<MenuTrigger> className={styles.tableCell}
<TableHeaderCell key={column.columnId}
className={styles.tableCell} {...columnSizing.getTableHeaderCellProps(column.columnId)}
key={column.columnId} {...headerSortProps(column.columnId)}
{...columnSizing.getTableHeaderCellProps(column.columnId)} >
> {column.renderHeaderCell()}
{column.renderHeaderCell()} </TableHeaderCell>
</TableHeaderCell>
</MenuTrigger>
<MenuPopover>
<MenuList>
<MenuItem onClick={columnSizing.enableKeyboardMode(column.columnId)}>
Keyboard Column Resizing
</MenuItem>
</MenuList>
</MenuPopover>
</Menu>
))} ))}
</TableRow> </TableRow>
<div className={styles.tableHeaderFiller}></div>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<List <List

View File

@@ -1,3 +1,5 @@
import { useEffect, useRef } from "react";
/** /**
* Utility class to help with selection. * Utility class to help with selection.
* This emulates File Explorer selection behavior. * 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 <Allotment.Pane
minSize={175} minSize={55}
preferredSize="35%" preferredSize="35%"
> >
<div <div
@@ -53,52 +55,61 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
} }
> >
<div <div
className="___77lcry0_0000000 f10pi13n" className="___9o87uj0_0000000 ffefeo0"
> >
<div <div
className="___1rwkz4r_0000000 f1euv43f f1l8gmrm f1e31b4d f150nix6 fy6ml6n f19g0ac" style={
{
"height": "100%",
"width": "calc(100% + -11px)",
}
}
> >
<Button <DocumentsTableComponent
appearance="transparent" collection={
aria-label="Refresh"
icon={<ArrowClockwise16Filled />}
onClick={[Function]}
onKeyDown={[Function]}
size="small"
style={
{ {
"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> </div>
<div
className="___9o87uj0_0000000 ffefeo0"
>
<DocumentsTableComponent
columnHeaders={
{
"idHeader": "id",
"partitionKeyHeaders": [],
}
}
isSelectionDisabled={true}
items={[]}
onItemClicked={[Function]}
onSelectedRowsChange={[Function]}
selectedRows={
Set {
0,
}
}
/>
</div>
</div> </div>
</Allotment.Pane> </Allotment.Pane>
<Allotment.Pane <Allotment.Pane
minSize={300} minSize={30}
preferredSize="65%"
> >
<div <div
style={ style={

View File

@@ -3,7 +3,7 @@ import MongoUtility from "../../../Common/MongoUtility";
import * as ViewModels from "../../../Contracts/ViewModels"; import * as ViewModels from "../../../Contracts/ViewModels";
import Explorer from "../../Explorer"; import Explorer from "../../Explorer";
import { NewQueryTab } from "../QueryTab/QueryTab"; import { NewQueryTab } from "../QueryTab/QueryTab";
import QueryTabComponent, { IQueryTabComponentProps, ITabAccessor } from "../QueryTab/QueryTabComponent"; import { IQueryTabComponentProps, ITabAccessor, QueryTabComponent } from "../QueryTab/QueryTabComponent";
export interface IMongoQueryTabProps { export interface IMongoQueryTabProps {
container: Explorer; container: Explorer;

View File

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

View File

@@ -0,0 +1,143 @@
import {
Button,
DataGrid,
DataGridBody,
DataGridCell,
DataGridHeader,
DataGridHeaderCell,
DataGridRow,
TableCellLayout,
TableColumnDefinition,
TableColumnSizingOptions,
createTableColumn,
tokens,
} from "@fluentui/react-components";
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";
import React from "react";
const severityIcons = {
[QueryErrorSeverity.Error]: <ErrorCircleFilled color={tokens.colorPaletteRedBackground3} />,
[QueryErrorSeverity.Warning]: <WarningFilled color={tokens.colorPaletteYellowForeground1} />,
};
export const ErrorList: React.FC<{ errors: QueryError[] }> = ({ errors }) => {
const styles = useQueryTabStyles();
const onErrorDetailsClick = (): boolean => {
useNotificationConsole.getState().expandConsole();
return false;
};
const columns: TableColumnDefinition<QueryError>[] = [
createTableColumn<QueryError>({
columnId: "code",
compare: (item1, item2) => item1.code.localeCompare(item2.code),
renderHeaderCell: () => "Code",
renderCell: (item) => <TableCellLayout truncate>{item.code}</TableCellLayout>,
}),
createTableColumn<QueryError>({
columnId: "severity",
compare: (item1, item2) => compareSeverity(item1.severity, item2.severity),
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) => (
<TableCellLayout truncate>
{item.location
? item.location.start.lineNumber
? `Line ${item.location.start.lineNumber}`
: "<unknown>"
: "<no location>"}
</TableCellLayout>
),
}),
createTableColumn<QueryError>({
columnId: "message",
compare: (item1, item2) => item1.message.localeCompare(item2.message),
renderHeaderCell: () => "Message",
renderCell: (item) => (
<div className={styles.errorListMessageCell}>
<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"
icon={<MoreHorizontalRegular />}
onClick={onErrorDetailsClick}
/>
</div>
</div>
),
}),
];
const columnSizingOptions: TableColumnSizingOptions = {
code: {
minWidth: 90,
idealWidth: 90,
defaultWidth: 90,
},
severity: {
minWidth: 100,
idealWidth: 100,
defaultWidth: 100,
},
location: {
minWidth: 100,
idealWidth: 100,
defaultWidth: 100,
},
message: {
minWidth: 500,
},
};
return (
<DataGrid
data-test="QueryTab/ResultsPane/ErrorList"
items={errors}
columns={columns}
sortable
resizableColumns
columnSizingOptions={columnSizingOptions}
focusMode="composite"
>
<DataGridHeader>
<DataGridRow>
{({ renderHeaderCell }) => <DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>}
</DataGridRow>
</DataGridHeader>
<DataGridBody<QueryError>>
{({ item, rowId }) => (
<DataGridRow<QueryError> key={rowId} data-test={`Row:${rowId}`}>
{({ columnId, renderCell }) => (
<DataGridCell data-test={`Row:${rowId}/Column:${columnId}`}>{renderCell(item)}</DataGridCell>
)}
</DataGridRow>
)}
</DataGridBody>
</DataGrid>
);
};

View File

@@ -1,544 +1,93 @@
import { import { Link } from "@fluentui/react-components";
DetailsList, import QueryError from "Common/QueryError";
DetailsListLayoutMode, import { IndeterminateProgressBar } from "Explorer/Controls/IndeterminateProgressBar";
IColumn, import { MessageBanner } from "Explorer/Controls/MessageBanner";
Icon, import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
IconButton,
Link,
Pivot,
PivotItem,
SelectionMode,
Stack,
Text,
TooltipHost,
} from "@fluentui/react";
import { HttpHeaders, NormalizedEventKey } from "Common/Constants";
import MongoUtility from "Common/MongoUtility";
import { QueryMetrics } from "Contracts/DataModels";
import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
import { IDocument } from "Explorer/Tabs/QueryTab/QueryTabComponent";
import { userContext } from "UserContext";
import copy from "clipboard-copy";
import { useNotificationConsole } from "hooks/useNotificationConsole";
import React from "react"; import React from "react";
import CopilotCopy from "../../../../images/CopilotCopy.svg";
import DownloadQueryMetrics from "../../../../images/DownloadQuery.svg";
import QueryEditorNext from "../../../../images/Query-Editor-Next.svg";
import RunQuery from "../../../../images/RunQuery.png"; import RunQuery from "../../../../images/RunQuery.png";
import InfoColor from "../../../../images/info_color.svg";
import { QueryResults } from "../../../Contracts/ViewModels"; import { QueryResults } from "../../../Contracts/ViewModels";
import { ErrorList } from "./ErrorList";
import { ResultsView } from "./ResultsView";
interface QueryResultProps { export interface ResultsViewProps {
isMongoDB: boolean; isMongoDB: boolean;
queryEditorContent: string;
error: string;
isExecuting: boolean;
queryResults: QueryResults; queryResults: QueryResults;
executeQueryDocumentsPage: (firstItemIndex: number) => Promise<void>; executeQueryDocumentsPage: (firstItemIndex: number) => Promise<void>;
} }
interface QueryResultProps extends ResultsViewProps {
queryEditorContent: string;
errors: QueryError[];
isExecuting: boolean;
}
const ExecuteQueryCallToAction: React.FC = () => {
const styles = useQueryTabStyles();
return (
<div data-test="QueryTab/ResultsPane/ExecuteCTA" className={styles.executeCallToAction}>
<div>
<p>
<img src={RunQuery} aria-hidden="true" />
</p>
<p>Execute a query to see the results</p>
</div>
</div>
);
};
export const QueryResultSection: React.FC<QueryResultProps> = ({ export const QueryResultSection: React.FC<QueryResultProps> = ({
isMongoDB, isMongoDB,
queryEditorContent, queryEditorContent,
error, errors,
queryResults, queryResults,
isExecuting,
executeQueryDocumentsPage, executeQueryDocumentsPage,
isExecuting,
}: QueryResultProps): JSX.Element => { }: QueryResultProps): JSX.Element => {
const queryMetrics = React.useRef(queryResults?.headers?.[HttpHeaders.queryMetrics]); const styles = useQueryTabStyles();
React.useEffect(() => {
const latestQueryMetrics = queryResults?.headers?.[HttpHeaders.queryMetrics];
if (latestQueryMetrics && Object.keys(latestQueryMetrics).length > 0) {
queryMetrics.current = latestQueryMetrics;
}
}, [queryResults]);
const onRender = (item: IDocument): JSX.Element => (
<>
<Text style={{ paddingLeft: 10, margin: 0 }}>{`${item.metric}`}</Text>
</>
);
const columns: IColumn[] = [
{
key: "column1",
name: "Description",
iconName: "Info",
isIconOnly: true,
minWidth: 10,
maxWidth: 12,
iconClassName: "iconheadercell",
data: String,
fieldName: "",
onRender: (item: IDocument) => {
if (item.toolTip !== "") {
return (
<>
<TooltipHost content={`${item.toolTip}`}>
<Link style={{ color: "#323130" }}>
<Icon iconName="Info" ariaLabel={`${item.toolTip}`} className="panelInfoIcon" tabIndex={0} />
</Link>
</TooltipHost>
</>
);
} else {
return undefined;
}
},
},
{
key: "column2",
name: "METRIC",
minWidth: 200,
data: String,
fieldName: "metric",
onRender,
},
{
key: "column3",
name: "VALUE",
minWidth: 200,
data: String,
fieldName: "value",
},
];
const maybeSubQuery = queryEditorContent && /.*\(.*SELECT.*\)/i.test(queryEditorContent); const maybeSubQuery = queryEditorContent && /.*\(.*SELECT.*\)/i.test(queryEditorContent);
const queryResultsString = queryResults
? isMongoDB
? MongoUtility.tojson(queryResults.documents, undefined, false)
: JSON.stringify(queryResults.documents, undefined, 4)
: "";
const onErrorDetailsClick = (): boolean => {
useNotificationConsole.getState().expandConsole();
return false;
};
const onErrorDetailsKeyPress = (event: React.KeyboardEvent<HTMLAnchorElement>): boolean => {
if (event.key === NormalizedEventKey.Space || event.key === NormalizedEventKey.Enter) {
onErrorDetailsClick();
return false;
}
return true;
};
const onDownloadQueryMetricsCsvClick = (): boolean => {
downloadQueryMetricsCsvData();
return false;
};
const onDownloadQueryMetricsCsvKeyPress = (event: React.KeyboardEvent<HTMLAnchorElement>): boolean => {
if (event.key === NormalizedEventKey.Space || NormalizedEventKey.Enter) {
downloadQueryMetricsCsvData();
return false;
}
return true;
};
const downloadQueryMetricsCsvData = (): void => {
const csvData: string = generateQueryMetricsCsvData();
if (!csvData) {
return;
}
if (navigator.msSaveBlob) {
// for IE and Edge
navigator.msSaveBlob(
new Blob([csvData], { type: "data:text/csv;charset=utf-8" }),
"PerPartitionQueryMetrics.csv",
);
} else {
const downloadLink: HTMLAnchorElement = document.createElement("a");
downloadLink.href = "data:text/csv;charset=utf-8," + encodeURI(csvData);
downloadLink.target = "_self";
downloadLink.download = "QueryMetricsPerPartition.csv";
// for some reason, FF displays the download prompt only when
// the link is added to the dom so we add and remove it
document.body.appendChild(downloadLink);
downloadLink.click();
downloadLink.remove();
}
};
const getAggregatedQueryMetrics = (): QueryMetrics => {
const aggregatedQueryMetrics = {
documentLoadTime: 0,
documentWriteTime: 0,
indexHitDocumentCount: 0,
outputDocumentCount: 0,
outputDocumentSize: 0,
indexLookupTime: 0,
retrievedDocumentCount: 0,
retrievedDocumentSize: 0,
vmExecutionTime: 0,
runtimeExecutionTimes: {
queryEngineExecutionTime: 0,
systemFunctionExecutionTime: 0,
userDefinedFunctionExecutionTime: 0,
},
totalQueryExecutionTime: 0,
} as QueryMetrics;
if (queryMetrics.current) {
Object.keys(queryMetrics.current).forEach((partitionKeyRangeId) => {
const queryMetricsPerPartition = queryMetrics.current[partitionKeyRangeId];
if (!queryMetricsPerPartition) {
return;
}
aggregatedQueryMetrics.documentLoadTime += queryMetricsPerPartition.documentLoadTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.documentWriteTime +=
queryMetricsPerPartition.documentWriteTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.indexHitDocumentCount += queryMetricsPerPartition.indexHitDocumentCount || 0;
aggregatedQueryMetrics.outputDocumentCount += queryMetricsPerPartition.outputDocumentCount || 0;
aggregatedQueryMetrics.outputDocumentSize += queryMetricsPerPartition.outputDocumentSize || 0;
aggregatedQueryMetrics.indexLookupTime += queryMetricsPerPartition.indexLookupTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.retrievedDocumentCount += queryMetricsPerPartition.retrievedDocumentCount || 0;
aggregatedQueryMetrics.retrievedDocumentSize += queryMetricsPerPartition.retrievedDocumentSize || 0;
aggregatedQueryMetrics.vmExecutionTime += queryMetricsPerPartition.vmExecutionTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.totalQueryExecutionTime +=
queryMetricsPerPartition.totalQueryExecutionTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.runtimeExecutionTimes.queryEngineExecutionTime +=
queryMetricsPerPartition.runtimeExecutionTimes?.queryEngineExecutionTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.runtimeExecutionTimes.systemFunctionExecutionTime +=
queryMetricsPerPartition.runtimeExecutionTimes?.systemFunctionExecutionTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.runtimeExecutionTimes.userDefinedFunctionExecutionTime +=
queryMetricsPerPartition.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.totalMilliseconds() || 0;
});
}
return aggregatedQueryMetrics;
};
const generateQueryMetricsCsvData = (): string => {
if (queryMetrics.current) {
let csvData =
[
"Partition key range id",
"Retrieved document count",
"Retrieved document size (in bytes)",
"Output document count",
"Output document size (in bytes)",
"Index hit document count",
"Index lookup time (ms)",
"Document load time (ms)",
"Query engine execution time (ms)",
"System function execution time (ms)",
"User defined function execution time (ms)",
"Document write time (ms)",
].join(",") + "\n";
Object.keys(queryMetrics.current).forEach((partitionKeyRangeId) => {
const queryMetricsPerPartition = queryMetrics.current[partitionKeyRangeId];
csvData +=
[
partitionKeyRangeId,
queryMetricsPerPartition.retrievedDocumentCount,
queryMetricsPerPartition.retrievedDocumentSize,
queryMetricsPerPartition.outputDocumentCount,
queryMetricsPerPartition.outputDocumentSize,
queryMetricsPerPartition.indexHitDocumentCount,
queryMetricsPerPartition.indexLookupTime?.totalMilliseconds(),
queryMetricsPerPartition.documentLoadTime?.totalMilliseconds(),
queryMetricsPerPartition.runtimeExecutionTimes?.queryEngineExecutionTime?.totalMilliseconds(),
queryMetricsPerPartition.runtimeExecutionTimes?.systemFunctionExecutionTime?.totalMilliseconds(),
queryMetricsPerPartition.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.totalMilliseconds(),
queryMetricsPerPartition.documentWriteTime?.totalMilliseconds(),
].join(",") + "\n";
});
return csvData;
}
return undefined;
};
const onFetchNextPageClick = async (): Promise<void> => {
const { firstItemIndex, itemCount } = queryResults;
await executeQueryDocumentsPage(firstItemIndex + itemCount - 1);
};
const generateQueryStatsItems = (): IDocument[] => {
const items: IDocument[] = [
{
metric: "Request Charge",
value: `${queryResults.requestCharge} RUs`,
toolTip: "Request Charge",
},
{
metric: "Showing Results",
value: queryResults.itemCount > 0 ? `${queryResults.firstItemIndex} - ${queryResults.lastItemIndex}` : `0 - 0`,
toolTip: "Showing Results",
},
];
if (userContext.apiType === "SQL") {
const aggregatedQueryMetrics = getAggregatedQueryMetrics();
items.push(
{
metric: "Retrieved document count",
value: aggregatedQueryMetrics.retrievedDocumentCount?.toString() || "",
toolTip: "Total number of retrieved documents",
},
{
metric: "Retrieved document size",
value: `${aggregatedQueryMetrics.retrievedDocumentSize?.toString() || 0} bytes`,
toolTip: "Total size of retrieved documents in bytes",
},
{
metric: "Output document count",
value: aggregatedQueryMetrics.outputDocumentCount?.toString() || "",
toolTip: "Number of output documents",
},
{
metric: "Output document size",
value: `${aggregatedQueryMetrics.outputDocumentSize?.toString() || 0} bytes`,
toolTip: "Total size of output documents in bytes",
},
{
metric: "Index hit document count",
value: aggregatedQueryMetrics.indexHitDocumentCount?.toString() || "",
toolTip: "Total number of documents matched by the filter",
},
{
metric: "Index lookup time",
value: `${aggregatedQueryMetrics.indexLookupTime?.toString() || 0} ms`,
toolTip: "Time spent in physical index layer",
},
{
metric: "Document load time",
value: `${aggregatedQueryMetrics.documentLoadTime?.toString() || 0} ms`,
toolTip: "Time spent in loading documents",
},
{
metric: "Query engine execution time",
value: `${aggregatedQueryMetrics.runtimeExecutionTimes?.queryEngineExecutionTime?.toString() || 0} ms`,
toolTip:
"Time spent by the query engine to execute the query expression (excludes other execution times like load documents or write results)",
},
{
metric: "System function execution time",
value: `${aggregatedQueryMetrics.runtimeExecutionTimes?.systemFunctionExecutionTime?.toString() || 0} ms`,
toolTip: "Total time spent executing system (built-in) functions",
},
{
metric: "User defined function execution time",
value: `${
aggregatedQueryMetrics.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.toString() || 0
} ms`,
toolTip: "Total time spent executing user-defined functions",
},
{
metric: "Document write time",
value: `${aggregatedQueryMetrics.documentWriteTime.toString() || 0} ms`,
toolTip: "Time spent to write query result set to response buffer",
},
);
}
if (queryResults.roundTrips) {
items.push({
metric: "Round Trips",
value: queryResults.roundTrips?.toString(),
toolTip: "Number of round trips",
});
}
if (queryResults.activityId) {
items.push({
metric: "Activity id",
value: queryResults.activityId,
toolTip: "",
});
}
return items;
};
const onClickCopyResults = (): void => {
copy(queryResultsString);
};
return ( return (
<Stack style={{ height: "100%" }}> <div data-test="QueryTab/ResultsPane" className={styles.queryResultsPanel}>
{isMongoDB && queryEditorContent.length === 0 && ( {isExecuting && <IndeterminateProgressBar />}
<div className="mongoQueryHelper"> <MessageBanner
Start by writing a Mongo query, for example: <strong>{"{'id':'foo'}"}</strong> or{" "} messageId="QueryEditor.EmptyMongoQuery"
<strong> visible={isMongoDB && queryEditorContent.length === 0}
{"{ "} className={styles.queryResultsMessage}
{" }"} >
</strong>{" "} Start by writing a Mongo query, for example: <strong>{"{'id':'foo'}"}</strong> or{" "}
to get all the documents. <strong>
</div> {"{ "}
)} {" }"}
{maybeSubQuery && ( </strong>{" "}
<div className="warningErrorContainer" aria-live="assertive"> to get all the documents.
<div className="warningErrorContent"> </MessageBanner>
<span> {/* {maybeSubQuery && ( */}
<img className="paneErrorIcon" src={InfoColor} alt="Error" /> <MessageBanner
</span> messageId="QueryEditor.SubQueryWarning"
<span className="warningErrorDetailsLinkContainer"> visible={maybeSubQuery}
We detected you may be using a subquery. To learn more about subqueries effectively,{" "} className={styles.queryResultsMessage}
<a >
href="https://learn.microsoft.com/azure/cosmos-db/nosql/query/subquery" We detected you may be using a subquery. To learn more about subqueries effectively,{" "}
target="_blank" <Link
rel="noreferrer" href="https://learn.microsoft.com/azure/cosmos-db/nosql/query/subquery"
> target="_blank"
visit the documentation rel="noreferrer noopener"
</a> >
</span> visit the documentation
</div> </Link>
</div> </MessageBanner>
)}
{/* <!-- Query Errors Tab - Start--> */}
{error && (
<div className="active queryErrorsHeaderContainer">
<span className="queryErrors" data-toggle="tab">
Errors
</span>
</div>
)}
{/* <!-- Query Errors Tab - End --> */}
{/* <!-- Query Results & Errors Content Container - Start--> */} {/* <!-- Query Results & Errors Content Container - Start--> */}
<div className="queryResultErrorContentContainer"> {errors.length > 0 ? (
{!queryResults && !error && !isExecuting && ( <ErrorList errors={errors} />
<div className="queryEditorWatermark"> ) : queryResults ? (
<p> <ResultsView
<img src={RunQuery} alt="Execute Query Watermark" /> queryResults={queryResults}
</p> executeQueryDocumentsPage={executeQueryDocumentsPage}
<p className="queryEditorWatermarkText">Execute a query to see the results</p> isMongoDB={isMongoDB}
</div> />
)} ) : (
{(queryResults || !!error) && ( <ExecuteQueryCallToAction />
<div className="queryResultsErrorsContent"> )}
{!error && ( </div>
<Pivot aria-label="Successful execution" style={{ height: "100%" }}>
<PivotItem
headerText="Results"
headerButtonProps={{
"data-order": 1,
"data-title": "Results",
}}
style={{ height: "100%" }}
>
<div className="result-metadata">
<span>
<span>
{queryResults.itemCount > 0
? `${queryResults.firstItemIndex} - ${queryResults.lastItemIndex}`
: `0 - 0`}
</span>
</span>
{queryResults.hasMoreResults && (
<>
<span className="queryResultDivider">|</span>
<span className="queryResultNextEnable">
<a onClick={() => onFetchNextPageClick()}>
<span>Load more</span>
<img className="queryResultnextImg" src={QueryEditorNext} alt="Fetch next page" />
</a>
</span>
</>
)}
<IconButton
style={{
height: "100%",
verticalAlign: "middle",
float: "right",
}}
iconProps={{ imageProps: { src: CopilotCopy } }}
title="Copy to Clipboard"
ariaLabel="Copy"
onClick={onClickCopyResults}
/>
</div>
{queryResults && queryResultsString?.length > 0 && !error && (
<div
style={{
paddingBottom: "100px",
height: "100%",
}}
>
<EditorReact
language={"json"}
content={queryResultsString}
isReadOnly={true}
ariaLabel={"Query results"}
/>
</div>
)}
</PivotItem>
<PivotItem
headerText="Query Stats"
headerButtonProps={{
"data-order": 2,
"data-title": "Query Stats",
}}
style={{ height: "100%", overflowY: "scroll" }}
>
{queryResults && !error && (
<div className="queryMetricsSummaryContainer">
<div className="queryMetricsSummary">
<h3>Query Statistics</h3>
<DetailsList
items={generateQueryStatsItems()}
columns={columns}
selectionMode={SelectionMode.none}
layoutMode={DetailsListLayoutMode.justified}
compact={true}
/>
</div>
{userContext.apiType === "SQL" && (
<div className="downloadMetricsLinkContainer">
<a
id="downloadMetricsLink"
role="button"
tabIndex={0}
onClick={() => onDownloadQueryMetricsCsvClick()}
onKeyPress={(event: React.KeyboardEvent<HTMLAnchorElement>) =>
onDownloadQueryMetricsCsvKeyPress(event)
}
>
<img
className="downloadCsvImg"
src={DownloadQueryMetrics}
alt="download query metrics csv"
/>
<span>Per-partition query metrics (CSV)</span>
</a>
</div>
)}
</div>
)}
</PivotItem>
</Pivot>
)}
{/* <!-- Query Errors Content - Start--> */}
{!!error && (
<div className="tab-pane active">
<div className="errorContent">
<span className="errorMessage">{error}</span>
<span className="errorDetailsLink">
<a
onClick={() => onErrorDetailsClick()}
onKeyPress={(event: React.KeyboardEvent<HTMLAnchorElement>) => onErrorDetailsKeyPress(event)}
id="error-display"
tabIndex={0}
aria-label="Error details link"
>
More details
</a>
</span>
</div>
</div>
)}
{/* <!-- Query Errors Content - End--> */}
</div>
)}
</div>
</Stack>
); );
}; };

View File

@@ -7,10 +7,11 @@ import * as DataModels from "../../../Contracts/DataModels";
import type { QueryTabOptions } from "../../../Contracts/ViewModels"; import type { QueryTabOptions } from "../../../Contracts/ViewModels";
import { useTabs } from "../../../hooks/useTabs"; import { useTabs } from "../../../hooks/useTabs";
import Explorer from "../../Explorer"; import Explorer from "../../Explorer";
import QueryTabComponent, { import {
IQueryTabComponentProps, IQueryTabComponentProps,
ITabAccessor, ITabAccessor,
QueryTabFunctionComponent, QueryTabComponent,
QueryTabCopilotComponent,
} from "../../Tabs/QueryTab/QueryTabComponent"; } from "../../Tabs/QueryTab/QueryTabComponent";
import TabsBase from "../TabsBase"; import TabsBase from "../TabsBase";
@@ -49,7 +50,7 @@ export class NewQueryTab extends TabsBase {
public render(): JSX.Element { public render(): JSX.Element {
return userContext.apiType === "SQL" ? ( return userContext.apiType === "SQL" ? (
<CopilotProvider> <CopilotProvider>
<QueryTabFunctionComponent {...this.iQueryTabComponentProps} /> <QueryTabCopilotComponent {...this.iQueryTabComponentProps} />
</CopilotProvider> </CopilotProvider>
) : ( ) : (
<QueryTabComponent {...this.iQueryTabComponentProps} /> <QueryTabComponent {...this.iQueryTabComponentProps} />

View File

@@ -2,11 +2,14 @@ import { fireEvent, render } from "@testing-library/react";
import { CollectionTabKind } from "Contracts/ViewModels"; import { CollectionTabKind } from "Contracts/ViewModels";
import { CopilotProvider } from "Explorer/QueryCopilot/QueryCopilotContext"; import { CopilotProvider } from "Explorer/QueryCopilot/QueryCopilotContext";
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar"; import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
import QueryTabComponent, { import { CopilotSubComponentNames } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import {
IQueryTabComponentProps, IQueryTabComponentProps,
QueryTabFunctionComponent, QueryTabComponent,
QueryTabCopilotComponent,
} from "Explorer/Tabs/QueryTab/QueryTabComponent"; } from "Explorer/Tabs/QueryTab/QueryTabComponent";
import TabsBase from "Explorer/Tabs/TabsBase"; import TabsBase from "Explorer/Tabs/TabsBase";
import { AppStateComponentNames, StorePath } from "Shared/AppStatePersistenceUtility";
import { updateUserContext, userContext } from "UserContext"; import { updateUserContext, userContext } from "UserContext";
import { mount } from "enzyme"; import { mount } from "enzyme";
import { useQueryCopilot } from "hooks/useQueryCopilot"; import { useQueryCopilot } from "hooks/useQueryCopilot";
@@ -15,6 +18,24 @@ import React from "react";
jest.mock("Explorer/Controls/Editor/EditorReact"); 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", () => { describe("QueryTabComponent", () => {
const mockStore = useQueryCopilot.getState(); const mockStore = useQueryCopilot.getState();
beforeEach(() => { beforeEach(() => {
@@ -31,7 +52,7 @@ describe("QueryTabComponent", () => {
}, },
}); });
const propsMock: Readonly<IQueryTabComponentProps> = { const propsMock: Readonly<IQueryTabComponentProps> = {
collection: { databaseId: "CopilotSampleDb" }, collection: { databaseId: "CopilotSampleDB" },
onTabAccessor: () => jest.fn(), onTabAccessor: () => jest.fn(),
isExecutionError: false, isExecutionError: false,
tabId: "mockTabId", tabId: "mockTabId",
@@ -42,13 +63,24 @@ describe("QueryTabComponent", () => {
const { container } = render(<QueryTabComponent {...propsMock} />); const { container } = render(<QueryTabComponent {...propsMock} />);
const launchCopilotButton = container.querySelector(".queryEditorWatermarkText"); const launchCopilotButton = container.querySelector('[data-test="QueryTab/ResultsPane/ExecuteCTA"]');
fireEvent.keyDown(launchCopilotButton, { key: "c", altKey: true }); fireEvent.keyDown(launchCopilotButton, { key: "c", altKey: true });
expect(mockStore.setShowCopilotSidebar).toHaveBeenCalledWith(true); expect(mockStore.setShowCopilotSidebar).toHaveBeenCalledWith(true);
}); });
it("copilot should be enabled by default when tab is active", () => { 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().setCopilotEnabled(true);
useQueryCopilot.getState().setCopilotUserDBEnabled(true); useQueryCopilot.getState().setCopilotUserDBEnabled(true);
const activeTab = new TabsBase({ const activeTab = new TabsBase({
@@ -70,7 +102,7 @@ describe("QueryTabComponent", () => {
const container = mount( const container = mount(
<CopilotProvider> <CopilotProvider>
<QueryTabFunctionComponent {...propsMock} /> <QueryTabCopilotComponent {...propsMock} />
</CopilotProvider>, </CopilotProvider>,
); );
expect(container.find(QueryCopilotPromptbar).exists()).toBe(true); expect(container.find(QueryCopilotPromptbar).exists()).toBe(true);

View File

@@ -1,15 +1,21 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable no-console */ /* eslint-disable no-console */
import { FeedOptions, QueryOperationOptions } from "@azure/cosmos"; import { FeedOptions, QueryOperationOptions } from "@azure/cosmos";
import QueryError, { createMonacoErrorLocationResolver, createMonacoMarkersForQueryErrors } from "Common/QueryError";
import { SplitterDirection } from "Common/Splitter"; import { SplitterDirection } from "Common/Splitter";
import { Platform, configContext } from "ConfigContext"; import { Platform, configContext } from "ConfigContext";
import { useDialog } from "Explorer/Controls/Dialog"; import { useDialog } from "Explorer/Controls/Dialog";
import { monaco } from "Explorer/LazyMonaco";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { QueryCopilotFeedbackModal } from "Explorer/QueryCopilot/Modal/QueryCopilotFeedbackModal"; import { QueryCopilotFeedbackModal } from "Explorer/QueryCopilot/Modal/QueryCopilotFeedbackModal";
import { useCopilotStore } from "Explorer/QueryCopilot/QueryCopilotContext"; import { useCopilotStore } from "Explorer/QueryCopilot/QueryCopilotContext";
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar"; import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
import { readCopilotToggleStatus, saveCopilotToggleStatus } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import { OnExecuteQueryClick, QueryDocumentsPerPage } from "Explorer/QueryCopilot/Shared/QueryCopilotClient"; import { OnExecuteQueryClick, QueryDocumentsPerPage } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { QueryCopilotSidebar } from "Explorer/QueryCopilot/V2/Sidebar/QueryCopilotSidebar"; import { QueryCopilotSidebar } from "Explorer/QueryCopilot/V2/Sidebar/QueryCopilotSidebar";
import { QueryResultSection } from "Explorer/Tabs/QueryTab/QueryResultSection"; import { QueryResultSection } from "Explorer/Tabs/QueryTab/QueryResultSection";
import { QueryTabStyles, useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
import { CosmosFluentProvider } from "Explorer/Theme/ThemeUtil";
import { useSelectedNode } from "Explorer/useSelectedNode"; import { useSelectedNode } from "Explorer/useSelectedNode";
import { KeyboardAction } from "KeyboardShortcuts"; import { KeyboardAction } from "KeyboardShortcuts";
import { QueryConstants } from "Shared/Constants"; import { QueryConstants } from "Shared/Constants";
@@ -21,10 +27,10 @@ import {
ruThresholdEnabled, ruThresholdEnabled,
} from "Shared/StorageUtility"; } from "Shared/StorageUtility";
import { Action } from "Shared/Telemetry/TelemetryConstants"; import { Action } from "Shared/Telemetry/TelemetryConstants";
import { Allotment } from "allotment";
import { QueryCopilotState, useQueryCopilot } from "hooks/useQueryCopilot"; import { QueryCopilotState, useQueryCopilot } from "hooks/useQueryCopilot";
import { TabsState, useTabs } from "hooks/useTabs"; import { TabsState, useTabs } from "hooks/useTabs";
import React, { Fragment } from "react"; import React, { Fragment, createRef } from "react";
import SplitterLayout from "react-splitter-layout";
import "react-splitter-layout/lib/index.css"; import "react-splitter-layout/lib/index.css";
import { format } from "react-string-format"; import { format } from "react-string-format";
import QueryCommandIcon from "../../../../images/CopilotCommand.svg"; import QueryCommandIcon from "../../../../images/CopilotCommand.svg";
@@ -35,7 +41,6 @@ import ExecuteQueryIcon from "../../../../images/ExecuteQuery.svg";
import CheckIcon from "../../../../images/check-1.svg"; import CheckIcon from "../../../../images/check-1.svg";
import SaveQueryIcon from "../../../../images/save-cosmos.svg"; import SaveQueryIcon from "../../../../images/save-cosmos.svg";
import { NormalizedEventKey } from "../../../Common/Constants"; import { NormalizedEventKey } from "../../../Common/Constants";
import { getErrorMessage } from "../../../Common/ErrorHandlingUtils";
import * as HeadersUtility from "../../../Common/HeadersUtility"; import * as HeadersUtility from "../../../Common/HeadersUtility";
import { MinimalQueryIterator } from "../../../Common/IteratorUtilities"; import { MinimalQueryIterator } from "../../../Common/IteratorUtilities";
import { queryIterator } from "../../../Common/MongoProxyClient"; import { queryIterator } from "../../../Common/MongoProxyClient";
@@ -43,7 +48,6 @@ import { queryDocuments } from "../../../Common/dataAccess/queryDocuments";
import { queryDocumentsPage } from "../../../Common/dataAccess/queryDocumentsPage"; import { queryDocumentsPage } from "../../../Common/dataAccess/queryDocumentsPage";
import * as DataModels from "../../../Contracts/DataModels"; import * as DataModels from "../../../Contracts/DataModels";
import * as ViewModels from "../../../Contracts/ViewModels"; import * as ViewModels from "../../../Contracts/ViewModels";
import * as StringUtility from "../../../Shared/StringUtility";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor"; import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
import { userContext } from "../../../UserContext"; import { userContext } from "../../../UserContext";
import * as QueryUtils from "../../../Utils/QueryUtils"; import * as QueryUtils from "../../../Utils/QueryUtils";
@@ -51,7 +55,6 @@ import { useSidePanel } from "../../../hooks/useSidePanel";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import { EditorReact } from "../../Controls/Editor/EditorReact"; import { EditorReact } from "../../Controls/Editor/EditorReact";
import Explorer from "../../Explorer"; import Explorer from "../../Explorer";
import { useCommandBar } from "../../Menus/CommandBar/CommandBarComponentAdapter";
import { BrowseQueriesPane } from "../../Panes/BrowseQueriesPane/BrowseQueriesPane"; import { BrowseQueriesPane } from "../../Panes/BrowseQueriesPane/BrowseQueriesPane";
import { SaveQueryPane } from "../../Panes/SaveQueryPane/SaveQueryPane"; import { SaveQueryPane } from "../../Panes/SaveQueryPane/SaveQueryPane";
import TabsBase from "../TabsBase"; import TabsBase from "../TabsBase";
@@ -102,8 +105,9 @@ interface IQueryTabStates {
toggleState: ToggleState; toggleState: ToggleState;
sqlQueryEditorContent: string; sqlQueryEditorContent: string;
selectedContent: string; selectedContent: string;
selection?: monaco.Selection;
executedSelection?: monaco.Selection; // We need to capture the selection that was used when executing, in case the user changes their section while the query is executing.
queryResults: ViewModels.QueryResults; queryResults: ViewModels.QueryResults;
error: string;
isExecutionError: boolean; isExecutionError: boolean;
isExecuting: boolean; isExecuting: boolean;
showCopilotSidebar: boolean; showCopilotSidebar: boolean;
@@ -112,9 +116,12 @@ interface IQueryTabStates {
copilotActive: boolean; copilotActive: boolean;
currentTabActive: boolean; currentTabActive: boolean;
queryResultsView: SplitterDirection; queryResultsView: SplitterDirection;
errors?: QueryError[];
modelMarkers?: monaco.editor.IMarkerData[];
} }
export const QueryTabFunctionComponent = (props: IQueryTabComponentProps): any => { export const QueryTabCopilotComponent = (props: IQueryTabComponentProps): any => {
const styles = useQueryTabStyles();
const copilotStore = useCopilotStore(); const copilotStore = useCopilotStore();
const isSampleCopilotActive = useSelectedNode.getState().isQueryCopilotCollectionSelected(); const isSampleCopilotActive = useSelectedNode.getState().isQueryCopilotCollectionSelected();
const queryTabProps = { const queryTabProps = {
@@ -125,10 +132,20 @@ export const QueryTabFunctionComponent = (props: IQueryTabComponentProps): any =
isSampleCopilotActive: isSampleCopilotActive, isSampleCopilotActive: isSampleCopilotActive,
copilotStore: copilotStore, copilotStore: copilotStore,
}; };
return <QueryTabComponent {...queryTabProps}></QueryTabComponent>; return <QueryTabComponentImpl styles={styles} {...queryTabProps}></QueryTabComponentImpl>;
}; };
export default class QueryTabComponent extends React.Component<IQueryTabComponentProps, IQueryTabStates> { export const QueryTabComponent = (props: IQueryTabComponentProps): any => {
const styles = useQueryTabStyles();
return <QueryTabComponentImpl styles={styles} {...props}></QueryTabComponentImpl>;
};
type QueryTabComponentImplProps = IQueryTabComponentProps & {
styles: QueryTabStyles;
};
// Inner (legacy) class component. We only use this component via one of the two functional components above (since we need to use the `useQueryTabStyles` hook).
class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps, IQueryTabStates> {
public queryEditorId: string; public queryEditorId: string;
public executeQueryButton: Button; public executeQueryButton: Button;
public saveQueryButton: Button; public saveQueryButton: Button;
@@ -139,16 +156,19 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
public isCopilotTabActive: boolean; public isCopilotTabActive: boolean;
private _iterator: MinimalQueryIterator; private _iterator: MinimalQueryIterator;
private queryAbortController: AbortController; private queryAbortController: AbortController;
queryEditor: React.RefObject<EditorReact>;
constructor(props: IQueryTabComponentProps) { constructor(props: QueryTabComponentImplProps) {
super(props); super(props);
this.queryEditor = createRef<EditorReact>();
this.state = { this.state = {
toggleState: ToggleState.Result, toggleState: ToggleState.Result,
sqlQueryEditorContent: props.isPreferredApiMongoDB ? "{}" : props.queryText || "SELECT * FROM c", sqlQueryEditorContent: props.isPreferredApiMongoDB ? "{}" : props.queryText || "SELECT * FROM c",
selectedContent: "", selectedContent: "",
queryResults: undefined, queryResults: undefined,
error: "", errors: [],
isExecutionError: this.props.isExecutionError, isExecutionError: this.props.isExecutionError,
isExecuting: false, isExecuting: false,
showCopilotSidebar: useQueryCopilot.getState().showCopilotSidebar, showCopilotSidebar: useQueryCopilot.getState().showCopilotSidebar,
@@ -189,13 +209,7 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
private _queryCopilotActive(): boolean { private _queryCopilotActive(): boolean {
if (this.props.copilotEnabled) { if (this.props.copilotEnabled) {
const cachedCopilotToggleStatus: string = localStorage.getItem( return readCopilotToggleStatus(userContext.databaseAccount);
`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`,
);
const copilotInitialActive: boolean = cachedCopilotToggleStatus
? StringUtility.toBoolean(cachedCopilotToggleStatus)
: true;
return copilotInitialActive;
} }
return false; return false;
} }
@@ -221,9 +235,10 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
public onExecuteQueryClick = async (): Promise<void> => { public onExecuteQueryClick = async (): Promise<void> => {
this._iterator = undefined; this._iterator = undefined;
setTimeout(async () => { setTimeout(async () => {
await this._executeQueryDocumentsPage(0); await this._executeQueryDocumentsPage(0);
}, 100); }, 100); // TODO: Revert this
if (this.state.copilotActive) { if (this.state.copilotActive) {
const query = this.state.sqlQueryEditorContent.split("\r\n")?.pop(); const query = this.state.sqlQueryEditorContent.split("\r\n")?.pop();
const isqueryEdited = this.props.copilotStore.generatedQuery && this.props.copilotStore.generatedQuery !== query; const isqueryEdited = this.props.copilotStore.generatedQuery && this.props.copilotStore.generatedQuery !== query;
@@ -302,23 +317,22 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
} }
private async _executeQueryDocumentsPage(firstItemIndex: number): Promise<void> { private async _executeQueryDocumentsPage(firstItemIndex: number): Promise<void> {
// Capture the query content and the selection being executed (if any).
const query = this.state.selectedContent || this.state.sqlQueryEditorContent;
const selection = this.state.selection;
this.setState({
// Track the executed selection so that we can evaluate error positions relative to it, even if the user changes their current selection.
executedSelection: selection,
});
this.queryAbortController = new AbortController(); this.queryAbortController = new AbortController();
if (this._iterator === undefined) { if (this._iterator === undefined) {
this._iterator = this.props.isPreferredApiMongoDB this._iterator = this.props.isPreferredApiMongoDB
? queryIterator( ? queryIterator(this.props.collection.databaseId, this.props.viewModelcollection, query)
this.props.collection.databaseId, : queryDocuments(this.props.collection.databaseId, this.props.collection.id(), query, {
this.props.viewModelcollection, enableCrossPartitionQuery: HeadersUtility.shouldEnableCrossPartitionKey(),
this.state.selectedContent || this.state.sqlQueryEditorContent, abortSignal: this.queryAbortController.signal,
) } as unknown as FeedOptions);
: queryDocuments(
this.props.collection.databaseId,
this.props.collection.id(),
this.state.selectedContent || this.state.sqlQueryEditorContent,
{
enableCrossPartitionQuery: HeadersUtility.shouldEnableCrossPartitionKey(),
abortSignal: this.queryAbortController.signal,
} as unknown as FeedOptions,
);
} }
await this._queryDocumentsPage(firstItemIndex); await this._queryDocumentsPage(firstItemIndex);
@@ -383,18 +397,22 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
firstItemIndex, firstItemIndex,
queryDocuments, queryDocuments,
); );
this.setState({ queryResults, error: "" }); this.setState({ queryResults, errors: [] });
} catch (error) { } catch (error) {
this.props.tabsBaseInstance.isExecutionError(true); this.props.tabsBaseInstance.isExecutionError(true);
this.setState({ this.setState({
isExecutionError: true, isExecutionError: true,
}); });
const errorMessage = getErrorMessage(error);
this.setState({
error: errorMessage,
});
document.getElementById("error-display").focus(); // Try to parse this as a query error
const queryErrors = QueryError.tryParse(
error,
createMonacoErrorLocationResolver(this.queryEditor.current.editor, this.state.executedSelection),
);
this.setState({
errors: queryErrors,
modelMarkers: createMonacoMarkersForQueryErrors(queryErrors),
});
} finally { } finally {
this.props.tabsBaseInstance.isExecuting(false); this.props.tabsBaseInstance.isExecuting(false);
this.setState({ this.setState({
@@ -560,7 +578,7 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
private _toggleCopilot = (active: boolean) => { private _toggleCopilot = (active: boolean) => {
this.setState({ copilotActive: active }); this.setState({ copilotActive: active });
useQueryCopilot.getState().setCopilotEnabledforExecution(active); useQueryCopilot.getState().setCopilotEnabledforExecution(active);
localStorage.setItem(`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`, active.toString()); saveCopilotToggleStatus(userContext.databaseAccount, active);
TelemetryProcessor.traceSuccess(active ? Action.ActivateQueryCopilot : Action.DeactivateQueryCopilot, { TelemetryProcessor.traceSuccess(active ? Action.ActivateQueryCopilot : Action.DeactivateQueryCopilot, {
databaseName: this.props.collection.databaseId, databaseName: this.props.collection.databaseId,
@@ -584,6 +602,9 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
this.setState({ this.setState({
sqlQueryEditorContent: newContent, sqlQueryEditorContent: newContent,
queryCopilotGeneratedQuery: "", queryCopilotGeneratedQuery: "",
// Clear the markers when the user edits the document.
modelMarkers: [],
}); });
if (this.isPreferredApiMongoDB) { if (this.isPreferredApiMongoDB) {
if (newContent.length > 0) { if (newContent.length > 0) {
@@ -604,14 +625,16 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
useCommandBar.getState().setContextButtons(this.getTabsButtons()); useCommandBar.getState().setContextButtons(this.getTabsButtons());
} }
public onSelectedContent(selectedContent: string): void { public onSelectedContent(selectedContent: string, selection: monaco.Selection): void {
if (selectedContent.trim().length > 0) { if (selectedContent.trim().length > 0) {
this.setState({ this.setState({
selectedContent, selectedContent,
selection,
}); });
} else { } else {
this.setState({ this.setState({
selectedContent: "", selectedContent: "",
selection: undefined,
}); });
} }
@@ -668,9 +691,10 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
} }
private getEditorAndQueryResult(): JSX.Element { private getEditorAndQueryResult(): JSX.Element {
const vertical = this.state.queryResultsView === SplitterDirection.Horizontal;
return ( return (
<Fragment> <Fragment>
<div className="tab-pane" id={this.props.tabId} role="tabpanel"> <CosmosFluentProvider id={this.props.tabId} className={this.props.styles.queryTab} role="tabpanel">
{this.props.copilotEnabled && this.state.currentTabActive && this.state.copilotActive && ( {this.props.copilotEnabled && this.state.currentTabActive && this.state.copilotActive && (
<QueryCopilotPromptbar <QueryCopilotPromptbar
explorer={this.props.collection.container} explorer={this.props.collection.container}
@@ -679,40 +703,33 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
containerId={this.props.collection.id()} containerId={this.props.collection.id()}
></QueryCopilotPromptbar> ></QueryCopilotPromptbar>
)} )}
<div className="tabPaneContentContainer"> {/* Set 'key' to the value of vertical to force re-rendering when vertical changes, to work around https://github.com/johnwalley/allotment/issues/457 */}
<SplitterLayout <Allotment key={vertical.toString()} vertical={vertical}>
primaryIndex={0} <Allotment.Pane data-test="QueryTab/EditorPane">
primaryMinSize={20} <EditorReact
secondaryMinSize={20} ref={this.queryEditor}
// Percentage is a bit better when the splitter flips from vertical to horizontal. className={this.props.styles.queryEditor}
percentage={true} language={"sql"}
// NOTE: It is intentional that this looks reversed! content={this.getEditorContent()}
// The 'vertical' property refers to the stacking of the panes so is the opposite of the orientation of the splitter itself modelMarkers={this.state.modelMarkers}
// (vertically stacked => horizontal splitter) isReadOnly={false}
// Our setting refers to the orientation of the splitter, so we need to reverse it here. wordWrap={"on"}
vertical={this.state.queryResultsView === SplitterDirection.Horizontal} ariaLabel={"Editing Query"}
> lineNumbers={"on"}
<Fragment> onContentChanged={(newContent: string) => this.onChangeContent(newContent)}
<div className="queryEditor" style={{ height: "100%" }}> onContentSelected={(selectedContent: string, selection: monaco.Selection) =>
<EditorReact this.onSelectedContent(selectedContent, selection)
language={"sql"} }
content={this.getEditorContent()} />
isReadOnly={false} </Allotment.Pane>
wordWrap={"on"} <Allotment.Pane>
ariaLabel={"Editing Query"}
lineNumbers={"on"}
onContentChanged={(newContent: string) => this.onChangeContent(newContent)}
onContentSelected={(selectedContent: string) => this.onSelectedContent(selectedContent)}
/>
</div>
</Fragment>
{this.props.isSampleCopilotActive ? ( {this.props.isSampleCopilotActive ? (
<QueryResultSection <QueryResultSection
isMongoDB={this.props.isPreferredApiMongoDB} isMongoDB={this.props.isPreferredApiMongoDB}
queryEditorContent={this.state.sqlQueryEditorContent} queryEditorContent={this.state.sqlQueryEditorContent}
error={this.props.copilotStore?.errorMessage} errors={this.props.copilotStore?.errors}
queryResults={this.props.copilotStore?.queryResults}
isExecuting={this.props.copilotStore?.isExecuting} isExecuting={this.props.copilotStore?.isExecuting}
queryResults={this.props.copilotStore?.queryResults}
executeQueryDocumentsPage={(firstItemIndex: number) => executeQueryDocumentsPage={(firstItemIndex: number) =>
QueryDocumentsPerPage( QueryDocumentsPerPage(
firstItemIndex, firstItemIndex,
@@ -725,17 +742,17 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
<QueryResultSection <QueryResultSection
isMongoDB={this.props.isPreferredApiMongoDB} isMongoDB={this.props.isPreferredApiMongoDB}
queryEditorContent={this.state.sqlQueryEditorContent} queryEditorContent={this.state.sqlQueryEditorContent}
error={this.state.error} errors={this.state.errors}
queryResults={this.state.queryResults}
isExecuting={this.state.isExecuting} isExecuting={this.state.isExecuting}
queryResults={this.state.queryResults}
executeQueryDocumentsPage={(firstItemIndex: number) => executeQueryDocumentsPage={(firstItemIndex: number) =>
this._executeQueryDocumentsPage(firstItemIndex) this._executeQueryDocumentsPage(firstItemIndex)
} }
/> />
)} )}
</SplitterLayout> </Allotment.Pane>
</div> </Allotment>
</div> </CosmosFluentProvider>
{this.props.copilotEnabled && this.props.copilotStore?.showFeedbackModal && ( {this.props.copilotEnabled && this.props.copilotStore?.showFeedbackModal && (
<QueryCopilotFeedbackModal <QueryCopilotFeedbackModal
explorer={this.props.collection.container} explorer={this.props.collection.container}
@@ -751,7 +768,7 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
render(): JSX.Element { render(): JSX.Element {
const shouldScaleElements = this.state.showCopilotSidebar && this.isCopilotTabActive; const shouldScaleElements = this.state.showCopilotSidebar && this.isCopilotTabActive;
return ( return (
<div style={{ display: "flex", flexDirection: "row", height: "100%" }}> <div data-test="QueryTab" style={{ display: "flex", flexDirection: "row", height: "100%" }}>
<div style={{ width: shouldScaleElements ? "70%" : "100%", height: "100%" }}> <div style={{ width: shouldScaleElements ? "70%" : "100%", height: "100%" }}>
{this.getEditorAndQueryResult()} {this.getEditorAndQueryResult()}
</div> </div>

View File

@@ -0,0 +1,396 @@
import {
Button,
DataGrid,
DataGridBody,
DataGridCell,
DataGridHeader,
DataGridHeaderCell,
DataGridRow,
SelectTabData,
SelectTabEvent,
Tab,
TabList,
TableColumnDefinition,
createTableColumn,
} from "@fluentui/react-components";
import { ArrowDownloadRegular, CopyRegular } from "@fluentui/react-icons";
import { HttpHeaders } from "Common/Constants";
import MongoUtility from "Common/MongoUtility";
import { QueryMetrics } from "Contracts/DataModels";
import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
import { IDocument } from "Explorer/Tabs/QueryTab/QueryTabComponent";
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
import { userContext } from "UserContext";
import copy from "clipboard-copy";
import React, { useCallback, useState } from "react";
import { ResultsViewProps } from "./QueryResultSection";
enum ResultsTabs {
Results = "results",
QueryStats = "queryStats",
}
const ResultsTab: React.FC<ResultsViewProps> = ({ queryResults, isMongoDB, executeQueryDocumentsPage }) => {
const styles = useQueryTabStyles();
const queryResultsString = queryResults
? isMongoDB
? MongoUtility.tojson(queryResults.documents, undefined, false)
: JSON.stringify(queryResults.documents, undefined, 4)
: "";
const onClickCopyResults = (): void => {
copy(queryResultsString);
};
const onFetchNextPageClick = async (): Promise<void> => {
const { firstItemIndex, itemCount } = queryResults;
await executeQueryDocumentsPage(firstItemIndex + itemCount - 1);
};
return (
<>
<div className={styles.queryResultsBar}>
<div>
{queryResults.itemCount > 0 ? `${queryResults.firstItemIndex} - ${queryResults.lastItemIndex}` : `0 - 0`}
</div>
{queryResults.hasMoreResults && (
<a href="#" onClick={() => onFetchNextPageClick()}>
Load more
</a>
)}
<div className={styles.flexGrowSpacer} />
<Button
size="small"
appearance="transparent"
icon={<CopyRegular />}
title="Copy to Clipboard"
aria-label="Copy"
onClick={onClickCopyResults}
/>
</div>
<div className={styles.queryResultsViewer}>
<EditorReact language={"json"} content={queryResultsString} isReadOnly={true} ariaLabel={"Query results"} />
</div>
</>
);
};
const QueryStatsTab: React.FC<Pick<ResultsViewProps, "queryResults">> = ({ queryResults }) => {
const styles = useQueryTabStyles();
const queryMetrics = React.useRef(queryResults?.headers?.[HttpHeaders.queryMetrics]);
React.useEffect(() => {
const latestQueryMetrics = queryResults?.headers?.[HttpHeaders.queryMetrics];
if (latestQueryMetrics && Object.keys(latestQueryMetrics).length > 0) {
queryMetrics.current = latestQueryMetrics;
}
}, [queryResults]);
const getAggregatedQueryMetrics = (): QueryMetrics => {
const aggregatedQueryMetrics = {
documentLoadTime: 0,
documentWriteTime: 0,
indexHitDocumentCount: 0,
outputDocumentCount: 0,
outputDocumentSize: 0,
indexLookupTime: 0,
retrievedDocumentCount: 0,
retrievedDocumentSize: 0,
vmExecutionTime: 0,
runtimeExecutionTimes: {
queryEngineExecutionTime: 0,
systemFunctionExecutionTime: 0,
userDefinedFunctionExecutionTime: 0,
},
totalQueryExecutionTime: 0,
} as QueryMetrics;
if (queryMetrics.current) {
Object.keys(queryMetrics.current).forEach((partitionKeyRangeId) => {
const queryMetricsPerPartition = queryMetrics.current[partitionKeyRangeId];
if (!queryMetricsPerPartition) {
return;
}
aggregatedQueryMetrics.documentLoadTime += queryMetricsPerPartition.documentLoadTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.documentWriteTime +=
queryMetricsPerPartition.documentWriteTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.indexHitDocumentCount += queryMetricsPerPartition.indexHitDocumentCount || 0;
aggregatedQueryMetrics.outputDocumentCount += queryMetricsPerPartition.outputDocumentCount || 0;
aggregatedQueryMetrics.outputDocumentSize += queryMetricsPerPartition.outputDocumentSize || 0;
aggregatedQueryMetrics.indexLookupTime += queryMetricsPerPartition.indexLookupTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.retrievedDocumentCount += queryMetricsPerPartition.retrievedDocumentCount || 0;
aggregatedQueryMetrics.retrievedDocumentSize += queryMetricsPerPartition.retrievedDocumentSize || 0;
aggregatedQueryMetrics.vmExecutionTime += queryMetricsPerPartition.vmExecutionTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.totalQueryExecutionTime +=
queryMetricsPerPartition.totalQueryExecutionTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.runtimeExecutionTimes.queryEngineExecutionTime +=
queryMetricsPerPartition.runtimeExecutionTimes?.queryEngineExecutionTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.runtimeExecutionTimes.systemFunctionExecutionTime +=
queryMetricsPerPartition.runtimeExecutionTimes?.systemFunctionExecutionTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.runtimeExecutionTimes.userDefinedFunctionExecutionTime +=
queryMetricsPerPartition.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.totalMilliseconds() || 0;
});
}
return aggregatedQueryMetrics;
};
const columns: TableColumnDefinition<IDocument>[] = [
createTableColumn<IDocument>({
columnId: "metric",
renderHeaderCell: () => "Metric",
renderCell: (item) => item.metric,
}),
createTableColumn<IDocument>({
columnId: "value",
renderHeaderCell: () => "Value",
renderCell: (item) => item.value,
}),
];
const generateQueryStatsItems = (): IDocument[] => {
const items: IDocument[] = [
{
metric: "Request Charge",
value: `${queryResults.requestCharge} RUs`,
toolTip: "Request Charge",
},
{
metric: "Showing Results",
value: queryResults.itemCount > 0 ? `${queryResults.firstItemIndex} - ${queryResults.lastItemIndex}` : `0 - 0`,
toolTip: "Showing Results",
},
];
if (userContext.apiType === "SQL") {
const aggregatedQueryMetrics = getAggregatedQueryMetrics();
items.push(
{
metric: "Retrieved document count",
value: aggregatedQueryMetrics.retrievedDocumentCount?.toString() || "",
toolTip: "Total number of retrieved documents",
},
{
metric: "Retrieved document size",
value: `${aggregatedQueryMetrics.retrievedDocumentSize?.toString() || 0} bytes`,
toolTip: "Total size of retrieved documents in bytes",
},
{
metric: "Output document count",
value: aggregatedQueryMetrics.outputDocumentCount?.toString() || "",
toolTip: "Number of output documents",
},
{
metric: "Output document size",
value: `${aggregatedQueryMetrics.outputDocumentSize?.toString() || 0} bytes`,
toolTip: "Total size of output documents in bytes",
},
{
metric: "Index hit document count",
value: aggregatedQueryMetrics.indexHitDocumentCount?.toString() || "",
toolTip: "Total number of documents matched by the filter",
},
{
metric: "Index lookup time",
value: `${aggregatedQueryMetrics.indexLookupTime?.toString() || 0} ms`,
toolTip: "Time spent in physical index layer",
},
{
metric: "Document load time",
value: `${aggregatedQueryMetrics.documentLoadTime?.toString() || 0} ms`,
toolTip: "Time spent in loading documents",
},
{
metric: "Query engine execution time",
value: `${aggregatedQueryMetrics.runtimeExecutionTimes?.queryEngineExecutionTime?.toString() || 0} ms`,
toolTip:
"Time spent by the query engine to execute the query expression (excludes other execution times like load documents or write results)",
},
{
metric: "System function execution time",
value: `${aggregatedQueryMetrics.runtimeExecutionTimes?.systemFunctionExecutionTime?.toString() || 0} ms`,
toolTip: "Total time spent executing system (built-in) functions",
},
{
metric: "User defined function execution time",
value: `${
aggregatedQueryMetrics.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.toString() || 0
} ms`,
toolTip: "Total time spent executing user-defined functions",
},
{
metric: "Document write time",
value: `${aggregatedQueryMetrics.documentWriteTime.toString() || 0} ms`,
toolTip: "Time spent to write query result set to response buffer",
},
);
}
if (queryResults.roundTrips) {
items.push({
metric: "Round Trips",
value: queryResults.roundTrips?.toString(),
toolTip: "Number of round trips",
});
}
if (queryResults.activityId) {
items.push({
metric: "Activity id",
value: queryResults.activityId,
toolTip: "",
});
}
return items;
};
const generateQueryMetricsCsvData = (): string => {
if (queryMetrics.current) {
let csvData =
[
"Partition key range id",
"Retrieved document count",
"Retrieved document size (in bytes)",
"Output document count",
"Output document size (in bytes)",
"Index hit document count",
"Index lookup time (ms)",
"Document load time (ms)",
"Query engine execution time (ms)",
"System function execution time (ms)",
"User defined function execution time (ms)",
"Document write time (ms)",
].join(",") + "\n";
Object.keys(queryMetrics.current).forEach((partitionKeyRangeId) => {
const queryMetricsPerPartition = queryMetrics.current[partitionKeyRangeId];
csvData +=
[
partitionKeyRangeId,
queryMetricsPerPartition.retrievedDocumentCount,
queryMetricsPerPartition.retrievedDocumentSize,
queryMetricsPerPartition.outputDocumentCount,
queryMetricsPerPartition.outputDocumentSize,
queryMetricsPerPartition.indexHitDocumentCount,
queryMetricsPerPartition.indexLookupTime?.totalMilliseconds(),
queryMetricsPerPartition.documentLoadTime?.totalMilliseconds(),
queryMetricsPerPartition.runtimeExecutionTimes?.queryEngineExecutionTime?.totalMilliseconds(),
queryMetricsPerPartition.runtimeExecutionTimes?.systemFunctionExecutionTime?.totalMilliseconds(),
queryMetricsPerPartition.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.totalMilliseconds(),
queryMetricsPerPartition.documentWriteTime?.totalMilliseconds(),
].join(",") + "\n";
});
return csvData;
}
return undefined;
};
const downloadQueryMetricsCsvData = (): void => {
const csvData: string = generateQueryMetricsCsvData();
if (!csvData) {
return;
}
if (navigator.msSaveBlob) {
// for IE and Edge
navigator.msSaveBlob(
new Blob([csvData], { type: "data:text/csv;charset=utf-8" }),
"PerPartitionQueryMetrics.csv",
);
} else {
const downloadLink: HTMLAnchorElement = document.createElement("a");
downloadLink.href = "data:text/csv;charset=utf-8," + encodeURI(csvData);
downloadLink.target = "_self";
downloadLink.download = "QueryMetricsPerPartition.csv";
// for some reason, FF displays the download prompt only when
// the link is added to the dom so we add and remove it
document.body.appendChild(downloadLink);
downloadLink.click();
downloadLink.remove();
}
};
const onDownloadQueryMetricsCsvClick = (): boolean => {
downloadQueryMetricsCsvData();
return false;
};
return (
<div className={styles.metricsGridContainer}>
<DataGrid
data-test="QueryTab/ResultsPane/ResultsView/QueryStatsList"
className={styles.queryStatsGrid}
items={generateQueryStatsItems()}
columns={columns}
sortable
getRowId={(item) => item.metric}
focusMode="composite"
>
<DataGridHeader>
<DataGridRow>
{({ renderHeaderCell }) => <DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>}
</DataGridRow>
</DataGridHeader>
<DataGridBody<IDocument>>
{({ item, rowId }) => (
<DataGridRow<IDocument> key={rowId} data-test={`Row:${rowId}`}>
{({ columnId, renderCell }) => (
<DataGridCell data-test={`Row:${rowId}/Column:${columnId}`}>{renderCell(item)}</DataGridCell>
)}
</DataGridRow>
)}
</DataGridBody>
</DataGrid>
<div className={styles.metricsGridButtons}>
{userContext.apiType === "SQL" && (
<Button appearance="subtle" onClick={() => onDownloadQueryMetricsCsvClick()} icon={<ArrowDownloadRegular />}>
Per-partition query metrics (CSV)
</Button>
)}
</div>
</div>
);
};
export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResults, executeQueryDocumentsPage }) => {
const styles = useQueryTabStyles();
const [activeTab, setActiveTab] = useState<ResultsTabs>(ResultsTabs.Results);
const onTabSelect = useCallback((event: SelectTabEvent, data: SelectTabData) => {
setActiveTab(data.value as ResultsTabs);
}, []);
return (
<div data-test="QueryTab/ResultsPane/ResultsView" className={styles.queryResultsTabPanel}>
<TabList selectedValue={activeTab} onTabSelect={onTabSelect}>
<Tab
data-test="QueryTab/ResultsPane/ResultsView/ResultsTab"
id={ResultsTabs.Results}
value={ResultsTabs.Results}
>
Results
</Tab>
<Tab
data-test="QueryTab/ResultsPane/ResultsView/QueryStatsTab"
id={ResultsTabs.QueryStats}
value={ResultsTabs.QueryStats}
>
Query Stats
</Tab>
</TabList>
<div className={styles.queryResultsTabContentContainer}>
{activeTab === ResultsTabs.Results && (
<ResultsTab
queryResults={queryResults}
isMongoDB={isMongoDB}
executeQueryDocumentsPage={executeQueryDocumentsPage}
/>
)}
{activeTab === ResultsTabs.QueryStats && <QueryStatsTab queryResults={queryResults} />}
</div>
</div>
);
};

View File

@@ -0,0 +1,96 @@
import { makeStyles, shorthands } from "@fluentui/react-components";
import { cosmosShorthands } from "Explorer/Theme/ThemeUtil";
export type QueryTabStyles = ReturnType<typeof useQueryTabStyles>;
export const useQueryTabStyles = makeStyles({
queryTab: {
height: "100%",
display: "flex",
flexDirection: "column",
},
queryEditor: {
...shorthands.border("none"),
paddingTop: "4px",
height: "100%",
width: "100%",
},
executeCallToAction: {
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
textAlign: "center",
},
queryResultsPanel: {
height: "100%",
display: "flex",
flexDirection: "column",
},
queryResultsMessage: {
...shorthands.margin("5px"),
},
queryResultsBody: {
flexGrow: 1,
justifySelf: "stretch",
},
queryResultsTabPanel: {
height: "100%",
display: "flex",
rowGap: "12px",
flexDirection: "column",
},
queryResultsTabContentContainer: {
display: "flex",
flexDirection: "column",
flexGrow: 1,
paddingLeft: "12px",
paddingRight: "12px",
overflow: "auto",
},
queryResultsViewer: {
flexGrow: 1,
},
queryResultsBar: {
display: "flex",
flexDirection: "row",
columnGap: "4px",
paddingBottom: "4px",
},
flexGrowSpacer: {
flexGrow: 1,
},
queryStatsGrid: {
flexGrow: 1,
overflow: "auto",
},
metricsGridContainer: {
display: "flex",
flexDirection: "column",
paddingBottom: "6px",
maxHeight: "100%",
},
metricsGridButtons: {
...cosmosShorthands.borderTop(),
},
errorListTableCell: {
textOverflow: "ellipsis",
whiteSpace: "nowrap",
overflow: "hidden",
},
errorListMessageCell: {
display: "flex",
flexDirection: "row",
width: "100%",
alignItems: "center",
},
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 { Resource, StoredProcedureDefinition } from "@azure/cosmos";
import { Pivot, PivotItem } from "@fluentui/react"; import { Pivot, PivotItem } from "@fluentui/react";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { KeyboardAction } from "KeyboardShortcuts"; import { KeyboardAction } from "KeyboardShortcuts";
import React from "react"; import React from "react";
import ExecuteQueryIcon from "../../../../images/ExecuteQuery.svg"; import ExecuteQueryIcon from "../../../../images/ExecuteQuery.svg";
@@ -15,7 +16,6 @@ import { useTabs } from "../../../hooks/useTabs";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import { EditorReact } from "../../Controls/Editor/EditorReact"; import { EditorReact } from "../../Controls/Editor/EditorReact";
import Explorer from "../../Explorer"; import Explorer from "../../Explorer";
import { useCommandBar } from "../../Menus/CommandBar/CommandBarComponentAdapter";
import StoredProcedure from "../../Tree/StoredProcedure"; import StoredProcedure from "../../Tree/StoredProcedure";
import { useSelectedNode } from "../../useSelectedNode"; import { useSelectedNode } from "../../useSelectedNode";
import ScriptTabBase from "../ScriptTabBase"; 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 { CassandraProxyEndpoints, MongoProxyEndpoints } from "Common/Constants";
import { sendMessage } from "Common/MessageHandler"; import { sendMessage } from "Common/MessageHandler";
import { Platform, configContext, updateConfigContext } from "ConfigContext"; import { configContext } from "ConfigContext";
import { IpRule } from "Contracts/DataModels"; import { IpRule } from "Contracts/DataModels";
import { MessageTypes } from "Contracts/ExplorerContracts"; import { MessageTypes } from "Contracts/ExplorerContracts";
import { CollectionTabKind } from "Contracts/ViewModels"; import { CollectionTabKind } from "Contracts/ViewModels";
import Explorer from "Explorer/Explorer"; 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 { QueryCopilotTab } from "Explorer/QueryCopilot/QueryCopilotTab";
import { SplashScreen } from "Explorer/SplashScreen/SplashScreen"; import { SplashScreen } from "Explorer/SplashScreen/SplashScreen";
import { ConnectTab } from "Explorer/Tabs/ConnectTab"; import { ConnectTab } from "Explorer/Tabs/ConnectTab";
@@ -16,7 +17,6 @@ import { VcoreMongoConnectTab } from "Explorer/Tabs/VCoreMongoConnectTab";
import { VcoreMongoQuickstartTab } from "Explorer/Tabs/VCoreMongoQuickstartTab"; import { VcoreMongoQuickstartTab } from "Explorer/Tabs/VCoreMongoQuickstartTab";
import { LayoutConstants } from "Explorer/Theme/ThemeUtil"; import { LayoutConstants } from "Explorer/Theme/ThemeUtil";
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts"; import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
import { hasRUThresholdBeenConfigured } from "Shared/StorageUtility";
import { userContext } from "UserContext"; import { userContext } from "UserContext";
import { CassandraProxyOutboundIPs, MongoProxyOutboundIPs, PortalBackendIPs } from "Utils/EndpointUtils"; import { CassandraProxyOutboundIPs, MongoProxyOutboundIPs, PortalBackendIPs } from "Utils/EndpointUtils";
import { useTeachingBubble } from "hooks/useTeachingBubble"; import { useTeachingBubble } from "hooks/useTeachingBubble";
@@ -37,9 +37,6 @@ interface TabsProps {
export const Tabs = ({ explorer }: TabsProps): JSX.Element => { export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
const { openedTabs, openedReactTabs, activeTab, activeReactTab, networkSettingsWarning } = useTabs(); const { openedTabs, openedReactTabs, activeTab, activeReactTab, networkSettingsWarning } = useTabs();
const [showRUThresholdMessageBar, setShowRUThresholdMessageBar] = useState<boolean>(
userContext.apiType === "SQL" && configContext.platform !== Platform.Fabric && !hasRUThresholdBeenConfigured(),
);
const [ const [
showMongoAndCassandraProxiesNetworkSettingsWarningState, showMongoAndCassandraProxiesNetworkSettingsWarningState,
setShowMongoAndCassandraProxiesNetworkSettingsWarningState, setShowMongoAndCassandraProxiesNetworkSettingsWarningState,
@@ -57,7 +54,8 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
const defaultMessageBarStyles: IMessageBarStyles = { const defaultMessageBarStyles: IMessageBarStyles = {
root: { root: {
height: `${LayoutConstants.rowHeight}px`, height: `${LayoutConstants.rowHeight}px`,
overflow: "auto", overflow: "hidden",
flexDirection: "row",
}, },
}; };
@@ -86,30 +84,6 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
{networkSettingsWarning} {networkSettingsWarning}
</MessageBar> </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 && ( {showMongoAndCassandraProxiesNetworkSettingsWarningState && (
<MessageBar <MessageBar
messageBarType={MessageBarType.warning} messageBarType={MessageBarType.warning}
@@ -118,7 +92,7 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
setShowMongoAndCassandraProxiesNetworkSettingsWarningState(false); 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.`} re-enable "Allow access from Azure Portal" on the Networking blade for your account.`}
</MessageBar> </MessageBar>
)} )}
@@ -133,6 +107,7 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
</ul> </ul>
</div> </div>
<div className="tabPanesContainer"> <div className="tabPanesContainer">
{userContext.features.commandBarV2 && <CommandBarV2 explorer={explorer} />}
{activeReactTab !== undefined && getReactTabContent(activeReactTab, explorer)} {activeReactTab !== undefined && getReactTabContent(activeReactTab, explorer)}
{openedTabs.map((tab) => ( {openedTabs.map((tab) => (
<TabPane key={tab.tabId} tab={tab} active={activeTab === tab} /> <TabPane key={tab.tabId} tab={tab} active={activeTab === tab} />
@@ -298,11 +273,15 @@ function TabPane({ tab, active }: { tab: Tab; active: boolean }) {
if (tab) { if (tab) {
if ("render" in tab) { if ("render" in tab) {
return <div {...attrs}>{tab.render()}</div>; return (
<div data-test={`Tab:${tab.tabId}`} {...attrs}>
{tab.render()}
</div>
);
} }
} }
return <div {...attrs} ref={ref} data-bind="html:html" />; return <div data-test={`Tab:${tab.tabId}`} {...attrs} ref={ref} data-bind="html:html" />;
} }
const onKeyPressReactTab = (e: KeyboardEvent, tabKind: ReactTabKind): void => { const onKeyPressReactTab = (e: KeyboardEvent, tabKind: ReactTabKind): void => {
@@ -393,12 +372,6 @@ const showMongoAndCassandraProxiesNetworkSettingsWarning = (): boolean => {
ipAddressesFromIPRules.includes(mongoProxyOutboundIP), ipAddressesFromIPRules.includes(mongoProxyOutboundIP),
); );
if (ipRulesIncludeMongoProxy) {
updateConfigContext({
MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED: true,
});
}
return !ipRulesIncludeMongoProxy; return !ipRulesIncludeMongoProxy;
} else if (userContext.apiType === "Cassandra") { } else if (userContext.apiType === "Cassandra") {
const isProdOrMpacCassandraProxyEndpoint: boolean = [ const isProdOrMpacCassandraProxyEndpoint: boolean = [
@@ -417,12 +390,6 @@ const showMongoAndCassandraProxiesNetworkSettingsWarning = (): boolean => {
(cassandraProxyOutboundIP: string) => ipAddressesFromIPRules.includes(cassandraProxyOutboundIP), (cassandraProxyOutboundIP: string) => ipAddressesFromIPRules.includes(cassandraProxyOutboundIP),
); );
if (ipRulesIncludeCassandraProxy) {
updateConfigContext({
CASSANDRA_PROXY_OUTBOUND_IPS_ALLOWLISTED: true,
});
}
return !ipRulesIncludeCassandraProxy; return !ipRulesIncludeCassandraProxy;
} }
} }

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
import { import {
BrandVariants, BrandVariants,
ComponentProps,
FluentProvider, FluentProvider,
FluentProviderSlots,
Theme, Theme,
createLightTheme, createLightTheme,
makeStyles, makeStyles,
@@ -10,16 +12,19 @@ import {
webLightTheme, webLightTheme,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { Platform, configContext } from "ConfigContext"; import { Platform, configContext } from "ConfigContext";
import React, { PropsWithChildren } from "react"; import React from "react";
import { appThemeFabricTealBrandRamp } from "../../Platform/Fabric/FabricTheme"; import { appThemeFabricTealBrandRamp } from "../../Platform/Fabric/FabricTheme";
export const LayoutConstants = { export const LayoutConstants = {
rowHeight: 36, rowHeight: 32,
}; };
export type CosmosFluentProviderProps = PropsWithChildren<{ // Our CosmosFluentProvider has the same props as a FluentProvider.
className?: string; export type CosmosFluentProviderProps = Omit<ComponentProps<FluentProviderSlots, "root">, "dir">;
}>;
// PropsWithChildren<{
// className?: string;
// }>;
const useDefaultRootStyles = makeStyles({ const useDefaultRootStyles = makeStyles({
fluentProvider: { fluentProvider: {
@@ -32,15 +37,37 @@ const useDefaultRootStyles = makeStyles({
}, },
}); });
export const CosmosFluentProvider: React.FC<CosmosFluentProviderProps> = ({ children, className }) => { const FluentProviderContext = React.createContext({
isInFluentProvider: false,
});
export const CosmosFluentProvider: React.FC<CosmosFluentProviderProps> = ({ children, className, ...props }) => {
// We use a React context to ensure that nested CosmosFluentProviders don't create nested FluentProviders.
// This helps during the transition from Fluent UI 8 to Fluent UI 9.
// As we convert components to Fluent UI 9, if we end up with nested FluentProviders, the inner FluentProvider will be a no-op.
const { isInFluentProvider } = React.useContext(FluentProviderContext);
const styles = useDefaultRootStyles(); const styles = useDefaultRootStyles();
if (isInFluentProvider) {
// We're already in a fluent context, don't create another.
console.warn("Nested CosmosFluentProvider detected. This is likely a bug.");
return (
<div className={className} {...props}>
{children}
</div>
);
}
return ( return (
<FluentProvider <FluentProviderContext.Provider value={{ isInFluentProvider: true }}>
theme={getPlatformTheme(configContext.platform)} <FluentProvider
className={mergeClasses(styles.fluentProvider, className)} theme={getPlatformTheme(configContext.platform)}
> className={mergeClasses(styles.fluentProvider, className)}
{children} {...props}
</FluentProvider> >
{children}
</FluentProvider>
</FluentProviderContext.Provider>
); );
}; };
@@ -64,15 +91,30 @@ const appThemePortalBrandRamp: BrandVariants = {
160: "#CDD8EF", 160: "#CDD8EF",
}; };
const cosmosThemeElements = { export enum LayoutSize {
layoutRowHeight: `${LayoutConstants.rowHeight}px`, 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", sidebarMinimumWidth: "200px",
sidebarInitialWidth: "300px", sidebarInitialWidth: "300px",
}; };
export type CosmosTheme = Theme & typeof cosmosThemeElements; export const tokens = themeToTokensObject({ ...webLightTheme, ...cosmosTheme, ...sizeMappings[LayoutSize.Compact] });
export const tokens = themeToTokensObject({ ...webLightTheme, ...cosmosThemeElements });
export const cosmosShorthands = { export const cosmosShorthands = {
border: () => shorthands.border("1px", "solid", tokens.colorNeutralStroke2), border: () => shorthands.border("1px", "solid", tokens.colorNeutralStroke2),
@@ -90,6 +132,7 @@ export function getPlatformTheme(platform: Platform): CosmosTheme {
return { return {
...baseTheme, ...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 { Resource, StoredProcedureDefinition, TriggerDefinition, UserDefinedFunctionDefinition } from "@azure/cosmos";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { useNotebook } from "Explorer/Notebook/useNotebook"; import { useNotebook } from "Explorer/Notebook/useNotebook";
import { DocumentsTabV2 } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2"; import { DocumentsTabV2 } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2";
import * as ko from "knockout"; import * as ko from "knockout";
import * as _ from "underscore"; import * as _ from "underscore";
import * as Constants from "../../Common/Constants"; import * as Constants from "../../Common/Constants";
import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils"; import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils";
import * as Logger from "../../Common/Logger";
import { fetchPortalNotifications } from "../../Common/PortalNotifications";
import { bulkCreateDocument } from "../../Common/dataAccess/bulkCreateDocument"; import { bulkCreateDocument } from "../../Common/dataAccess/bulkCreateDocument";
import { createDocument } from "../../Common/dataAccess/createDocument"; import { createDocument } from "../../Common/dataAccess/createDocument";
import { getCollectionUsageSizeInKB } from "../../Common/dataAccess/getCollectionDataUsageSize"; import { getCollectionUsageSizeInKB } from "../../Common/dataAccess/getCollectionDataUsageSize";
@@ -25,7 +24,6 @@ import { logConsoleInfo } from "../../Utils/NotificationConsoleUtils";
import { SqlTriggerResource } from "../../Utils/arm/generatedClients/cosmos/types"; import { SqlTriggerResource } from "../../Utils/arm/generatedClients/cosmos/types";
import { useTabs } from "../../hooks/useTabs"; import { useTabs } from "../../hooks/useTabs";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import { CassandraAPIDataClient, CassandraTableKey, CassandraTableKeys } from "../Tables/TableDataClient"; import { CassandraAPIDataClient, CassandraTableKey, CassandraTableKeys } from "../Tables/TableDataClient";
import ConflictsTab from "../Tabs/ConflictsTab"; import ConflictsTab from "../Tabs/ConflictsTab";
import GraphTab from "../Tabs/GraphTab"; import GraphTab from "../Tabs/GraphTab";
@@ -1020,41 +1018,6 @@ export default class Collection implements ViewModels.Collection {
this.uploadFiles(event.originalEvent.dataTransfer.files); 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[] }> { public async uploadFiles(files: FileList): Promise<{ data: UploadDetailsRecord[] }> {
const data = await Promise.all(Array.from(files).map((file) => this.uploadFile(file))); 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 { AuthType } from "../../AuthType";
import * as Constants from "../../Common/Constants"; import * as Constants from "../../Common/Constants";
import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils"; 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 { readCollections, readCollectionsWithPagination } from "../../Common/dataAccess/readCollections";
import { readDatabaseOffer } from "../../Common/dataAccess/readDatabaseOffer"; import { readDatabaseOffer } from "../../Common/dataAccess/readDatabaseOffer";
import * as DataModels from "../../Contracts/DataModels"; import * as DataModels from "../../Contracts/DataModels";
@@ -76,7 +74,6 @@ export default class Database implements ViewModels.Database {
await useDatabases.getState().loadAllOffers(); await useDatabases.getState().loadAllOffers();
} }
const pendingNotificationsPromise: Promise<DataModels.Notification> = this.getPendingThroughputSplitNotification();
const tabKind = ViewModels.CollectionTabKind.DatabaseSettingsV2; const tabKind = ViewModels.CollectionTabKind.DatabaseSettingsV2;
const matchingTabs = useTabs.getState().getTabs(tabKind, (tab) => tab.node?.id() === this.id()); const matchingTabs = useTabs.getState().getTabs(tabKind, (tab) => tab.node?.id() === this.id());
let settingsTab = matchingTabs?.[0] as DatabaseSettingsTabV2; let settingsTab = matchingTabs?.[0] as DatabaseSettingsTabV2;
@@ -87,53 +84,39 @@ export default class Database implements ViewModels.Database {
dataExplorerArea: Constants.Areas.Tab, dataExplorerArea: Constants.Areas.Tab,
tabTitle: "Scale", 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, try {
tabTitle: "Scale", const tabOptions: ViewModels.TabOptions = {
error: errorMessage, tabKind,
errorStack: getErrorStack(error), title: "Scale",
}, tabPath: "",
startKey, node: this,
); rid: this.rid,
logConsoleError(`Error while fetching database settings for database ${this.id()}: ${errorMessage}`); database: this,
throw error; 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 { } else {
pendingNotificationsPromise.then( useTabs.getState().activateTab(settingsTab);
(pendingNotification: DataModels.Notification) => {
settingsTab.pendingNotification(pendingNotification);
useTabs.getState().activateTab(settingsTab);
},
() => {
settingsTab.pendingNotification(undefined);
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[]): { private getDeltaCollections(updatedCollectionsList: DataModels.Collection[]): {
toAdd: DataModels.Collection[]; toAdd: DataModels.Collection[];
toDelete: Collection[]; toDelete: Collection[];

View File

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

View File

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

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