mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-29 05:41:40 +00:00
Compare commits
110 Commits
NPS_Dialog
...
users/sind
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6473adf40 | ||
|
|
451316cad4 | ||
|
|
b456e53b2f | ||
|
|
ac2e2a6f8e | ||
|
|
3138580eae | ||
|
|
de5ba041e9 | ||
|
|
aa88815c6e | ||
|
|
ae7184f7ea | ||
|
|
5a2f78b51e | ||
|
|
3a6769280b | ||
|
|
fbc2e1335b | ||
|
|
4768ba3642 | ||
|
|
eb0d7b71b3 | ||
|
|
261289b031 | ||
|
|
fae4589427 | ||
|
|
cbcb7e6240 | ||
|
|
e0b773d920 | ||
|
|
9ec2cea95c | ||
|
|
1a4f713a79 | ||
|
|
7128133874 | ||
|
|
053dc9d76b | ||
|
|
23b2e59560 | ||
|
|
869d81dfbc | ||
|
|
42a1c6c319 | ||
|
|
9f1cc4cd5c | ||
|
|
bd564c665b | ||
|
|
78154bd976 | ||
|
|
91649d2f52 | ||
|
|
d7647b2ecf | ||
|
|
2c7e788358 | ||
|
|
fdbbbd7378 | ||
|
|
82bdeff158 | ||
|
|
825a5d5257 | ||
|
|
d75553a94d | ||
|
|
50c47a82d6 | ||
|
|
2c2f0c8d7b | ||
|
|
cfc8196c4b | ||
|
|
87024f4bf4 | ||
|
|
fe9730206e | ||
|
|
7e95f5d8c8 | ||
|
|
1be221e106 | ||
|
|
8e7a3db67e | ||
|
|
07c0ead523 | ||
|
|
4296b5ae02 | ||
|
|
e8a5658799 | ||
|
|
b4973e8367 | ||
|
|
4b207f3fa6 | ||
|
|
c5b7f599b3 | ||
|
|
6aeac542b1 | ||
|
|
0d22d4ab4d | ||
|
|
0658448b54 | ||
|
|
833d677d20 | ||
|
|
038142c180 | ||
|
|
7e5c6420ad | ||
|
|
89a3a040d8 | ||
|
|
94d3fcb30f | ||
|
|
ec3afa0526 | ||
|
|
4176a8a9a9 | ||
|
|
311cf9aa5a | ||
|
|
d3722f2c99 | ||
|
|
5a5e155205 | ||
|
|
2226169a71 | ||
|
|
6f35fb5526 | ||
|
|
805a4ae168 | ||
|
|
cc89691da3 | ||
|
|
24860a6842 | ||
|
|
bf6b362610 | ||
|
|
baca7922b4 | ||
|
|
b59ba20ed0 | ||
|
|
bc8094f44f | ||
|
|
9203276a24 | ||
|
|
d7825f4f78 | ||
|
|
e51c28c634 | ||
|
|
3b86a9477f | ||
|
|
521ff39eb0 | ||
|
|
2e2db3c2a9 | ||
|
|
2b84af60f4 | ||
|
|
40283ff7f1 | ||
|
|
29a1a819c3 | ||
|
|
5a16eec29d | ||
|
|
2b11e0e52b | ||
|
|
89374a16ba | ||
|
|
dc289ece75 | ||
|
|
1ddd372c6d | ||
|
|
2740657b4a | ||
|
|
8c888a751c | ||
|
|
8140f0edb1 | ||
|
|
ab5239df09 | ||
|
|
3e48393fbb | ||
|
|
0079a9147f | ||
|
|
912688dc14 | ||
|
|
8849526fab | ||
|
|
24af64a66d | ||
|
|
be871737ad | ||
|
|
4d8bb5c3ea | ||
|
|
10a8505b9a | ||
|
|
ef7c2fe2f7 | ||
|
|
4c7aca95e1 | ||
|
|
2243ad895a | ||
|
|
b2d5f91fe1 | ||
|
|
a712193477 | ||
|
|
5ee411693c | ||
|
|
16c7b2567b | ||
|
|
78d9a0cd8d | ||
|
|
c6ad538559 | ||
|
|
2bc09a6efe | ||
|
|
d3a3033b25 | ||
|
|
6bdc714e11 | ||
|
|
5042f28229 | ||
|
|
e1430fd06f |
16
.devcontainer/Dockerfile
Normal file
16
.devcontainer/Dockerfile
Normal 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
|
||||||
32
.devcontainer/devcontainer.json
Normal file
32
.devcontainer/devcontainer.json
Normal 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
4
.devcontainer/oncreate
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Install packages once, to prime the node_modules directory.
|
||||||
|
npm ci
|
||||||
@@ -18,7 +18,7 @@ Run `npm start` to start the development server and automatically rebuild on cha
|
|||||||
### Hosted Development (https://cosmos.azure.com)
|
### Hosted Development (https://cosmos.azure.com)
|
||||||
|
|
||||||
- Visit: `https://localhost:1234/hostedExplorer.html`
|
- Visit: `https://localhost:1234/hostedExplorer.html`
|
||||||
- The default webpack dev server configuration will proxy requests to the production portal backend: `https://main.documentdb.ext.azure.com`. This will allow you to use production connection strings on your local machine.
|
- The default webpack dev server configuration will proxy requests to the production portal backend: `https://cdb-ms-mpac-pbe.cosmos.azure.com`. This will allow you to use production connection strings on your local machine.
|
||||||
|
|
||||||
### Emulator Development
|
### Emulator Development
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Visit: <code>https://localhost:1234/hostedExplorer.html</code></li>
|
<li>Visit: <code>https://localhost:1234/hostedExplorer.html</code></li>
|
||||||
<li>The default webpack dev server configuration will proxy requests to the production portal backend: <code>https://main.documentdb.ext.azure.com</code>. This will allow you to use production connection strings on your local machine.</li>
|
<li>The default webpack dev server configuration will proxy requests to the production portal backend: <code>https://cdb-ms-mpac-pbe.cosmos.azure.com</code>. This will allow you to use production connection strings on your local machine.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<a href="#emulator-development" id="emulator-development" style="color: inherit; text-decoration: none;">
|
<a href="#emulator-development" id="emulator-development" style="color: inherit; text-decoration: none;">
|
||||||
<h3>Emulator Development</h3>
|
<h3>Emulator Development</h3>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,8 @@ a:link {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tabPanesContainer {
|
.tabPanesContainer {
|
||||||
grid-row: span 2; // Fill the remaining space
|
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
flex-grow: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
938
package-lock.json
generated
938
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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: [
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const port = process.env.PORT || 3000;
|
|||||||
const fetch = require("node-fetch");
|
const fetch = require("node-fetch");
|
||||||
|
|
||||||
const api = createProxyMiddleware("/api", {
|
const api = createProxyMiddleware("/api", {
|
||||||
target: "https://main.documentdb.ext.azure.com",
|
target: "https://cdb-ms-mpac-pbe.cosmos.azure.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
logLevel: "debug",
|
logLevel: "debug",
|
||||||
bypass: (req, res) => {
|
bypass: (req, res) => {
|
||||||
@@ -16,7 +16,7 @@ const api = createProxyMiddleware("/api", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const proxy = createProxyMiddleware("/proxy", {
|
const proxy = createProxyMiddleware("/proxy", {
|
||||||
target: "https://main.documentdb.ext.azure.com",
|
target: "https://cdb-ms-mpac-pbe.cosmos.azure.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
logLevel: "debug",
|
logLevel: "debug",
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -152,6 +155,18 @@ export class MongoProxyEndpoints {
|
|||||||
public static readonly Mooncake: string = "https://cdb-mc-prod-mp.cosmos.azure.cn";
|
public static readonly Mooncake: string = "https://cdb-mc-prod-mp.cosmos.azure.cn";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class MongoProxyApi {
|
||||||
|
public static readonly ResourceList: string = "ResourceList";
|
||||||
|
public static readonly QueryDocuments: string = "QueryDocuments";
|
||||||
|
public static readonly CreateDocument: string = "CreateDocumen";
|
||||||
|
public static readonly ReadDocument: string = "ReadDocument";
|
||||||
|
public static readonly UpdateDocument: string = "UpdateDocument";
|
||||||
|
public static readonly DeleteDocument: string = "DeleteDocument";
|
||||||
|
public static readonly CreateCollectionWithProxy: string = "CreateCollectionWithProxy";
|
||||||
|
public static readonly LegacyMongoShell: string = "LegacyMongoShell";
|
||||||
|
public static readonly BulkDelete: string = "BulkDelete";
|
||||||
|
}
|
||||||
|
|
||||||
export class CassandraProxyEndpoints {
|
export class CassandraProxyEndpoints {
|
||||||
public static readonly Development: string = "https://localhost:7240";
|
public static readonly Development: string = "https://localhost:7240";
|
||||||
public static readonly Mpac: string = "https://cdb-ms-mpac-cp.cosmos.azure.com";
|
public static readonly Mpac: string = "https://cdb-ms-mpac-cp.cosmos.azure.com";
|
||||||
@@ -183,6 +198,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 +305,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 +517,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 = {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Platform, resetConfigContext, updateConfigContext } from "../ConfigContext";
|
import { PortalBackendEndpoints } from "Common/Constants";
|
||||||
|
import { configContext, Platform, resetConfigContext, updateConfigContext } from "../ConfigContext";
|
||||||
import { updateUserContext } from "../UserContext";
|
import { updateUserContext } from "../UserContext";
|
||||||
import { endpoint, getTokenFromAuthService, requestPlugin } from "./CosmosClient";
|
import { endpoint, getTokenFromAuthService, requestPlugin } from "./CosmosClient";
|
||||||
|
|
||||||
@@ -20,22 +21,22 @@ describe("getTokenFromAuthService", () => {
|
|||||||
|
|
||||||
it("builds the correct URL in production", () => {
|
it("builds the correct URL in production", () => {
|
||||||
updateConfigContext({
|
updateConfigContext({
|
||||||
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
|
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Prod,
|
||||||
});
|
});
|
||||||
getTokenFromAuthService("GET", "dbs", "foo");
|
getTokenFromAuthService("GET", "dbs", "foo");
|
||||||
expect(window.fetch).toHaveBeenCalledWith(
|
expect(window.fetch).toHaveBeenCalledWith(
|
||||||
"https://main.documentdb.ext.azure.com/api/guest/runtimeproxy/authorizationTokens",
|
`${configContext.PORTAL_BACKEND_ENDPOINT}/api/connectionstring/runtimeproxy/authorizationtokens`,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds the correct URL in dev", () => {
|
it("builds the correct URL in dev", () => {
|
||||||
updateConfigContext({
|
updateConfigContext({
|
||||||
BACKEND_ENDPOINT: "https://localhost:1234",
|
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Development,
|
||||||
});
|
});
|
||||||
getTokenFromAuthService("GET", "dbs", "foo");
|
getTokenFromAuthService("GET", "dbs", "foo");
|
||||||
expect(window.fetch).toHaveBeenCalledWith(
|
expect(window.fetch).toHaveBeenCalledWith(
|
||||||
"https://localhost:1234/api/guest/runtimeproxy/authorizationTokens",
|
`${configContext.PORTAL_BACKEND_ENDPOINT}/api/connectionstring/runtimeproxy/authorizationtokens`,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -78,7 +79,7 @@ describe("requestPlugin", () => {
|
|||||||
const next = jest.fn();
|
const next = jest.fn();
|
||||||
updateConfigContext({
|
updateConfigContext({
|
||||||
platform: Platform.Hosted,
|
platform: Platform.Hosted,
|
||||||
BACKEND_ENDPOINT: "https://localhost:1234",
|
PORTAL_BACKEND_ENDPOINT: "https://localhost:1234",
|
||||||
PROXY_PATH: "/proxy",
|
PROXY_PATH: "/proxy",
|
||||||
});
|
});
|
||||||
const headers = {};
|
const headers = {};
|
||||||
|
|||||||
@@ -3,15 +3,18 @@ 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 { updateUserContext, 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";
|
import { runCommand } from "hooks/useDatabaseAccounts";
|
||||||
|
import { acquireTokenWithMsal, getMsalInstance } from "Utils/AuthorizationUtils";
|
||||||
|
|
||||||
const _global = typeof self === "undefined" ? window : self;
|
const _global = typeof self === "undefined" ? window : self;
|
||||||
|
|
||||||
@@ -26,12 +29,47 @@ export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => {
|
|||||||
);
|
);
|
||||||
if (!userContext.aadToken) {
|
if (!userContext.aadToken) {
|
||||||
logConsoleError(
|
logConsoleError(
|
||||||
`AAD token does not exist. Please click on "Login for Entra ID" button prior to performing Entra ID RBAC operations`,
|
`AAD token does not exist. Please use the "Login for Entra ID" button in the Toolbar prior to performing Entra ID RBAC operations`,
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const AUTH_PREFIX = `type=aad&ver=1.0&sig=`;
|
const AUTH_PREFIX = `type=aad&ver=1.0&sig=`;
|
||||||
const authorizationToken = `${AUTH_PREFIX}${userContext.aadToken}`;
|
let authorizationToken;
|
||||||
|
|
||||||
|
try {
|
||||||
|
authorizationToken = `${AUTH_PREFIX}${userContext.aadToken}`;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === "ExpiredAuthenticationToken") {
|
||||||
|
// Renew the AAD token using runCommand
|
||||||
|
const newToken = await runCommand(async () => {
|
||||||
|
// Implement the logic to acquire a new AAD token
|
||||||
|
const msalInstance = await getMsalInstance();
|
||||||
|
const cachedAccount = msalInstance.getAllAccounts()?.[0];
|
||||||
|
const cachedTenantId = localStorage.getItem("cachedTenantId");
|
||||||
|
|
||||||
|
msalInstance.setActiveAccount(cachedAccount);
|
||||||
|
const newAccessToken = await acquireTokenWithMsal(msalInstance, {
|
||||||
|
authority: `${configContext.AAD_ENDPOINT}${cachedTenantId}`,
|
||||||
|
scopes: [`${configContext.ARM_ENDPOINT}/.default`],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update user context with the new token
|
||||||
|
updateUserContext({ aadToken: newAccessToken });
|
||||||
|
authorizationToken = `${AUTH_PREFIX}${userContext.aadToken}`;
|
||||||
|
return newAccessToken;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retry getting the token after renewing
|
||||||
|
const retryResult = await getTokenFromAuthService(verb, resourceType, resourceId);
|
||||||
|
headers[HttpHeaders.msDate] = retryResult.XDate;
|
||||||
|
return decodeURIComponent(retryResult.PrimaryReadWriteToken);
|
||||||
|
} else {
|
||||||
|
console.error('An error occurred:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return authorizationToken;
|
return authorizationToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +161,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;
|
||||||
|
|||||||
3
src/Common/DatabaseUtility.ts
Normal file
3
src/Common/DatabaseUtility.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function getNewDatabaseSharedThroughputDefault(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ export interface TableEntityProps {
|
|||||||
isEntityValueDisable?: boolean;
|
isEntityValueDisable?: boolean;
|
||||||
entityTimeValue: string;
|
entityTimeValue: string;
|
||||||
entityValueType: string;
|
entityValueType: string;
|
||||||
|
entityProperty: string;
|
||||||
onEntityValueChange: (event: React.FormEvent<HTMLElement>, newInput?: string) => void;
|
onEntityValueChange: (event: React.FormEvent<HTMLElement>, newInput?: string) => void;
|
||||||
onSelectDate: (date: Date | null | undefined) => void;
|
onSelectDate: (date: Date | null | undefined) => void;
|
||||||
onEntityTimeValueChange: (event: React.FormEvent<HTMLElement>, newInput?: string) => void;
|
onEntityTimeValueChange: (event: React.FormEvent<HTMLElement>, newInput?: string) => void;
|
||||||
@@ -26,6 +27,7 @@ export const EntityValue: FunctionComponent<TableEntityProps> = ({
|
|||||||
onSelectDate,
|
onSelectDate,
|
||||||
isEntityValueDisable,
|
isEntityValueDisable,
|
||||||
onEntityTimeValueChange,
|
onEntityTimeValueChange,
|
||||||
|
entityProperty,
|
||||||
}: TableEntityProps): JSX.Element => {
|
}: TableEntityProps): JSX.Element => {
|
||||||
if (isEntityTypeDate) {
|
if (isEntityTypeDate) {
|
||||||
return (
|
return (
|
||||||
@@ -51,15 +53,20 @@ export const EntityValue: FunctionComponent<TableEntityProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextField
|
<>
|
||||||
label={entityValueLabel && entityValueLabel}
|
<span id={entityProperty} className="screenReaderOnly">
|
||||||
className="addEntityTextField"
|
Edit Property {entityProperty} {attributeValueLabel}
|
||||||
disabled={isEntityValueDisable}
|
</span>
|
||||||
type={entityValueType}
|
<TextField
|
||||||
placeholder={entityValuePlaceholder}
|
label={entityValueLabel && entityValueLabel}
|
||||||
value={typeof entityValue === "string" ? entityValue : ""}
|
className="addEntityTextField"
|
||||||
onChange={onEntityValueChange}
|
disabled={isEntityValueDisable}
|
||||||
ariaLabel={attributeValueLabel}
|
type={entityValueType}
|
||||||
/>
|
placeholder={entityValuePlaceholder}
|
||||||
|
value={typeof entityValue === "string" ? entityValue : ""}
|
||||||
|
onChange={onEntityValueChange}
|
||||||
|
aria-labelledby={entityProperty}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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];
|
||||||
|
};
|
||||||
|
|||||||
@@ -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.";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { MongoProxyEndpoints } from "Common/Constants";
|
||||||
import { AuthType } from "../AuthType";
|
import { AuthType } from "../AuthType";
|
||||||
import { resetConfigContext, updateConfigContext } from "../ConfigContext";
|
import { configContext, resetConfigContext, updateConfigContext } from "../ConfigContext";
|
||||||
import { DatabaseAccount } from "../Contracts/DataModels";
|
import { DatabaseAccount } from "../Contracts/DataModels";
|
||||||
import { Collection } from "../Contracts/ViewModels";
|
import { Collection } from "../Contracts/ViewModels";
|
||||||
import DocumentId from "../Explorer/Tree/DocumentId";
|
import DocumentId from "../Explorer/Tree/DocumentId";
|
||||||
@@ -71,7 +72,7 @@ describe("MongoProxyClient", () => {
|
|||||||
databaseAccount,
|
databaseAccount,
|
||||||
});
|
});
|
||||||
updateConfigContext({
|
updateConfigContext({
|
||||||
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
|
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
||||||
});
|
});
|
||||||
window.fetch = jest.fn().mockImplementation(fetchMock);
|
window.fetch = jest.fn().mockImplementation(fetchMock);
|
||||||
});
|
});
|
||||||
@@ -82,16 +83,16 @@ describe("MongoProxyClient", () => {
|
|||||||
it("builds the correct URL", () => {
|
it("builds the correct URL", () => {
|
||||||
queryDocuments(databaseId, collection, true, "{}");
|
queryDocuments(databaseId, collection, true, "{}");
|
||||||
expect(window.fetch).toHaveBeenCalledWith(
|
expect(window.fetch).toHaveBeenCalledWith(
|
||||||
"https://main.documentdb.ext.azure.com/api/mongo/explorer/resourcelist?db=testDB&coll=testCollection&resourceUrl=bardbs%2FtestDB%2Fcolls%2FtestCollection%2Fdocs%2F&rid=testCollectionrid&rtype=docs&sid=&rg=&dba=foo&pk=pk",
|
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer/resourcelist`,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds the correct proxy URL in development", () => {
|
it("builds the correct proxy URL in development", () => {
|
||||||
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
|
updateConfigContext({ MONGO_PROXY_ENDPOINT: "https://localhost:1234" });
|
||||||
queryDocuments(databaseId, collection, true, "{}");
|
queryDocuments(databaseId, collection, true, "{}");
|
||||||
expect(window.fetch).toHaveBeenCalledWith(
|
expect(window.fetch).toHaveBeenCalledWith(
|
||||||
"https://localhost:1234/api/mongo/explorer/resourcelist?db=testDB&coll=testCollection&resourceUrl=bardbs%2FtestDB%2Fcolls%2FtestCollection%2Fdocs%2F&rid=testCollectionrid&rtype=docs&sid=&rg=&dba=foo&pk=pk",
|
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer/resourcelist`,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -103,7 +104,7 @@ describe("MongoProxyClient", () => {
|
|||||||
databaseAccount,
|
databaseAccount,
|
||||||
});
|
});
|
||||||
updateConfigContext({
|
updateConfigContext({
|
||||||
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
|
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
||||||
});
|
});
|
||||||
window.fetch = jest.fn().mockImplementation(fetchMock);
|
window.fetch = jest.fn().mockImplementation(fetchMock);
|
||||||
});
|
});
|
||||||
@@ -114,16 +115,16 @@ describe("MongoProxyClient", () => {
|
|||||||
it("builds the correct URL", () => {
|
it("builds the correct URL", () => {
|
||||||
readDocument(databaseId, collection, documentId);
|
readDocument(databaseId, collection, documentId);
|
||||||
expect(window.fetch).toHaveBeenCalledWith(
|
expect(window.fetch).toHaveBeenCalledWith(
|
||||||
"https://main.documentdb.ext.azure.com/api/mongo/explorer?db=testDB&coll=testCollection&resourceUrl=bardb%2FtestDB%2Fdb%2FtestCollection%2FtestId&rid=testId&rtype=docs&sid=&rg=&dba=foo&pk=pk",
|
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds the correct proxy URL in development", () => {
|
it("builds the correct proxy URL in development", () => {
|
||||||
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
|
updateConfigContext({ MONGO_PROXY_ENDPOINT: "https://localhost:1234" });
|
||||||
readDocument(databaseId, collection, documentId);
|
readDocument(databaseId, collection, documentId);
|
||||||
expect(window.fetch).toHaveBeenCalledWith(
|
expect(window.fetch).toHaveBeenCalledWith(
|
||||||
"https://localhost:1234/api/mongo/explorer?db=testDB&coll=testCollection&resourceUrl=bardb%2FtestDB%2Fdb%2FtestCollection%2FtestId&rid=testId&rtype=docs&sid=&rg=&dba=foo&pk=pk",
|
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -135,7 +136,7 @@ describe("MongoProxyClient", () => {
|
|||||||
databaseAccount,
|
databaseAccount,
|
||||||
});
|
});
|
||||||
updateConfigContext({
|
updateConfigContext({
|
||||||
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
|
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
||||||
});
|
});
|
||||||
window.fetch = jest.fn().mockImplementation(fetchMock);
|
window.fetch = jest.fn().mockImplementation(fetchMock);
|
||||||
});
|
});
|
||||||
@@ -146,16 +147,16 @@ describe("MongoProxyClient", () => {
|
|||||||
it("builds the correct URL", () => {
|
it("builds the correct URL", () => {
|
||||||
readDocument(databaseId, collection, documentId);
|
readDocument(databaseId, collection, documentId);
|
||||||
expect(window.fetch).toHaveBeenCalledWith(
|
expect(window.fetch).toHaveBeenCalledWith(
|
||||||
"https://main.documentdb.ext.azure.com/api/mongo/explorer?db=testDB&coll=testCollection&resourceUrl=bardb%2FtestDB%2Fdb%2FtestCollection%2FtestId&rid=testId&rtype=docs&sid=&rg=&dba=foo&pk=pk",
|
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds the correct proxy URL in development", () => {
|
it("builds the correct proxy URL in development", () => {
|
||||||
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
|
updateConfigContext({ MONGO_PROXY_ENDPOINT: "https://localhost:1234" });
|
||||||
readDocument(databaseId, collection, documentId);
|
readDocument(databaseId, collection, documentId);
|
||||||
expect(window.fetch).toHaveBeenCalledWith(
|
expect(window.fetch).toHaveBeenCalledWith(
|
||||||
"https://localhost:1234/api/mongo/explorer?db=testDB&coll=testCollection&resourceUrl=bardb%2FtestDB%2Fdb%2FtestCollection%2FtestId&rid=testId&rtype=docs&sid=&rg=&dba=foo&pk=pk",
|
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -167,7 +168,7 @@ describe("MongoProxyClient", () => {
|
|||||||
databaseAccount,
|
databaseAccount,
|
||||||
});
|
});
|
||||||
updateConfigContext({
|
updateConfigContext({
|
||||||
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
|
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
||||||
});
|
});
|
||||||
window.fetch = jest.fn().mockImplementation(fetchMock);
|
window.fetch = jest.fn().mockImplementation(fetchMock);
|
||||||
});
|
});
|
||||||
@@ -178,7 +179,7 @@ describe("MongoProxyClient", () => {
|
|||||||
it("builds the correct URL", () => {
|
it("builds the correct URL", () => {
|
||||||
updateDocument(databaseId, collection, documentId, "{}");
|
updateDocument(databaseId, collection, documentId, "{}");
|
||||||
expect(window.fetch).toHaveBeenCalledWith(
|
expect(window.fetch).toHaveBeenCalledWith(
|
||||||
"https://main.documentdb.ext.azure.com/api/mongo/explorer?db=testDB&coll=testCollection&resourceUrl=bardb%2FtestDB%2Fdb%2FtestCollection%2Fdocs%2FtestId&rid=testId&rtype=docs&sid=&rg=&dba=foo&pk=pk",
|
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -187,7 +188,7 @@ describe("MongoProxyClient", () => {
|
|||||||
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
|
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
|
||||||
updateDocument(databaseId, collection, documentId, "{}");
|
updateDocument(databaseId, collection, documentId, "{}");
|
||||||
expect(window.fetch).toHaveBeenCalledWith(
|
expect(window.fetch).toHaveBeenCalledWith(
|
||||||
"https://localhost:1234/api/mongo/explorer?db=testDB&coll=testCollection&resourceUrl=bardb%2FtestDB%2Fdb%2FtestCollection%2Fdocs%2FtestId&rid=testId&rtype=docs&sid=&rg=&dba=foo&pk=pk",
|
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -199,7 +200,7 @@ describe("MongoProxyClient", () => {
|
|||||||
databaseAccount,
|
databaseAccount,
|
||||||
});
|
});
|
||||||
updateConfigContext({
|
updateConfigContext({
|
||||||
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
|
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
||||||
});
|
});
|
||||||
window.fetch = jest.fn().mockImplementation(fetchMock);
|
window.fetch = jest.fn().mockImplementation(fetchMock);
|
||||||
});
|
});
|
||||||
@@ -210,16 +211,16 @@ describe("MongoProxyClient", () => {
|
|||||||
it("builds the correct URL", () => {
|
it("builds the correct URL", () => {
|
||||||
deleteDocument(databaseId, collection, documentId);
|
deleteDocument(databaseId, collection, documentId);
|
||||||
expect(window.fetch).toHaveBeenCalledWith(
|
expect(window.fetch).toHaveBeenCalledWith(
|
||||||
"https://main.documentdb.ext.azure.com/api/mongo/explorer?db=testDB&coll=testCollection&resourceUrl=bardb%2FtestDB%2Fdb%2FtestCollection%2Fdocs%2FtestId&rid=testId&rtype=docs&sid=&rg=&dba=foo&pk=pk",
|
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds the correct proxy URL in development", () => {
|
it("builds the correct proxy URL in development", () => {
|
||||||
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
|
updateConfigContext({ MONGO_PROXY_ENDPOINT: "https://localhost:1234" });
|
||||||
deleteDocument(databaseId, collection, documentId);
|
deleteDocument(databaseId, collection, documentId);
|
||||||
expect(window.fetch).toHaveBeenCalledWith(
|
expect(window.fetch).toHaveBeenCalledWith(
|
||||||
"https://localhost:1234/api/mongo/explorer?db=testDB&coll=testCollection&resourceUrl=bardb%2FtestDB%2Fdb%2FtestCollection%2Fdocs%2FtestId&rid=testId&rtype=docs&sid=&rg=&dba=foo&pk=pk",
|
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -231,13 +232,13 @@ describe("MongoProxyClient", () => {
|
|||||||
databaseAccount,
|
databaseAccount,
|
||||||
});
|
});
|
||||||
updateConfigContext({
|
updateConfigContext({
|
||||||
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
|
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns a production endpoint", () => {
|
it("returns a production endpoint", () => {
|
||||||
const endpoint = getEndpoint("https://main.documentdb.ext.azure.com");
|
const endpoint = getEndpoint(configContext.MONGO_PROXY_ENDPOINT);
|
||||||
expect(endpoint).toEqual("https://main.documentdb.ext.azure.com/api/mongo/explorer");
|
expect(endpoint).toEqual(`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns a development endpoint", () => {
|
it("returns a development endpoint", () => {
|
||||||
@@ -249,18 +250,19 @@ describe("MongoProxyClient", () => {
|
|||||||
updateUserContext({
|
updateUserContext({
|
||||||
authType: AuthType.EncryptedToken,
|
authType: AuthType.EncryptedToken,
|
||||||
});
|
});
|
||||||
const endpoint = getEndpoint("https://main.documentdb.ext.azure.com");
|
const endpoint = getEndpoint(configContext.MONGO_PROXY_ENDPOINT);
|
||||||
expect(endpoint).toEqual("https://main.documentdb.ext.azure.com/api/guest/mongo/explorer");
|
expect(endpoint).toEqual(`${configContext.MONGO_PROXY_ENDPOINT}/api/connectionstring/mongo/explorer`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getFeatureEndpointOrDefault", () => {
|
describe("getFeatureEndpointOrDefault", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetConfigContext();
|
resetConfigContext();
|
||||||
updateConfigContext({
|
updateConfigContext({
|
||||||
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
|
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
||||||
});
|
});
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
"feature.mongoProxyEndpoint": "https://localhost:12901",
|
"feature.mongoProxyEndpoint": MongoProxyEndpoints.Prod,
|
||||||
"feature.mongoProxyAPIs": "readDocument|createDocument",
|
"feature.mongoProxyAPIs": "readDocument|createDocument",
|
||||||
});
|
});
|
||||||
const features = extractFeatures(params);
|
const features = extractFeatures(params);
|
||||||
@@ -272,12 +274,12 @@ describe("MongoProxyClient", () => {
|
|||||||
|
|
||||||
it("returns a local endpoint", () => {
|
it("returns a local endpoint", () => {
|
||||||
const endpoint = getFeatureEndpointOrDefault("readDocument");
|
const endpoint = getFeatureEndpointOrDefault("readDocument");
|
||||||
expect(endpoint).toEqual("https://localhost:12901/api/mongo/explorer");
|
expect(endpoint).toEqual(`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns a production endpoint", () => {
|
it("returns a production endpoint", () => {
|
||||||
const endpoint = getFeatureEndpointOrDefault("deleteDocument");
|
const endpoint = getFeatureEndpointOrDefault("DeleteDocument");
|
||||||
expect(endpoint).toEqual("https://main.documentdb.ext.azure.com/api/mongo/explorer");
|
expect(endpoint).toEqual(`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Constants as CosmosSDKConstants } from "@azure/cosmos";
|
import { Constants as CosmosSDKConstants } from "@azure/cosmos";
|
||||||
import {
|
import {
|
||||||
allowedMongoProxyEndpoints,
|
|
||||||
allowedMongoProxyEndpoints_ToBeDeprecated,
|
allowedMongoProxyEndpoints_ToBeDeprecated,
|
||||||
|
defaultAllowedMongoProxyEndpoints,
|
||||||
validateEndpoint,
|
validateEndpoint,
|
||||||
} from "Utils/EndpointUtils";
|
} from "Utils/EndpointUtils";
|
||||||
import queryString from "querystring";
|
import queryString from "querystring";
|
||||||
@@ -14,7 +14,7 @@ import DocumentId from "../Explorer/Tree/DocumentId";
|
|||||||
import { hasFlag } from "../Platform/Hosted/extractFeatures";
|
import { hasFlag } from "../Platform/Hosted/extractFeatures";
|
||||||
import { userContext } from "../UserContext";
|
import { userContext } from "../UserContext";
|
||||||
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
|
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
|
||||||
import { ApiType, ContentType, HttpHeaders, HttpStatusCodes, MongoProxyEndpoints } from "./Constants";
|
import { ApiType, ContentType, HttpHeaders, HttpStatusCodes, MongoProxyApi, MongoProxyEndpoints } from "./Constants";
|
||||||
import { MinimalQueryIterator } from "./IteratorUtilities";
|
import { MinimalQueryIterator } from "./IteratorUtilities";
|
||||||
import { sendMessage } from "./MessageHandler";
|
import { sendMessage } from "./MessageHandler";
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ export function queryDocuments(
|
|||||||
query: string,
|
query: string,
|
||||||
continuationToken?: string,
|
continuationToken?: string,
|
||||||
): Promise<QueryResponse> {
|
): Promise<QueryResponse> {
|
||||||
if (!useMongoProxyEndpoint("resourcelist") || !useMongoProxyEndpoint("queryDocuments")) {
|
if (!useMongoProxyEndpoint(MongoProxyApi.ResourceList) || !useMongoProxyEndpoint(MongoProxyApi.QueryDocuments)) {
|
||||||
return queryDocuments_ToBeDeprecated(databaseId, collection, isResourceList, query, continuationToken);
|
return queryDocuments_ToBeDeprecated(databaseId, collection, isResourceList, query, continuationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ export function queryDocuments(
|
|||||||
query,
|
query,
|
||||||
};
|
};
|
||||||
|
|
||||||
const endpoint = getFeatureEndpointOrDefault("resourcelist") || "";
|
const endpoint = getFeatureEndpointOrDefault(MongoProxyApi.ResourceList) || "";
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
...defaultHeaders,
|
...defaultHeaders,
|
||||||
@@ -194,7 +194,7 @@ export function readDocument(
|
|||||||
collection: Collection,
|
collection: Collection,
|
||||||
documentId: DocumentId,
|
documentId: DocumentId,
|
||||||
): Promise<DataModels.DocumentId> {
|
): Promise<DataModels.DocumentId> {
|
||||||
if (!useMongoProxyEndpoint("readDocument")) {
|
if (!useMongoProxyEndpoint(MongoProxyApi.ReadDocument)) {
|
||||||
return readDocument_ToBeDeprecated(databaseId, collection, documentId);
|
return readDocument_ToBeDeprecated(databaseId, collection, documentId);
|
||||||
}
|
}
|
||||||
const { databaseAccount } = userContext;
|
const { databaseAccount } = userContext;
|
||||||
@@ -217,7 +217,7 @@ export function readDocument(
|
|||||||
: "",
|
: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const endpoint = getFeatureEndpointOrDefault("readDocument");
|
const endpoint = getFeatureEndpointOrDefault(MongoProxyApi.ReadDocument);
|
||||||
|
|
||||||
return window
|
return window
|
||||||
.fetch(endpoint, {
|
.fetch(endpoint, {
|
||||||
@@ -289,7 +289,7 @@ export function createDocument(
|
|||||||
partitionKeyProperty: string,
|
partitionKeyProperty: string,
|
||||||
documentContent: unknown,
|
documentContent: unknown,
|
||||||
): Promise<DataModels.DocumentId> {
|
): Promise<DataModels.DocumentId> {
|
||||||
if (!useMongoProxyEndpoint("createDocument")) {
|
if (!useMongoProxyEndpoint(MongoProxyApi.CreateDocument)) {
|
||||||
return createDocument_ToBeDeprecated(databaseId, collection, partitionKeyProperty, documentContent);
|
return createDocument_ToBeDeprecated(databaseId, collection, partitionKeyProperty, documentContent);
|
||||||
}
|
}
|
||||||
const { databaseAccount } = userContext;
|
const { databaseAccount } = userContext;
|
||||||
@@ -308,7 +308,7 @@ export function createDocument(
|
|||||||
documentContent: JSON.stringify(documentContent),
|
documentContent: JSON.stringify(documentContent),
|
||||||
};
|
};
|
||||||
|
|
||||||
const endpoint = getFeatureEndpointOrDefault("createDocument");
|
const endpoint = getFeatureEndpointOrDefault(MongoProxyApi.CreateDocument);
|
||||||
|
|
||||||
return window
|
return window
|
||||||
.fetch(`${endpoint}/createDocument`, {
|
.fetch(`${endpoint}/createDocument`, {
|
||||||
@@ -373,7 +373,7 @@ export function updateDocument(
|
|||||||
documentId: DocumentId,
|
documentId: DocumentId,
|
||||||
documentContent: string,
|
documentContent: string,
|
||||||
): Promise<DataModels.DocumentId> {
|
): Promise<DataModels.DocumentId> {
|
||||||
if (!useMongoProxyEndpoint("updateDocument")) {
|
if (!useMongoProxyEndpoint(MongoProxyApi.UpdateDocument)) {
|
||||||
return updateDocument_ToBeDeprecated(databaseId, collection, documentId, documentContent);
|
return updateDocument_ToBeDeprecated(databaseId, collection, documentId, documentContent);
|
||||||
}
|
}
|
||||||
const { databaseAccount } = userContext;
|
const { databaseAccount } = userContext;
|
||||||
@@ -396,7 +396,7 @@ export function updateDocument(
|
|||||||
: "",
|
: "",
|
||||||
documentContent,
|
documentContent,
|
||||||
};
|
};
|
||||||
const endpoint = getFeatureEndpointOrDefault("updateDocument");
|
const endpoint = getFeatureEndpointOrDefault(MongoProxyApi.UpdateDocument);
|
||||||
|
|
||||||
return window
|
return window
|
||||||
.fetch(endpoint, {
|
.fetch(endpoint, {
|
||||||
@@ -464,7 +464,7 @@ export function updateDocument_ToBeDeprecated(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function deleteDocument(databaseId: string, collection: Collection, documentId: DocumentId): Promise<void> {
|
export function deleteDocument(databaseId: string, collection: Collection, documentId: DocumentId): Promise<void> {
|
||||||
if (!useMongoProxyEndpoint("deleteDocument")) {
|
if (!useMongoProxyEndpoint(MongoProxyApi.DeleteDocument)) {
|
||||||
return deleteDocument_ToBeDeprecated(databaseId, collection, documentId);
|
return deleteDocument_ToBeDeprecated(databaseId, collection, documentId);
|
||||||
}
|
}
|
||||||
const { databaseAccount } = userContext;
|
const { databaseAccount } = userContext;
|
||||||
@@ -486,7 +486,7 @@ export function deleteDocument(databaseId: string, collection: Collection, docum
|
|||||||
? documentId.partitionKeyProperties?.[0]
|
? documentId.partitionKeyProperties?.[0]
|
||||||
: "",
|
: "",
|
||||||
};
|
};
|
||||||
const endpoint = getFeatureEndpointOrDefault("deleteDocument");
|
const endpoint = getFeatureEndpointOrDefault(MongoProxyApi.DeleteDocument);
|
||||||
|
|
||||||
return window
|
return window
|
||||||
.fetch(endpoint, {
|
.fetch(endpoint, {
|
||||||
@@ -550,10 +550,56 @@ 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: string[] = documentIds.map((documentId) => {
|
||||||
|
const idComponents = documentId.self.split("/");
|
||||||
|
return idComponents[5];
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
databaseID: databaseId,
|
||||||
|
collectionID: collection.id(),
|
||||||
|
resourceUrl: `${resourceEndpoint}`,
|
||||||
|
resourceIDs: rids,
|
||||||
|
subscriptionID: userContext.subscriptionId,
|
||||||
|
resourceGroup: userContext.resourceGroup,
|
||||||
|
databaseAccountName: databaseAccount.name,
|
||||||
|
};
|
||||||
|
const endpoint = getFeatureEndpointOrDefault(MongoProxyApi.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> {
|
||||||
if (!useMongoProxyEndpoint("createCollectionWithProxy")) {
|
if (!useMongoProxyEndpoint(MongoProxyApi.CreateCollectionWithProxy)) {
|
||||||
return createMongoCollectionWithProxy_ToBeDeprecated(params);
|
return createMongoCollectionWithProxy_ToBeDeprecated(params);
|
||||||
}
|
}
|
||||||
const { databaseAccount } = userContext;
|
const { databaseAccount } = userContext;
|
||||||
@@ -576,7 +622,7 @@ export function createMongoCollectionWithProxy(
|
|||||||
isSharded: !!shardKey,
|
isSharded: !!shardKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
const endpoint = getFeatureEndpointOrDefault("createCollectionWithProxy");
|
const endpoint = getFeatureEndpointOrDefault(MongoProxyApi.CreateCollectionWithProxy);
|
||||||
|
|
||||||
return window
|
return window
|
||||||
.fetch(`${endpoint}/createCollection`, {
|
.fetch(`${endpoint}/createCollection`, {
|
||||||
@@ -643,15 +689,16 @@ export function createMongoCollectionWithProxy_ToBeDeprecated(
|
|||||||
}
|
}
|
||||||
export function getFeatureEndpointOrDefault(feature: string): string {
|
export function getFeatureEndpointOrDefault(feature: string): string {
|
||||||
let endpoint;
|
let endpoint;
|
||||||
|
const allowedMongoProxyEndpoints = configContext.allowedMongoProxyEndpoints || [
|
||||||
|
...defaultAllowedMongoProxyEndpoints,
|
||||||
|
...allowedMongoProxyEndpoints_ToBeDeprecated,
|
||||||
|
];
|
||||||
if (useMongoProxyEndpoint(feature)) {
|
if (useMongoProxyEndpoint(feature)) {
|
||||||
endpoint = configContext.MONGO_PROXY_ENDPOINT;
|
endpoint = configContext.MONGO_PROXY_ENDPOINT;
|
||||||
} else {
|
} else {
|
||||||
endpoint =
|
endpoint =
|
||||||
hasFlag(userContext.features.mongoProxyAPIs, feature) &&
|
hasFlag(userContext.features.mongoProxyAPIs, feature) &&
|
||||||
validateEndpoint(userContext.features.mongoProxyEndpoint, [
|
validateEndpoint(userContext.features.mongoProxyEndpoint, allowedMongoProxyEndpoints)
|
||||||
...allowedMongoProxyEndpoints,
|
|
||||||
...allowedMongoProxyEndpoints_ToBeDeprecated,
|
|
||||||
])
|
|
||||||
? userContext.features.mongoProxyEndpoint
|
? userContext.features.mongoProxyEndpoint
|
||||||
: configContext.MONGO_BACKEND_ENDPOINT || configContext.BACKEND_ENDPOINT;
|
: configContext.MONGO_BACKEND_ENDPOINT || configContext.BACKEND_ENDPOINT;
|
||||||
}
|
}
|
||||||
@@ -672,26 +719,84 @@ export function getEndpoint(endpoint: string): string {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMongoProxyEndpoint(api: string): boolean {
|
export function useMongoProxyEndpoint(mongoProxyApi: string): boolean {
|
||||||
const activeMongoProxyEndpoints: string[] = [
|
const mongoProxyEnvironmentMap: { [key: string]: string[] } = {
|
||||||
MongoProxyEndpoints.Local,
|
[MongoProxyApi.ResourceList]: [
|
||||||
MongoProxyEndpoints.Mpac,
|
MongoProxyEndpoints.Local,
|
||||||
MongoProxyEndpoints.Prod,
|
MongoProxyEndpoints.Mpac,
|
||||||
MongoProxyEndpoints.Fairfax,
|
MongoProxyEndpoints.Prod,
|
||||||
];
|
MongoProxyEndpoints.Fairfax,
|
||||||
let canAccessMongoProxy: boolean = userContext.databaseAccount.properties.publicNetworkAccess === "Enabled";
|
MongoProxyEndpoints.Mooncake,
|
||||||
if (
|
],
|
||||||
configContext.MONGO_PROXY_ENDPOINT !== MongoProxyEndpoints.Local &&
|
[MongoProxyApi.QueryDocuments]: [
|
||||||
userContext.databaseAccount.properties.ipRules?.length > 0
|
MongoProxyEndpoints.Local,
|
||||||
) {
|
MongoProxyEndpoints.Mpac,
|
||||||
canAccessMongoProxy = canAccessMongoProxy && configContext.MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED;
|
MongoProxyEndpoints.Prod,
|
||||||
|
MongoProxyEndpoints.Fairfax,
|
||||||
|
MongoProxyEndpoints.Mooncake,
|
||||||
|
],
|
||||||
|
[MongoProxyApi.CreateDocument]: [
|
||||||
|
MongoProxyEndpoints.Local,
|
||||||
|
MongoProxyEndpoints.Mpac,
|
||||||
|
MongoProxyEndpoints.Prod,
|
||||||
|
MongoProxyEndpoints.Fairfax,
|
||||||
|
MongoProxyEndpoints.Mooncake,
|
||||||
|
],
|
||||||
|
[MongoProxyApi.ReadDocument]: [
|
||||||
|
MongoProxyEndpoints.Local,
|
||||||
|
MongoProxyEndpoints.Mpac,
|
||||||
|
MongoProxyEndpoints.Prod,
|
||||||
|
MongoProxyEndpoints.Fairfax,
|
||||||
|
MongoProxyEndpoints.Mooncake,
|
||||||
|
],
|
||||||
|
[MongoProxyApi.UpdateDocument]: [
|
||||||
|
MongoProxyEndpoints.Local,
|
||||||
|
MongoProxyEndpoints.Mpac,
|
||||||
|
MongoProxyEndpoints.Prod,
|
||||||
|
MongoProxyEndpoints.Fairfax,
|
||||||
|
MongoProxyEndpoints.Mooncake,
|
||||||
|
],
|
||||||
|
[MongoProxyApi.DeleteDocument]: [
|
||||||
|
MongoProxyEndpoints.Local,
|
||||||
|
MongoProxyEndpoints.Mpac,
|
||||||
|
MongoProxyEndpoints.Prod,
|
||||||
|
MongoProxyEndpoints.Fairfax,
|
||||||
|
MongoProxyEndpoints.Mooncake,
|
||||||
|
],
|
||||||
|
[MongoProxyApi.CreateCollectionWithProxy]: [
|
||||||
|
MongoProxyEndpoints.Local,
|
||||||
|
MongoProxyEndpoints.Mpac,
|
||||||
|
MongoProxyEndpoints.Prod,
|
||||||
|
MongoProxyEndpoints.Fairfax,
|
||||||
|
MongoProxyEndpoints.Mooncake,
|
||||||
|
],
|
||||||
|
[MongoProxyApi.LegacyMongoShell]: [
|
||||||
|
MongoProxyEndpoints.Local,
|
||||||
|
MongoProxyEndpoints.Mpac,
|
||||||
|
MongoProxyEndpoints.Prod,
|
||||||
|
MongoProxyEndpoints.Fairfax,
|
||||||
|
MongoProxyEndpoints.Mooncake,
|
||||||
|
],
|
||||||
|
[MongoProxyApi.BulkDelete]: [
|
||||||
|
MongoProxyEndpoints.Local,
|
||||||
|
MongoProxyEndpoints.Mpac,
|
||||||
|
MongoProxyEndpoints.Prod,
|
||||||
|
MongoProxyEndpoints.Fairfax,
|
||||||
|
MongoProxyEndpoints.Mooncake,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!mongoProxyEnvironmentMap[mongoProxyApi] || !configContext.MONGO_PROXY_ENDPOINT) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return mongoProxyEnvironmentMap[mongoProxyApi].includes(configContext.MONGO_PROXY_ENDPOINT);
|
||||||
canAccessMongoProxy &&
|
}
|
||||||
configContext.NEW_MONGO_APIS?.includes(api) &&
|
|
||||||
activeMongoProxyEndpoints.includes(configContext.MONGO_PROXY_ENDPOINT)
|
export class ThrottlingError extends Error {
|
||||||
);
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: This function throws most of the time except on Forbidden which is a bit strange
|
// TODO: This function throws most of the time except on Forbidden which is a bit strange
|
||||||
@@ -703,6 +808,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[];
|
|
||||||
};
|
|
||||||
94
src/Common/QueryError.test.ts
Normal file
94
src/Common/QueryError.test.ts
Normal 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
247
src/Common/QueryError.ts
Normal 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),
|
||||||
|
};
|
||||||
@@ -135,6 +135,7 @@ export const TableEntity: FunctionComponent<TableEntityProps> = ({
|
|||||||
onEntityValueChange={onEntityValueChange}
|
onEntityValueChange={onEntityValueChange}
|
||||||
onSelectDate={onSelectDate}
|
onSelectDate={onSelectDate}
|
||||||
onEntityTimeValueChange={onEntityTimeValueChange}
|
onEntityTimeValueChange={onEntityTimeValueChange}
|
||||||
|
entityProperty={entityProperty}
|
||||||
/>
|
/>
|
||||||
{!isEntityValueDisable && (
|
{!isEntityValueDisable && (
|
||||||
<TooltipHost content="Edit property" id="editTooltip">
|
<TooltipHost content="Edit property" id="editTooltip">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -5,19 +5,20 @@ import {
|
|||||||
MongoProxyEndpoints,
|
MongoProxyEndpoints,
|
||||||
PortalBackendEndpoints,
|
PortalBackendEndpoints,
|
||||||
} from "Common/Constants";
|
} from "Common/Constants";
|
||||||
|
import { userContext } from "UserContext";
|
||||||
import {
|
import {
|
||||||
allowedAadEndpoints,
|
allowedAadEndpoints,
|
||||||
allowedArcadiaEndpoints,
|
allowedArcadiaEndpoints,
|
||||||
allowedCassandraProxyEndpoints,
|
|
||||||
allowedEmulatorEndpoints,
|
allowedEmulatorEndpoints,
|
||||||
allowedGraphEndpoints,
|
allowedGraphEndpoints,
|
||||||
allowedHostedExplorerEndpoints,
|
allowedHostedExplorerEndpoints,
|
||||||
allowedJunoOrigins,
|
allowedJunoOrigins,
|
||||||
allowedMongoBackendEndpoints,
|
allowedMongoBackendEndpoints,
|
||||||
allowedMongoProxyEndpoints,
|
|
||||||
allowedMsalRedirectEndpoints,
|
allowedMsalRedirectEndpoints,
|
||||||
defaultAllowedArmEndpoints,
|
defaultAllowedArmEndpoints,
|
||||||
defaultAllowedBackendEndpoints,
|
defaultAllowedBackendEndpoints,
|
||||||
|
defaultAllowedCassandraProxyEndpoints,
|
||||||
|
defaultAllowedMongoProxyEndpoints,
|
||||||
validateEndpoint,
|
validateEndpoint,
|
||||||
} from "Utils/EndpointUtils";
|
} from "Utils/EndpointUtils";
|
||||||
|
|
||||||
@@ -32,10 +33,13 @@ export interface ConfigContext {
|
|||||||
platform: Platform;
|
platform: Platform;
|
||||||
allowedArmEndpoints: ReadonlyArray<string>;
|
allowedArmEndpoints: ReadonlyArray<string>;
|
||||||
allowedBackendEndpoints: ReadonlyArray<string>;
|
allowedBackendEndpoints: ReadonlyArray<string>;
|
||||||
|
allowedCassandraProxyEndpoints: ReadonlyArray<string>;
|
||||||
|
allowedMongoProxyEndpoints: ReadonlyArray<string>;
|
||||||
allowedParentFrameOrigins: ReadonlyArray<string>;
|
allowedParentFrameOrigins: ReadonlyArray<string>;
|
||||||
gitSha?: string;
|
gitSha?: string;
|
||||||
proxyPath?: string;
|
proxyPath?: string;
|
||||||
AAD_ENDPOINT: string;
|
AAD_ENDPOINT: string;
|
||||||
|
ENVIRONMENT: string;
|
||||||
ARM_AUTH_AREA: string;
|
ARM_AUTH_AREA: string;
|
||||||
ARM_ENDPOINT: string;
|
ARM_ENDPOINT: string;
|
||||||
EMULATOR_ENDPOINT?: string;
|
EMULATOR_ENDPOINT?: string;
|
||||||
@@ -49,14 +53,11 @@ 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;
|
CASSANDRA_PROXY_ENDPOINT: string;
|
||||||
NEW_MONGO_APIS?: 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;
|
||||||
@@ -75,9 +76,12 @@ let configContext: Readonly<ConfigContext> = {
|
|||||||
platform: Platform.Portal,
|
platform: Platform.Portal,
|
||||||
allowedArmEndpoints: defaultAllowedArmEndpoints,
|
allowedArmEndpoints: defaultAllowedArmEndpoints,
|
||||||
allowedBackendEndpoints: defaultAllowedBackendEndpoints,
|
allowedBackendEndpoints: defaultAllowedBackendEndpoints,
|
||||||
|
allowedCassandraProxyEndpoints: defaultAllowedCassandraProxyEndpoints,
|
||||||
|
allowedMongoProxyEndpoints: defaultAllowedMongoProxyEndpoints,
|
||||||
allowedParentFrameOrigins: [
|
allowedParentFrameOrigins: [
|
||||||
`^https:\\/\\/cosmos\\.azure\\.(com|cn|us)$`,
|
`^https:\\/\\/cosmos\\.azure\\.(com|cn|us)$`,
|
||||||
`^https:\\/\\/[\\.\\w]*portal\\.azure\\.(com|cn|us)$`,
|
`^https:\\/\\/[\\.\\w]*portal\\.azure\\.(com|cn|us)$`,
|
||||||
|
`^https:\\/\\/cdb-(ms|ff|mc)-prod-pbe\\.cosmos\\.azure\\.(com|us|cn)$`,
|
||||||
`^https:\\/\\/[\\.\\w]*portal\\.microsoftazure\\.de$`,
|
`^https:\\/\\/[\\.\\w]*portal\\.microsoftazure\\.de$`,
|
||||||
`^https:\\/\\/[\\.\\w]*ext\\.azure\\.(com|cn|us)$`,
|
`^https:\\/\\/[\\.\\w]*ext\\.azure\\.(com|cn|us)$`,
|
||||||
`^https:\\/\\/[\\.\\w]*\\.ext\\.microsoftazure\\.de$`,
|
`^https:\\/\\/[\\.\\w]*\\.ext\\.microsoftazure\\.de$`,
|
||||||
@@ -87,11 +91,11 @@ 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/",
|
||||||
AAD_ENDPOINT: "https://login.microsoftonline.com/",
|
AAD_ENDPOINT: "",
|
||||||
ARM_AUTH_AREA: "https://management.azure.com/",
|
ARM_AUTH_AREA: "https://management.azure.com/",
|
||||||
ARM_ENDPOINT: "https://management.azure.com/",
|
ARM_ENDPOINT: "https://management.azure.com/",
|
||||||
ARM_API_VERSION: "2016-06-01",
|
ARM_API_VERSION: "2016-06-01",
|
||||||
@@ -108,20 +112,8 @@ let configContext: Readonly<ConfigContext> = {
|
|||||||
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
|
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
|
||||||
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Prod,
|
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Prod,
|
||||||
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
||||||
NEW_MONGO_APIS: [
|
|
||||||
// "resourcelist",
|
|
||||||
// "queryDocuments",
|
|
||||||
// "createDocument",
|
|
||||||
// "readDocument",
|
|
||||||
// "updateDocument",
|
|
||||||
// "deleteDocument",
|
|
||||||
// "createCollectionWithProxy",
|
|
||||||
// "legacyMongoShell",
|
|
||||||
],
|
|
||||||
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,
|
||||||
};
|
};
|
||||||
@@ -167,7 +159,12 @@ export function updateConfigContext(newContext: Partial<ConfigContext>): void {
|
|||||||
delete newContext.BACKEND_ENDPOINT;
|
delete newContext.BACKEND_ENDPOINT;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!validateEndpoint(newContext.MONGO_PROXY_ENDPOINT, allowedMongoProxyEndpoints)) {
|
if (
|
||||||
|
!validateEndpoint(
|
||||||
|
newContext.MONGO_PROXY_ENDPOINT,
|
||||||
|
configContext.allowedMongoProxyEndpoints || defaultAllowedMongoProxyEndpoints,
|
||||||
|
)
|
||||||
|
) {
|
||||||
delete newContext.MONGO_PROXY_ENDPOINT;
|
delete newContext.MONGO_PROXY_ENDPOINT;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +172,12 @@ export function updateConfigContext(newContext: Partial<ConfigContext>): void {
|
|||||||
delete newContext.MONGO_BACKEND_ENDPOINT;
|
delete newContext.MONGO_BACKEND_ENDPOINT;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!validateEndpoint(newContext.CASSANDRA_PROXY_ENDPOINT, allowedCassandraProxyEndpoints)) {
|
if (
|
||||||
|
!validateEndpoint(
|
||||||
|
newContext.CASSANDRA_PROXY_ENDPOINT,
|
||||||
|
configContext.allowedCassandraProxyEndpoints || defaultAllowedCassandraProxyEndpoints,
|
||||||
|
)
|
||||||
|
) {
|
||||||
delete newContext.CASSANDRA_PROXY_ENDPOINT;
|
delete newContext.CASSANDRA_PROXY_ENDPOINT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -384,6 +381,7 @@ export enum TerminalKind {
|
|||||||
export interface DataExplorerInputsFrame {
|
export interface DataExplorerInputsFrame {
|
||||||
databaseAccount: any;
|
databaseAccount: any;
|
||||||
subscriptionId?: string;
|
subscriptionId?: string;
|
||||||
|
tenantId?: string;
|
||||||
resourceGroup?: string;
|
resourceGroup?: string;
|
||||||
masterKey?: string;
|
masterKey?: string;
|
||||||
hasWriteAccess?: boolean;
|
hasWriteAccess?: boolean;
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -52,13 +56,15 @@ export const createDatabaseContextMenu = (container: Explorer, databaseId: strin
|
|||||||
if (userContext.apiType !== "Tables" || userContext.features.enableSDKoperations) {
|
if (userContext.apiType !== "Tables" || userContext.features.enableSDKoperations) {
|
||||||
items.push({
|
items.push({
|
||||||
iconSrc: DeleteDatabaseIcon,
|
iconSrc: DeleteDatabaseIcon,
|
||||||
onClick: () =>
|
onClick: (lastFocusedElement?: React.RefObject<HTMLElement>) => {
|
||||||
useSidePanel
|
(useSidePanel.getState().getRef = lastFocusedElement),
|
||||||
.getState()
|
useSidePanel
|
||||||
.openSidePanel(
|
.getState()
|
||||||
"Delete " + getDatabaseName(),
|
.openSidePanel(
|
||||||
<DeleteDatabaseConfirmationPanel refreshDatabases={() => container.refreshAllDatabases()} />,
|
"Delete " + getDatabaseName(),
|
||||||
),
|
<DeleteDatabaseConfirmationPanel refreshDatabases={() => container.refreshAllDatabases()} />,
|
||||||
|
);
|
||||||
|
},
|
||||||
label: `Delete ${getDatabaseName()}`,
|
label: `Delete ${getDatabaseName()}`,
|
||||||
styleClass: "deleteDatabaseMenuItem",
|
styleClass: "deleteDatabaseMenuItem",
|
||||||
});
|
});
|
||||||
@@ -142,14 +148,15 @@ export const createCollectionContextMenuButton = (
|
|||||||
if (configContext.platform !== Platform.Fabric) {
|
if (configContext.platform !== Platform.Fabric) {
|
||||||
items.push({
|
items.push({
|
||||||
iconSrc: DeleteCollectionIcon,
|
iconSrc: DeleteCollectionIcon,
|
||||||
onClick: () => {
|
onClick: (lastFocusedElement?: React.RefObject<HTMLElement>) => {
|
||||||
useSelectedNode.getState().setSelectedNode(selectedCollection);
|
useSelectedNode.getState().setSelectedNode(selectedCollection);
|
||||||
useSidePanel
|
(useSidePanel.getState().getRef = lastFocusedElement),
|
||||||
.getState()
|
useSidePanel
|
||||||
.openSidePanel(
|
.getState()
|
||||||
"Delete " + getCollectionName(),
|
.openSidePanel(
|
||||||
<DeleteCollectionConfirmationPane refreshDatabases={() => container.refreshAllDatabases()} />,
|
"Delete " + getCollectionName(),
|
||||||
);
|
<DeleteCollectionConfirmationPane refreshDatabases={() => container.refreshAllDatabases()} />,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
label: `Delete ${getCollectionName()}`,
|
label: `Delete ${getCollectionName()}`,
|
||||||
styleClass: "deleteCollectionMenuItem",
|
styleClass: "deleteCollectionMenuItem",
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,37 @@ import * as React from "react";
|
|||||||
import { loadMonaco, monaco } from "../../LazyMonaco";
|
import { loadMonaco, monaco } from "../../LazyMonaco";
|
||||||
// import "./EditorReact.less";
|
// import "./EditorReact.less";
|
||||||
|
|
||||||
|
// In development, add a function to window to allow us to get the editor instance for a given element
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const win = window as any;
|
||||||
|
win._monaco_getEditorForElement =
|
||||||
|
win._monaco_getEditorForElement ||
|
||||||
|
((element: HTMLElement) => {
|
||||||
|
const editorId = element.dataset["monacoEditorId"];
|
||||||
|
if (!editorId || !win.__monaco_editors || typeof win.__monaco_editors !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return win.__monaco_editors[editorId];
|
||||||
|
});
|
||||||
|
|
||||||
|
win._monaco_getEditorContentForElement =
|
||||||
|
win._monaco_getEditorContentForElement ||
|
||||||
|
((element: HTMLElement) => {
|
||||||
|
const editor = win._monaco_getEditorForElement(element);
|
||||||
|
return editor ? editor.getValue() : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
win._monaco_setEditorContentForElement =
|
||||||
|
win._monaco_setEditorContentForElement ||
|
||||||
|
((element: HTMLElement, text: string) => {
|
||||||
|
const editor = win._monaco_getEditorForElement(element);
|
||||||
|
if (editor) {
|
||||||
|
editor.setValue(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
interface EditorReactStates {
|
interface EditorReactStates {
|
||||||
showEditor: boolean;
|
showEditor: boolean;
|
||||||
}
|
}
|
||||||
@@ -11,7 +42,7 @@ export interface EditorReactProps {
|
|||||||
content: string;
|
content: string;
|
||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
ariaLabel: string; // Sets what will be read to the user to define the control
|
ariaLabel: string; // Sets what will be read to the user to define the control
|
||||||
onContentSelected?: (selectedContent: string) => void; // Called when text is selected
|
onContentSelected?: (selectedContent: string, selection: monaco.Selection) => void; // Called when text is selected
|
||||||
onContentChanged?: (newContent: string) => void; // Called when text is changed
|
onContentChanged?: (newContent: string) => void; // Called when text is changed
|
||||||
theme?: string; // Monaco editor theme
|
theme?: string; // Monaco editor theme
|
||||||
wordWrap?: monaco.editor.IEditorOptions["wordWrap"];
|
wordWrap?: monaco.editor.IEditorOptions["wordWrap"];
|
||||||
@@ -25,6 +56,7 @@ export interface EditorReactProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
spinnerClassName?: string;
|
spinnerClassName?: string;
|
||||||
|
|
||||||
|
modelMarkers?: monaco.editor.IMarkerData[];
|
||||||
enableWordWrapContextMenuItem?: boolean; // Enable/Disable "Word Wrap" context menu item
|
enableWordWrapContextMenuItem?: boolean; // Enable/Disable "Word Wrap" context menu item
|
||||||
onWordWrapChanged?: (wordWrap: "on" | "off") => void; // Called when word wrap is changed
|
onWordWrapChanged?: (wordWrap: "on" | "off") => void; // Called when word wrap is changed
|
||||||
}
|
}
|
||||||
@@ -32,10 +64,25 @@ export interface EditorReactProps {
|
|||||||
export class EditorReact extends React.Component<EditorReactProps, EditorReactStates> {
|
export class EditorReact extends React.Component<EditorReactProps, EditorReactStates> {
|
||||||
private static readonly VIEWING_OPTIONS_GROUP_ID = "viewingoptions"; // Group ID for the context menu group
|
private static readonly VIEWING_OPTIONS_GROUP_ID = "viewingoptions"; // Group ID for the context menu group
|
||||||
private rootNode: HTMLElement;
|
private rootNode: HTMLElement;
|
||||||
private editor: monaco.editor.IStandaloneCodeEditor;
|
public editor: monaco.editor.IStandaloneCodeEditor;
|
||||||
private selectionListener: monaco.IDisposable;
|
private selectionListener: monaco.IDisposable;
|
||||||
|
monacoApi: {
|
||||||
private monacoEditorOptionsWordWrap: monaco.editor.EditorOption;
|
default: typeof monaco;
|
||||||
|
Emitter: typeof monaco.Emitter;
|
||||||
|
MarkerTag: typeof monaco.MarkerTag;
|
||||||
|
MarkerSeverity: typeof monaco.MarkerSeverity;
|
||||||
|
CancellationTokenSource: typeof monaco.CancellationTokenSource;
|
||||||
|
Uri: typeof monaco.Uri;
|
||||||
|
KeyCode: typeof monaco.KeyCode;
|
||||||
|
KeyMod: typeof monaco.KeyMod;
|
||||||
|
Position: typeof monaco.Position;
|
||||||
|
Range: typeof monaco.Range;
|
||||||
|
Selection: typeof monaco.Selection;
|
||||||
|
SelectionDirection: typeof monaco.SelectionDirection;
|
||||||
|
Token: typeof monaco.Token;
|
||||||
|
editor: typeof monaco.editor;
|
||||||
|
languages: typeof monaco.languages;
|
||||||
|
};
|
||||||
|
|
||||||
public constructor(props: EditorReactProps) {
|
public constructor(props: EditorReactProps) {
|
||||||
super(props);
|
super(props);
|
||||||
@@ -64,7 +111,7 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
|||||||
|
|
||||||
if (this.props.content !== existingContent) {
|
if (this.props.content !== existingContent) {
|
||||||
if (this.props.isReadOnly) {
|
if (this.props.isReadOnly) {
|
||||||
this.editor.setValue(this.props.content);
|
this.editor.setValue(this.props.content || ""); // Monaco throws an error if you set the value to undefined.
|
||||||
} else {
|
} else {
|
||||||
this.editor.pushUndoStop();
|
this.editor.pushUndoStop();
|
||||||
this.editor.executeEdits("", [
|
this.editor.executeEdits("", [
|
||||||
@@ -75,6 +122,8 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.monacoApi.editor.setModelMarkers(this.editor.getModel(), "owner", this.props.modelMarkers || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentWillUnmount(): void {
|
public componentWillUnmount(): void {
|
||||||
@@ -88,6 +137,7 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
|||||||
<Spinner size={SpinnerSize.large} className={this.props.spinnerClassName || "spinner"} />
|
<Spinner size={SpinnerSize.large} className={this.props.spinnerClassName || "spinner"} />
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
|
data-test="EditorReact/Host/Unloaded"
|
||||||
className={this.props.className || "jsonEditor"}
|
className={this.props.className || "jsonEditor"}
|
||||||
style={this.props.monacoContainerStyles}
|
style={this.props.monacoContainerStyles}
|
||||||
ref={(elt: HTMLElement) => this.setRef(elt)}
|
ref={(elt: HTMLElement) => this.setRef(elt)}
|
||||||
@@ -98,6 +148,18 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
|||||||
|
|
||||||
protected configureEditor(editor: monaco.editor.IStandaloneCodeEditor) {
|
protected configureEditor(editor: monaco.editor.IStandaloneCodeEditor) {
|
||||||
this.editor = editor;
|
this.editor = editor;
|
||||||
|
this.rootNode.dataset["test"] = "EditorReact/Host/Loaded";
|
||||||
|
|
||||||
|
// In development, we want to be able to access the editor instance from the console
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
this.rootNode.dataset["monacoEditorId"] = this.editor.getId();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const win = window as any;
|
||||||
|
|
||||||
|
win["__monaco_editors"] = win["__monaco_editors"] || {};
|
||||||
|
win["__monaco_editors"][this.editor.getId()] = this.editor;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.props.isReadOnly && this.props.onContentChanged) {
|
if (!this.props.isReadOnly && this.props.onContentChanged) {
|
||||||
// Hooking the model's onDidChangeContent event because of some event ordering issues.
|
// Hooking the model's onDidChangeContent event because of some event ordering issues.
|
||||||
// If a single user input causes BOTH the editor content to change AND the cursor selection to change (which is likely),
|
// If a single user input causes BOTH the editor content to change AND the cursor selection to change (which is likely),
|
||||||
@@ -115,7 +177,7 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
|||||||
this.selectionListener = this.editor.onDidChangeCursorSelection(
|
this.selectionListener = this.editor.onDidChangeCursorSelection(
|
||||||
(event: monaco.editor.ICursorSelectionChangedEvent) => {
|
(event: monaco.editor.ICursorSelectionChangedEvent) => {
|
||||||
const selectedContent: string = this.editor.getModel().getValueInRange(event.selection);
|
const selectedContent: string = this.editor.getModel().getValueInRange(event.selection);
|
||||||
this.props.onContentSelected(selectedContent);
|
this.props.onContentSelected(selectedContent, event.selection);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -130,7 +192,7 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
|||||||
// Method that will be executed when the action is triggered.
|
// Method that will be executed when the action is triggered.
|
||||||
// @param editor The editor instance is passed in as a convenience
|
// @param editor The editor instance is passed in as a convenience
|
||||||
run: (ed) => {
|
run: (ed) => {
|
||||||
const newOption = ed.getOption(this.monacoEditorOptionsWordWrap) === "on" ? "off" : "on";
|
const newOption = ed.getOption(this.monacoApi.editor.EditorOption.wordWrap) === "on" ? "off" : "on";
|
||||||
ed.updateOptions({ wordWrap: newOption });
|
ed.updateOptions({ wordWrap: newOption });
|
||||||
this.props.onWordWrapChanged(newOption);
|
this.props.onWordWrapChanged(newOption);
|
||||||
},
|
},
|
||||||
@@ -156,16 +218,14 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
|||||||
lineDecorationsWidth: this.props.lineDecorationsWidth,
|
lineDecorationsWidth: this.props.lineDecorationsWidth,
|
||||||
minimap: this.props.minimap,
|
minimap: this.props.minimap,
|
||||||
scrollBeyondLastLine: this.props.scrollBeyondLastLine,
|
scrollBeyondLastLine: this.props.scrollBeyondLastLine,
|
||||||
|
fixedOverflowWidgets: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.rootNode.innerHTML = "";
|
this.rootNode.innerHTML = "";
|
||||||
const lazymonaco = await loadMonaco();
|
this.monacoApi = await loadMonaco();
|
||||||
|
|
||||||
// We can only get this constant after loading monaco lazily
|
|
||||||
this.monacoEditorOptionsWordWrap = lazymonaco.editor.EditorOption.wordWrap;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
createCallback(lazymonaco?.editor?.create(this.rootNode, options));
|
createCallback(this.monacoApi.editor.create(this.rootNode, options));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// This could happen if the parent node suddenly disappears during create()
|
// This could happen if the parent node suddenly disappears during create()
|
||||||
console.error("Unable to create EditorReact", error);
|
console.error("Unable to create EditorReact", error);
|
||||||
|
|||||||
37
src/Explorer/Controls/IndeterminateProgressBar.tsx
Normal file
37
src/Explorer/Controls/IndeterminateProgressBar.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { ProgressBar, makeStyles } from "@fluentui/react-components";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
indeterminateProgressBarRoot: {
|
||||||
|
"@media screen and (prefers-reduced-motion: reduce)": {
|
||||||
|
animationIterationCount: "infinite",
|
||||||
|
animationDuration: "3s",
|
||||||
|
animationName: {
|
||||||
|
"0%": {
|
||||||
|
opacity: ".2", // matches indeterminate bar width
|
||||||
|
},
|
||||||
|
"50%": {
|
||||||
|
opacity: "1",
|
||||||
|
},
|
||||||
|
"100%": {
|
||||||
|
opacity: ".2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
indeterminateProgressBarBar: {
|
||||||
|
"@media screen and (prefers-reduced-motion: reduce)": {
|
||||||
|
maxWidth: "100%",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const IndeterminateProgressBar: React.FC = () => {
|
||||||
|
const styles = useStyles();
|
||||||
|
return (
|
||||||
|
<ProgressBar
|
||||||
|
bar={{ className: styles.indeterminateProgressBarBar }}
|
||||||
|
className={styles.indeterminateProgressBarRoot}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
68
src/Explorer/Controls/MessageBanner.tsx
Normal file
68
src/Explorer/Controls/MessageBanner.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Button, MessageBar, MessageBarActions, MessageBarBody } from "@fluentui/react-components";
|
||||||
|
import { DismissRegular } from "@fluentui/react-icons";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
export enum MessageBannerState {
|
||||||
|
/** The banner should be visible if the triggering conditions are met. */
|
||||||
|
Allowed = "allowed",
|
||||||
|
|
||||||
|
/** The banner has been dismissed by the user and will not be shown until the component is recreated, even if the visibility condition is true. */
|
||||||
|
Dismissed = "dismissed",
|
||||||
|
|
||||||
|
/** The banner has been supressed by the user and will not be shown at all, even if the visibility condition is true. */
|
||||||
|
Suppressed = "suppressed",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MessageBannerProps = {
|
||||||
|
/** A CSS class for the root MessageBar component */
|
||||||
|
className: string;
|
||||||
|
|
||||||
|
/** A unique ID for the message that will be used to store it's dismiss/suppress state across sessions. */
|
||||||
|
messageId: string;
|
||||||
|
|
||||||
|
/** The current visibility state for the banner IGNORING the user's dimiss/suppress preference
|
||||||
|
*
|
||||||
|
* If this value is true but the user has dismissed the banner, the banner will NOT be shown.
|
||||||
|
*/
|
||||||
|
visible: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A component that shows a message banner which can be dismissed by the user.
|
||||||
|
*
|
||||||
|
* In the future, this can also support persisting the dismissed state in local storage without requiring changes to all the components that use it.
|
||||||
|
*
|
||||||
|
* A message banner can be in three "states":
|
||||||
|
* - Allowed: The banner should be visible if the triggering conditions are met.
|
||||||
|
* - Dismissed: The banner has been dismissed by the user and will not be shown until the component is recreated, even if the visibility condition is true.
|
||||||
|
* - Suppressed: The banner has been supressed by the user and will not be shown at all, even if the visibility condition is true.
|
||||||
|
*
|
||||||
|
* The "Dismissed" state represents the user clicking the "x" in the banner to dismiss it.
|
||||||
|
* The "Suppressed" state represents the user clicking "Don't show this again".
|
||||||
|
*/
|
||||||
|
export const MessageBanner: React.FC<MessageBannerProps> = ({ visible, className, children }) => {
|
||||||
|
const [state, setState] = useState<MessageBannerState>(MessageBannerState.Allowed);
|
||||||
|
|
||||||
|
if (state !== MessageBannerState.Allowed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!visible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageBar className={className}>
|
||||||
|
<MessageBarBody>{children}</MessageBarBody>
|
||||||
|
<MessageBarActions
|
||||||
|
containerAction={
|
||||||
|
<Button
|
||||||
|
aria-label="dismiss"
|
||||||
|
appearance="transparent"
|
||||||
|
icon={<DismissRegular />}
|
||||||
|
onClick={() => setState(MessageBannerState.Dismissed)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
></MessageBarActions>
|
||||||
|
</MessageBar>
|
||||||
|
);
|
||||||
|
};
|
||||||
79
src/Explorer/Controls/ProgressModalDialog.tsx
Normal file
79
src/Explorer/Controls/ProgressModalDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
`;
|
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { useCallback } from "react";
|
|||||||
|
|
||||||
export interface TreeNodeMenuItem {
|
export interface TreeNodeMenuItem {
|
||||||
label: string;
|
label: string;
|
||||||
onClick: () => void;
|
onClick: (value?: React.RefObject<HTMLElement>) => void;
|
||||||
iconSrc?: string;
|
iconSrc?: string;
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
styleClass?: string;
|
styleClass?: string;
|
||||||
@@ -74,6 +74,7 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
|
|||||||
openItems,
|
openItems,
|
||||||
}: TreeNodeComponentProps): JSX.Element => {
|
}: TreeNodeComponentProps): JSX.Element => {
|
||||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||||
|
const contextMenuRef = React.useRef<HTMLButtonElement>(null);
|
||||||
const treeStyles = useTreeStyles();
|
const treeStyles = useTreeStyles();
|
||||||
|
|
||||||
const getSortedChildren = (treeNode: TreeNode): TreeNode[] => {
|
const getSortedChildren = (treeNode: TreeNode): TreeNode[] => {
|
||||||
@@ -141,7 +142,7 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
|
|||||||
data-test={`TreeNode/ContextMenuItem:${menuItem.label}`}
|
data-test={`TreeNode/ContextMenuItem:${menuItem.label}`}
|
||||||
disabled={menuItem.isDisabled}
|
disabled={menuItem.isDisabled}
|
||||||
key={menuItem.label}
|
key={menuItem.label}
|
||||||
onClick={menuItem.onClick}
|
onClick={() => menuItem.onClick(contextMenuRef)}
|
||||||
>
|
>
|
||||||
{menuItem.label}
|
{menuItem.label}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@@ -149,18 +150,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 +176,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],
|
||||||
)}
|
)}
|
||||||
@@ -190,6 +191,7 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
|
|||||||
className={mergeClasses(treeStyles.actionsButton, shouldShowAsSelected && treeStyles.selectedItem)}
|
className={mergeClasses(treeStyles.actionsButton, shouldShowAsSelected && treeStyles.selectedItem)}
|
||||||
data-test="TreeNode/ContextMenuTrigger"
|
data-test="TreeNode/ContextMenuTrigger"
|
||||||
appearance="subtle"
|
appearance="subtle"
|
||||||
|
ref={contextMenuRef}
|
||||||
icon={<MoreHorizontal20Regular />}
|
icon={<MoreHorizontal20Regular />}
|
||||||
/>
|
/>
|
||||||
</MenuTrigger>
|
</MenuTrigger>
|
||||||
@@ -200,12 +202,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}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1252,14 +1478,14 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
|
|||||||
<MenuList>
|
<MenuList>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
data-test="TreeNode/ContextMenuItem:enabledItem"
|
data-test="TreeNode/ContextMenuItem:enabledItem"
|
||||||
onClick={[MockFunction enabledItemClick]}
|
onClick={[Function]}
|
||||||
>
|
>
|
||||||
enabledItem
|
enabledItem
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
data-test="TreeNode/ContextMenuItem:disabledItem"
|
data-test="TreeNode/ContextMenuItem:disabledItem"
|
||||||
disabled={true}
|
disabled={true}
|
||||||
onClick={[MockFunction disabledItemClick]}
|
onClick={[Function]}
|
||||||
>
|
>
|
||||||
disabledItem
|
disabledItem
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@@ -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>
|
||||||
@@ -1292,7 +1518,7 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
data-test="TreeNode/ContextMenuItem:enabledItem"
|
data-test="TreeNode/ContextMenuItem:enabledItem"
|
||||||
key="enabledItem"
|
key="enabledItem"
|
||||||
onClick={[MockFunction enabledItemClick]}
|
onClick={[Function]}
|
||||||
>
|
>
|
||||||
enabledItem
|
enabledItem
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@@ -1300,7 +1526,7 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
|
|||||||
data-test="TreeNode/ContextMenuItem:disabledItem"
|
data-test="TreeNode/ContextMenuItem:disabledItem"
|
||||||
disabled={true}
|
disabled={true}
|
||||||
key="disabledItem"
|
key="disabledItem"
|
||||||
onClick={[MockFunction disabledItemClick]}
|
onClick={[Function]}
|
||||||
>
|
>
|
||||||
disabledItem
|
disabledItem
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
|
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 { 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";
|
||||||
import { scheduleRefreshDatabaseResourceToken } from "Platform/Fabric/FabricUtil";
|
import { scheduleRefreshDatabaseResourceToken } from "Platform/Fabric/FabricUtil";
|
||||||
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
|
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
|
||||||
import { acquireTokenWithMsal, getMsalInstance } from "Utils/AuthorizationUtils";
|
import { acquireMsalTokenForAccount } from "Utils/AuthorizationUtils";
|
||||||
import { allowedNotebookServerUrls, validateEndpoint } from "Utils/EndpointUtils";
|
import { allowedNotebookServerUrls, validateEndpoint } from "Utils/EndpointUtils";
|
||||||
|
import { update } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
|
||||||
import { useQueryCopilot } from "hooks/useQueryCopilot";
|
import { useQueryCopilot } from "hooks/useQueryCopilot";
|
||||||
import * as ko from "knockout";
|
import * as ko from "knockout";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import _ from "underscore";
|
import _ from "underscore";
|
||||||
import * as msal from "@azure/msal-browser";
|
|
||||||
import shallow from "zustand/shallow";
|
import shallow from "zustand/shallow";
|
||||||
import { AuthType } from "../AuthType";
|
import { AuthType } from "../AuthType";
|
||||||
import { BindingHandlersRegisterer } from "../Bindings/BindingHandlersRegisterer";
|
import { BindingHandlersRegisterer } from "../Bindings/BindingHandlersRegisterer";
|
||||||
@@ -67,8 +70,6 @@ import { ResourceTreeAdapter } from "./Tree/ResourceTreeAdapter";
|
|||||||
import StoredProcedure from "./Tree/StoredProcedure";
|
import StoredProcedure from "./Tree/StoredProcedure";
|
||||||
import { useDatabases } from "./useDatabases";
|
import { useDatabases } from "./useDatabases";
|
||||||
import { useSelectedNode } from "./useSelectedNode";
|
import { useSelectedNode } from "./useSelectedNode";
|
||||||
import { update } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
|
|
||||||
import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
|
|
||||||
|
|
||||||
BindingHandlersRegisterer.registerBindingHandlers();
|
BindingHandlersRegisterer.registerBindingHandlers();
|
||||||
|
|
||||||
@@ -258,25 +259,8 @@ export default class Explorer {
|
|||||||
|
|
||||||
public async openLoginForEntraIDPopUp(): Promise<void> {
|
public async openLoginForEntraIDPopUp(): Promise<void> {
|
||||||
if (userContext.databaseAccount.properties?.documentEndpoint) {
|
if (userContext.databaseAccount.properties?.documentEndpoint) {
|
||||||
const hrefEndpoint = new URL(userContext.databaseAccount.properties.documentEndpoint).href.replace(
|
|
||||||
/\/$/,
|
|
||||||
"/.default",
|
|
||||||
);
|
|
||||||
const msalInstance = await getMsalInstance();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await msalInstance.loginPopup({
|
const aadToken = await acquireMsalTokenForAccount(userContext.databaseAccount, false);
|
||||||
redirectUri: configContext.msalRedirectURI,
|
|
||||||
scopes: [],
|
|
||||||
});
|
|
||||||
localStorage.setItem("cachedTenantId", response.tenantId);
|
|
||||||
const cachedAccount = msalInstance.getAllAccounts()?.[0];
|
|
||||||
msalInstance.setActiveAccount(cachedAccount);
|
|
||||||
const aadToken = await acquireTokenWithMsal(msalInstance, {
|
|
||||||
forceRefresh: true,
|
|
||||||
scopes: [hrefEndpoint],
|
|
||||||
authority: `${configContext.AAD_ENDPOINT}${localStorage.getItem("cachedTenantId")}`,
|
|
||||||
});
|
|
||||||
updateUserContext({ aadToken: aadToken });
|
updateUserContext({ aadToken: aadToken });
|
||||||
useDataPlaneRbac.setState({ aadTokenUpdated: true });
|
useDataPlaneRbac.setState({ aadTokenUpdated: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -295,7 +279,7 @@ export default class Explorer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public openNPSSurveyDialog(): void {
|
public openNPSSurveyDialog(): void {
|
||||||
if (!Platform.Portal) {
|
if (!Platform.Portal || !["Postgres", "SQL", "Mongo"].includes(userContext.apiType)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1119,7 +1103,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 +1162,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();
|
||||||
|
|||||||
@@ -167,22 +167,18 @@ export function createContextCommandBarButtons(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createControlCommandBarButtons(container: Explorer): CommandButtonComponentProps[] {
|
export function createControlCommandBarButtons(container: Explorer): CommandButtonComponentProps[] {
|
||||||
const buttons: CommandButtonComponentProps[] =
|
const buttons: CommandButtonComponentProps[] = [
|
||||||
configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly
|
{
|
||||||
? []
|
iconSrc: SettingsIcon,
|
||||||
: [
|
iconAlt: "Settings",
|
||||||
{
|
onCommandClick: () => useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={container} />),
|
||||||
iconSrc: SettingsIcon,
|
commandButtonLabel: undefined,
|
||||||
iconAlt: "Settings",
|
ariaLabel: "Settings",
|
||||||
onCommandClick: () =>
|
tooltipText: "Settings",
|
||||||
useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={container} />),
|
hasPopup: true,
|
||||||
commandButtonLabel: undefined,
|
disabled: false,
|
||||||
ariaLabel: "Settings",
|
},
|
||||||
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";
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ export class NotificationConsoleComponent extends React.Component<
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="expandCollapseButton"
|
className="expandCollapseButton"
|
||||||
|
data-test="NotificationConsole/ExpandCollapseButton"
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={"console button" + (this.props.isConsoleExpanded ? " expanded" : " collapsed")}
|
aria-label={"console button" + (this.props.isConsoleExpanded ? " expanded" : " collapsed")}
|
||||||
@@ -147,7 +148,7 @@ export class NotificationConsoleComponent extends React.Component<
|
|||||||
height={this.props.isConsoleExpanded ? "auto" : 0}
|
height={this.props.isConsoleExpanded ? "auto" : 0}
|
||||||
onAnimationEnd={this.onConsoleWasExpanded}
|
onAnimationEnd={this.onConsoleWasExpanded}
|
||||||
>
|
>
|
||||||
<div className="notificationConsoleContents">
|
<div data-test="NotificationConsole/Contents" className="notificationConsoleContents">
|
||||||
<div className="notificationConsoleControls">
|
<div className="notificationConsoleControls">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
label="Filter:"
|
label="Filter:"
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ exports[`NotificationConsoleComponent renders the console 1`] = `
|
|||||||
aria-expanded={true}
|
aria-expanded={true}
|
||||||
aria-label="console button collapsed"
|
aria-label="console button collapsed"
|
||||||
className="expandCollapseButton"
|
className="expandCollapseButton"
|
||||||
|
data-test="NotificationConsole/ExpandCollapseButton"
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
@@ -109,6 +110,7 @@ exports[`NotificationConsoleComponent renders the console 1`] = `
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="notificationConsoleContents"
|
className="notificationConsoleContents"
|
||||||
|
data-test="NotificationConsole/Contents"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="notificationConsoleControls"
|
className="notificationConsoleControls"
|
||||||
@@ -245,6 +247,7 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
|
|||||||
aria-expanded={true}
|
aria-expanded={true}
|
||||||
aria-label="console button collapsed"
|
aria-label="console button collapsed"
|
||||||
className="expandCollapseButton"
|
className="expandCollapseButton"
|
||||||
|
data-test="NotificationConsole/ExpandCollapseButton"
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
@@ -280,6 +283,7 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="notificationConsoleContents"
|
className="notificationConsoleContents"
|
||||||
|
data-test="NotificationConsole/Contents"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="notificationConsoleControls"
|
className="notificationConsoleControls"
|
||||||
|
|||||||
@@ -1,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));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ import {
|
|||||||
} from "@fluentui/react";
|
} from "@fluentui/react";
|
||||||
import * as Constants from "Common/Constants";
|
import * as Constants from "Common/Constants";
|
||||||
import { createCollection } from "Common/dataAccess/createCollection";
|
import { createCollection } from "Common/dataAccess/createCollection";
|
||||||
|
import { getNewDatabaseSharedThroughputDefault } from "Common/DatabaseUtility";
|
||||||
import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
|
import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
|
||||||
import { configContext, Platform } from "ConfigContext";
|
import { configContext, Platform } from "ConfigContext";
|
||||||
import * as DataModels from "Contracts/DataModels";
|
import * as DataModels from "Contracts/DataModels";
|
||||||
import { SubscriptionType } from "Contracts/SubscriptionType";
|
|
||||||
import { AddVectorEmbeddingPolicyForm } from "Explorer/Panes/VectorSearchPanel/AddVectorEmbeddingPolicyForm";
|
import { AddVectorEmbeddingPolicyForm } from "Explorer/Panes/VectorSearchPanel/AddVectorEmbeddingPolicyForm";
|
||||||
import { useSidePanel } from "hooks/useSidePanel";
|
import { useSidePanel } from "hooks/useSidePanel";
|
||||||
import { useTeachingBubble } from "hooks/useTeachingBubble";
|
import { useTeachingBubble } from "hooks/useTeachingBubble";
|
||||||
@@ -125,7 +125,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
|||||||
createNewDatabase:
|
createNewDatabase:
|
||||||
userContext.apiType !== "Tables" && configContext.platform !== Platform.Fabric && !this.props.databaseId,
|
userContext.apiType !== "Tables" && configContext.platform !== Platform.Fabric && !this.props.databaseId,
|
||||||
newDatabaseId: props.isQuickstart ? this.getSampleDBName() : "",
|
newDatabaseId: props.isQuickstart ? this.getSampleDBName() : "",
|
||||||
isSharedThroughputChecked: this.getSharedThroughputDefault(),
|
isSharedThroughputChecked: getNewDatabaseSharedThroughputDefault(),
|
||||||
selectedDatabaseId:
|
selectedDatabaseId:
|
||||||
userContext.apiType === "Tables" ? CollectionCreation.TablesAPIDefaultDatabase : this.props.databaseId,
|
userContext.apiType === "Tables" ? CollectionCreation.TablesAPIDefaultDatabase : this.props.databaseId,
|
||||||
collectionId: props.isQuickstart ? `Sample${getCollectionName()}` : "",
|
collectionId: props.isQuickstart ? `Sample${getCollectionName()}` : "",
|
||||||
@@ -1138,10 +1138,6 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
|||||||
return userContext.databaseAccount?.properties?.enableFreeTier;
|
return userContext.databaseAccount?.properties?.enableFreeTier;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSharedThroughputDefault(): boolean {
|
|
||||||
return userContext.subscriptionType !== SubscriptionType.EA && !isServerlessAccount();
|
|
||||||
}
|
|
||||||
|
|
||||||
private getFreeTierIndexingText(): string {
|
private getFreeTierIndexingText(): string {
|
||||||
return this.state.enableIndexing
|
return this.state.enableIndexing
|
||||||
? "All properties in your documents will be indexed by default for flexible and efficient queries."
|
? "All properties in your documents will be indexed by default for flexible and efficient queries."
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Checkbox, Stack, Text, TextField } from "@fluentui/react";
|
import { Checkbox, Stack, Text, TextField } from "@fluentui/react";
|
||||||
|
import { getNewDatabaseSharedThroughputDefault } from "Common/DatabaseUtility";
|
||||||
import React, { FunctionComponent, useEffect, useState } from "react";
|
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||||
import * as Constants from "../../../Common/Constants";
|
import * as Constants from "../../../Common/Constants";
|
||||||
import { getErrorMessage, getErrorStack } from "../../../Common/ErrorHandlingUtils";
|
import { getErrorMessage, getErrorStack } from "../../../Common/ErrorHandlingUtils";
|
||||||
@@ -48,7 +49,7 @@ export const AddDatabasePanel: FunctionComponent<AddDatabasePaneProps> = ({
|
|||||||
|
|
||||||
const databaseLevelThroughputTooltipText = `Provisioned throughput at the ${databaseLabel} level will be shared across all ${collectionsLabel} within the ${databaseLabel}.`;
|
const databaseLevelThroughputTooltipText = `Provisioned throughput at the ${databaseLabel} level will be shared across all ${collectionsLabel} within the ${databaseLabel}.`;
|
||||||
const [databaseCreateNewShared, setDatabaseCreateNewShared] = useState<boolean>(
|
const [databaseCreateNewShared, setDatabaseCreateNewShared] = useState<boolean>(
|
||||||
subscriptionType !== SubscriptionType.EA && !isServerlessAccount(),
|
getNewDatabaseSharedThroughputDefault(),
|
||||||
);
|
);
|
||||||
const [formErrors, setFormErrors] = useState<string>("");
|
const [formErrors, setFormErrors] = useState<string>("");
|
||||||
const [isExecuting, setIsExecuting] = useState<boolean>(false);
|
const [isExecuting, setIsExecuting] = useState<boolean>(false);
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ exports[`AddDatabasePane Pane should render Default properly 1`] = `
|
|||||||
horizontal={true}
|
horizontal={true}
|
||||||
>
|
>
|
||||||
<StyledCheckboxBase
|
<StyledCheckboxBase
|
||||||
checked={true}
|
checked={false}
|
||||||
label="Provision throughput"
|
label="Provision throughput"
|
||||||
onChange={[Function]}
|
onChange={[Function]}
|
||||||
styles={
|
styles={
|
||||||
@@ -90,14 +90,6 @@ exports[`AddDatabasePane Pane should render Default properly 1`] = `
|
|||||||
</InfoTooltip>
|
</InfoTooltip>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
<ThroughputInput
|
|
||||||
isDatabase={true}
|
|
||||||
isSharded={true}
|
|
||||||
onCostAcknowledgeChange={[Function]}
|
|
||||||
setIsAutoscale={[Function]}
|
|
||||||
setIsThroughputCapExceeded={[Function]}
|
|
||||||
setThroughputValue={[Function]}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</RightPaneForm>
|
</RightPaneForm>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseCo
|
|||||||
message:
|
message:
|
||||||
"Warning! The action you are about to take cannot be undone. Continuing will permanently delete this resource and all of its children resources.",
|
"Warning! The action you are about to take cannot be undone. Continuing will permanently delete this resource and all of its children resources.",
|
||||||
};
|
};
|
||||||
const confirmDatabase = `Confirm by typing the ${getDatabaseName()} id`;
|
const confirmDatabase = `Confirm by typing the ${getDatabaseName()} id (name)`;
|
||||||
const reasonInfo = `Help us improve Azure Cosmos DB! What is the reason why you are deleting this ${getDatabaseName()}?`;
|
const reasonInfo = `Help us improve Azure Cosmos DB! What is the reason why you are deleting this ${getDatabaseName()}?`;
|
||||||
return (
|
return (
|
||||||
<RightPaneForm {...props}>
|
<RightPaneForm {...props}>
|
||||||
@@ -132,7 +132,7 @@ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseCo
|
|||||||
<div className="panelMainContent">
|
<div className="panelMainContent">
|
||||||
<div className="confirmDeleteInput">
|
<div className="confirmDeleteInput">
|
||||||
<span className="mandatoryStar">* </span>
|
<span className="mandatoryStar">* </span>
|
||||||
<Text variant="small">Confirm by typing the {getDatabaseName()} id</Text>
|
<Text variant="small">{confirmDatabase}</Text>
|
||||||
<TextField
|
<TextField
|
||||||
id="confirmDatabaseId"
|
id="confirmDatabaseId"
|
||||||
data-test="Input:confirmDatabaseId"
|
data-test="Input:confirmDatabaseId"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -106,7 +106,7 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
|
|||||||
horizontal={true}
|
horizontal={true}
|
||||||
>
|
>
|
||||||
<StyledCheckboxBase
|
<StyledCheckboxBase
|
||||||
checked={true}
|
checked={false}
|
||||||
label="Share throughput across containers"
|
label="Share throughput across containers"
|
||||||
onChange={[Function]}
|
onChange={[Function]}
|
||||||
styles={
|
styles={
|
||||||
@@ -137,14 +137,6 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
|
|||||||
/>
|
/>
|
||||||
</StyledTooltipHostBase>
|
</StyledTooltipHostBase>
|
||||||
</Stack>
|
</Stack>
|
||||||
<ThroughputInput
|
|
||||||
isDatabase={true}
|
|
||||||
isSharded={true}
|
|
||||||
onCostAcknowledgeChange={[Function]}
|
|
||||||
setIsAutoscale={[Function]}
|
|
||||||
setIsThroughputCapExceeded={[Function]}
|
|
||||||
setThroughputValue={[Function]}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
<Separator
|
<Separator
|
||||||
className="panelSeparator"
|
className="panelSeparator"
|
||||||
@@ -263,6 +255,14 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
|
|||||||
</CustomizedDefaultButton>
|
</CustomizedDefaultButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
<ThroughputInput
|
||||||
|
isDatabase={false}
|
||||||
|
isSharded={true}
|
||||||
|
onCostAcknowledgeChange={[Function]}
|
||||||
|
setIsAutoscale={[Function]}
|
||||||
|
setIsThroughputCapExceeded={[Function]}
|
||||||
|
setThroughputValue={[Function]}
|
||||||
|
/>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack
|
<Stack
|
||||||
horizontal={true}
|
horizontal={true}
|
||||||
|
|||||||
@@ -361,13 +361,11 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
|
|||||||
<span
|
<span
|
||||||
className="css-113"
|
className="css-113"
|
||||||
>
|
>
|
||||||
Confirm by typing the
|
Confirm by typing the Database id (name)
|
||||||
Database
|
|
||||||
id
|
|
||||||
</span>
|
</span>
|
||||||
</Text>
|
</Text>
|
||||||
<StyledTextFieldBase
|
<StyledTextFieldBase
|
||||||
ariaLabel="Confirm by typing the Database id"
|
ariaLabel="Confirm by typing the Database id (name)"
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
data-test="Input:confirmDatabaseId"
|
data-test="Input:confirmDatabaseId"
|
||||||
id="confirmDatabaseId"
|
id="confirmDatabaseId"
|
||||||
@@ -382,7 +380,7 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TextFieldBase
|
<TextFieldBase
|
||||||
ariaLabel="Confirm by typing the Database id"
|
ariaLabel="Confirm by typing the Database id (name)"
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
data-test="Input:confirmDatabaseId"
|
data-test="Input:confirmDatabaseId"
|
||||||
deferredValidationTime={200}
|
deferredValidationTime={200}
|
||||||
@@ -677,7 +675,7 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
aria-invalid={false}
|
aria-invalid={false}
|
||||||
aria-label="Confirm by typing the Database id"
|
aria-label="Confirm by typing the Database id (name)"
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
className="ms-TextField-field field-117"
|
className="ms-TextField-field field-117"
|
||||||
data-test="Input:confirmDatabaseId"
|
data-test="Input:confirmDatabaseId"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { MinimalQueryIterator } from "Common/IteratorUtilities";
|
import { MinimalQueryIterator } from "Common/IteratorUtilities";
|
||||||
|
import QueryError from "Common/QueryError";
|
||||||
import { QueryResults } from "Contracts/ViewModels";
|
import { QueryResults } from "Contracts/ViewModels";
|
||||||
import { CopilotMessage } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
|
import { CopilotMessage } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
|
||||||
import { guid } from "Explorer/Tables/Utilities";
|
import { guid } from "Explorer/Tables/Utilities";
|
||||||
@@ -28,7 +29,7 @@ const CopilotProvider = ({ children }: { children: React.ReactNode }): JSX.Eleme
|
|||||||
showSamplePrompts: false,
|
showSamplePrompts: false,
|
||||||
queryIterator: undefined,
|
queryIterator: undefined,
|
||||||
queryResults: undefined,
|
queryResults: undefined,
|
||||||
errorMessage: "",
|
errors: [],
|
||||||
isSamplePromptsOpen: false,
|
isSamplePromptsOpen: false,
|
||||||
showPromptTeachingBubble: true,
|
showPromptTeachingBubble: true,
|
||||||
showDeletePopup: false,
|
showDeletePopup: false,
|
||||||
@@ -64,7 +65,7 @@ const CopilotProvider = ({ children }: { children: React.ReactNode }): JSX.Eleme
|
|||||||
setShowSamplePrompts: (showSamplePrompts: boolean) => set({ showSamplePrompts }),
|
setShowSamplePrompts: (showSamplePrompts: boolean) => set({ showSamplePrompts }),
|
||||||
setQueryIterator: (queryIterator: MinimalQueryIterator | undefined) => set({ queryIterator }),
|
setQueryIterator: (queryIterator: MinimalQueryIterator | undefined) => set({ queryIterator }),
|
||||||
setQueryResults: (queryResults: QueryResults | undefined) => set({ queryResults }),
|
setQueryResults: (queryResults: QueryResults | undefined) => set({ queryResults }),
|
||||||
setErrorMessage: (errorMessage: string) => set({ errorMessage }),
|
setErrors: (errors: QueryError[]) => set({ errors }),
|
||||||
setIsSamplePromptsOpen: (isSamplePromptsOpen: boolean) => set({ isSamplePromptsOpen }),
|
setIsSamplePromptsOpen: (isSamplePromptsOpen: boolean) => set({ isSamplePromptsOpen }),
|
||||||
setShowPromptTeachingBubble: (showPromptTeachingBubble: boolean) => set({ showPromptTeachingBubble }),
|
setShowPromptTeachingBubble: (showPromptTeachingBubble: boolean) => set({ showPromptTeachingBubble }),
|
||||||
setShowDeletePopup: (showDeletePopup: boolean) => set({ showDeletePopup }),
|
setShowDeletePopup: (showDeletePopup: boolean) => set({ showDeletePopup }),
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextField,
|
TextField,
|
||||||
} from "@fluentui/react";
|
} from "@fluentui/react";
|
||||||
import { HttpStatusCodes } from "Common/Constants";
|
import { HttpStatusCodes, NormalizedEventKey } from "Common/Constants";
|
||||||
import { handleError } from "Common/ErrorHandlingUtils";
|
import { handleError } from "Common/ErrorHandlingUtils";
|
||||||
|
import QueryError, { QueryErrorSeverity } from "Common/QueryError";
|
||||||
import { createUri } from "Common/UrlUtility";
|
import { createUri } from "Common/UrlUtility";
|
||||||
import { CopyPopup } from "Explorer/QueryCopilot/Popup/CopyPopup";
|
import { CopyPopup } from "Explorer/QueryCopilot/Popup/CopyPopup";
|
||||||
import { DeletePopup } from "Explorer/QueryCopilot/Popup/DeletePopup";
|
import { DeletePopup } from "Explorer/QueryCopilot/Popup/DeletePopup";
|
||||||
@@ -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 we’ll generate the query for you."
|
placeholder="Ask a question in natural language and we’ll generate the query for you."
|
||||||
aria-labelledby="copilot-textfield-label"
|
aria-labelledby="copilot-textfield-label"
|
||||||
onRenderSuffix={() => {
|
onRenderSuffix={() => {
|
||||||
@@ -432,6 +464,8 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
|||||||
setShowSamplePrompts(false);
|
setShowSamplePrompts(false);
|
||||||
inputEdited.current = true;
|
inputEdited.current = true;
|
||||||
}}
|
}}
|
||||||
|
elementRef={itemRefs.current[i]}
|
||||||
|
onKeyDown={handleKeyDownForItem}
|
||||||
onRenderIcon={() => <Image src={RecentIcon} styles={{ root: { overflow: "unset" } }} />}
|
onRenderIcon={() => <Image src={RecentIcon} styles={{ root: { overflow: "unset" } }} />}
|
||||||
styles={promptStyles}
|
styles={promptStyles}
|
||||||
>
|
>
|
||||||
@@ -454,14 +488,16 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
|||||||
>
|
>
|
||||||
Suggested Prompts
|
Suggested Prompts
|
||||||
</Text>
|
</Text>
|
||||||
{filteredSuggestedPrompts.map((prompt) => (
|
{filteredSuggestedPrompts.map((prompt, index) => (
|
||||||
<DefaultButton
|
<DefaultButton
|
||||||
key={prompt.id}
|
key={prompt.id}
|
||||||
|
elementRef={itemRefs.current[filteredHistories.length + index]}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUserPrompt(prompt.text);
|
setUserPrompt(prompt.text);
|
||||||
setShowSamplePrompts(false);
|
setShowSamplePrompts(false);
|
||||||
inputEdited.current = true;
|
inputEdited.current = true;
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={handleKeyDownForItem}
|
||||||
onRenderIcon={() => <Image src={HintIcon} />}
|
onRenderIcon={() => <Image src={HintIcon} />}
|
||||||
styles={promptStyles}
|
styles={promptStyles}
|
||||||
>
|
>
|
||||||
@@ -514,7 +550,9 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
|||||||
</Link>
|
</Link>
|
||||||
{showErrorMessageBar && (
|
{showErrorMessageBar && (
|
||||||
<MessageBar messageBarType={MessageBarType.error}>
|
<MessageBar messageBarType={MessageBarType.error}>
|
||||||
{errorMessage ? errorMessage : "We ran into an error and were not able to execute query."}
|
{errors.length > 0
|
||||||
|
? errors[0].message
|
||||||
|
: "We ran into an error and were not able to execute query."}
|
||||||
</MessageBar>
|
</MessageBar>
|
||||||
)}
|
)}
|
||||||
{showInvalidQueryMessageBar && (
|
{showInvalidQueryMessageBar && (
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
|
|||||||
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
|
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
|
||||||
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 (
|
||||||
|
|||||||
@@ -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));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -36,4 +36,5 @@ export interface ITableQuery {
|
|||||||
|
|
||||||
export interface ITableEntityIdentity {
|
export interface ITableEntityIdentity {
|
||||||
RowKey: string;
|
RowKey: string;
|
||||||
|
PartitionKey?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
|
|||||||
113
src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts
Normal file
113
src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts
Normal 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(),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
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 { ProgressModalDialog } from "Explorer/Controls/ProgressModalDialog";
|
||||||
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
|
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
|
||||||
import {
|
import {
|
||||||
ButtonsDependencies,
|
ButtonsDependencies,
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -459,7 +478,29 @@ describe("Documents tab (noSql API)", () => {
|
|||||||
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
@@ -470,7 +511,18 @@ describe("Documents tab (noSql API)", () => {
|
|||||||
.onCommandClick(undefined);
|
.onCommandClick(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
@@ -1,4 +1,4 @@
|
|||||||
import { deleteDocument } from "Common/MongoProxyClient";
|
import { deleteDocuments } from "Common/MongoProxyClient";
|
||||||
import { Platform, updateConfigContext } from "ConfigContext";
|
import { Platform, updateConfigContext } from "ConfigContext";
|
||||||
import { EditorReactProps } from "Explorer/Controls/Editor/EditorReact";
|
import { EditorReactProps } from "Explorer/Controls/Editor/EditorReact";
|
||||||
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
|
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
|
||||||
@@ -49,7 +49,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", () => ({
|
||||||
@@ -178,9 +180,9 @@ describe("Documents tab (Mongo API)", () => {
|
|||||||
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
|
||||||
@@ -189,7 +191,7 @@ describe("Documents tab (Mongo API)", () => {
|
|||||||
.onCommandClick(undefined);
|
.onCommandClick(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockDeleteDocument).toHaveBeenCalled();
|
expect(mockDeleteDocuments).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { TableRowId } from "@fluentui/react-components";
|
import { TableRowId } from "@fluentui/react-components";
|
||||||
import { mount } from "enzyme";
|
import { mount } from "enzyme";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||||
import { DocumentsTableComponent, IDocumentsTableComponentProps } from "./DocumentsTableComponent";
|
import { DocumentsTableComponent, IDocumentsTableComponentProps } from "./DocumentsTableComponent";
|
||||||
|
|
||||||
const PARTITION_KEY_HEADER = "partitionKey";
|
const PARTITION_KEY_HEADER = "partitionKey";
|
||||||
@@ -20,11 +21,19 @@ describe("DocumentsTableComponent", () => {
|
|||||||
height: 0,
|
height: 0,
|
||||||
width: 0,
|
width: 0,
|
||||||
},
|
},
|
||||||
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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,52 +1,85 @@
|
|||||||
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 { 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 +92,201 @@ 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>
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
{!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 +376,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 +390,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 +445,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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default class MongoShellTabComponent extends Component<
|
|||||||
constructor(props: IMongoShellTabComponentProps) {
|
constructor(props: IMongoShellTabComponentProps) {
|
||||||
super(props);
|
super(props);
|
||||||
this._logTraces = new Map();
|
this._logTraces = new Map();
|
||||||
this._useMongoProxyEndpoint = useMongoProxyEndpoint("legacyMongoShell");
|
this._useMongoProxyEndpoint = useMongoProxyEndpoint(Constants.MongoProxyApi.LegacyMongoShell);
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
url: getMongoShellUrl(this._useMongoProxyEndpoint),
|
url: getMongoShellUrl(this._useMongoProxyEndpoint),
|
||||||
|
|||||||
143
src/Explorer/Tabs/QueryTab/ErrorList.tsx
Normal file
143
src/Explorer/Tabs/QueryTab/ErrorList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import * as DataModels from "../../../Contracts/DataModels";
|
|||||||
import type { QueryTabOptions } from "../../../Contracts/ViewModels";
|
import type { QueryTabOptions } from "../../../Contracts/ViewModels";
|
||||||
import { useTabs } from "../../../hooks/useTabs";
|
import { useTabs } from "../../../hooks/useTabs";
|
||||||
import Explorer from "../../Explorer";
|
import Explorer from "../../Explorer";
|
||||||
import QueryTabComponent, {
|
import {
|
||||||
IQueryTabComponentProps,
|
IQueryTabComponentProps,
|
||||||
ITabAccessor,
|
ITabAccessor,
|
||||||
QueryTabFunctionComponent,
|
QueryTabComponent,
|
||||||
|
QueryTabCopilotComponent,
|
||||||
} from "../../Tabs/QueryTab/QueryTabComponent";
|
} from "../../Tabs/QueryTab/QueryTabComponent";
|
||||||
import TabsBase from "../TabsBase";
|
import TabsBase from "../TabsBase";
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ export class NewQueryTab extends TabsBase {
|
|||||||
public render(): JSX.Element {
|
public render(): JSX.Element {
|
||||||
return userContext.apiType === "SQL" ? (
|
return userContext.apiType === "SQL" ? (
|
||||||
<CopilotProvider>
|
<CopilotProvider>
|
||||||
<QueryTabFunctionComponent {...this.iQueryTabComponentProps} />
|
<QueryTabCopilotComponent {...this.iQueryTabComponentProps} />
|
||||||
</CopilotProvider>
|
</CopilotProvider>
|
||||||
) : (
|
) : (
|
||||||
<QueryTabComponent {...this.iQueryTabComponentProps} />
|
<QueryTabComponent {...this.iQueryTabComponentProps} />
|
||||||
|
|||||||
@@ -2,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);
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
import { FeedOptions, QueryOperationOptions } from "@azure/cosmos";
|
import { FeedOptions, QueryOperationOptions } from "@azure/cosmos";
|
||||||
|
import QueryError, { createMonacoErrorLocationResolver, createMonacoMarkersForQueryErrors } from "Common/QueryError";
|
||||||
import { SplitterDirection } from "Common/Splitter";
|
import { SplitterDirection } from "Common/Splitter";
|
||||||
import { Platform, configContext } from "ConfigContext";
|
import { Platform, configContext } from "ConfigContext";
|
||||||
import { useDialog } from "Explorer/Controls/Dialog";
|
import { useDialog } from "Explorer/Controls/Dialog";
|
||||||
|
import { monaco } from "Explorer/LazyMonaco";
|
||||||
import { QueryCopilotFeedbackModal } from "Explorer/QueryCopilot/Modal/QueryCopilotFeedbackModal";
|
import { QueryCopilotFeedbackModal } from "Explorer/QueryCopilot/Modal/QueryCopilotFeedbackModal";
|
||||||
import { useCopilotStore } from "Explorer/QueryCopilot/QueryCopilotContext";
|
import { useCopilotStore } from "Explorer/QueryCopilot/QueryCopilotContext";
|
||||||
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
|
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
|
||||||
|
import { 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 +26,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 +40,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 +47,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";
|
||||||
@@ -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>
|
||||||
|
|||||||
396
src/Explorer/Tabs/QueryTab/ResultsView.tsx
Normal file
396
src/Explorer/Tabs/QueryTab/ResultsView.tsx
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DataGrid,
|
||||||
|
DataGridBody,
|
||||||
|
DataGridCell,
|
||||||
|
DataGridHeader,
|
||||||
|
DataGridHeaderCell,
|
||||||
|
DataGridRow,
|
||||||
|
SelectTabData,
|
||||||
|
SelectTabEvent,
|
||||||
|
Tab,
|
||||||
|
TabList,
|
||||||
|
TableColumnDefinition,
|
||||||
|
createTableColumn,
|
||||||
|
} from "@fluentui/react-components";
|
||||||
|
import { ArrowDownloadRegular, CopyRegular } from "@fluentui/react-icons";
|
||||||
|
import { HttpHeaders } from "Common/Constants";
|
||||||
|
import MongoUtility from "Common/MongoUtility";
|
||||||
|
import { QueryMetrics } from "Contracts/DataModels";
|
||||||
|
import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
|
||||||
|
import { IDocument } from "Explorer/Tabs/QueryTab/QueryTabComponent";
|
||||||
|
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
|
||||||
|
import { userContext } from "UserContext";
|
||||||
|
import copy from "clipboard-copy";
|
||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
import { ResultsViewProps } from "./QueryResultSection";
|
||||||
|
|
||||||
|
enum ResultsTabs {
|
||||||
|
Results = "results",
|
||||||
|
QueryStats = "queryStats",
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResultsTab: React.FC<ResultsViewProps> = ({ queryResults, isMongoDB, executeQueryDocumentsPage }) => {
|
||||||
|
const styles = useQueryTabStyles();
|
||||||
|
const queryResultsString = queryResults
|
||||||
|
? isMongoDB
|
||||||
|
? MongoUtility.tojson(queryResults.documents, undefined, false)
|
||||||
|
: JSON.stringify(queryResults.documents, undefined, 4)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const onClickCopyResults = (): void => {
|
||||||
|
copy(queryResultsString);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFetchNextPageClick = async (): Promise<void> => {
|
||||||
|
const { firstItemIndex, itemCount } = queryResults;
|
||||||
|
await executeQueryDocumentsPage(firstItemIndex + itemCount - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.queryResultsBar}>
|
||||||
|
<div>
|
||||||
|
{queryResults.itemCount > 0 ? `${queryResults.firstItemIndex} - ${queryResults.lastItemIndex}` : `0 - 0`}
|
||||||
|
</div>
|
||||||
|
{queryResults.hasMoreResults && (
|
||||||
|
<a href="#" onClick={() => onFetchNextPageClick()}>
|
||||||
|
Load more
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<div className={styles.flexGrowSpacer} />
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="transparent"
|
||||||
|
icon={<CopyRegular />}
|
||||||
|
title="Copy to Clipboard"
|
||||||
|
aria-label="Copy"
|
||||||
|
onClick={onClickCopyResults}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.queryResultsViewer}>
|
||||||
|
<EditorReact language={"json"} content={queryResultsString} isReadOnly={true} ariaLabel={"Query results"} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const QueryStatsTab: React.FC<Pick<ResultsViewProps, "queryResults">> = ({ queryResults }) => {
|
||||||
|
const styles = useQueryTabStyles();
|
||||||
|
const queryMetrics = React.useRef(queryResults?.headers?.[HttpHeaders.queryMetrics]);
|
||||||
|
React.useEffect(() => {
|
||||||
|
const latestQueryMetrics = queryResults?.headers?.[HttpHeaders.queryMetrics];
|
||||||
|
if (latestQueryMetrics && Object.keys(latestQueryMetrics).length > 0) {
|
||||||
|
queryMetrics.current = latestQueryMetrics;
|
||||||
|
}
|
||||||
|
}, [queryResults]);
|
||||||
|
|
||||||
|
const getAggregatedQueryMetrics = (): QueryMetrics => {
|
||||||
|
const aggregatedQueryMetrics = {
|
||||||
|
documentLoadTime: 0,
|
||||||
|
documentWriteTime: 0,
|
||||||
|
indexHitDocumentCount: 0,
|
||||||
|
outputDocumentCount: 0,
|
||||||
|
outputDocumentSize: 0,
|
||||||
|
indexLookupTime: 0,
|
||||||
|
retrievedDocumentCount: 0,
|
||||||
|
retrievedDocumentSize: 0,
|
||||||
|
vmExecutionTime: 0,
|
||||||
|
runtimeExecutionTimes: {
|
||||||
|
queryEngineExecutionTime: 0,
|
||||||
|
systemFunctionExecutionTime: 0,
|
||||||
|
userDefinedFunctionExecutionTime: 0,
|
||||||
|
},
|
||||||
|
totalQueryExecutionTime: 0,
|
||||||
|
} as QueryMetrics;
|
||||||
|
|
||||||
|
if (queryMetrics.current) {
|
||||||
|
Object.keys(queryMetrics.current).forEach((partitionKeyRangeId) => {
|
||||||
|
const queryMetricsPerPartition = queryMetrics.current[partitionKeyRangeId];
|
||||||
|
if (!queryMetricsPerPartition) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
aggregatedQueryMetrics.documentLoadTime += queryMetricsPerPartition.documentLoadTime?.totalMilliseconds() || 0;
|
||||||
|
aggregatedQueryMetrics.documentWriteTime +=
|
||||||
|
queryMetricsPerPartition.documentWriteTime?.totalMilliseconds() || 0;
|
||||||
|
aggregatedQueryMetrics.indexHitDocumentCount += queryMetricsPerPartition.indexHitDocumentCount || 0;
|
||||||
|
aggregatedQueryMetrics.outputDocumentCount += queryMetricsPerPartition.outputDocumentCount || 0;
|
||||||
|
aggregatedQueryMetrics.outputDocumentSize += queryMetricsPerPartition.outputDocumentSize || 0;
|
||||||
|
aggregatedQueryMetrics.indexLookupTime += queryMetricsPerPartition.indexLookupTime?.totalMilliseconds() || 0;
|
||||||
|
aggregatedQueryMetrics.retrievedDocumentCount += queryMetricsPerPartition.retrievedDocumentCount || 0;
|
||||||
|
aggregatedQueryMetrics.retrievedDocumentSize += queryMetricsPerPartition.retrievedDocumentSize || 0;
|
||||||
|
aggregatedQueryMetrics.vmExecutionTime += queryMetricsPerPartition.vmExecutionTime?.totalMilliseconds() || 0;
|
||||||
|
aggregatedQueryMetrics.totalQueryExecutionTime +=
|
||||||
|
queryMetricsPerPartition.totalQueryExecutionTime?.totalMilliseconds() || 0;
|
||||||
|
aggregatedQueryMetrics.runtimeExecutionTimes.queryEngineExecutionTime +=
|
||||||
|
queryMetricsPerPartition.runtimeExecutionTimes?.queryEngineExecutionTime?.totalMilliseconds() || 0;
|
||||||
|
aggregatedQueryMetrics.runtimeExecutionTimes.systemFunctionExecutionTime +=
|
||||||
|
queryMetricsPerPartition.runtimeExecutionTimes?.systemFunctionExecutionTime?.totalMilliseconds() || 0;
|
||||||
|
aggregatedQueryMetrics.runtimeExecutionTimes.userDefinedFunctionExecutionTime +=
|
||||||
|
queryMetricsPerPartition.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.totalMilliseconds() || 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return aggregatedQueryMetrics;
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: TableColumnDefinition<IDocument>[] = [
|
||||||
|
createTableColumn<IDocument>({
|
||||||
|
columnId: "metric",
|
||||||
|
renderHeaderCell: () => "Metric",
|
||||||
|
renderCell: (item) => item.metric,
|
||||||
|
}),
|
||||||
|
createTableColumn<IDocument>({
|
||||||
|
columnId: "value",
|
||||||
|
renderHeaderCell: () => "Value",
|
||||||
|
renderCell: (item) => item.value,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const generateQueryStatsItems = (): IDocument[] => {
|
||||||
|
const items: IDocument[] = [
|
||||||
|
{
|
||||||
|
metric: "Request Charge",
|
||||||
|
value: `${queryResults.requestCharge} RUs`,
|
||||||
|
toolTip: "Request Charge",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: "Showing Results",
|
||||||
|
value: queryResults.itemCount > 0 ? `${queryResults.firstItemIndex} - ${queryResults.lastItemIndex}` : `0 - 0`,
|
||||||
|
toolTip: "Showing Results",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (userContext.apiType === "SQL") {
|
||||||
|
const aggregatedQueryMetrics = getAggregatedQueryMetrics();
|
||||||
|
items.push(
|
||||||
|
{
|
||||||
|
metric: "Retrieved document count",
|
||||||
|
value: aggregatedQueryMetrics.retrievedDocumentCount?.toString() || "",
|
||||||
|
toolTip: "Total number of retrieved documents",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: "Retrieved document size",
|
||||||
|
value: `${aggregatedQueryMetrics.retrievedDocumentSize?.toString() || 0} bytes`,
|
||||||
|
toolTip: "Total size of retrieved documents in bytes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: "Output document count",
|
||||||
|
value: aggregatedQueryMetrics.outputDocumentCount?.toString() || "",
|
||||||
|
toolTip: "Number of output documents",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: "Output document size",
|
||||||
|
value: `${aggregatedQueryMetrics.outputDocumentSize?.toString() || 0} bytes`,
|
||||||
|
toolTip: "Total size of output documents in bytes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: "Index hit document count",
|
||||||
|
value: aggregatedQueryMetrics.indexHitDocumentCount?.toString() || "",
|
||||||
|
toolTip: "Total number of documents matched by the filter",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: "Index lookup time",
|
||||||
|
value: `${aggregatedQueryMetrics.indexLookupTime?.toString() || 0} ms`,
|
||||||
|
toolTip: "Time spent in physical index layer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: "Document load time",
|
||||||
|
value: `${aggregatedQueryMetrics.documentLoadTime?.toString() || 0} ms`,
|
||||||
|
toolTip: "Time spent in loading documents",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: "Query engine execution time",
|
||||||
|
value: `${aggregatedQueryMetrics.runtimeExecutionTimes?.queryEngineExecutionTime?.toString() || 0} ms`,
|
||||||
|
toolTip:
|
||||||
|
"Time spent by the query engine to execute the query expression (excludes other execution times like load documents or write results)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: "System function execution time",
|
||||||
|
value: `${aggregatedQueryMetrics.runtimeExecutionTimes?.systemFunctionExecutionTime?.toString() || 0} ms`,
|
||||||
|
toolTip: "Total time spent executing system (built-in) functions",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: "User defined function execution time",
|
||||||
|
value: `${
|
||||||
|
aggregatedQueryMetrics.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.toString() || 0
|
||||||
|
} ms`,
|
||||||
|
toolTip: "Total time spent executing user-defined functions",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metric: "Document write time",
|
||||||
|
value: `${aggregatedQueryMetrics.documentWriteTime.toString() || 0} ms`,
|
||||||
|
toolTip: "Time spent to write query result set to response buffer",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryResults.roundTrips) {
|
||||||
|
items.push({
|
||||||
|
metric: "Round Trips",
|
||||||
|
value: queryResults.roundTrips?.toString(),
|
||||||
|
toolTip: "Number of round trips",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryResults.activityId) {
|
||||||
|
items.push({
|
||||||
|
metric: "Activity id",
|
||||||
|
value: queryResults.activityId,
|
||||||
|
toolTip: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateQueryMetricsCsvData = (): string => {
|
||||||
|
if (queryMetrics.current) {
|
||||||
|
let csvData =
|
||||||
|
[
|
||||||
|
"Partition key range id",
|
||||||
|
"Retrieved document count",
|
||||||
|
"Retrieved document size (in bytes)",
|
||||||
|
"Output document count",
|
||||||
|
"Output document size (in bytes)",
|
||||||
|
"Index hit document count",
|
||||||
|
"Index lookup time (ms)",
|
||||||
|
"Document load time (ms)",
|
||||||
|
"Query engine execution time (ms)",
|
||||||
|
"System function execution time (ms)",
|
||||||
|
"User defined function execution time (ms)",
|
||||||
|
"Document write time (ms)",
|
||||||
|
].join(",") + "\n";
|
||||||
|
|
||||||
|
Object.keys(queryMetrics.current).forEach((partitionKeyRangeId) => {
|
||||||
|
const queryMetricsPerPartition = queryMetrics.current[partitionKeyRangeId];
|
||||||
|
csvData +=
|
||||||
|
[
|
||||||
|
partitionKeyRangeId,
|
||||||
|
queryMetricsPerPartition.retrievedDocumentCount,
|
||||||
|
queryMetricsPerPartition.retrievedDocumentSize,
|
||||||
|
queryMetricsPerPartition.outputDocumentCount,
|
||||||
|
queryMetricsPerPartition.outputDocumentSize,
|
||||||
|
queryMetricsPerPartition.indexHitDocumentCount,
|
||||||
|
queryMetricsPerPartition.indexLookupTime?.totalMilliseconds(),
|
||||||
|
queryMetricsPerPartition.documentLoadTime?.totalMilliseconds(),
|
||||||
|
queryMetricsPerPartition.runtimeExecutionTimes?.queryEngineExecutionTime?.totalMilliseconds(),
|
||||||
|
queryMetricsPerPartition.runtimeExecutionTimes?.systemFunctionExecutionTime?.totalMilliseconds(),
|
||||||
|
queryMetricsPerPartition.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.totalMilliseconds(),
|
||||||
|
queryMetricsPerPartition.documentWriteTime?.totalMilliseconds(),
|
||||||
|
].join(",") + "\n";
|
||||||
|
});
|
||||||
|
|
||||||
|
return csvData;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadQueryMetricsCsvData = (): void => {
|
||||||
|
const csvData: string = generateQueryMetricsCsvData();
|
||||||
|
if (!csvData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigator.msSaveBlob) {
|
||||||
|
// for IE and Edge
|
||||||
|
navigator.msSaveBlob(
|
||||||
|
new Blob([csvData], { type: "data:text/csv;charset=utf-8" }),
|
||||||
|
"PerPartitionQueryMetrics.csv",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const downloadLink: HTMLAnchorElement = document.createElement("a");
|
||||||
|
downloadLink.href = "data:text/csv;charset=utf-8," + encodeURI(csvData);
|
||||||
|
downloadLink.target = "_self";
|
||||||
|
downloadLink.download = "QueryMetricsPerPartition.csv";
|
||||||
|
|
||||||
|
// for some reason, FF displays the download prompt only when
|
||||||
|
// the link is added to the dom so we add and remove it
|
||||||
|
document.body.appendChild(downloadLink);
|
||||||
|
downloadLink.click();
|
||||||
|
downloadLink.remove();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDownloadQueryMetricsCsvClick = (): boolean => {
|
||||||
|
downloadQueryMetricsCsvData();
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.metricsGridContainer}>
|
||||||
|
<DataGrid
|
||||||
|
data-test="QueryTab/ResultsPane/ResultsView/QueryStatsList"
|
||||||
|
className={styles.queryStatsGrid}
|
||||||
|
items={generateQueryStatsItems()}
|
||||||
|
columns={columns}
|
||||||
|
sortable
|
||||||
|
getRowId={(item) => item.metric}
|
||||||
|
focusMode="composite"
|
||||||
|
>
|
||||||
|
<DataGridHeader>
|
||||||
|
<DataGridRow>
|
||||||
|
{({ renderHeaderCell }) => <DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>}
|
||||||
|
</DataGridRow>
|
||||||
|
</DataGridHeader>
|
||||||
|
<DataGridBody<IDocument>>
|
||||||
|
{({ item, rowId }) => (
|
||||||
|
<DataGridRow<IDocument> key={rowId} data-test={`Row:${rowId}`}>
|
||||||
|
{({ columnId, renderCell }) => (
|
||||||
|
<DataGridCell data-test={`Row:${rowId}/Column:${columnId}`}>{renderCell(item)}</DataGridCell>
|
||||||
|
)}
|
||||||
|
</DataGridRow>
|
||||||
|
)}
|
||||||
|
</DataGridBody>
|
||||||
|
</DataGrid>
|
||||||
|
<div className={styles.metricsGridButtons}>
|
||||||
|
{userContext.apiType === "SQL" && (
|
||||||
|
<Button appearance="subtle" onClick={() => onDownloadQueryMetricsCsvClick()} icon={<ArrowDownloadRegular />}>
|
||||||
|
Per-partition query metrics (CSV)
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResults, executeQueryDocumentsPage }) => {
|
||||||
|
const styles = useQueryTabStyles();
|
||||||
|
const [activeTab, setActiveTab] = useState<ResultsTabs>(ResultsTabs.Results);
|
||||||
|
|
||||||
|
const onTabSelect = useCallback((event: SelectTabEvent, data: SelectTabData) => {
|
||||||
|
setActiveTab(data.value as ResultsTabs);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-test="QueryTab/ResultsPane/ResultsView" className={styles.queryResultsTabPanel}>
|
||||||
|
<TabList selectedValue={activeTab} onTabSelect={onTabSelect}>
|
||||||
|
<Tab
|
||||||
|
data-test="QueryTab/ResultsPane/ResultsView/ResultsTab"
|
||||||
|
id={ResultsTabs.Results}
|
||||||
|
value={ResultsTabs.Results}
|
||||||
|
>
|
||||||
|
Results
|
||||||
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
data-test="QueryTab/ResultsPane/ResultsView/QueryStatsTab"
|
||||||
|
id={ResultsTabs.QueryStats}
|
||||||
|
value={ResultsTabs.QueryStats}
|
||||||
|
>
|
||||||
|
Query Stats
|
||||||
|
</Tab>
|
||||||
|
</TabList>
|
||||||
|
<div className={styles.queryResultsTabContentContainer}>
|
||||||
|
{activeTab === ResultsTabs.Results && (
|
||||||
|
<ResultsTab
|
||||||
|
queryResults={queryResults}
|
||||||
|
isMongoDB={isMongoDB}
|
||||||
|
executeQueryDocumentsPage={executeQueryDocumentsPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === ResultsTabs.QueryStats && <QueryStatsTab queryResults={queryResults} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
96
src/Explorer/Tabs/QueryTab/Styles.ts
Normal file
96
src/Explorer/Tabs/QueryTab/Styles.ts
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
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";
|
||||||
@@ -16,7 +16,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 +36,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 +53,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 +83,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 +91,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>
|
||||||
)}
|
)}
|
||||||
@@ -298,11 +271,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 +370,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 +388,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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";
|
||||||
@@ -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>(() => {
|
||||||
|
|||||||
@@ -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.
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ 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";
|
||||||
@@ -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)));
|
||||||
|
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user