mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-01-08 03:57:31 +00:00
Compare commits
3 Commits
users/aisa
...
NPS_Dialog
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b7cbfbf23 | ||
|
|
76913d42f1 | ||
|
|
7ab2bd38f4 |
@@ -1,16 +0,0 @@
|
||||
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
|
||||
@@ -1,32 +0,0 @@
|
||||
// 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"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/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)
|
||||
|
||||
- Visit: `https://localhost:1234/hostedExplorer.html`
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
### Emulator Development
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
</a>
|
||||
<ul>
|
||||
<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://cdb-ms-mpac-pbe.cosmos.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://main.documentdb.ext.azure.com</code>. This will allow you to use production connection strings on your local machine.</li>
|
||||
</ul>
|
||||
<a href="#emulator-development" id="emulator-development" style="color: inherit; text-decoration: none;">
|
||||
<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
|
||||
transformIgnorePatterns: ["/node_modules/(?!@fluentui/react-icons)", "/externals/"],
|
||||
transformIgnorePatterns: ["/node_modules/", "/externals/"],
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -1906,14 +1906,8 @@ input::-webkit-calendar-picker-indicator::after {
|
||||
}
|
||||
|
||||
.nav-tabs-margin {
|
||||
height: 32px;
|
||||
padding-top: 5px;
|
||||
background-color: #f2f2f2;
|
||||
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.navTabHeight {
|
||||
@@ -2358,8 +2352,8 @@ a:link {
|
||||
|
||||
.tabsManagerContainer {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template-rows: 36px 36px 1fr;
|
||||
min-width: 0; // This prevents it to grow past the parent's width if its content is too wide
|
||||
}
|
||||
|
||||
@@ -2616,8 +2610,9 @@ a:link {
|
||||
}
|
||||
|
||||
.tabPanesContainer {
|
||||
grid-row: span 2; // Fill the remaining space
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -3117,7 +3112,3 @@ a:link {
|
||||
background: white;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebarContainer {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -20,18 +20,14 @@ a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.splashLoaderContainer {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
#divExplorer {
|
||||
background-color: #f5f5f5;
|
||||
padding: @FabricBoxMargin;
|
||||
}
|
||||
|
||||
.resourceTreeAndTabs {
|
||||
border-radius: 0px;
|
||||
box-shadow: @FabricBoxBorderShadow;
|
||||
margin: @FabricBoxMargin;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 0px;
|
||||
background-color: #ffffff;
|
||||
@@ -50,6 +46,7 @@ a:focus {
|
||||
background-color: #ffffff;
|
||||
border-radius: @FabricBoxBorderRadius @FabricBoxBorderRadius 0px 0px;
|
||||
box-shadow: @FabricBoxBorderShadow;
|
||||
margin: @FabricBoxMargin;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 0px;
|
||||
padding-top: 2px;
|
||||
@@ -170,6 +167,7 @@ a:focus {
|
||||
.dataExplorerErrorConsoleContainer {
|
||||
border-radius: 0px 0px @FabricBoxBorderRadius @FabricBoxBorderRadius;
|
||||
box-shadow: @FabricBoxBorderShadow;
|
||||
margin: @FabricBoxMargin;
|
||||
margin-top: 0px;
|
||||
width: auto;
|
||||
align-self: auto;
|
||||
|
||||
938
package-lock.json
generated
938
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ export default defineConfig({
|
||||
reporter: process.env.CI ? "blob" : "html",
|
||||
timeout: 10 * 60 * 1000,
|
||||
use: {
|
||||
actionTimeout: 5 * 60 * 1000,
|
||||
trace: "off",
|
||||
video: "off",
|
||||
screenshot: "on",
|
||||
@@ -22,8 +23,7 @@ export default defineConfig({
|
||||
},
|
||||
|
||||
expect: {
|
||||
// Many of our expectations take a little longer than the default 5 seconds.
|
||||
timeout: 15 * 1000,
|
||||
timeout: 5 * 60 * 1000,
|
||||
},
|
||||
|
||||
projects: [
|
||||
|
||||
@@ -4,7 +4,7 @@ const port = process.env.PORT || 3000;
|
||||
const fetch = require("node-fetch");
|
||||
|
||||
const api = createProxyMiddleware("/api", {
|
||||
target: "https://cdb-ms-mpac-pbe.cosmos.azure.com",
|
||||
target: "https://main.documentdb.ext.azure.com",
|
||||
changeOrigin: true,
|
||||
logLevel: "debug",
|
||||
bypass: (req, res) => {
|
||||
@@ -16,7 +16,7 @@ const api = createProxyMiddleware("/api", {
|
||||
});
|
||||
|
||||
const proxy = createProxyMiddleware("/proxy", {
|
||||
target: "https://cdb-ms-mpac-pbe.cosmos.azure.com",
|
||||
target: "https://main.documentdb.ext.azure.com",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
logLevel: "debug",
|
||||
|
||||
@@ -130,6 +130,12 @@ export enum MongoBackendEndpointType {
|
||||
remote,
|
||||
}
|
||||
|
||||
export class BackendApi {
|
||||
public static readonly GenerateToken: string = "GenerateToken";
|
||||
public static readonly PortalSettings: string = "PortalSettings";
|
||||
public static readonly AccountRestrictions: string = "AccountRestrictions";
|
||||
}
|
||||
|
||||
export class PortalBackendEndpoints {
|
||||
public static readonly Development: string = "https://localhost:7235";
|
||||
public static readonly Mpac: string = "https://cdb-ms-mpac-pbe.cosmos.azure.com";
|
||||
@@ -139,7 +145,7 @@ export class PortalBackendEndpoints {
|
||||
}
|
||||
|
||||
export class MongoProxyEndpoints {
|
||||
public static readonly Development: string = "https://localhost:7238";
|
||||
public static readonly Local: string = "https://localhost:7238";
|
||||
public static readonly Mpac: string = "https://cdb-ms-mpac-mp.cosmos.azure.com";
|
||||
public static readonly Prod: string = "https://cdb-ms-prod-mp.cosmos.azure.com";
|
||||
public static readonly Fairfax: string = "https://cdb-ff-prod-mp.cosmos.azure.us";
|
||||
@@ -154,6 +160,18 @@ export class CassandraProxyEndpoints {
|
||||
public static readonly Mooncake: string = "https://cdb-mc-prod-cp.cosmos.azure.cn";
|
||||
}
|
||||
|
||||
//TODO: Remove this when new backend is migrated over
|
||||
export class CassandraBackend {
|
||||
public static readonly createOrDeleteApi: string = "api/cassandra/createordelete";
|
||||
public static readonly guestCreateOrDeleteApi: string = "api/guest/cassandra/createordelete";
|
||||
public static readonly queryApi: string = "api/cassandra";
|
||||
public static readonly guestQueryApi: string = "api/guest/cassandra";
|
||||
public static readonly keysApi: string = "api/cassandra/keys";
|
||||
public static readonly guestKeysApi: string = "api/guest/cassandra/keys";
|
||||
public static readonly schemaApi: string = "api/cassandra/schema";
|
||||
public static readonly guestSchemaApi: string = "api/guest/cassandra/schema";
|
||||
}
|
||||
|
||||
export class CassandraProxyAPIs {
|
||||
public static readonly createOrDeleteApi: string = "api/cassandra/createordelete";
|
||||
public static readonly connectionStringCreateOrDeleteApi: string = "api/connectionstring/cassandra/createordelete";
|
||||
@@ -165,12 +183,6 @@ export class CassandraProxyAPIs {
|
||||
public static readonly connectionStringSchemaApi: string = "api/connectionstring/cassandra/schema";
|
||||
}
|
||||
|
||||
export class AadEndpoints {
|
||||
public static readonly Prod: string = "https://login.microsoftonline.com/";
|
||||
public static readonly Fairfax: string = "https://login.microsoftonline.us/";
|
||||
public static readonly Mooncake: string = "https://login.partner.microsoftonline.cn/";
|
||||
}
|
||||
|
||||
export class Queries {
|
||||
public static CustomPageOption: string = "custom";
|
||||
public static UnlimitedPageOption: string = "unlimited";
|
||||
@@ -272,7 +284,6 @@ export class HttpStatusCodes {
|
||||
public static readonly Accepted: number = 202;
|
||||
public static readonly NoContent: number = 204;
|
||||
public static readonly NotModified: number = 304;
|
||||
public static readonly BadRequest: number = 400;
|
||||
public static readonly Unauthorized: number = 401;
|
||||
public static readonly Forbidden: number = 403;
|
||||
public static readonly NotFound: number = 404;
|
||||
@@ -484,7 +495,7 @@ export class PriorityLevel {
|
||||
public static readonly Default = "low";
|
||||
}
|
||||
|
||||
export const QueryCopilotSampleDatabaseId = "CopilotSampleDB";
|
||||
export const QueryCopilotSampleDatabaseId = "CopilotSampleDb";
|
||||
export const QueryCopilotSampleContainerId = "SampleContainer";
|
||||
|
||||
export const QueryCopilotSampleContainerSchema = {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { PortalBackendEndpoints } from "Common/Constants";
|
||||
import { configContext, Platform, resetConfigContext, updateConfigContext } from "../ConfigContext";
|
||||
import { Platform, resetConfigContext, updateConfigContext } from "../ConfigContext";
|
||||
import { updateUserContext } from "../UserContext";
|
||||
import { endpoint, getTokenFromAuthService, requestPlugin } from "./CosmosClient";
|
||||
|
||||
@@ -21,22 +20,22 @@ describe("getTokenFromAuthService", () => {
|
||||
|
||||
it("builds the correct URL in production", () => {
|
||||
updateConfigContext({
|
||||
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Prod,
|
||||
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
|
||||
});
|
||||
getTokenFromAuthService("GET", "dbs", "foo");
|
||||
expect(window.fetch).toHaveBeenCalledWith(
|
||||
`${configContext.PORTAL_BACKEND_ENDPOINT}/api/connectionstring/runtimeproxy/authorizationtokens`,
|
||||
"https://main.documentdb.ext.azure.com/api/guest/runtimeproxy/authorizationTokens",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("builds the correct URL in dev", () => {
|
||||
updateConfigContext({
|
||||
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Development,
|
||||
BACKEND_ENDPOINT: "https://localhost:1234",
|
||||
});
|
||||
getTokenFromAuthService("GET", "dbs", "foo");
|
||||
expect(window.fetch).toHaveBeenCalledWith(
|
||||
`${configContext.PORTAL_BACKEND_ENDPOINT}/api/connectionstring/runtimeproxy/authorizationtokens`,
|
||||
"https://localhost:1234/api/guest/runtimeproxy/authorizationTokens",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -79,7 +78,7 @@ describe("requestPlugin", () => {
|
||||
const next = jest.fn();
|
||||
updateConfigContext({
|
||||
platform: Platform.Hosted,
|
||||
PORTAL_BACKEND_ENDPOINT: "https://localhost:1234",
|
||||
BACKEND_ENDPOINT: "https://localhost:1234",
|
||||
PROXY_PATH: "/proxy",
|
||||
});
|
||||
const headers = {};
|
||||
|
||||
@@ -5,13 +5,13 @@ import { checkDatabaseResourceTokensValidity } from "Platform/Fabric/FabricUtil"
|
||||
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
|
||||
import { AuthType } from "../AuthType";
|
||||
import { PriorityLevel } from "../Common/Constants";
|
||||
import * as Logger from "../Common/Logger";
|
||||
import { Platform, configContext } from "../ConfigContext";
|
||||
import { userContext } from "../UserContext";
|
||||
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
|
||||
import * as PriorityBasedExecutionUtils from "../Utils/PriorityBasedExecutionUtils";
|
||||
import { EmulatorMasterKey, HttpHeaders } from "./Constants";
|
||||
import { getErrorMessage } from "./ErrorHandlingUtils";
|
||||
import * as Logger from "../Common/Logger";
|
||||
|
||||
const _global = typeof self === "undefined" ? window : self;
|
||||
|
||||
@@ -26,7 +26,7 @@ export const tokenProvider = async (requestInfo: Cosmos.RequestInfo) => {
|
||||
);
|
||||
if (!userContext.aadToken) {
|
||||
logConsoleError(
|
||||
`AAD token does not exist. Please use the "Login for Entra ID" button in the Toolbar prior to performing Entra ID RBAC operations`,
|
||||
`AAD token does not exist. Please click on "Login for Entra ID" button prior to performing Entra ID RBAC operations`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -125,8 +125,8 @@ export async function getTokenFromAuthService(
|
||||
resourceId?: string,
|
||||
): Promise<AuthorizationToken> {
|
||||
try {
|
||||
const host: string = configContext.PORTAL_BACKEND_ENDPOINT;
|
||||
const response: Response = await _global.fetch(host + "/api/connectionstring/runtimeproxy/authorizationtokens", {
|
||||
const host = configContext.BACKEND_ENDPOINT;
|
||||
const response = await _global.fetch(host + "/api/guest/runtimeproxy/authorizationTokens", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
@@ -138,7 +138,8 @@ export async function getTokenFromAuthService(
|
||||
resourceId,
|
||||
}),
|
||||
});
|
||||
const result: AuthorizationToken = await response.json();
|
||||
//TODO I am not sure why we have to parse the JSON again here. fetch should do it for us when we call .json()
|
||||
const result = JSON.parse(await response.json());
|
||||
return result;
|
||||
} catch (error) {
|
||||
logConsoleError(`Failed to get authorization headers for ${resourceType}: ${getErrorMessage(error)}`);
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export function getNewDatabaseSharedThroughputDefault(): boolean {
|
||||
return false;
|
||||
}
|
||||
@@ -10,7 +10,6 @@ export interface TableEntityProps {
|
||||
isEntityValueDisable?: boolean;
|
||||
entityTimeValue: string;
|
||||
entityValueType: string;
|
||||
entityProperty: string;
|
||||
onEntityValueChange: (event: React.FormEvent<HTMLElement>, newInput?: string) => void;
|
||||
onSelectDate: (date: Date | null | undefined) => void;
|
||||
onEntityTimeValueChange: (event: React.FormEvent<HTMLElement>, newInput?: string) => void;
|
||||
@@ -27,7 +26,6 @@ export const EntityValue: FunctionComponent<TableEntityProps> = ({
|
||||
onSelectDate,
|
||||
isEntityValueDisable,
|
||||
onEntityTimeValueChange,
|
||||
entityProperty,
|
||||
}: TableEntityProps): JSX.Element => {
|
||||
if (isEntityTypeDate) {
|
||||
return (
|
||||
@@ -53,10 +51,6 @@ export const EntityValue: FunctionComponent<TableEntityProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span id={entityProperty} className="screenReaderOnly">
|
||||
Edit Property {entityProperty} {attributeValueLabel}
|
||||
</span>
|
||||
<TextField
|
||||
label={entityValueLabel && entityValueLabel}
|
||||
className="addEntityTextField"
|
||||
@@ -65,8 +59,7 @@ export const EntityValue: FunctionComponent<TableEntityProps> = ({
|
||||
placeholder={entityValuePlaceholder}
|
||||
value={typeof entityValue === "string" ? entityValue : ""}
|
||||
onChange={onEntityValueChange}
|
||||
aria-labelledby={entityProperty}
|
||||
ariaLabel={attributeValueLabel}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { PortalBackendEndpoints } from "Common/Constants";
|
||||
import { updateConfigContext } from "ConfigContext";
|
||||
import * as EnvironmentUtility from "./EnvironmentUtility";
|
||||
|
||||
describe("Environment Utility Test", () => {
|
||||
@@ -13,18 +11,4 @@ describe("Environment Utility Test", () => {
|
||||
const expectedResult = "test/";
|
||||
expect(EnvironmentUtility.normalizeArmEndpoint(uri)).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it("Detect environment is Mpac", () => {
|
||||
updateConfigContext({
|
||||
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Mpac,
|
||||
});
|
||||
expect(EnvironmentUtility.getEnvironment()).toBe(EnvironmentUtility.Environment.Mpac);
|
||||
});
|
||||
|
||||
it("Detect environment is Development", () => {
|
||||
updateConfigContext({
|
||||
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Development,
|
||||
});
|
||||
expect(EnvironmentUtility.getEnvironment()).toBe(EnvironmentUtility.Environment.Development);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,29 +1,6 @@
|
||||
import { PortalBackendEndpoints } from "Common/Constants";
|
||||
import { configContext } from "ConfigContext";
|
||||
|
||||
export function normalizeArmEndpoint(uri: string): string {
|
||||
if (uri && uri.slice(-1) !== "/") {
|
||||
return `${uri}/`;
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
export enum Environment {
|
||||
Development = "Development",
|
||||
Mpac = "MPAC",
|
||||
Prod = "Prod",
|
||||
Fairfax = "Fairfax",
|
||||
Mooncake = "Mooncake",
|
||||
}
|
||||
|
||||
export const getEnvironment = (): Environment => {
|
||||
const environmentMap: { [key: string]: Environment } = {
|
||||
[PortalBackendEndpoints.Development]: Environment.Development,
|
||||
[PortalBackendEndpoints.Mpac]: Environment.Mpac,
|
||||
[PortalBackendEndpoints.Prod]: Environment.Prod,
|
||||
[PortalBackendEndpoints.Fairfax]: Environment.Fairfax,
|
||||
[PortalBackendEndpoints.Mooncake]: Environment.Mooncake,
|
||||
};
|
||||
|
||||
return environmentMap[configContext.PORTAL_BACKEND_ENDPOINT];
|
||||
};
|
||||
|
||||
@@ -53,8 +53,7 @@ const replaceKnownError = (errorMessage: string): string => {
|
||||
return "Partition key paths must contain only valid characters and not contain a trailing slash or wildcard character.";
|
||||
} else if (
|
||||
errorMessage?.indexOf("The user aborted a request") >= 0 ||
|
||||
errorMessage?.indexOf("The operation was aborted") >= 0 ||
|
||||
errorMessage === "signal is aborted without reason"
|
||||
errorMessage?.indexOf("The operation was aborted") >= 0
|
||||
) {
|
||||
return "User aborted query.";
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ export function sendMessage(data: any): void {
|
||||
}
|
||||
|
||||
export function sendReadyMessage(): void {
|
||||
console.log("SENDING READY MESSAGE");
|
||||
_sendMessage({
|
||||
signature: "pcIframe",
|
||||
kind: "ready",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { MongoProxyEndpoints } from "Common/Constants";
|
||||
import { AuthType } from "../AuthType";
|
||||
import { configContext, resetConfigContext, updateConfigContext } from "../ConfigContext";
|
||||
import { resetConfigContext, updateConfigContext } from "../ConfigContext";
|
||||
import { DatabaseAccount } from "../Contracts/DataModels";
|
||||
import { Collection } from "../Contracts/ViewModels";
|
||||
import DocumentId from "../Explorer/Tree/DocumentId";
|
||||
@@ -72,8 +71,7 @@ describe("MongoProxyClient", () => {
|
||||
databaseAccount,
|
||||
});
|
||||
updateConfigContext({
|
||||
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
||||
globallyEnabledMongoAPIs: [],
|
||||
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
|
||||
});
|
||||
window.fetch = jest.fn().mockImplementation(fetchMock);
|
||||
});
|
||||
@@ -84,19 +82,16 @@ describe("MongoProxyClient", () => {
|
||||
it("builds the correct URL", () => {
|
||||
queryDocuments(databaseId, collection, true, "{}");
|
||||
expect(window.fetch).toHaveBeenCalledWith(
|
||||
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer/resourcelist`,
|
||||
"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",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("builds the correct proxy URL in development", () => {
|
||||
updateConfigContext({
|
||||
MONGO_PROXY_ENDPOINT: "https://localhost:1234",
|
||||
globallyEnabledMongoAPIs: [],
|
||||
});
|
||||
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
|
||||
queryDocuments(databaseId, collection, true, "{}");
|
||||
expect(window.fetch).toHaveBeenCalledWith(
|
||||
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer/resourcelist`,
|
||||
"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",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -108,8 +103,7 @@ describe("MongoProxyClient", () => {
|
||||
databaseAccount,
|
||||
});
|
||||
updateConfigContext({
|
||||
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
||||
globallyEnabledMongoAPIs: [],
|
||||
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
|
||||
});
|
||||
window.fetch = jest.fn().mockImplementation(fetchMock);
|
||||
});
|
||||
@@ -120,19 +114,16 @@ describe("MongoProxyClient", () => {
|
||||
it("builds the correct URL", () => {
|
||||
readDocument(databaseId, collection, documentId);
|
||||
expect(window.fetch).toHaveBeenCalledWith(
|
||||
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
|
||||
"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",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("builds the correct proxy URL in development", () => {
|
||||
updateConfigContext({
|
||||
MONGO_PROXY_ENDPOINT: "https://localhost:1234",
|
||||
globallyEnabledMongoAPIs: [],
|
||||
});
|
||||
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
|
||||
readDocument(databaseId, collection, documentId);
|
||||
expect(window.fetch).toHaveBeenCalledWith(
|
||||
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
|
||||
"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",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -144,8 +135,7 @@ describe("MongoProxyClient", () => {
|
||||
databaseAccount,
|
||||
});
|
||||
updateConfigContext({
|
||||
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
||||
globallyEnabledMongoAPIs: [],
|
||||
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
|
||||
});
|
||||
window.fetch = jest.fn().mockImplementation(fetchMock);
|
||||
});
|
||||
@@ -156,19 +146,16 @@ describe("MongoProxyClient", () => {
|
||||
it("builds the correct URL", () => {
|
||||
readDocument(databaseId, collection, documentId);
|
||||
expect(window.fetch).toHaveBeenCalledWith(
|
||||
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
|
||||
"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",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("builds the correct proxy URL in development", () => {
|
||||
updateConfigContext({
|
||||
MONGO_PROXY_ENDPOINT: "https://localhost:1234",
|
||||
globallyEnabledMongoAPIs: [],
|
||||
});
|
||||
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
|
||||
readDocument(databaseId, collection, documentId);
|
||||
expect(window.fetch).toHaveBeenCalledWith(
|
||||
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
|
||||
"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",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -180,8 +167,7 @@ describe("MongoProxyClient", () => {
|
||||
databaseAccount,
|
||||
});
|
||||
updateConfigContext({
|
||||
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
||||
globallyEnabledMongoAPIs: [],
|
||||
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
|
||||
});
|
||||
window.fetch = jest.fn().mockImplementation(fetchMock);
|
||||
});
|
||||
@@ -192,19 +178,16 @@ describe("MongoProxyClient", () => {
|
||||
it("builds the correct URL", () => {
|
||||
updateDocument(databaseId, collection, documentId, "{}");
|
||||
expect(window.fetch).toHaveBeenCalledWith(
|
||||
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
|
||||
"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",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("builds the correct proxy URL in development", () => {
|
||||
updateConfigContext({
|
||||
MONGO_BACKEND_ENDPOINT: "https://localhost:1234",
|
||||
globallyEnabledMongoAPIs: [],
|
||||
});
|
||||
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
|
||||
updateDocument(databaseId, collection, documentId, "{}");
|
||||
expect(window.fetch).toHaveBeenCalledWith(
|
||||
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
|
||||
"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",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -216,8 +199,7 @@ describe("MongoProxyClient", () => {
|
||||
databaseAccount,
|
||||
});
|
||||
updateConfigContext({
|
||||
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
||||
globallyEnabledMongoAPIs: [],
|
||||
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
|
||||
});
|
||||
window.fetch = jest.fn().mockImplementation(fetchMock);
|
||||
});
|
||||
@@ -228,19 +210,16 @@ describe("MongoProxyClient", () => {
|
||||
it("builds the correct URL", () => {
|
||||
deleteDocument(databaseId, collection, documentId);
|
||||
expect(window.fetch).toHaveBeenCalledWith(
|
||||
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
|
||||
"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",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("builds the correct proxy URL in development", () => {
|
||||
updateConfigContext({
|
||||
MONGO_PROXY_ENDPOINT: "https://localhost:1234",
|
||||
globallyEnabledMongoAPIs: [],
|
||||
});
|
||||
updateConfigContext({ MONGO_BACKEND_ENDPOINT: "https://localhost:1234" });
|
||||
deleteDocument(databaseId, collection, documentId);
|
||||
expect(window.fetch).toHaveBeenCalledWith(
|
||||
`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`,
|
||||
"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",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -252,14 +231,13 @@ describe("MongoProxyClient", () => {
|
||||
databaseAccount,
|
||||
});
|
||||
updateConfigContext({
|
||||
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
||||
globallyEnabledMongoAPIs: [],
|
||||
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a production endpoint", () => {
|
||||
const endpoint = getEndpoint(configContext.MONGO_PROXY_ENDPOINT);
|
||||
expect(endpoint).toEqual(`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`);
|
||||
const endpoint = getEndpoint("https://main.documentdb.ext.azure.com");
|
||||
expect(endpoint).toEqual("https://main.documentdb.ext.azure.com/api/mongo/explorer");
|
||||
});
|
||||
|
||||
it("returns a development endpoint", () => {
|
||||
@@ -271,20 +249,18 @@ describe("MongoProxyClient", () => {
|
||||
updateUserContext({
|
||||
authType: AuthType.EncryptedToken,
|
||||
});
|
||||
const endpoint = getEndpoint(configContext.MONGO_PROXY_ENDPOINT);
|
||||
expect(endpoint).toEqual(`${configContext.MONGO_PROXY_ENDPOINT}/api/connectionstring/mongo/explorer`);
|
||||
const endpoint = getEndpoint("https://main.documentdb.ext.azure.com");
|
||||
expect(endpoint).toEqual("https://main.documentdb.ext.azure.com/api/guest/mongo/explorer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFeatureEndpointOrDefault", () => {
|
||||
beforeEach(() => {
|
||||
resetConfigContext();
|
||||
updateConfigContext({
|
||||
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
|
||||
globallyEnabledMongoAPIs: [],
|
||||
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
|
||||
});
|
||||
const params = new URLSearchParams({
|
||||
"feature.mongoProxyEndpoint": MongoProxyEndpoints.Prod,
|
||||
"feature.mongoProxyEndpoint": "https://localhost:12901",
|
||||
"feature.mongoProxyAPIs": "readDocument|createDocument",
|
||||
});
|
||||
const features = extractFeatures(params);
|
||||
@@ -295,13 +271,13 @@ describe("MongoProxyClient", () => {
|
||||
});
|
||||
|
||||
it("returns a local endpoint", () => {
|
||||
const endpoint = getFeatureEndpointOrDefault();
|
||||
expect(endpoint).toEqual(`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`);
|
||||
const endpoint = getFeatureEndpointOrDefault("readDocument");
|
||||
expect(endpoint).toEqual("https://localhost:12901/api/mongo/explorer");
|
||||
});
|
||||
|
||||
it("returns a production endpoint", () => {
|
||||
const endpoint = getFeatureEndpointOrDefault();
|
||||
expect(endpoint).toEqual(`${configContext.MONGO_PROXY_ENDPOINT}/api/mongo/explorer`);
|
||||
const endpoint = getFeatureEndpointOrDefault("deleteDocument");
|
||||
expect(endpoint).toEqual("https://main.documentdb.ext.azure.com/api/mongo/explorer");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { Constants as CosmosSDKConstants } from "@azure/cosmos";
|
||||
import {
|
||||
allowedMongoProxyEndpoints,
|
||||
allowedMongoProxyEndpoints_ToBeDeprecated,
|
||||
validateEndpoint,
|
||||
} from "Utils/EndpointUtils";
|
||||
import queryString from "querystring";
|
||||
import { AuthType } from "../AuthType";
|
||||
import { configContext } from "../ConfigContext";
|
||||
import * as DataModels from "../Contracts/DataModels";
|
||||
import { MessageTypes } from "../Contracts/ExplorerContracts";
|
||||
import { Collection } from "../Contracts/ViewModels";
|
||||
import DocumentId from "../Explorer/Tree/DocumentId";
|
||||
import { hasFlag } from "../Platform/Hosted/extractFeatures";
|
||||
import { userContext } from "../UserContext";
|
||||
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
|
||||
import { ApiType, ContentType, HttpHeaders, HttpStatusCodes } from "./Constants";
|
||||
import { ApiType, ContentType, HttpHeaders, HttpStatusCodes, MongoProxyEndpoints } from "./Constants";
|
||||
import { MinimalQueryIterator } from "./IteratorUtilities";
|
||||
import { sendMessage } from "./MessageHandler";
|
||||
|
||||
@@ -60,6 +67,10 @@ export function queryDocuments(
|
||||
query: string,
|
||||
continuationToken?: string,
|
||||
): Promise<QueryResponse> {
|
||||
if (!useMongoProxyEndpoint("resourcelist") || !useMongoProxyEndpoint("queryDocuments")) {
|
||||
return queryDocuments_ToBeDeprecated(databaseId, collection, isResourceList, query, continuationToken);
|
||||
}
|
||||
|
||||
const { databaseAccount } = userContext;
|
||||
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
|
||||
const params = {
|
||||
@@ -78,7 +89,7 @@ export function queryDocuments(
|
||||
query,
|
||||
};
|
||||
|
||||
const endpoint = getFeatureEndpointOrDefault();
|
||||
const endpoint = getFeatureEndpointOrDefault("resourcelist") || "";
|
||||
|
||||
const headers = {
|
||||
...defaultHeaders,
|
||||
@@ -116,11 +127,76 @@ export function queryDocuments(
|
||||
});
|
||||
}
|
||||
|
||||
function queryDocuments_ToBeDeprecated(
|
||||
databaseId: string,
|
||||
collection: Collection,
|
||||
isResourceList: boolean,
|
||||
query: string,
|
||||
continuationToken?: string,
|
||||
): Promise<QueryResponse> {
|
||||
const { databaseAccount } = userContext;
|
||||
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
|
||||
const params = {
|
||||
db: databaseId,
|
||||
coll: collection.id(),
|
||||
resourceUrl: `${resourceEndpoint}dbs/${databaseId}/colls/${collection.id()}/docs/`,
|
||||
rid: collection.rid,
|
||||
rtype: "docs",
|
||||
sid: userContext.subscriptionId,
|
||||
rg: userContext.resourceGroup,
|
||||
dba: databaseAccount.name,
|
||||
pk:
|
||||
collection && collection.partitionKey && !collection.partitionKey.systemKey
|
||||
? collection.partitionKeyProperties?.[0]
|
||||
: "",
|
||||
};
|
||||
|
||||
const endpoint = getFeatureEndpointOrDefault("resourcelist") || "";
|
||||
|
||||
const headers = {
|
||||
...defaultHeaders,
|
||||
...authHeaders(),
|
||||
[CosmosSDKConstants.HttpHeaders.IsQuery]: "true",
|
||||
[CosmosSDKConstants.HttpHeaders.PopulateQueryMetrics]: "true",
|
||||
[CosmosSDKConstants.HttpHeaders.EnableScanInQuery]: "true",
|
||||
[CosmosSDKConstants.HttpHeaders.EnableCrossPartitionQuery]: "true",
|
||||
[CosmosSDKConstants.HttpHeaders.ParallelizeCrossPartitionQuery]: "true",
|
||||
[HttpHeaders.contentType]: "application/query+json",
|
||||
};
|
||||
|
||||
if (continuationToken) {
|
||||
headers[CosmosSDKConstants.HttpHeaders.Continuation] = continuationToken;
|
||||
}
|
||||
|
||||
const path = isResourceList ? "/resourcelist" : "";
|
||||
|
||||
return window
|
||||
.fetch(`${endpoint}${path}?${queryString.stringify(params)}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ query }),
|
||||
headers,
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (response.ok) {
|
||||
return {
|
||||
continuationToken: response.headers.get(CosmosSDKConstants.HttpHeaders.Continuation),
|
||||
documents: (await response.json()).Documents as DataModels.DocumentId[],
|
||||
headers: response.headers,
|
||||
};
|
||||
}
|
||||
await errorHandling(response, "querying documents", params);
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
export function readDocument(
|
||||
databaseId: string,
|
||||
collection: Collection,
|
||||
documentId: DocumentId,
|
||||
): Promise<DataModels.DocumentId> {
|
||||
if (!useMongoProxyEndpoint("readDocument")) {
|
||||
return readDocument_ToBeDeprecated(databaseId, collection, documentId);
|
||||
}
|
||||
const { databaseAccount } = userContext;
|
||||
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
|
||||
const idComponents = documentId.self.split("/");
|
||||
@@ -141,7 +217,7 @@ export function readDocument(
|
||||
: "",
|
||||
};
|
||||
|
||||
const endpoint = getFeatureEndpointOrDefault();
|
||||
const endpoint = getFeatureEndpointOrDefault("readDocument");
|
||||
|
||||
return window
|
||||
.fetch(endpoint, {
|
||||
@@ -161,12 +237,61 @@ export function readDocument(
|
||||
});
|
||||
}
|
||||
|
||||
export function readDocument_ToBeDeprecated(
|
||||
databaseId: string,
|
||||
collection: Collection,
|
||||
documentId: DocumentId,
|
||||
): Promise<DataModels.DocumentId> {
|
||||
const { databaseAccount } = userContext;
|
||||
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
|
||||
const idComponents = documentId.self.split("/");
|
||||
const path = idComponents.slice(0, 4).join("/");
|
||||
const rid = encodeURIComponent(idComponents[5]);
|
||||
const params = {
|
||||
db: databaseId,
|
||||
coll: collection.id(),
|
||||
resourceUrl: `${resourceEndpoint}${path}/${rid}`,
|
||||
rid,
|
||||
rtype: "docs",
|
||||
sid: userContext.subscriptionId,
|
||||
rg: userContext.resourceGroup,
|
||||
dba: databaseAccount.name,
|
||||
pk:
|
||||
documentId && documentId.partitionKey && !documentId.partitionKey.systemKey
|
||||
? documentId.partitionKeyProperties?.[0]
|
||||
: "",
|
||||
};
|
||||
|
||||
const endpoint = getFeatureEndpointOrDefault("readDocument");
|
||||
|
||||
return window
|
||||
.fetch(`${endpoint}?${queryString.stringify(params)}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...authHeaders(),
|
||||
[CosmosSDKConstants.HttpHeaders.PartitionKey]: encodeURIComponent(
|
||||
JSON.stringify(documentId.partitionKeyHeader()),
|
||||
),
|
||||
},
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
return await errorHandling(response, "reading document", params);
|
||||
});
|
||||
}
|
||||
|
||||
export function createDocument(
|
||||
databaseId: string,
|
||||
collection: Collection,
|
||||
partitionKeyProperty: string,
|
||||
documentContent: unknown,
|
||||
): Promise<DataModels.DocumentId> {
|
||||
if (!useMongoProxyEndpoint("createDocument")) {
|
||||
return createDocument_ToBeDeprecated(databaseId, collection, partitionKeyProperty, documentContent);
|
||||
}
|
||||
const { databaseAccount } = userContext;
|
||||
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
|
||||
const params = {
|
||||
@@ -183,7 +308,7 @@ export function createDocument(
|
||||
documentContent: JSON.stringify(documentContent),
|
||||
};
|
||||
|
||||
const endpoint = getFeatureEndpointOrDefault();
|
||||
const endpoint = getFeatureEndpointOrDefault("createDocument");
|
||||
|
||||
return window
|
||||
.fetch(`${endpoint}/createDocument`, {
|
||||
@@ -203,12 +328,54 @@ export function createDocument(
|
||||
});
|
||||
}
|
||||
|
||||
export function createDocument_ToBeDeprecated(
|
||||
databaseId: string,
|
||||
collection: Collection,
|
||||
partitionKeyProperty: string,
|
||||
documentContent: unknown,
|
||||
): Promise<DataModels.DocumentId> {
|
||||
const { databaseAccount } = userContext;
|
||||
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
|
||||
const params = {
|
||||
db: databaseId,
|
||||
coll: collection.id(),
|
||||
resourceUrl: `${resourceEndpoint}dbs/${databaseId}/colls/${collection.id()}/docs/`,
|
||||
rid: collection.rid,
|
||||
rtype: "docs",
|
||||
sid: userContext.subscriptionId,
|
||||
rg: userContext.resourceGroup,
|
||||
dba: databaseAccount.name,
|
||||
pk: collection && collection.partitionKey && !collection.partitionKey.systemKey ? partitionKeyProperty : "",
|
||||
};
|
||||
|
||||
const endpoint = getFeatureEndpointOrDefault("createDocument");
|
||||
|
||||
return window
|
||||
.fetch(`${endpoint}/resourcelist?${queryString.stringify(params)}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(documentContent),
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...authHeaders(),
|
||||
},
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
return await errorHandling(response, "creating document", params);
|
||||
});
|
||||
}
|
||||
|
||||
export function updateDocument(
|
||||
databaseId: string,
|
||||
collection: Collection,
|
||||
documentId: DocumentId,
|
||||
documentContent: string,
|
||||
): Promise<DataModels.DocumentId> {
|
||||
if (!useMongoProxyEndpoint("updateDocument")) {
|
||||
return updateDocument_ToBeDeprecated(databaseId, collection, documentId, documentContent);
|
||||
}
|
||||
const { databaseAccount } = userContext;
|
||||
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
|
||||
const idComponents = documentId.self.split("/");
|
||||
@@ -229,7 +396,7 @@ export function updateDocument(
|
||||
: "",
|
||||
documentContent,
|
||||
};
|
||||
const endpoint = getFeatureEndpointOrDefault();
|
||||
const endpoint = getFeatureEndpointOrDefault("updateDocument");
|
||||
|
||||
return window
|
||||
.fetch(endpoint, {
|
||||
@@ -250,7 +417,56 @@ export function updateDocument(
|
||||
});
|
||||
}
|
||||
|
||||
export function updateDocument_ToBeDeprecated(
|
||||
databaseId: string,
|
||||
collection: Collection,
|
||||
documentId: DocumentId,
|
||||
documentContent: string,
|
||||
): Promise<DataModels.DocumentId> {
|
||||
const { databaseAccount } = userContext;
|
||||
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
|
||||
const idComponents = documentId.self.split("/");
|
||||
const path = idComponents.slice(0, 5).join("/");
|
||||
const rid = encodeURIComponent(idComponents[5]);
|
||||
const params = {
|
||||
db: databaseId,
|
||||
coll: collection.id(),
|
||||
resourceUrl: `${resourceEndpoint}${path}/${rid}`,
|
||||
rid,
|
||||
rtype: "docs",
|
||||
sid: userContext.subscriptionId,
|
||||
rg: userContext.resourceGroup,
|
||||
dba: databaseAccount.name,
|
||||
pk:
|
||||
documentId && documentId.partitionKey && !documentId.partitionKey.systemKey
|
||||
? documentId.partitionKeyProperties?.[0]
|
||||
: "",
|
||||
};
|
||||
const endpoint = getFeatureEndpointOrDefault("updateDocument");
|
||||
|
||||
return window
|
||||
.fetch(`${endpoint}?${queryString.stringify(params)}`, {
|
||||
method: "PUT",
|
||||
body: documentContent,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...authHeaders(),
|
||||
[HttpHeaders.contentType]: ContentType.applicationJson,
|
||||
[CosmosSDKConstants.HttpHeaders.PartitionKey]: JSON.stringify(documentId.partitionKeyHeader()),
|
||||
},
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
return await errorHandling(response, "updating document", params);
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteDocument(databaseId: string, collection: Collection, documentId: DocumentId): Promise<void> {
|
||||
if (!useMongoProxyEndpoint("deleteDocument")) {
|
||||
return deleteDocument_ToBeDeprecated(databaseId, collection, documentId);
|
||||
}
|
||||
const { databaseAccount } = userContext;
|
||||
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
|
||||
const idComponents = documentId.self.split("/");
|
||||
@@ -270,7 +486,7 @@ export function deleteDocument(databaseId: string, collection: Collection, docum
|
||||
? documentId.partitionKeyProperties?.[0]
|
||||
: "",
|
||||
};
|
||||
const endpoint = getFeatureEndpointOrDefault();
|
||||
const endpoint = getFeatureEndpointOrDefault("deleteDocument");
|
||||
|
||||
return window
|
||||
.fetch(endpoint, {
|
||||
@@ -290,55 +506,56 @@ export function deleteDocument(databaseId: string, collection: Collection, docum
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteDocuments(
|
||||
export function deleteDocument_ToBeDeprecated(
|
||||
databaseId: string,
|
||||
collection: Collection,
|
||||
documentIds: DocumentId[],
|
||||
): Promise<{
|
||||
deletedCount: number;
|
||||
isAcknowledged: boolean;
|
||||
}> {
|
||||
documentId: DocumentId,
|
||||
): Promise<void> {
|
||||
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 path = idComponents.slice(0, 5).join("/");
|
||||
const rid = encodeURIComponent(idComponents[5]);
|
||||
const params = {
|
||||
databaseID: databaseId,
|
||||
collectionID: collection.id(),
|
||||
resourceUrl: `${resourceEndpoint}`,
|
||||
resourceIDs: rids,
|
||||
subscriptionID: userContext.subscriptionId,
|
||||
resourceGroup: userContext.resourceGroup,
|
||||
databaseAccountName: databaseAccount.name,
|
||||
db: databaseId,
|
||||
coll: collection.id(),
|
||||
resourceUrl: `${resourceEndpoint}${path}/${rid}`,
|
||||
rid,
|
||||
rtype: "docs",
|
||||
sid: userContext.subscriptionId,
|
||||
rg: userContext.resourceGroup,
|
||||
dba: databaseAccount.name,
|
||||
pk:
|
||||
documentId && documentId.partitionKey && !documentId.partitionKey.systemKey
|
||||
? documentId.partitionKeyProperties?.[0]
|
||||
: "",
|
||||
};
|
||||
const endpoint = getFeatureEndpointOrDefault();
|
||||
const endpoint = getFeatureEndpointOrDefault("deleteDocument");
|
||||
|
||||
return window
|
||||
.fetch(`${endpoint}/bulkdelete`, {
|
||||
.fetch(`${endpoint}?${queryString.stringify(params)}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify(params),
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...authHeaders(),
|
||||
[HttpHeaders.contentType]: ContentType.applicationJson,
|
||||
[CosmosSDKConstants.HttpHeaders.PartitionKey]: JSON.stringify(documentId.partitionKeyHeader()),
|
||||
},
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
return result;
|
||||
return undefined;
|
||||
}
|
||||
return await errorHandling(response, "deleting documents", params);
|
||||
return await errorHandling(response, "deleting document", params);
|
||||
});
|
||||
}
|
||||
|
||||
export function createMongoCollectionWithProxy(
|
||||
params: DataModels.CreateCollectionParams,
|
||||
): Promise<DataModels.Collection> {
|
||||
if (!useMongoProxyEndpoint("createCollectionWithProxy")) {
|
||||
return createMongoCollectionWithProxy_ToBeDeprecated(params);
|
||||
}
|
||||
const { databaseAccount } = userContext;
|
||||
const shardKey: string = params.partitionKey?.paths[0];
|
||||
|
||||
@@ -359,7 +576,7 @@ export function createMongoCollectionWithProxy(
|
||||
isSharded: !!shardKey,
|
||||
};
|
||||
|
||||
const endpoint = getFeatureEndpointOrDefault();
|
||||
const endpoint = getFeatureEndpointOrDefault("createCollectionWithProxy");
|
||||
|
||||
return window
|
||||
.fetch(`${endpoint}/createCollection`, {
|
||||
@@ -379,8 +596,66 @@ export function createMongoCollectionWithProxy(
|
||||
});
|
||||
}
|
||||
|
||||
export function getFeatureEndpointOrDefault(): string {
|
||||
const endpoint: string = configContext.MONGO_PROXY_ENDPOINT;
|
||||
export function createMongoCollectionWithProxy_ToBeDeprecated(
|
||||
params: DataModels.CreateCollectionParams,
|
||||
): Promise<DataModels.Collection> {
|
||||
const { databaseAccount } = userContext;
|
||||
const shardKey: string = params.partitionKey?.paths[0];
|
||||
const mongoParams: DataModels.MongoParameters = {
|
||||
resourceUrl: databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint,
|
||||
db: params.databaseId,
|
||||
coll: params.collectionId,
|
||||
pk: shardKey,
|
||||
offerThroughput: params.autoPilotMaxThroughput || params.offerThroughput,
|
||||
cd: params.createNewDatabase,
|
||||
st: params.databaseLevelThroughput,
|
||||
is: !!shardKey,
|
||||
rid: "",
|
||||
rtype: "colls",
|
||||
sid: userContext.subscriptionId,
|
||||
rg: userContext.resourceGroup,
|
||||
dba: databaseAccount.name,
|
||||
isAutoPilot: !!params.autoPilotMaxThroughput,
|
||||
};
|
||||
|
||||
const endpoint = getFeatureEndpointOrDefault("createCollectionWithProxy");
|
||||
|
||||
return window
|
||||
.fetch(
|
||||
`${endpoint}/createCollection?${queryString.stringify(
|
||||
mongoParams as unknown as queryString.ParsedUrlQueryInput,
|
||||
)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...authHeaders(),
|
||||
[HttpHeaders.contentType]: "application/json",
|
||||
},
|
||||
},
|
||||
)
|
||||
.then(async (response) => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
return await errorHandling(response, "creating collection", mongoParams);
|
||||
});
|
||||
}
|
||||
export function getFeatureEndpointOrDefault(feature: string): string {
|
||||
let endpoint;
|
||||
if (useMongoProxyEndpoint(feature)) {
|
||||
endpoint = configContext.MONGO_PROXY_ENDPOINT;
|
||||
} else {
|
||||
endpoint =
|
||||
hasFlag(userContext.features.mongoProxyAPIs, feature) &&
|
||||
validateEndpoint(userContext.features.mongoProxyEndpoint, [
|
||||
...allowedMongoProxyEndpoints,
|
||||
...allowedMongoProxyEndpoints_ToBeDeprecated,
|
||||
])
|
||||
? userContext.features.mongoProxyEndpoint
|
||||
: configContext.MONGO_BACKEND_ENDPOINT || configContext.BACKEND_ENDPOINT;
|
||||
}
|
||||
|
||||
return getEndpoint(endpoint);
|
||||
}
|
||||
|
||||
@@ -397,10 +672,26 @@ export function getEndpoint(endpoint: string): string {
|
||||
return url;
|
||||
}
|
||||
|
||||
export class ThrottlingError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
export function useMongoProxyEndpoint(api: string): boolean {
|
||||
const activeMongoProxyEndpoints: string[] = [
|
||||
MongoProxyEndpoints.Local,
|
||||
MongoProxyEndpoints.Mpac,
|
||||
MongoProxyEndpoints.Prod,
|
||||
MongoProxyEndpoints.Fairfax,
|
||||
];
|
||||
let canAccessMongoProxy: boolean = userContext.databaseAccount.properties.publicNetworkAccess === "Enabled";
|
||||
if (
|
||||
configContext.MONGO_PROXY_ENDPOINT !== MongoProxyEndpoints.Local &&
|
||||
userContext.databaseAccount.properties.ipRules?.length > 0
|
||||
) {
|
||||
canAccessMongoProxy = canAccessMongoProxy && configContext.MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED;
|
||||
}
|
||||
|
||||
return (
|
||||
canAccessMongoProxy &&
|
||||
configContext.NEW_MONGO_APIS?.includes(api) &&
|
||||
activeMongoProxyEndpoints.includes(configContext.MONGO_PROXY_ENDPOINT)
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: This function throws most of the time except on Forbidden which is a bit strange
|
||||
@@ -412,14 +703,6 @@ async function errorHandling(response: Response, action: string, params: unknown
|
||||
if (response.status === HttpStatusCodes.Forbidden) {
|
||||
sendMessage({ type: MessageTypes.ForbiddenError, reason: errorMessage });
|
||||
return;
|
||||
} else if (
|
||||
response.status === HttpStatusCodes.BadRequest &&
|
||||
errorMessage.includes("Error=16500") &&
|
||||
errorMessage.includes("RetryAfterMs=")
|
||||
) {
|
||||
// If throttling is happening, Cosmos DB will return a 400 with a body of:
|
||||
// A write operation resulted in an error. Error=16500, RetryAfterMs=4, Details='Batch write error.
|
||||
throw new ThrottlingError(errorMessage);
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
39
src/Common/PortalNotifications.ts
Normal file
39
src/Common/PortalNotifications.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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[];
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
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 }),
|
||||
),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,247 +0,0 @@
|
||||
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,7 +135,6 @@ export const TableEntity: FunctionComponent<TableEntityProps> = ({
|
||||
onEntityValueChange={onEntityValueChange}
|
||||
onSelectDate={onSelectDate}
|
||||
onEntityTimeValueChange={onEntityTimeValueChange}
|
||||
entityProperty={entityProperty}
|
||||
/>
|
||||
{!isEntityValueDisable && (
|
||||
<TooltipHost content="Edit property" id="editTooltip">
|
||||
|
||||
@@ -3,12 +3,11 @@ import * as React from "react";
|
||||
|
||||
export interface TooltipProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const InfoTooltip: React.FunctionComponent<TooltipProps> = ({ children, className }: TooltipProps) => {
|
||||
export const InfoTooltip: React.FunctionComponent<TooltipProps> = ({ children }: TooltipProps) => {
|
||||
return (
|
||||
<span className={className}>
|
||||
<span>
|
||||
<TooltipHost content={children}>
|
||||
<Icon iconName="Info" ariaLabel={children} className="panelInfoIcon" tabIndex={0} />
|
||||
</TooltipHost>
|
||||
|
||||
@@ -26,23 +26,14 @@ export const deleteDocument = async (collection: CollectionBase, documentId: Doc
|
||||
}
|
||||
};
|
||||
|
||||
export interface IBulkDeleteResult {
|
||||
documentId: DocumentId;
|
||||
requestCharge: number;
|
||||
statusCode: number;
|
||||
retryAfterMilliseconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk delete documents
|
||||
* @param collection
|
||||
* @param documentId
|
||||
* @returns array of results and status codes
|
||||
* @returns array of ids that were successfully deleted
|
||||
*/
|
||||
export const deleteDocuments = async (
|
||||
collection: CollectionBase,
|
||||
documentIds: DocumentId[],
|
||||
): Promise<IBulkDeleteResult[]> => {
|
||||
export const deleteDocuments = async (collection: CollectionBase, documentIds: DocumentId[]): Promise<DocumentId[]> => {
|
||||
const nbDocuments = documentIds.length;
|
||||
const clearMessage = logConsoleProgress(`Deleting ${documentIds.length} ${getEntityName(true)}`);
|
||||
try {
|
||||
const v2Container = await client().database(collection.databaseId).container(collection.id());
|
||||
@@ -65,17 +56,18 @@ export const deleteDocuments = async (
|
||||
operationType: BulkOperationType.Delete,
|
||||
}));
|
||||
|
||||
const promise = v2Container.items.bulk(operations).then((bulkResults) => {
|
||||
return bulkResults.map((bulkResult, index) => {
|
||||
const documentId = documentIdsChunk[index];
|
||||
return { ...bulkResult, documentId };
|
||||
});
|
||||
const promise = v2Container.items.bulk(operations).then((bulkResult) => {
|
||||
return documentIdsChunk.filter((_, index) => bulkResult[index].statusCode === 204);
|
||||
});
|
||||
promiseArray.push(promise);
|
||||
}
|
||||
|
||||
const allResult = await Promise.all(promiseArray);
|
||||
const flatAllResult = Array.prototype.concat.apply([], allResult);
|
||||
logConsoleInfo(
|
||||
`Successfully deleted ${getEntityName(flatAllResult.length > 1)}: ${flatAllResult.length} out of ${nbDocuments}`,
|
||||
);
|
||||
// TODO: handle case result.length != nbDocuments
|
||||
return flatAllResult;
|
||||
} catch (error) {
|
||||
handleError(
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { CassandraProxyEndpoints, JunoEndpoints, MongoProxyEndpoints, PortalBackendEndpoints } from "Common/Constants";
|
||||
import {
|
||||
BackendApi,
|
||||
CassandraProxyEndpoints,
|
||||
JunoEndpoints,
|
||||
MongoProxyEndpoints,
|
||||
PortalBackendEndpoints,
|
||||
} from "Common/Constants";
|
||||
import {
|
||||
allowedAadEndpoints,
|
||||
allowedArcadiaEndpoints,
|
||||
allowedCassandraProxyEndpoints,
|
||||
allowedEmulatorEndpoints,
|
||||
allowedGraphEndpoints,
|
||||
allowedHostedExplorerEndpoints,
|
||||
allowedJunoOrigins,
|
||||
allowedMongoBackendEndpoints,
|
||||
allowedMongoProxyEndpoints,
|
||||
allowedMsalRedirectEndpoints,
|
||||
defaultAllowedArmEndpoints,
|
||||
defaultAllowedCassandraProxyEndpoints,
|
||||
defaultAllowedMongoProxyEndpoints,
|
||||
defaultAllowedPortalBackendEndpoints,
|
||||
defaultAllowedBackendEndpoints,
|
||||
validateEndpoint,
|
||||
} from "Utils/EndpointUtils";
|
||||
|
||||
@@ -25,9 +31,7 @@ export enum Platform {
|
||||
export interface ConfigContext {
|
||||
platform: Platform;
|
||||
allowedArmEndpoints: ReadonlyArray<string>;
|
||||
allowedPortalBackendEndpoints: ReadonlyArray<string>;
|
||||
allowedCassandraProxyEndpoints: ReadonlyArray<string>;
|
||||
allowedMongoProxyEndpoints: ReadonlyArray<string>;
|
||||
allowedBackendEndpoints: ReadonlyArray<string>;
|
||||
allowedParentFrameOrigins: ReadonlyArray<string>;
|
||||
gitSha?: string;
|
||||
proxyPath?: string;
|
||||
@@ -44,10 +48,16 @@ export interface ConfigContext {
|
||||
CATALOG_API_KEY: string;
|
||||
ARCADIA_ENDPOINT: string;
|
||||
ARCADIA_LIVY_ENDPOINT_DNS_ZONE: string;
|
||||
PORTAL_BACKEND_ENDPOINT: string;
|
||||
BACKEND_ENDPOINT?: string;
|
||||
PORTAL_BACKEND_ENDPOINT?: string;
|
||||
NEW_BACKEND_APIS?: BackendApi[];
|
||||
MONGO_BACKEND_ENDPOINT?: string;
|
||||
MONGO_PROXY_ENDPOINT: string;
|
||||
CASSANDRA_PROXY_ENDPOINT: string;
|
||||
MONGO_PROXY_ENDPOINT?: string;
|
||||
MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED?: boolean;
|
||||
NEW_MONGO_APIS?: string[];
|
||||
CASSANDRA_PROXY_ENDPOINT?: string;
|
||||
CASSANDRA_PROXY_OUTBOUND_IPS_ALLOWLISTED: boolean;
|
||||
NEW_CASSANDRA_APIS?: string[];
|
||||
PROXY_PATH?: string;
|
||||
JUNO_ENDPOINT: string;
|
||||
GITHUB_CLIENT_ID: string;
|
||||
@@ -58,21 +68,16 @@ export interface ConfigContext {
|
||||
hostedExplorerURL: string;
|
||||
armAPIVersion?: string;
|
||||
msalRedirectURI?: string;
|
||||
globallyEnabledCassandraAPIs?: string[];
|
||||
globallyEnabledMongoAPIs?: string[];
|
||||
}
|
||||
|
||||
// Default configuration
|
||||
let configContext: Readonly<ConfigContext> = {
|
||||
platform: Platform.Portal,
|
||||
allowedArmEndpoints: defaultAllowedArmEndpoints,
|
||||
allowedPortalBackendEndpoints: defaultAllowedPortalBackendEndpoints,
|
||||
allowedCassandraProxyEndpoints: defaultAllowedCassandraProxyEndpoints,
|
||||
allowedMongoProxyEndpoints: defaultAllowedMongoProxyEndpoints,
|
||||
allowedBackendEndpoints: defaultAllowedBackendEndpoints,
|
||||
allowedParentFrameOrigins: [
|
||||
`^https:\\/\\/cosmos\\.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]*ext\\.azure\\.(com|cn|us)$`,
|
||||
`^https:\\/\\/[\\.\\w]*\\.ext\\.microsoftazure\\.de$`,
|
||||
@@ -82,7 +87,7 @@ let configContext: Readonly<ConfigContext> = {
|
||||
`^https:\\/\\/.*\\.analysis-df\\.net$`,
|
||||
`^https:\\/\\/.*\\.analysis-df\\.windows\\.net$`,
|
||||
`^https:\\/\\/.*\\.azure-test\\.net$`,
|
||||
`^https:\\/\\/cosmos-explorer-preview\\.azurewebsites\\.net$`,
|
||||
`^https:\\/\\/cosmos-explorer-preview\\.azurewebsites\\.net`,
|
||||
], // Webpack injects this at build time
|
||||
gitSha: process.env.GIT_SHA,
|
||||
hostedExplorerURL: "https://cosmos.azure.com/",
|
||||
@@ -100,13 +105,25 @@ let configContext: Readonly<ConfigContext> = {
|
||||
GITHUB_CLIENT_ID: "6cb2f63cf6f7b5cbdeca", // Registered OAuth app: https://github.com/organizations/AzureCosmosDBNotebooks/settings/applications/1189306
|
||||
GITHUB_TEST_ENV_CLIENT_ID: "b63fc8cbf87fd3c6e2eb", // Registered OAuth app: https://github.com/organizations/AzureCosmosDBNotebooks/settings/applications/1777772
|
||||
JUNO_ENDPOINT: JunoEndpoints.Prod,
|
||||
BACKEND_ENDPOINT: "https://main.documentdb.ext.azure.com",
|
||||
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.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,
|
||||
NEW_CASSANDRA_APIS: ["postQuery", "createOrDelete", "getKeys", "getSchema"],
|
||||
CASSANDRA_PROXY_OUTBOUND_IPS_ALLOWLISTED: false,
|
||||
isTerminalEnabled: false,
|
||||
isPhoenixEnabled: false,
|
||||
globallyEnabledCassandraAPIs: [],
|
||||
globallyEnabledMongoAPIs: [],
|
||||
};
|
||||
|
||||
export function resetConfigContext(): void {
|
||||
@@ -143,19 +160,14 @@ export function updateConfigContext(newContext: Partial<ConfigContext>): void {
|
||||
|
||||
if (
|
||||
!validateEndpoint(
|
||||
newContext.PORTAL_BACKEND_ENDPOINT,
|
||||
configContext.allowedPortalBackendEndpoints || defaultAllowedPortalBackendEndpoints,
|
||||
newContext.BACKEND_ENDPOINT,
|
||||
configContext.allowedBackendEndpoints || defaultAllowedBackendEndpoints,
|
||||
)
|
||||
) {
|
||||
delete newContext.PORTAL_BACKEND_ENDPOINT;
|
||||
delete newContext.BACKEND_ENDPOINT;
|
||||
}
|
||||
|
||||
if (
|
||||
!validateEndpoint(
|
||||
newContext.MONGO_PROXY_ENDPOINT,
|
||||
configContext.allowedMongoProxyEndpoints || defaultAllowedMongoProxyEndpoints,
|
||||
)
|
||||
) {
|
||||
if (!validateEndpoint(newContext.MONGO_PROXY_ENDPOINT, allowedMongoProxyEndpoints)) {
|
||||
delete newContext.MONGO_PROXY_ENDPOINT;
|
||||
}
|
||||
|
||||
@@ -163,12 +175,7 @@ export function updateConfigContext(newContext: Partial<ConfigContext>): void {
|
||||
delete newContext.MONGO_BACKEND_ENDPOINT;
|
||||
}
|
||||
|
||||
if (
|
||||
!validateEndpoint(
|
||||
newContext.CASSANDRA_PROXY_ENDPOINT,
|
||||
configContext.allowedCassandraProxyEndpoints || defaultAllowedCassandraProxyEndpoints,
|
||||
)
|
||||
) {
|
||||
if (!validateEndpoint(newContext.CASSANDRA_PROXY_ENDPOINT, allowedCassandraProxyEndpoints)) {
|
||||
delete newContext.CASSANDRA_PROXY_ENDPOINT;
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface Database extends TreeNode {
|
||||
openAddCollection(database: Database, event: MouseEvent): void;
|
||||
onSettingsClick: () => void;
|
||||
loadOffer(): Promise<void>;
|
||||
getPendingThroughputSplitNotification(): Promise<DataModels.Notification>;
|
||||
}
|
||||
|
||||
export interface CollectionBase extends TreeNode {
|
||||
@@ -190,6 +191,8 @@ export interface Collection extends CollectionBase {
|
||||
onDragOver(source: Collection, event: { originalEvent: DragEvent }): void;
|
||||
onDrop(source: Collection, event: { originalEvent: DragEvent }): void;
|
||||
uploadFiles(fileList: FileList): Promise<{ data: UploadDetailsRecord[] }>;
|
||||
|
||||
getPendingThroughputSplitNotification(): Promise<DataModels.Notification>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -382,14 +385,13 @@ export interface DataExplorerInputsFrame {
|
||||
databaseAccount: any;
|
||||
subscriptionId?: string;
|
||||
resourceGroup?: string;
|
||||
tenantId?: string;
|
||||
userName?: string;
|
||||
masterKey?: string;
|
||||
hasWriteAccess?: boolean;
|
||||
authorizationToken?: string;
|
||||
csmEndpoint?: string;
|
||||
dnsSuffix?: string;
|
||||
serverId?: string;
|
||||
extensionEndpoint?: string;
|
||||
portalBackendEndpoint?: string;
|
||||
mongoProxyEndpoint?: string;
|
||||
cassandraProxyEndpoint?: string;
|
||||
|
||||
@@ -41,10 +41,6 @@ export interface DatabaseContextMenuButtonParams {
|
||||
* New resource tree (in ReactJS)
|
||||
*/
|
||||
export const createDatabaseContextMenu = (container: Explorer, databaseId: string): TreeNodeMenuItem[] => {
|
||||
if (configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const items: TreeNodeMenuItem[] = [
|
||||
{
|
||||
iconSrc: AddCollectionIcon,
|
||||
@@ -56,15 +52,13 @@ export const createDatabaseContextMenu = (container: Explorer, databaseId: strin
|
||||
if (userContext.apiType !== "Tables" || userContext.features.enableSDKoperations) {
|
||||
items.push({
|
||||
iconSrc: DeleteDatabaseIcon,
|
||||
onClick: (lastFocusedElement?: React.RefObject<HTMLElement>) => {
|
||||
(useSidePanel.getState().getRef = lastFocusedElement),
|
||||
onClick: () =>
|
||||
useSidePanel
|
||||
.getState()
|
||||
.openSidePanel(
|
||||
"Delete " + getDatabaseName(),
|
||||
<DeleteDatabaseConfirmationPanel refreshDatabases={() => container.refreshAllDatabases()} />,
|
||||
);
|
||||
},
|
||||
),
|
||||
label: `Delete ${getDatabaseName()}`,
|
||||
styleClass: "deleteDatabaseMenuItem",
|
||||
});
|
||||
@@ -148,9 +142,8 @@ export const createCollectionContextMenuButton = (
|
||||
if (configContext.platform !== Platform.Fabric) {
|
||||
items.push({
|
||||
iconSrc: DeleteCollectionIcon,
|
||||
onClick: (lastFocusedElement?: React.RefObject<HTMLElement>) => {
|
||||
onClick: () => {
|
||||
useSelectedNode.getState().setSelectedNode(selectedCollection);
|
||||
(useSidePanel.getState().getRef = lastFocusedElement),
|
||||
useSidePanel
|
||||
.getState()
|
||||
.openSidePanel(
|
||||
|
||||
@@ -35,7 +35,7 @@ export interface DialogState {
|
||||
textFieldProps?: TextFieldProps,
|
||||
primaryButtonDisabled?: boolean,
|
||||
) => void;
|
||||
showOkModalDialog: (title: string, subText: string, linkProps?: LinkProps) => void;
|
||||
showOkModalDialog: (title: string, subText: string) => void;
|
||||
}
|
||||
|
||||
export const useDialog: UseStore<DialogState> = create((set, get) => ({
|
||||
@@ -83,7 +83,7 @@ export const useDialog: UseStore<DialogState> = create((set, get) => ({
|
||||
textFieldProps,
|
||||
primaryButtonDisabled,
|
||||
}),
|
||||
showOkModalDialog: (title: string, subText: string, linkProps?: LinkProps): void =>
|
||||
showOkModalDialog: (title: string, subText: string): void =>
|
||||
get().openDialog({
|
||||
isModal: true,
|
||||
title,
|
||||
@@ -94,7 +94,6 @@ export const useDialog: UseStore<DialogState> = create((set, get) => ({
|
||||
get().closeDialog();
|
||||
},
|
||||
onSecondaryButtonClick: undefined,
|
||||
linkProps,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -3,37 +3,6 @@ import * as React from "react";
|
||||
import { loadMonaco, monaco } from "../../LazyMonaco";
|
||||
// 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 {
|
||||
showEditor: boolean;
|
||||
}
|
||||
@@ -42,7 +11,7 @@ export interface EditorReactProps {
|
||||
content: string;
|
||||
isReadOnly: boolean;
|
||||
ariaLabel: string; // Sets what will be read to the user to define the control
|
||||
onContentSelected?: (selectedContent: string, selection: monaco.Selection) => void; // Called when text is selected
|
||||
onContentSelected?: (selectedContent: string) => void; // Called when text is selected
|
||||
onContentChanged?: (newContent: string) => void; // Called when text is changed
|
||||
theme?: string; // Monaco editor theme
|
||||
wordWrap?: monaco.editor.IEditorOptions["wordWrap"];
|
||||
@@ -56,7 +25,6 @@ export interface EditorReactProps {
|
||||
className?: string;
|
||||
spinnerClassName?: string;
|
||||
|
||||
modelMarkers?: monaco.editor.IMarkerData[];
|
||||
enableWordWrapContextMenuItem?: boolean; // Enable/Disable "Word Wrap" context menu item
|
||||
onWordWrapChanged?: (wordWrap: "on" | "off") => void; // Called when word wrap is changed
|
||||
}
|
||||
@@ -64,25 +32,10 @@ export interface EditorReactProps {
|
||||
export class EditorReact extends React.Component<EditorReactProps, EditorReactStates> {
|
||||
private static readonly VIEWING_OPTIONS_GROUP_ID = "viewingoptions"; // Group ID for the context menu group
|
||||
private rootNode: HTMLElement;
|
||||
public editor: monaco.editor.IStandaloneCodeEditor;
|
||||
private editor: monaco.editor.IStandaloneCodeEditor;
|
||||
private selectionListener: monaco.IDisposable;
|
||||
monacoApi: {
|
||||
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;
|
||||
};
|
||||
|
||||
private monacoEditorOptionsWordWrap: monaco.editor.EditorOption;
|
||||
|
||||
public constructor(props: EditorReactProps) {
|
||||
super(props);
|
||||
@@ -111,7 +64,7 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
||||
|
||||
if (this.props.content !== existingContent) {
|
||||
if (this.props.isReadOnly) {
|
||||
this.editor.setValue(this.props.content || ""); // Monaco throws an error if you set the value to undefined.
|
||||
this.editor.setValue(this.props.content);
|
||||
} else {
|
||||
this.editor.pushUndoStop();
|
||||
this.editor.executeEdits("", [
|
||||
@@ -122,8 +75,6 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
this.monacoApi.editor.setModelMarkers(this.editor.getModel(), "owner", this.props.modelMarkers || []);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
@@ -137,7 +88,6 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
||||
<Spinner size={SpinnerSize.large} className={this.props.spinnerClassName || "spinner"} />
|
||||
)}
|
||||
<div
|
||||
data-test="EditorReact/Host/Unloaded"
|
||||
className={this.props.className || "jsonEditor"}
|
||||
style={this.props.monacoContainerStyles}
|
||||
ref={(elt: HTMLElement) => this.setRef(elt)}
|
||||
@@ -148,18 +98,6 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
||||
|
||||
protected configureEditor(editor: monaco.editor.IStandaloneCodeEditor) {
|
||||
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) {
|
||||
// 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),
|
||||
@@ -177,7 +115,7 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
||||
this.selectionListener = this.editor.onDidChangeCursorSelection(
|
||||
(event: monaco.editor.ICursorSelectionChangedEvent) => {
|
||||
const selectedContent: string = this.editor.getModel().getValueInRange(event.selection);
|
||||
this.props.onContentSelected(selectedContent, event.selection);
|
||||
this.props.onContentSelected(selectedContent);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -192,7 +130,7 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
||||
// Method that will be executed when the action is triggered.
|
||||
// @param editor The editor instance is passed in as a convenience
|
||||
run: (ed) => {
|
||||
const newOption = ed.getOption(this.monacoApi.editor.EditorOption.wordWrap) === "on" ? "off" : "on";
|
||||
const newOption = ed.getOption(this.monacoEditorOptionsWordWrap) === "on" ? "off" : "on";
|
||||
ed.updateOptions({ wordWrap: newOption });
|
||||
this.props.onWordWrapChanged(newOption);
|
||||
},
|
||||
@@ -218,14 +156,16 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
||||
lineDecorationsWidth: this.props.lineDecorationsWidth,
|
||||
minimap: this.props.minimap,
|
||||
scrollBeyondLastLine: this.props.scrollBeyondLastLine,
|
||||
fixedOverflowWidgets: true,
|
||||
};
|
||||
|
||||
this.rootNode.innerHTML = "";
|
||||
this.monacoApi = await loadMonaco();
|
||||
const lazymonaco = await loadMonaco();
|
||||
|
||||
// We can only get this constant after loading monaco lazily
|
||||
this.monacoEditorOptionsWordWrap = lazymonaco.editor.EditorOption.wordWrap;
|
||||
|
||||
try {
|
||||
createCallback(this.monacoApi.editor.create(this.rootNode, options));
|
||||
createCallback(lazymonaco?.editor?.create(this.rootNode, options));
|
||||
} catch (error) {
|
||||
// This could happen if the parent node suddenly disappears during create()
|
||||
console.error("Unable to create EditorReact", error);
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,68 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,79 +0,0 @@
|
||||
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,6 +134,7 @@ describe("SettingsComponent", () => {
|
||||
readSettings: undefined,
|
||||
onSettingsClick: undefined,
|
||||
loadOffer: undefined,
|
||||
getPendingThroughputSplitNotification: undefined,
|
||||
} as ViewModels.Database;
|
||||
newCollection.getDatabase = () => newDatabase;
|
||||
newCollection.offer = ko.observable(undefined);
|
||||
|
||||
@@ -130,6 +130,7 @@ export interface SettingsComponentState {
|
||||
conflictResolutionPolicyProcedureBaseline: string;
|
||||
isConflictResolutionDirty: boolean;
|
||||
|
||||
initialNotification: DataModels.Notification;
|
||||
selectedTab: SettingsV2TabTypes;
|
||||
}
|
||||
|
||||
@@ -228,6 +229,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
conflictResolutionPolicyProcedureBaseline: undefined,
|
||||
isConflictResolutionDirty: false,
|
||||
|
||||
initialNotification: undefined,
|
||||
selectedTab: SettingsV2TabTypes.ScaleTab,
|
||||
};
|
||||
|
||||
@@ -1050,6 +1052,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
onMaxAutoPilotThroughputChange: this.onMaxAutoPilotThroughputChange,
|
||||
onScaleSaveableChange: this.onScaleSaveableChange,
|
||||
onScaleDiscardableChange: this.onScaleDiscardableChange,
|
||||
initialNotification: this.props.settingsTab.pendingNotification(),
|
||||
throughputError: this.state.throughputError,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { shallow } from "enzyme";
|
||||
import ko from "knockout";
|
||||
import React from "react";
|
||||
import * as Constants from "../../../../Common/Constants";
|
||||
import * as DataModels from "../../../../Contracts/DataModels";
|
||||
import { updateUserContext } from "../../../../UserContext";
|
||||
import Explorer from "../../../Explorer";
|
||||
import { throughputUnit } from "../SettingsRenderUtils";
|
||||
import { collection } from "../TestUtils";
|
||||
import { ScaleComponent, ScaleComponentProps } from "./ScaleComponent";
|
||||
import { ThroughputInputAutoPilotV3Component } from "./ThroughputInputComponents/ThroughputInputAutoPilotV3Component";
|
||||
|
||||
describe("ScaleComponent", () => {
|
||||
const targetThroughput = 6000;
|
||||
|
||||
const baseProps: ScaleComponentProps = {
|
||||
collection: collection,
|
||||
database: undefined,
|
||||
@@ -28,8 +36,39 @@ describe("ScaleComponent", () => {
|
||||
onScaleDiscardableChange: () => {
|
||||
return;
|
||||
},
|
||||
initialNotification: {
|
||||
description: `Throughput update for ${targetThroughput} ${throughputUnit}`,
|
||||
} as DataModels.Notification,
|
||||
};
|
||||
|
||||
it("renders with correct initial notification", () => {
|
||||
let wrapper = shallow(<ScaleComponent {...baseProps} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
expect(wrapper.exists(ThroughputInputAutoPilotV3Component)).toEqual(true);
|
||||
expect(wrapper.exists("#throughputApplyLongDelayMessage")).toEqual(true);
|
||||
expect(wrapper.exists("#throughputApplyShortDelayMessage")).toEqual(false);
|
||||
expect(wrapper.find("#throughputApplyLongDelayMessage").html()).toContain(`${targetThroughput}`);
|
||||
|
||||
const newCollection = { ...collection };
|
||||
const maxThroughput = 5000;
|
||||
newCollection.offer = ko.observable({
|
||||
manualThroughput: undefined,
|
||||
autoscaleMaxThroughput: maxThroughput,
|
||||
minimumThroughput: 400,
|
||||
id: "offer",
|
||||
offerReplacePending: true,
|
||||
});
|
||||
const newProps = {
|
||||
...baseProps,
|
||||
initialNotification: undefined as DataModels.Notification,
|
||||
collection: newCollection,
|
||||
};
|
||||
wrapper = shallow(<ScaleComponent {...newProps} />);
|
||||
expect(wrapper.exists("#throughputApplyShortDelayMessage")).toEqual(true);
|
||||
expect(wrapper.exists("#throughputApplyLongDelayMessage")).toEqual(false);
|
||||
expect(wrapper.find("#throughputApplyShortDelayMessage").html()).toContain(`${maxThroughput}`);
|
||||
});
|
||||
|
||||
it("autoScale disabled", () => {
|
||||
const scaleComponent = new ScaleComponent(baseProps);
|
||||
expect(scaleComponent.isAutoScaleEnabled()).toEqual(false);
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as AutoPilotUtils from "../../../../Utils/AutoPilotUtils";
|
||||
import { isRunningOnNationalCloud } from "../../../../Utils/CloudUtils";
|
||||
import {
|
||||
getTextFieldStyles,
|
||||
getThroughputApplyLongDelayMessage,
|
||||
getThroughputApplyShortDelayMessage,
|
||||
subComponentStackProps,
|
||||
throughputUnit,
|
||||
@@ -33,6 +34,7 @@ export interface ScaleComponentProps {
|
||||
onMaxAutoPilotThroughputChange: (newThroughput: number) => void;
|
||||
onScaleSaveableChange: (isScaleSaveable: boolean) => void;
|
||||
onScaleDiscardableChange: (isScaleDiscardable: boolean) => void;
|
||||
initialNotification: DataModels.Notification;
|
||||
throughputError?: string;
|
||||
}
|
||||
|
||||
@@ -100,6 +102,10 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
|
||||
};
|
||||
|
||||
public getInitialNotificationElement = (): JSX.Element => {
|
||||
if (this.props.initialNotification) {
|
||||
return this.getLongDelayMessage();
|
||||
}
|
||||
|
||||
if (this.offer?.offerReplacePending) {
|
||||
const throughput = this.offer.manualThroughput || this.offer.autoscaleMaxThroughput;
|
||||
return getThroughputApplyShortDelayMessage(
|
||||
@@ -114,6 +120,26 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
public getLongDelayMessage = (): JSX.Element => {
|
||||
const matches: string[] = this.props.initialNotification?.description.match(
|
||||
`Throughput update for (.*) ${throughputUnit}`,
|
||||
);
|
||||
|
||||
const throughput = this.props.throughputBaseline;
|
||||
const targetThroughput: number = matches.length > 1 && Number(matches[1]);
|
||||
if (targetThroughput) {
|
||||
return getThroughputApplyLongDelayMessage(
|
||||
this.props.wasAutopilotOriginallySet,
|
||||
throughput,
|
||||
throughputUnit,
|
||||
this.databaseId,
|
||||
this.collectionId,
|
||||
targetThroughput,
|
||||
);
|
||||
}
|
||||
return <></>;
|
||||
};
|
||||
|
||||
private getThroughputInputComponent = (): JSX.Element => (
|
||||
<ThroughputInputAutoPilotV3Component
|
||||
databaseAccount={userContext?.databaseAccount}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// 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,6 +44,7 @@ describe("SettingsUtils", () => {
|
||||
readSettings: undefined,
|
||||
onSettingsClick: undefined,
|
||||
loadOffer: undefined,
|
||||
getPendingThroughputSplitNotification: undefined,
|
||||
} as ViewModels.Database;
|
||||
};
|
||||
newCollection.offer(undefined);
|
||||
|
||||
@@ -17,7 +17,7 @@ export const useTreeStyles = makeStyles({
|
||||
minWidth: "100%",
|
||||
rowGap: "0px",
|
||||
paddingTop: "0px",
|
||||
[treeIconWidth]: "16px",
|
||||
[treeIconWidth]: "20px",
|
||||
[leafNodeSpacing]: "24px",
|
||||
},
|
||||
nodeIcon: {
|
||||
@@ -25,13 +25,12 @@ export const useTreeStyles = makeStyles({
|
||||
height: `var(${treeIconWidth})`,
|
||||
},
|
||||
treeItem: {},
|
||||
nodeLabel: {
|
||||
whiteSpace: "nowrap", // Don't wrap text, there will be a scrollbar.
|
||||
},
|
||||
nodeLabel: {},
|
||||
treeItemLayout: {
|
||||
fontSize: tokens.fontSizeBase300,
|
||||
height: tokens.layoutRowHeight,
|
||||
...cosmosShorthands.borderBottom(),
|
||||
paddingLeft: `calc(var(${treeItemLevelToken}, 1) * ${tokens.spacingHorizontalXXL})`,
|
||||
|
||||
// Some sneaky CSS variables stuff to change the background color of the action button on hover.
|
||||
[actionButtonBackground]: tokens.colorNeutralBackground1,
|
||||
|
||||
@@ -23,7 +23,7 @@ import { useCallback } from "react";
|
||||
|
||||
export interface TreeNodeMenuItem {
|
||||
label: string;
|
||||
onClick: (value?: React.RefObject<HTMLElement>) => void;
|
||||
onClick: () => void;
|
||||
iconSrc?: string;
|
||||
isDisabled?: boolean;
|
||||
styleClass?: string;
|
||||
@@ -74,7 +74,6 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
|
||||
openItems,
|
||||
}: TreeNodeComponentProps): JSX.Element => {
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||
const contextMenuRef = React.useRef<HTMLButtonElement>(null);
|
||||
const treeStyles = useTreeStyles();
|
||||
|
||||
const getSortedChildren = (treeNode: TreeNode): TreeNode[] => {
|
||||
@@ -142,7 +141,7 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
|
||||
data-test={`TreeNode/ContextMenuItem:${menuItem.label}`}
|
||||
disabled={menuItem.isDisabled}
|
||||
key={menuItem.label}
|
||||
onClick={() => menuItem.onClick(contextMenuRef)}
|
||||
onClick={menuItem.onClick}
|
||||
>
|
||||
{menuItem.label}
|
||||
</MenuItem>
|
||||
@@ -150,19 +149,18 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
|
||||
|
||||
// We use the expandIcon slot to hold the node icon too.
|
||||
// We only show a node icon for leaf nodes, even if a branch node has an iconSrc.
|
||||
const treeIcon =
|
||||
node.iconSrc === undefined ? undefined : typeof node.iconSrc === "string" ? (
|
||||
const expandIcon = isLoading ? (
|
||||
<Spinner size="extra-tiny" />
|
||||
) : !isBranch ? (
|
||||
typeof node.iconSrc === "string" ? (
|
||||
<img src={node.iconSrc} className={treeStyles.nodeIcon} alt="" />
|
||||
) : (
|
||||
node.iconSrc
|
||||
);
|
||||
|
||||
const expandIcon = isLoading ? (
|
||||
<Spinner size="extra-tiny" />
|
||||
) : !isBranch ? undefined : openItems.includes(treeNodeId) ? (
|
||||
<ChevronDown20Regular data-test="TreeNode/CollapseIcon" />
|
||||
)
|
||||
) : openItems.includes(treeNodeId) ? (
|
||||
<ChevronDown20Regular />
|
||||
) : (
|
||||
<ChevronRight20Regular data-text="TreeNode/ExpandIcon" />
|
||||
<ChevronRight20Regular />
|
||||
);
|
||||
|
||||
const treeItem = (
|
||||
@@ -176,6 +174,7 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
|
||||
<TreeItemLayout
|
||||
className={mergeClasses(
|
||||
treeStyles.treeItemLayout,
|
||||
expandIcon ? undefined : treeStyles.treeItemLayoutNoIcon,
|
||||
shouldShowAsSelected && treeStyles.selectedItem,
|
||||
node.className && treeStyles[node.className],
|
||||
)}
|
||||
@@ -191,7 +190,6 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
|
||||
className={mergeClasses(treeStyles.actionsButton, shouldShowAsSelected && treeStyles.selectedItem)}
|
||||
data-test="TreeNode/ContextMenuTrigger"
|
||||
appearance="subtle"
|
||||
ref={contextMenuRef}
|
||||
icon={<MoreHorizontal20Regular />}
|
||||
/>
|
||||
</MenuTrigger>
|
||||
@@ -202,13 +200,12 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
|
||||
),
|
||||
}
|
||||
}
|
||||
iconBefore={treeIcon}
|
||||
expandIcon={expandIcon}
|
||||
>
|
||||
<span className={treeStyles.nodeLabel}>{node.label}</span>
|
||||
</TreeItemLayout>
|
||||
{!node.isLoading && node.children?.length > 0 && (
|
||||
<Tree data-test={`Tree:${treeNodeId}`} className={treeStyles.tree}>
|
||||
<Tree className={treeStyles.tree}>
|
||||
{getSortedChildren(node).map((childNode: TreeNode) => (
|
||||
<TreeNodeComponent
|
||||
openItems={openItems}
|
||||
|
||||
@@ -10,23 +10,12 @@ exports[`TreeNodeComponent does not render children if the node is loading 1`] =
|
||||
>
|
||||
<TreeItemLayout
|
||||
actions={false}
|
||||
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root"
|
||||
expandIcon={
|
||||
<ChevronRight20Regular
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
/>
|
||||
}
|
||||
iconBefore={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="rootIcon"
|
||||
/>
|
||||
}
|
||||
expandIcon={<ChevronRight20Regular />}
|
||||
>
|
||||
<span
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
className=""
|
||||
>
|
||||
rootLabel
|
||||
</span>
|
||||
@@ -144,7 +133,6 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -163,7 +151,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
"itemType": "branch",
|
||||
"layoutRef": {
|
||||
"current": <div
|
||||
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root"
|
||||
>
|
||||
<div
|
||||
@@ -173,7 +161,6 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -186,21 +173,11 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="rootIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
class="___1h29e9h_0000000 fz5stix"
|
||||
class=""
|
||||
>
|
||||
rootLabel
|
||||
</span>
|
||||
@@ -225,7 +202,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root"
|
||||
>
|
||||
<div
|
||||
@@ -235,7 +212,6 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -248,29 +224,18 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="rootIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
class="___1h29e9h_0000000 fz5stix"
|
||||
class=""
|
||||
>
|
||||
rootLabel
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="fui-Tree rnv2ez3 ___17a32do_7zrvj80 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
|
||||
data-test="Tree:root"
|
||||
class="fui-Tree rnv2ez3 ___jy13a00_lpffjy0 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
|
||||
role="tree"
|
||||
>
|
||||
<div
|
||||
@@ -283,7 +248,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root/child1Label"
|
||||
>
|
||||
<div
|
||||
@@ -293,7 +258,6 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -306,21 +270,11 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="child1Icon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
class="___1h29e9h_0000000 fz5stix"
|
||||
class=""
|
||||
>
|
||||
child1Label
|
||||
</span>
|
||||
@@ -337,7 +291,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root/child2LoadingLabel"
|
||||
>
|
||||
<div
|
||||
@@ -347,7 +301,6 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -360,21 +313,11 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="child2LoadingIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
class="___1h29e9h_0000000 fz5stix"
|
||||
class=""
|
||||
>
|
||||
child2LoadingLabel
|
||||
</span>
|
||||
@@ -390,7 +333,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_vz3p260 fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vrg09j fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root/child3ExpandingLabel"
|
||||
>
|
||||
<div
|
||||
@@ -410,21 +353,11 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="child3ExpandingIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
class="___1h29e9h_0000000 fz5stix"
|
||||
class=""
|
||||
>
|
||||
child3ExpandingLabel
|
||||
</span>
|
||||
@@ -440,36 +373,22 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
>
|
||||
<TreeItemLayout
|
||||
actions={false}
|
||||
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root"
|
||||
expandIcon={
|
||||
<ChevronRight20Regular
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
/>
|
||||
}
|
||||
iconBefore={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="rootIcon"
|
||||
/>
|
||||
}
|
||||
expandIcon={<ChevronRight20Regular />}
|
||||
>
|
||||
<div
|
||||
className="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root"
|
||||
>
|
||||
<div
|
||||
aria-hidden={true}
|
||||
className="fui-TreeItemLayout__expandIcon rh4pu5o"
|
||||
>
|
||||
<ChevronRight20Regular
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
>
|
||||
<ChevronRight20Regular>
|
||||
<svg
|
||||
aria-hidden={true}
|
||||
className="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -483,21 +402,11 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
</svg>
|
||||
</ChevronRight20Regular>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden={true}
|
||||
className="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="rootIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
className=""
|
||||
>
|
||||
rootLabel
|
||||
</span>
|
||||
@@ -505,8 +414,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
</div>
|
||||
</TreeItemLayout>
|
||||
<Tree
|
||||
className="___17a32do_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
|
||||
data-test="Tree:root"
|
||||
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
|
||||
>
|
||||
<TreeProvider
|
||||
value={
|
||||
@@ -573,8 +481,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="fui-Tree rnv2ez3 ___17a32do_7zrvj80 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
|
||||
data-test="Tree:root"
|
||||
className="fui-Tree rnv2ez3 ___jy13a00_lpffjy0 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
|
||||
role="tree"
|
||||
>
|
||||
<TreeNodeComponent
|
||||
@@ -642,7 +549,6 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -661,7 +567,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
"itemType": "branch",
|
||||
"layoutRef": {
|
||||
"current": <div
|
||||
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root/child1Label"
|
||||
>
|
||||
<div
|
||||
@@ -671,7 +577,6 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -684,21 +589,11 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="child1Icon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
class="___1h29e9h_0000000 fz5stix"
|
||||
class=""
|
||||
>
|
||||
child1Label
|
||||
</span>
|
||||
@@ -723,7 +618,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root/child1Label"
|
||||
>
|
||||
<div
|
||||
@@ -733,7 +628,6 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -746,21 +640,11 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="child1Icon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
class="___1h29e9h_0000000 fz5stix"
|
||||
class=""
|
||||
>
|
||||
child1Label
|
||||
</span>
|
||||
@@ -774,36 +658,22 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
>
|
||||
<TreeItemLayout
|
||||
actions={false}
|
||||
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root/child1Label"
|
||||
expandIcon={
|
||||
<ChevronRight20Regular
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
/>
|
||||
}
|
||||
iconBefore={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="child1Icon"
|
||||
/>
|
||||
}
|
||||
expandIcon={<ChevronRight20Regular />}
|
||||
>
|
||||
<div
|
||||
className="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root/child1Label"
|
||||
>
|
||||
<div
|
||||
aria-hidden={true}
|
||||
className="fui-TreeItemLayout__expandIcon rh4pu5o"
|
||||
>
|
||||
<ChevronRight20Regular
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
>
|
||||
<ChevronRight20Regular>
|
||||
<svg
|
||||
aria-hidden={true}
|
||||
className="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -817,21 +687,11 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
</svg>
|
||||
</ChevronRight20Regular>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden={true}
|
||||
className="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="child1Icon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
className=""
|
||||
>
|
||||
child1Label
|
||||
</span>
|
||||
@@ -839,8 +699,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
</div>
|
||||
</TreeItemLayout>
|
||||
<Tree
|
||||
className="___17a32do_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
|
||||
data-test="Tree:root/child1Label"
|
||||
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
|
||||
>
|
||||
<TreeProvider
|
||||
value={
|
||||
@@ -913,7 +772,6 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -932,7 +790,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
"itemType": "branch",
|
||||
"layoutRef": {
|
||||
"current": <div
|
||||
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root/child2LoadingLabel"
|
||||
>
|
||||
<div
|
||||
@@ -942,7 +800,6 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -955,21 +812,11 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="child2LoadingIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
class="___1h29e9h_0000000 fz5stix"
|
||||
class=""
|
||||
>
|
||||
child2LoadingLabel
|
||||
</span>
|
||||
@@ -994,7 +841,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root/child2LoadingLabel"
|
||||
>
|
||||
<div
|
||||
@@ -1004,7 +851,6 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1017,21 +863,11 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="child2LoadingIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
class="___1h29e9h_0000000 fz5stix"
|
||||
class=""
|
||||
>
|
||||
child2LoadingLabel
|
||||
</span>
|
||||
@@ -1045,36 +881,22 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
>
|
||||
<TreeItemLayout
|
||||
actions={false}
|
||||
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root/child2LoadingLabel"
|
||||
expandIcon={
|
||||
<ChevronRight20Regular
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
/>
|
||||
}
|
||||
iconBefore={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="child2LoadingIcon"
|
||||
/>
|
||||
}
|
||||
expandIcon={<ChevronRight20Regular />}
|
||||
>
|
||||
<div
|
||||
className="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root/child2LoadingLabel"
|
||||
>
|
||||
<div
|
||||
aria-hidden={true}
|
||||
className="fui-TreeItemLayout__expandIcon rh4pu5o"
|
||||
>
|
||||
<ChevronRight20Regular
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
>
|
||||
<ChevronRight20Regular>
|
||||
<svg
|
||||
aria-hidden={true}
|
||||
className="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -1088,21 +910,11 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
</svg>
|
||||
</ChevronRight20Regular>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden={true}
|
||||
className="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="child2LoadingIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
className=""
|
||||
>
|
||||
child2LoadingLabel
|
||||
</span>
|
||||
@@ -1187,7 +999,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
"itemType": "leaf",
|
||||
"layoutRef": {
|
||||
"current": <div
|
||||
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_vz3p260 fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vrg09j fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root/child3ExpandingLabel"
|
||||
>
|
||||
<div
|
||||
@@ -1207,21 +1019,11 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="child3ExpandingIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
class="___1h29e9h_0000000 fz5stix"
|
||||
class=""
|
||||
>
|
||||
child3ExpandingLabel
|
||||
</span>
|
||||
@@ -1245,7 +1047,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_vz3p260 fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vrg09j fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root/child3ExpandingLabel"
|
||||
>
|
||||
<div
|
||||
@@ -1265,21 +1067,11 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="child3ExpandingIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
class="___1h29e9h_0000000 fz5stix"
|
||||
class=""
|
||||
>
|
||||
child3ExpandingLabel
|
||||
</span>
|
||||
@@ -1293,9 +1085,9 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
>
|
||||
<TreeItemLayout
|
||||
actions={false}
|
||||
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root/child3ExpandingLabel"
|
||||
iconBefore={
|
||||
expandIcon={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
@@ -1304,12 +1096,12 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_vz3p260 fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vrg09j fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root/child3ExpandingLabel"
|
||||
>
|
||||
<div
|
||||
aria-hidden={true}
|
||||
className="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
className="fui-TreeItemLayout__expandIcon rh4pu5o"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
@@ -1321,7 +1113,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
className="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
className=""
|
||||
>
|
||||
child3ExpandingLabel
|
||||
</span>
|
||||
@@ -1352,9 +1144,9 @@ exports[`TreeNodeComponent renders a loading spinner if the node is loading: loa
|
||||
>
|
||||
<TreeItemLayout
|
||||
actions={false}
|
||||
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root"
|
||||
iconBefore={
|
||||
expandIcon={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
@@ -1363,7 +1155,7 @@ exports[`TreeNodeComponent renders a loading spinner if the node is loading: loa
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
className=""
|
||||
>
|
||||
rootLabel
|
||||
</span>
|
||||
@@ -1381,23 +1173,16 @@ exports[`TreeNodeComponent renders a loading spinner if the node is loading: loa
|
||||
>
|
||||
<TreeItemLayout
|
||||
actions={false}
|
||||
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root"
|
||||
expandIcon={
|
||||
<Spinner
|
||||
size="extra-tiny"
|
||||
/>
|
||||
}
|
||||
iconBefore={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="rootIcon"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
className=""
|
||||
>
|
||||
rootLabel
|
||||
</span>
|
||||
@@ -1415,23 +1200,12 @@ exports[`TreeNodeComponent renders a node as expandable if it has empty, but def
|
||||
>
|
||||
<TreeItemLayout
|
||||
actions={false}
|
||||
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root"
|
||||
expandIcon={
|
||||
<ChevronRight20Regular
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
/>
|
||||
}
|
||||
iconBefore={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="rootIcon"
|
||||
/>
|
||||
}
|
||||
expandIcon={<ChevronRight20Regular />}
|
||||
>
|
||||
<span
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
className=""
|
||||
>
|
||||
rootLabel
|
||||
</span>
|
||||
@@ -1478,14 +1252,14 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
|
||||
<MenuList>
|
||||
<MenuItem
|
||||
data-test="TreeNode/ContextMenuItem:enabledItem"
|
||||
onClick={[Function]}
|
||||
onClick={[MockFunction enabledItemClick]}
|
||||
>
|
||||
enabledItem
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
data-test="TreeNode/ContextMenuItem:disabledItem"
|
||||
disabled={true}
|
||||
onClick={[Function]}
|
||||
onClick={[MockFunction disabledItemClick]}
|
||||
>
|
||||
disabledItem
|
||||
</MenuItem>
|
||||
@@ -1495,9 +1269,9 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
|
||||
"className": "___1r8p62d_0000000 f1xg1ack f1e31b4d",
|
||||
}
|
||||
}
|
||||
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root"
|
||||
iconBefore={
|
||||
expandIcon={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
@@ -1506,7 +1280,7 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
className=""
|
||||
>
|
||||
rootLabel
|
||||
</span>
|
||||
@@ -1518,7 +1292,7 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
|
||||
<MenuItem
|
||||
data-test="TreeNode/ContextMenuItem:enabledItem"
|
||||
key="enabledItem"
|
||||
onClick={[Function]}
|
||||
onClick={[MockFunction enabledItemClick]}
|
||||
>
|
||||
enabledItem
|
||||
</MenuItem>
|
||||
@@ -1526,7 +1300,7 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
|
||||
data-test="TreeNode/ContextMenuItem:disabledItem"
|
||||
disabled={true}
|
||||
key="disabledItem"
|
||||
onClick={[Function]}
|
||||
onClick={[MockFunction disabledItemClick]}
|
||||
>
|
||||
disabledItem
|
||||
</MenuItem>
|
||||
@@ -1545,9 +1319,9 @@ exports[`TreeNodeComponent renders a single node 1`] = `
|
||||
>
|
||||
<TreeItemLayout
|
||||
actions={false}
|
||||
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root"
|
||||
iconBefore={
|
||||
expandIcon={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
@@ -1556,7 +1330,7 @@ exports[`TreeNodeComponent renders a single node 1`] = `
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
className=""
|
||||
>
|
||||
rootLabel
|
||||
</span>
|
||||
@@ -1574,9 +1348,9 @@ exports[`TreeNodeComponent renders an icon if the node has one 1`] = `
|
||||
>
|
||||
<TreeItemLayout
|
||||
actions={false}
|
||||
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root"
|
||||
iconBefore={
|
||||
expandIcon={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
@@ -1585,7 +1359,7 @@ exports[`TreeNodeComponent renders an icon if the node has one 1`] = `
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
className=""
|
||||
>
|
||||
rootLabel
|
||||
</span>
|
||||
@@ -1603,30 +1377,18 @@ exports[`TreeNodeComponent renders selected parent node as selected if no descen
|
||||
>
|
||||
<TreeItemLayout
|
||||
actions={false}
|
||||
className="___rq9vxg0_1ykn2d2 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1nfm20t f1do9gdl"
|
||||
className="___kqkdor0_ihxn0o0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1nfm20t f1do9gdl"
|
||||
data-test="TreeNode:root"
|
||||
expandIcon={
|
||||
<ChevronRight20Regular
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
/>
|
||||
}
|
||||
iconBefore={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="rootIcon"
|
||||
/>
|
||||
}
|
||||
expandIcon={<ChevronRight20Regular />}
|
||||
>
|
||||
<span
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
className=""
|
||||
>
|
||||
rootLabel
|
||||
</span>
|
||||
</TreeItemLayout>
|
||||
<Tree
|
||||
className="___17a32do_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
|
||||
data-test="Tree:root"
|
||||
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
|
||||
>
|
||||
<TreeNodeComponent
|
||||
key="child1Label"
|
||||
@@ -1686,30 +1448,18 @@ exports[`TreeNodeComponent renders selected parent node as unselected if any des
|
||||
>
|
||||
<TreeItemLayout
|
||||
actions={false}
|
||||
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
|
||||
data-test="TreeNode:root"
|
||||
expandIcon={
|
||||
<ChevronRight20Regular
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
/>
|
||||
}
|
||||
iconBefore={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="rootIcon"
|
||||
/>
|
||||
}
|
||||
expandIcon={<ChevronRight20Regular />}
|
||||
>
|
||||
<span
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
className=""
|
||||
>
|
||||
rootLabel
|
||||
</span>
|
||||
</TreeItemLayout>
|
||||
<Tree
|
||||
className="___17a32do_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
|
||||
data-test="Tree:root"
|
||||
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
|
||||
>
|
||||
<TreeNodeComponent
|
||||
key="child1Label"
|
||||
@@ -1770,9 +1520,9 @@ exports[`TreeNodeComponent renders single selected leaf node as selected 1`] = `
|
||||
>
|
||||
<TreeItemLayout
|
||||
actions={false}
|
||||
className="___rq9vxg0_1ykn2d2 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1nfm20t f1do9gdl"
|
||||
className="___kqkdor0_ihxn0o0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1nfm20t f1do9gdl"
|
||||
data-test="TreeNode:root"
|
||||
iconBefore={
|
||||
expandIcon={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
@@ -1781,7 +1531,7 @@ exports[`TreeNodeComponent renders single selected leaf node as selected 1`] = `
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
className=""
|
||||
>
|
||||
rootLabel
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as msal from "@azure/msal-browser";
|
||||
import { Link } from "@fluentui/react/lib/Link";
|
||||
import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility";
|
||||
import { Environment, getEnvironment } from "Common/EnvironmentUtility";
|
||||
import { sendMessage } from "Common/MessageHandler";
|
||||
import { Platform, configContext } from "ConfigContext";
|
||||
import { MessageTypes } from "Contracts/ExplorerContracts";
|
||||
@@ -10,7 +9,7 @@ import { getCopilotEnabled, isCopilotFeatureRegistered } from "Explorer/QueryCop
|
||||
import { IGalleryItem } from "Juno/JunoClient";
|
||||
import { scheduleRefreshDatabaseResourceToken } from "Platform/Fabric/FabricUtil";
|
||||
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
|
||||
import { acquireMsalTokenForAccount } from "Utils/AuthorizationUtils";
|
||||
import { acquireTokenWithMsal, getMsalInstance } from "Utils/AuthorizationUtils";
|
||||
import { allowedNotebookServerUrls, validateEndpoint } from "Utils/EndpointUtils";
|
||||
import { update } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
|
||||
import { useQueryCopilot } from "hooks/useQueryCopilot";
|
||||
@@ -259,8 +258,25 @@ export default class Explorer {
|
||||
|
||||
public async openLoginForEntraIDPopUp(): Promise<void> {
|
||||
if (userContext.databaseAccount.properties?.documentEndpoint) {
|
||||
const hrefEndpoint = new URL(userContext.databaseAccount.properties.documentEndpoint).href.replace(
|
||||
/\/$/,
|
||||
"/.default",
|
||||
);
|
||||
const msalInstance = await getMsalInstance();
|
||||
|
||||
try {
|
||||
const aadToken = await acquireMsalTokenForAccount(userContext.databaseAccount, false);
|
||||
const response = await msalInstance.loginPopup({
|
||||
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 });
|
||||
useDataPlaneRbac.setState({ aadTokenUpdated: true });
|
||||
} catch (error) {
|
||||
@@ -1103,7 +1119,7 @@ export default class Explorer {
|
||||
}
|
||||
}
|
||||
|
||||
public openUploadItemsPane(): void {
|
||||
public openUploadItemsPanePane(): void {
|
||||
useSidePanel.getState().openSidePanel("Upload " + getUploadName(), <UploadItemsPane />);
|
||||
}
|
||||
public openExecuteSprocParamsPanel(storedProcedure: StoredProcedure): void {
|
||||
@@ -1162,11 +1178,7 @@ export default class Explorer {
|
||||
}
|
||||
|
||||
public async configureCopilot(): Promise<void> {
|
||||
if (
|
||||
userContext.apiType !== "SQL" ||
|
||||
!userContext.subscriptionId ||
|
||||
![Environment.Development, Environment.Mpac, Environment.Prod].includes(getEnvironment())
|
||||
) {
|
||||
if (userContext.apiType !== "SQL" || !userContext.subscriptionId) {
|
||||
return;
|
||||
}
|
||||
const copilotEnabledPromise = getCopilotEnabled();
|
||||
|
||||
@@ -167,11 +167,15 @@ export function createContextCommandBarButtons(
|
||||
}
|
||||
|
||||
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} />),
|
||||
onCommandClick: () =>
|
||||
useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={container} />),
|
||||
commandButtonLabel: undefined,
|
||||
ariaLabel: "Settings",
|
||||
tooltipText: "Settings",
|
||||
|
||||
@@ -131,7 +131,6 @@ export class NotificationConsoleComponent extends React.Component<
|
||||
</div>
|
||||
<div
|
||||
className="expandCollapseButton"
|
||||
data-test="NotificationConsole/ExpandCollapseButton"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={"console button" + (this.props.isConsoleExpanded ? " expanded" : " collapsed")}
|
||||
@@ -148,7 +147,7 @@ export class NotificationConsoleComponent extends React.Component<
|
||||
height={this.props.isConsoleExpanded ? "auto" : 0}
|
||||
onAnimationEnd={this.onConsoleWasExpanded}
|
||||
>
|
||||
<div data-test="NotificationConsole/Contents" className="notificationConsoleContents">
|
||||
<div className="notificationConsoleContents">
|
||||
<div className="notificationConsoleControls">
|
||||
<Dropdown
|
||||
label="Filter:"
|
||||
|
||||
@@ -74,7 +74,6 @@ exports[`NotificationConsoleComponent renders the console 1`] = `
|
||||
aria-expanded={true}
|
||||
aria-label="console button collapsed"
|
||||
className="expandCollapseButton"
|
||||
data-test="NotificationConsole/ExpandCollapseButton"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
@@ -110,7 +109,6 @@ exports[`NotificationConsoleComponent renders the console 1`] = `
|
||||
>
|
||||
<div
|
||||
className="notificationConsoleContents"
|
||||
data-test="NotificationConsole/Contents"
|
||||
>
|
||||
<div
|
||||
className="notificationConsoleControls"
|
||||
@@ -247,7 +245,6 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
|
||||
aria-expanded={true}
|
||||
aria-label="console button collapsed"
|
||||
className="expandCollapseButton"
|
||||
data-test="NotificationConsole/ExpandCollapseButton"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
@@ -283,7 +280,6 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
|
||||
>
|
||||
<div
|
||||
className="notificationConsoleContents"
|
||||
data-test="NotificationConsole/Contents"
|
||||
>
|
||||
<div
|
||||
className="notificationConsoleControls"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { clear, collectionWasOpened, getItems, Type } from "Explorer/MostRecentActivity/MostRecentActivity";
|
||||
import { observable } from "knockout";
|
||||
import { mostRecentActivity } from "./MostRecentActivity";
|
||||
|
||||
describe("MostRecentActivity", () => {
|
||||
const accountName = "some account";
|
||||
const accountId = "some account";
|
||||
|
||||
beforeEach(() => clear(accountName));
|
||||
beforeEach(() => mostRecentActivity.clear(accountId));
|
||||
|
||||
it("Has no items at first", () => {
|
||||
expect(getItems(accountName)).toStrictEqual([]);
|
||||
expect(mostRecentActivity.getItems(accountId)).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("Can record collections being opened", () => {
|
||||
@@ -18,9 +18,9 @@ describe("MostRecentActivity", () => {
|
||||
databaseId,
|
||||
};
|
||||
|
||||
collectionWasOpened(accountName, collection);
|
||||
mostRecentActivity.collectionWasOpened(accountId, collection);
|
||||
|
||||
const activity = getItems(accountName);
|
||||
const activity = mostRecentActivity.getItems(accountId);
|
||||
expect(activity).toEqual([
|
||||
expect.objectContaining({
|
||||
collectionId,
|
||||
@@ -29,24 +29,58 @@ describe("MostRecentActivity", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("Does not store duplicate entries", () => {
|
||||
const collectionId = "some collection";
|
||||
const databaseId = "some database";
|
||||
const collection = {
|
||||
id: observable(collectionId),
|
||||
databaseId,
|
||||
};
|
||||
it("Can record notebooks being opened", () => {
|
||||
const name = "some notebook";
|
||||
const path = "some path";
|
||||
const notebook = { name, path };
|
||||
|
||||
collectionWasOpened(accountName, collection);
|
||||
collectionWasOpened(accountName, collection);
|
||||
mostRecentActivity.notebookWasItemOpened(accountId, notebook);
|
||||
|
||||
const activity = getItems(accountName);
|
||||
expect(activity).toEqual([
|
||||
expect.objectContaining({
|
||||
type: Type.OpenCollection,
|
||||
collectionId,
|
||||
databaseId,
|
||||
}),
|
||||
]);
|
||||
const activity = mostRecentActivity.getItems(accountId);
|
||||
expect(activity).toEqual([expect.objectContaining(notebook)]);
|
||||
});
|
||||
|
||||
it("Filters out duplicates", () => {
|
||||
const name = "some notebook";
|
||||
const path = "some path";
|
||||
const notebook = { name, path };
|
||||
const sameNotebook = { name, path };
|
||||
|
||||
mostRecentActivity.notebookWasItemOpened(accountId, notebook);
|
||||
mostRecentActivity.notebookWasItemOpened(accountId, sameNotebook);
|
||||
|
||||
const activity = mostRecentActivity.getItems(accountId);
|
||||
expect(activity.length).toEqual(1);
|
||||
expect(activity).toEqual([expect.objectContaining(notebook)]);
|
||||
});
|
||||
|
||||
it("Allows for multiple accounts", () => {
|
||||
const name = "some notebook";
|
||||
const path = "some path";
|
||||
const notebook = { name, path };
|
||||
|
||||
const anotherNotebook = { name: "Another " + name, path };
|
||||
const anotherAccountId = "Another " + accountId;
|
||||
|
||||
mostRecentActivity.notebookWasItemOpened(accountId, notebook);
|
||||
mostRecentActivity.notebookWasItemOpened(anotherAccountId, anotherNotebook);
|
||||
|
||||
expect(mostRecentActivity.getItems(accountId)).toEqual([expect.objectContaining(notebook)]);
|
||||
expect(mostRecentActivity.getItems(anotherAccountId)).toEqual([expect.objectContaining(anotherNotebook)]);
|
||||
});
|
||||
|
||||
it("Can store multiple distinct elements, in FIFO order", () => {
|
||||
const name = "some notebook";
|
||||
const path = "some path";
|
||||
const first = { name, path };
|
||||
const second = { name: "Another " + name, path };
|
||||
const third = { name, path: "Another " + path };
|
||||
|
||||
mostRecentActivity.notebookWasItemOpened(accountId, first);
|
||||
mostRecentActivity.notebookWasItemOpened(accountId, second);
|
||||
mostRecentActivity.notebookWasItemOpened(accountId, third);
|
||||
|
||||
const activity = mostRecentActivity.getItems(accountId);
|
||||
expect(activity).toEqual([third, second, first].map(expect.objectContaining));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { AppStateComponentNames, deleteState, loadState, saveState } from "Shared/AppStatePersistenceUtility";
|
||||
import { CollectionBase } from "../../Contracts/ViewModels";
|
||||
import { LocalStorageUtility, StorageKey } from "../../Shared/StorageUtility";
|
||||
import { StorageKey, LocalStorageUtility } from "../../Shared/StorageUtility";
|
||||
import { NotebookContentItem } from "../Notebook/NotebookContentItem";
|
||||
|
||||
export enum Type {
|
||||
OpenCollection = "OpenCollection",
|
||||
OpenNotebook = "OpenNotebook",
|
||||
OpenCollection,
|
||||
OpenNotebook,
|
||||
}
|
||||
|
||||
export interface OpenNotebookItem {
|
||||
@@ -21,174 +21,158 @@ export interface OpenCollectionItem {
|
||||
|
||||
type Item = OpenNotebookItem | OpenCollectionItem;
|
||||
|
||||
const itemsMaxNumber: number = 5;
|
||||
// Update schemaVersion if you are going to change this interface
|
||||
interface StoredData {
|
||||
schemaVersion: string;
|
||||
itemsMap: { [accountId: string]: Item[] }; // FIFO
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate old data to new AppState
|
||||
* Stores most recent activity
|
||||
*/
|
||||
const migrateOldData = () => {
|
||||
class MostRecentActivity {
|
||||
private static readonly schemaVersion: string = "2";
|
||||
private static itemsMaxNumber: number = 5;
|
||||
private storedData: StoredData;
|
||||
constructor() {
|
||||
// Retrieve from local storage
|
||||
if (LocalStorageUtility.hasItem(StorageKey.MostRecentActivity)) {
|
||||
const oldDataSchemaVersion: string = "2";
|
||||
const rawData = LocalStorageUtility.getEntryString(StorageKey.MostRecentActivity);
|
||||
if (rawData) {
|
||||
const oldData = JSON.parse(rawData);
|
||||
if (oldData.schemaVersion === oldDataSchemaVersion) {
|
||||
const itemsMap: Record<string, Item[]> = oldData.itemsMap;
|
||||
Object.keys(itemsMap).forEach((accountId: string) => {
|
||||
const accountName = accountId.split("/").pop();
|
||||
if (accountName) {
|
||||
saveState(
|
||||
{
|
||||
componentName: AppStateComponentNames.MostRecentActivity,
|
||||
globalAccountName: accountName,
|
||||
},
|
||||
itemsMap[accountId].map((item) => {
|
||||
if ((item.type as unknown as number) === 0) {
|
||||
item.type = Type.OpenCollection;
|
||||
} else if ((item.type as unknown as number) === 1) {
|
||||
item.type = Type.OpenNotebook;
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!rawData) {
|
||||
this.storedData = MostRecentActivity.createEmptyData();
|
||||
} else {
|
||||
try {
|
||||
this.storedData = JSON.parse(rawData);
|
||||
} catch (e) {
|
||||
console.error("Unable to parse stored most recent activity. Use empty data:", rawData);
|
||||
this.storedData = MostRecentActivity.createEmptyData();
|
||||
}
|
||||
|
||||
// Remove old data
|
||||
// If version doesn't match or schema broke, nuke it!
|
||||
if (
|
||||
!this.storedData.hasOwnProperty("schemaVersion") ||
|
||||
this.storedData["schemaVersion"] !== MostRecentActivity.schemaVersion
|
||||
) {
|
||||
LocalStorageUtility.removeEntry(StorageKey.MostRecentActivity);
|
||||
this.storedData = MostRecentActivity.createEmptyData();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.storedData = MostRecentActivity.createEmptyData();
|
||||
}
|
||||
|
||||
for (let p in this.storedData.itemsMap) {
|
||||
this.cleanupItems(p);
|
||||
}
|
||||
this.saveToLocalStorage();
|
||||
}
|
||||
|
||||
private static createEmptyData(): StoredData {
|
||||
return {
|
||||
schemaVersion: MostRecentActivity.schemaVersion,
|
||||
itemsMap: {},
|
||||
};
|
||||
}
|
||||
|
||||
private static isEmpty(object: any) {
|
||||
return Object.keys(object).length === 0 && object.constructor === Object;
|
||||
}
|
||||
|
||||
private saveToLocalStorage() {
|
||||
if (MostRecentActivity.isEmpty(this.storedData.itemsMap)) {
|
||||
if (LocalStorageUtility.hasItem(StorageKey.MostRecentActivity)) {
|
||||
LocalStorageUtility.removeEntry(StorageKey.MostRecentActivity);
|
||||
}
|
||||
};
|
||||
// Don't save if empty
|
||||
return;
|
||||
}
|
||||
|
||||
const addItem = (accountName: string, newItem: Item): void => {
|
||||
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;
|
||||
// }
|
||||
|
||||
let items =
|
||||
(loadState({
|
||||
componentName: AppStateComponentNames.MostRecentActivity,
|
||||
globalAccountName: accountName,
|
||||
}) as Item[]) || [];
|
||||
|
||||
// Remove duplicate
|
||||
items = removeDuplicate(newItem, items);
|
||||
MostRecentActivity.removeDuplicate(newItem, this.storedData.itemsMap[accountId]);
|
||||
|
||||
items.unshift(newItem);
|
||||
items = cleanupItems(items, accountName);
|
||||
saveState(
|
||||
{
|
||||
componentName: AppStateComponentNames.MostRecentActivity,
|
||||
globalAccountName: accountName,
|
||||
},
|
||||
items,
|
||||
);
|
||||
};
|
||||
|
||||
export const getItems = (accountName: string): Item[] => {
|
||||
if (!accountName) {
|
||||
return [];
|
||||
this.storedData.itemsMap[accountId] = this.storedData.itemsMap[accountId] || [];
|
||||
this.storedData.itemsMap[accountId].unshift(newItem);
|
||||
this.cleanupItems(accountId);
|
||||
this.saveToLocalStorage();
|
||||
}
|
||||
|
||||
return (
|
||||
(loadState({
|
||||
componentName: AppStateComponentNames.MostRecentActivity,
|
||||
globalAccountName: accountName,
|
||||
}) as Item[]) || []
|
||||
);
|
||||
};
|
||||
|
||||
export const collectionWasOpened = (
|
||||
accountName: string,
|
||||
{ id, databaseId }: Pick<CollectionBase, "id" | "databaseId">,
|
||||
) => {
|
||||
if (accountName === undefined) {
|
||||
return;
|
||||
public getItems(accountId: string): Item[] {
|
||||
return this.storedData.itemsMap[accountId] || [];
|
||||
}
|
||||
|
||||
public collectionWasOpened(accountId: string, { id, databaseId }: Pick<CollectionBase, "id" | "databaseId">) {
|
||||
const collectionId = id();
|
||||
addItem(accountName, {
|
||||
this.addItem(accountId, {
|
||||
type: Type.OpenCollection,
|
||||
databaseId,
|
||||
collectionId,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const clear = (accountName: string): void => {
|
||||
if (!accountName) {
|
||||
public notebookWasItemOpened(accountId: string, { name, path }: Pick<NotebookContentItem, "name" | "path">) {
|
||||
this.addItem(accountId, {
|
||||
type: Type.OpenNotebook,
|
||||
name,
|
||||
path,
|
||||
});
|
||||
}
|
||||
|
||||
public clear(accountId: string): void {
|
||||
delete this.storedData.itemsMap[accountId];
|
||||
this.saveToLocalStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find items by doing strict comparison and remove from array if duplicate is found
|
||||
* @param item
|
||||
*/
|
||||
private static removeDuplicate(item: Item, itemsArray: Item[]): void {
|
||||
if (!itemsArray) {
|
||||
return;
|
||||
}
|
||||
|
||||
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>))
|
||||
) {
|
||||
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) {
|
||||
result.splice(index, 1);
|
||||
itemsArray.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove unknown types
|
||||
* Limit items to max number
|
||||
* Modifies the array.
|
||||
*/
|
||||
const cleanupItems = (items: Item[], accountName: string): Item[] => {
|
||||
if (accountName === undefined) {
|
||||
return [];
|
||||
private cleanupItems(accountId: string): void {
|
||||
if (!this.storedData.itemsMap.hasOwnProperty(accountId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemsArray = items.filter((item) => item.type in Type).slice(0, itemsMaxNumber);
|
||||
const itemsArray = this.storedData.itemsMap[accountId]
|
||||
.filter((item) => item.type in Type)
|
||||
.slice(0, MostRecentActivity.itemsMaxNumber);
|
||||
if (itemsArray.length === 0) {
|
||||
deleteState({
|
||||
componentName: AppStateComponentNames.MostRecentActivity,
|
||||
globalAccountName: accountName,
|
||||
});
|
||||
delete this.storedData.itemsMap[accountId];
|
||||
} else {
|
||||
this.storedData.itemsMap[accountId] = itemsArray;
|
||||
}
|
||||
}
|
||||
}
|
||||
return itemsArray;
|
||||
};
|
||||
|
||||
migrateOldData();
|
||||
export const mostRecentActivity = new MostRecentActivity();
|
||||
|
||||
@@ -127,7 +127,7 @@ export const useNotebook: UseStore<NotebookState> = create((set, get) => ({
|
||||
userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo"
|
||||
? databaseAccount?.location
|
||||
: databaseAccount?.properties?.writeLocations?.[0]?.locationName.toLowerCase();
|
||||
const disallowedLocationsUri: string = `${configContext.PORTAL_BACKEND_ENDPOINT}/api/disallowedlocations`;
|
||||
const disallowedLocationsUri = `${configContext.BACKEND_ENDPOINT}/api/disallowedLocations`;
|
||||
const authorizationHeader = getAuthorizationHeader();
|
||||
try {
|
||||
const response = await fetch(disallowedLocationsUri, {
|
||||
|
||||
@@ -17,10 +17,10 @@ import {
|
||||
} from "@fluentui/react";
|
||||
import * as Constants from "Common/Constants";
|
||||
import { createCollection } from "Common/dataAccess/createCollection";
|
||||
import { getNewDatabaseSharedThroughputDefault } from "Common/DatabaseUtility";
|
||||
import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
|
||||
import { configContext, Platform } from "ConfigContext";
|
||||
import * as DataModels from "Contracts/DataModels";
|
||||
import { SubscriptionType } from "Contracts/SubscriptionType";
|
||||
import { AddVectorEmbeddingPolicyForm } from "Explorer/Panes/VectorSearchPanel/AddVectorEmbeddingPolicyForm";
|
||||
import { useSidePanel } from "hooks/useSidePanel";
|
||||
import { useTeachingBubble } from "hooks/useTeachingBubble";
|
||||
@@ -125,7 +125,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
createNewDatabase:
|
||||
userContext.apiType !== "Tables" && configContext.platform !== Platform.Fabric && !this.props.databaseId,
|
||||
newDatabaseId: props.isQuickstart ? this.getSampleDBName() : "",
|
||||
isSharedThroughputChecked: getNewDatabaseSharedThroughputDefault(),
|
||||
isSharedThroughputChecked: this.getSharedThroughputDefault(),
|
||||
selectedDatabaseId:
|
||||
userContext.apiType === "Tables" ? CollectionCreation.TablesAPIDefaultDatabase : this.props.databaseId,
|
||||
collectionId: props.isQuickstart ? `Sample${getCollectionName()}` : "",
|
||||
@@ -1138,6 +1138,10 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
return userContext.databaseAccount?.properties?.enableFreeTier;
|
||||
}
|
||||
|
||||
private getSharedThroughputDefault(): boolean {
|
||||
return userContext.subscriptionType !== SubscriptionType.EA && !isServerlessAccount();
|
||||
}
|
||||
|
||||
private getFreeTierIndexingText(): string {
|
||||
return this.state.enableIndexing
|
||||
? "All properties in your documents will be indexed by default for flexible and efficient queries."
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Checkbox, Stack, Text, TextField } from "@fluentui/react";
|
||||
import { getNewDatabaseSharedThroughputDefault } from "Common/DatabaseUtility";
|
||||
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||
import * as Constants from "../../../Common/Constants";
|
||||
import { getErrorMessage, getErrorStack } from "../../../Common/ErrorHandlingUtils";
|
||||
@@ -49,7 +48,7 @@ export const AddDatabasePanel: FunctionComponent<AddDatabasePaneProps> = ({
|
||||
|
||||
const databaseLevelThroughputTooltipText = `Provisioned throughput at the ${databaseLabel} level will be shared across all ${collectionsLabel} within the ${databaseLabel}.`;
|
||||
const [databaseCreateNewShared, setDatabaseCreateNewShared] = useState<boolean>(
|
||||
getNewDatabaseSharedThroughputDefault(),
|
||||
subscriptionType !== SubscriptionType.EA && !isServerlessAccount(),
|
||||
);
|
||||
const [formErrors, setFormErrors] = useState<string>("");
|
||||
const [isExecuting, setIsExecuting] = useState<boolean>(false);
|
||||
|
||||
@@ -65,7 +65,7 @@ exports[`AddDatabasePane Pane should render Default properly 1`] = `
|
||||
horizontal={true}
|
||||
>
|
||||
<StyledCheckboxBase
|
||||
checked={false}
|
||||
checked={true}
|
||||
label="Provision throughput"
|
||||
onChange={[Function]}
|
||||
styles={
|
||||
@@ -90,6 +90,14 @@ exports[`AddDatabasePane Pane should render Default properly 1`] = `
|
||||
</InfoTooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<ThroughputInput
|
||||
isDatabase={true}
|
||||
isSharded={true}
|
||||
onCostAcknowledgeChange={[Function]}
|
||||
setIsAutoscale={[Function]}
|
||||
setIsThroughputCapExceeded={[Function]}
|
||||
setThroughputValue={[Function]}
|
||||
/>
|
||||
</div>
|
||||
</RightPaneForm>
|
||||
`;
|
||||
|
||||
@@ -124,7 +124,7 @@ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseCo
|
||||
message:
|
||||
"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 (name)`;
|
||||
const confirmDatabase = `Confirm by typing the ${getDatabaseName()} id`;
|
||||
const reasonInfo = `Help us improve Azure Cosmos DB! What is the reason why you are deleting this ${getDatabaseName()}?`;
|
||||
return (
|
||||
<RightPaneForm {...props}>
|
||||
@@ -132,7 +132,7 @@ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseCo
|
||||
<div className="panelMainContent">
|
||||
<div className="confirmDeleteInput">
|
||||
<span className="mandatoryStar">* </span>
|
||||
<Text variant="small">{confirmDatabase}</Text>
|
||||
<Text variant="small">Confirm by typing the {getDatabaseName()} id</Text>
|
||||
<TextField
|
||||
id="confirmDatabaseId"
|
||||
data-test="Input:confirmDatabaseId"
|
||||
|
||||
@@ -1,27 +1,22 @@
|
||||
import {
|
||||
AuthError as msalAuthError,
|
||||
BrowserAuthErrorMessage as msalBrowserAuthErrorMessage,
|
||||
} from "@azure/msal-browser";
|
||||
import {
|
||||
Checkbox,
|
||||
ChoiceGroup,
|
||||
DefaultButton,
|
||||
IChoiceGroupOption,
|
||||
ISpinButtonStyles,
|
||||
IToggleStyles,
|
||||
Icon,
|
||||
MessageBar,
|
||||
MessageBarType,
|
||||
Position,
|
||||
SpinButton,
|
||||
Toggle,
|
||||
TooltipHost,
|
||||
} from "@fluentui/react";
|
||||
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, makeStyles } from "@fluentui/react-components";
|
||||
import { AuthType } from "AuthType";
|
||||
import * as Constants from "Common/Constants";
|
||||
import { SplitterDirection } from "Common/Splitter";
|
||||
import { InfoTooltip } from "Common/Tooltip/InfoTooltip";
|
||||
import { Platform, configContext } from "ConfigContext";
|
||||
import { useDialog } from "Explorer/Controls/Dialog";
|
||||
import { useDatabases } from "Explorer/useDatabases";
|
||||
import { deleteAllStates } from "Shared/AppStatePersistenceUtility";
|
||||
import {
|
||||
DefaultRUThreshold,
|
||||
LocalStorageUtility,
|
||||
@@ -32,16 +27,16 @@ import {
|
||||
} from "Shared/StorageUtility";
|
||||
import * as StringUtility from "Shared/StringUtility";
|
||||
import { updateUserContext, userContext } from "UserContext";
|
||||
import { acquireMsalTokenForAccount } from "Utils/AuthorizationUtils";
|
||||
import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils";
|
||||
import * as PriorityBasedExecutionUtils from "Utils/PriorityBasedExecutionUtils";
|
||||
import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
|
||||
import { useQueryCopilot } from "hooks/useQueryCopilot";
|
||||
import { useSidePanel } from "hooks/useSidePanel";
|
||||
import React, { FunctionComponent, useState } from "react";
|
||||
import create, { UseStore } from "zustand";
|
||||
import Explorer from "../../Explorer";
|
||||
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
|
||||
import { AuthType } from "AuthType";
|
||||
import create, { UseStore } from "zustand";
|
||||
import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
|
||||
|
||||
export interface DataPlaneRbacState {
|
||||
dataPlaneRbacEnabled: boolean;
|
||||
@@ -55,39 +50,6 @@ export interface DataPlaneRbacState {
|
||||
|
||||
type DataPlaneRbacStore = UseStore<Partial<DataPlaneRbacState>>;
|
||||
|
||||
const useStyles = makeStyles({
|
||||
bulletList: {
|
||||
listStyleType: "disc",
|
||||
paddingLeft: "20px",
|
||||
},
|
||||
container: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
},
|
||||
firstItem: {
|
||||
flex: "1",
|
||||
},
|
||||
header: {
|
||||
marginRight: "5px",
|
||||
},
|
||||
headerIcon: {
|
||||
paddingTop: "4px",
|
||||
cursor: "pointer",
|
||||
},
|
||||
settingsSectionContainer: {
|
||||
paddingLeft: "15px",
|
||||
},
|
||||
settingsSectionDescription: {
|
||||
paddingBottom: "10px",
|
||||
fontSize: "12px",
|
||||
},
|
||||
subHeader: {
|
||||
marginRight: "5px",
|
||||
fontSize: "12px",
|
||||
},
|
||||
});
|
||||
|
||||
export const useDataPlaneRbac: DataPlaneRbacStore = create(() => ({
|
||||
dataPlaneRbacEnabled: false,
|
||||
}));
|
||||
@@ -111,6 +73,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
? LocalStorageUtility.getEntryString(StorageKey.DataPlaneRbacEnabled)
|
||||
: Constants.RBACOptions.setAutomaticRBACOption,
|
||||
);
|
||||
const [showDataPlaneRBACWarning, setShowDataPlaneRBACWarning] = useState<boolean>(false);
|
||||
|
||||
const [ruThresholdEnabled, setRUThresholdEnabled] = useState<boolean>(isRUThresholdEnabled());
|
||||
const [ruThreshold, setRUThreshold] = useState<number>(getRUThreshold());
|
||||
@@ -170,9 +133,6 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
const [copilotSampleDBEnabled, setCopilotSampleDBEnabled] = useState<boolean>(
|
||||
LocalStorageUtility.getEntryString(StorageKey.CopilotSampleDBEnabled) === "true",
|
||||
);
|
||||
|
||||
const styles = useStyles();
|
||||
|
||||
const explorerVersion = configContext.gitSha;
|
||||
const shouldShowQueryPageOptions = userContext.apiType === "SQL";
|
||||
const shouldShowGraphAutoVizOption = userContext.apiType === "Gremlin";
|
||||
@@ -193,7 +153,6 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
|
||||
LocalStorageUtility.setEntryNumber(StorageKey.CustomItemPerPage, customItemPerPage);
|
||||
|
||||
if (configContext.platform !== Platform.Fabric) {
|
||||
LocalStorageUtility.setEntryString(StorageKey.DataPlaneRbacEnabled, enableDataPlaneRBACOption);
|
||||
if (
|
||||
enableDataPlaneRBACOption === Constants.RBACOptions.setTrueRBACOption ||
|
||||
@@ -205,24 +164,6 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
hasDataPlaneRbacSettingChanged: true,
|
||||
});
|
||||
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: true });
|
||||
try {
|
||||
const aadToken = await acquireMsalTokenForAccount(userContext.databaseAccount, true);
|
||||
updateUserContext({ aadToken: aadToken });
|
||||
useDataPlaneRbac.setState({ aadTokenUpdated: true });
|
||||
} catch (authError) {
|
||||
if (
|
||||
authError instanceof msalAuthError &&
|
||||
authError.errorCode === msalBrowserAuthErrorMessage.popUpWindowError.code
|
||||
) {
|
||||
logConsoleError(
|
||||
`We were unable to establish authorization for this account, due to pop-ups being disabled in the browser.\nPlease enable pop-ups for this site and click on "Login for Entra ID" button`,
|
||||
);
|
||||
} else {
|
||||
logConsoleError(
|
||||
`"Failed to acquire authorization token automatically. Please click on "Login for Entra ID" button to enable Entra ID RBAC operations`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
updateUserContext({
|
||||
dataPlaneRbacEnabled: false,
|
||||
@@ -251,7 +192,6 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LocalStorageUtility.setEntryBoolean(StorageKey.RUThresholdEnabled, ruThresholdEnabled);
|
||||
LocalStorageUtility.setEntryBoolean(StorageKey.QueryTimeoutEnabled, queryTimeoutEnabled);
|
||||
@@ -367,6 +307,13 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
option: IChoiceGroupOption,
|
||||
): void => {
|
||||
setEnableDataPlaneRBACOption(option.key);
|
||||
|
||||
const shouldShowWarning =
|
||||
(option.key === Constants.RBACOptions.setTrueRBACOption ||
|
||||
(option.key === Constants.RBACOptions.setAutomaticRBACOption &&
|
||||
userContext.databaseAccount.properties.disableLocalAuth === true)) &&
|
||||
!useDataPlaneRbac.getState().aadTokenUpdated;
|
||||
setShowDataPlaneRBACWarning(shouldShowWarning);
|
||||
};
|
||||
|
||||
const handleOnRUThresholdToggleChange = (ev: React.MouseEvent<HTMLElement>, checked?: boolean): void => {
|
||||
@@ -481,19 +428,18 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
|
||||
return (
|
||||
<RightPaneForm {...genericPaneProps}>
|
||||
<div className={`paneMainContent ${styles.container}`}>
|
||||
<Accordion className={styles.firstItem}>
|
||||
<div className="paneMainContent">
|
||||
{shouldShowQueryPageOptions && (
|
||||
<AccordionItem value="1">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Page Options</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Choose Custom to specify a fixed amount of query results to show, or choose Unlimited to show as
|
||||
many query results per page.
|
||||
</div>
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart">
|
||||
<fieldset>
|
||||
<legend id="pageOptions" className="settingsSectionLabel legendLabel">
|
||||
Page Options
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
Choose Custom to specify a fixed amount of query results to show, or choose Unlimited to show as many
|
||||
query results per page.
|
||||
</InfoTooltip>
|
||||
<ChoiceGroup
|
||||
ariaLabelledBy="pageOptions"
|
||||
selectedKey={pageOption}
|
||||
@@ -501,15 +447,14 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
styles={choiceButtonStyles}
|
||||
onChange={handleOnPageOptionChange}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className={`tabs ${styles.settingsSectionContainer}`}>
|
||||
<div className="tabs settingsSectionPart">
|
||||
{isCustomPageOptionSelected() && (
|
||||
<div className="tabcontent">
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Query results per page{" "}
|
||||
<InfoTooltip className={styles.headerIcon}>
|
||||
Enter the number of query results that should be shown per page.
|
||||
</InfoTooltip>
|
||||
<div className="settingsSectionLabel">
|
||||
Query results per page
|
||||
<InfoTooltip>Enter the number of query results that should be shown per page.</InfoTooltip>
|
||||
</div>
|
||||
|
||||
<SpinButton
|
||||
@@ -529,19 +474,19 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
)}
|
||||
{userContext.apiType === "SQL" &&
|
||||
userContext.authType === AuthType.AAD &&
|
||||
configContext.platform !== Platform.Fabric && (
|
||||
<AccordionItem value="2">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Enable Entra ID RBAC</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
{userContext.apiType === "SQL" && userContext.authType === AuthType.AAD && (
|
||||
<>
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart">
|
||||
<fieldset>
|
||||
<legend id="enableDataPlaneRBACOptions" className="settingsSectionLabel legendLabel">
|
||||
Enable Entra ID RBAC
|
||||
</legend>
|
||||
<TooltipHost
|
||||
content={
|
||||
<>
|
||||
Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable Entra
|
||||
ID RBAC.
|
||||
<a
|
||||
@@ -552,7 +497,22 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
{" "}
|
||||
Learn more{" "}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Icon iconName="Info" ariaLabel="Info tooltip" className="panelInfoIcon" tabIndex={0} />
|
||||
</TooltipHost>
|
||||
{showDataPlaneRBACWarning && configContext.platform === Platform.Portal && (
|
||||
<MessageBar
|
||||
messageBarType={MessageBarType.warning}
|
||||
isMultiline={true}
|
||||
onDismiss={() => setShowDataPlaneRBACWarning(false)}
|
||||
dismissButtonAriaLabel="Close"
|
||||
>
|
||||
Please click on "Login for Entra ID RBAC" button prior to performing Entra ID RBAC
|
||||
operations
|
||||
</MessageBar>
|
||||
)}
|
||||
<ChoiceGroup
|
||||
ariaLabelledBy="enableDataPlaneRBACOptions"
|
||||
options={dataPlaneRBACOptionsList}
|
||||
@@ -560,23 +520,58 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
selectedKey={enableDataPlaneRBACOption}
|
||||
onChange={handleOnDataPlaneRBACOptionChange}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{userContext.apiType === "SQL" && (
|
||||
<>
|
||||
<AccordionItem value="3">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Query Timeout</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
When a query reaches a specified time limit, a popup with an option to cancel the query will show
|
||||
unless automatic cancellation has been enabled.
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart">
|
||||
<div>
|
||||
<legend id="ruThresholdLabel" className="settingsSectionLabel legendLabel">
|
||||
RU Threshold
|
||||
</legend>
|
||||
<InfoTooltip>If a query exceeds a configured RU threshold, the query will be aborted.</InfoTooltip>
|
||||
</div>
|
||||
<div>
|
||||
<Toggle
|
||||
styles={toggleStyles}
|
||||
label="Enable RU threshold"
|
||||
onChange={handleOnRUThresholdToggleChange}
|
||||
defaultChecked={ruThresholdEnabled}
|
||||
/>
|
||||
</div>
|
||||
{ruThresholdEnabled && (
|
||||
<div>
|
||||
<SpinButton
|
||||
label="RU Threshold (RU)"
|
||||
labelPosition={Position.top}
|
||||
defaultValue={(ruThreshold || DefaultRUThreshold).toString()}
|
||||
min={1}
|
||||
step={1000}
|
||||
onChange={handleOnRUThresholdSpinButtonChange}
|
||||
incrementButtonAriaLabel="Increase value by 1000"
|
||||
decrementButtonAriaLabel="Decrease value by 1000"
|
||||
styles={spinButtonStyles}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart">
|
||||
<div>
|
||||
<legend id="queryTimeoutLabel" className="settingsSectionLabel legendLabel">
|
||||
Query Timeout
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
When a query reaches a specified time limit, a popup with an option to cancel the query will show
|
||||
unless automatic cancellation has been enabled
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<div>
|
||||
<Toggle
|
||||
styles={toggleStyles}
|
||||
label="Enable query timeout"
|
||||
@@ -585,7 +580,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
/>
|
||||
</div>
|
||||
{queryTimeoutEnabled && (
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div>
|
||||
<SpinButton
|
||||
label="Query timeout (ms)"
|
||||
labelPosition={Position.top}
|
||||
@@ -605,52 +600,17 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="4">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>RU Limit</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
If a query exceeds a configured RU limit, the query will be aborted.
|
||||
</div>
|
||||
<Toggle
|
||||
styles={toggleStyles}
|
||||
label="Enable RU limit"
|
||||
onChange={handleOnRUThresholdToggleChange}
|
||||
defaultChecked={ruThresholdEnabled}
|
||||
/>
|
||||
</div>
|
||||
{ruThresholdEnabled && (
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<SpinButton
|
||||
label="RU Limit (RU)"
|
||||
labelPosition={Position.top}
|
||||
defaultValue={(ruThreshold || DefaultRUThreshold).toString()}
|
||||
min={1}
|
||||
step={1000}
|
||||
onChange={handleOnRUThresholdSpinButtonChange}
|
||||
incrementButtonAriaLabel="Increase value by 1000"
|
||||
decrementButtonAriaLabel="Decrease value by 1000"
|
||||
styles={spinButtonStyles}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="5">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Default Query Results View</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Select the default view to use when displaying query results.
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart">
|
||||
<div>
|
||||
<legend id="defaultQueryResultsView" className="settingsSectionLabel legendLabel">
|
||||
Default Query Results View
|
||||
</legend>
|
||||
<InfoTooltip>Select the default view to use when displaying query results.</InfoTooltip>
|
||||
</div>
|
||||
<div>
|
||||
<ChoiceGroup
|
||||
ariaLabelledBy="defaultQueryResultsView"
|
||||
selectedKey={defaultQueryResultsView}
|
||||
@@ -659,25 +619,21 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
onChange={handleOnDefaultQueryResultsViewChange}
|
||||
/>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<AccordionItem value="6">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Retry Settings</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Retry policy associated with throttled requests during CosmosDB queries.
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart">
|
||||
<div className="settingsSectionLabel">
|
||||
Retry Settings
|
||||
<InfoTooltip>Retry policy associated with throttled requests during CosmosDB queries.</InfoTooltip>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.subHeader}>Max retry attempts</span>
|
||||
<InfoTooltip className={styles.headerIcon}>
|
||||
Max number of retries to be performed for a request. Default value 9.
|
||||
</InfoTooltip>
|
||||
<legend id="queryRetryAttemptsLabel" className="settingsSectionLabel legendLabel">
|
||||
Max retry attempts
|
||||
</legend>
|
||||
<InfoTooltip>Max number of retries to be performed for a request. Default value 9.</InfoTooltip>
|
||||
</div>
|
||||
<SpinButton
|
||||
labelPosition={Position.top}
|
||||
@@ -693,10 +649,12 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
styles={spinButtonStyles}
|
||||
/>
|
||||
<div>
|
||||
<span className={styles.subHeader}>Fixed retry interval (ms)</span>
|
||||
<InfoTooltip className={styles.headerIcon}>
|
||||
Fixed retry interval in milliseconds to wait between each retry ignoring the retryAfter returned as
|
||||
part of the response. Default value is 0 milliseconds.
|
||||
<legend id="queryRetryAttemptsLabel" className="settingsSectionLabel legendLabel">
|
||||
Fixed retry interval (ms)
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
Fixed retry interval in milliseconds to wait between each retry ignoring the retryAfter returned as part
|
||||
of the response. Default value is 0 milliseconds.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<SpinButton
|
||||
@@ -713,8 +671,10 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
styles={spinButtonStyles}
|
||||
/>
|
||||
<div>
|
||||
<span className={styles.subHeader}>Max wait time (s)</span>
|
||||
<InfoTooltip className={styles.headerIcon}>
|
||||
<legend id="queryRetryAttemptsLabel" className="settingsSectionLabel legendLabel">
|
||||
Max wait time (s)
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
Max wait time in seconds to wait for a request while the retries are happening. Default value 30
|
||||
seconds.
|
||||
</InfoTooltip>
|
||||
@@ -733,17 +693,14 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
styles={spinButtonStyles}
|
||||
/>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="7">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Enable container pagination</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
</div>
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart settingsSectionInlineCheckbox">
|
||||
<div className="settingsSectionLabel">
|
||||
Enable container pagination
|
||||
<InfoTooltip>
|
||||
Load 50 containers at a time. Currently, containers are not pulled in alphanumeric order.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<Checkbox
|
||||
styles={{
|
||||
@@ -753,23 +710,20 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
ariaLabel="Enable container pagination"
|
||||
checked={containerPaginationEnabled}
|
||||
onChange={() => setContainerPaginationEnabled(!containerPaginationEnabled)}
|
||||
label="Enable container pagination"
|
||||
/>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
{shouldShowCrossPartitionOption && (
|
||||
<AccordionItem value="8">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Enable cross-partition query</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Send more than one request while executing a query. More than one request is necessary if the query
|
||||
is not scoped to single partition key value.
|
||||
</div>
|
||||
{shouldShowCrossPartitionOption && (
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart settingsSectionInlineCheckbox">
|
||||
<div className="settingsSectionLabel">
|
||||
Enable cross-partition query
|
||||
<InfoTooltip>
|
||||
Send more than one request while executing a query. More than one request is necessary if the query is
|
||||
not scoped to single partition key value.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
|
||||
<Checkbox
|
||||
styles={{
|
||||
label: { padding: 0 },
|
||||
@@ -778,25 +732,22 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
ariaLabel="Enable cross partition query"
|
||||
checked={crossPartitionQueryEnabled}
|
||||
onChange={() => setCrossPartitionQueryEnabled(!crossPartitionQueryEnabled)}
|
||||
label="Enable cross-partition query"
|
||||
/>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShowParallelismOption && (
|
||||
<AccordionItem value="9">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Max degree of parallelism</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart">
|
||||
<div className="settingsSectionLabel">
|
||||
Max degree of parallelism
|
||||
<InfoTooltip>
|
||||
Gets or sets the number of concurrent operations run client side during parallel query execution. A
|
||||
positive property value limits the number of concurrent operations to the set value. If it is set to
|
||||
less than 0, the system automatically decides the number of concurrent operations to run.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
|
||||
<SpinButton
|
||||
min={-1}
|
||||
step={1}
|
||||
@@ -804,33 +755,26 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
role="textbox"
|
||||
id="max-degree"
|
||||
value={"" + maxDegreeOfParallelism}
|
||||
onIncrement={(newValue) =>
|
||||
setMaxDegreeOfParallelism(parseInt(newValue) + 1 || maxDegreeOfParallelism)
|
||||
}
|
||||
onDecrement={(newValue) =>
|
||||
setMaxDegreeOfParallelism(parseInt(newValue) - 1 || maxDegreeOfParallelism)
|
||||
}
|
||||
onIncrement={(newValue) => setMaxDegreeOfParallelism(parseInt(newValue) + 1 || maxDegreeOfParallelism)}
|
||||
onDecrement={(newValue) => setMaxDegreeOfParallelism(parseInt(newValue) - 1 || maxDegreeOfParallelism)}
|
||||
onValidate={(newValue) => setMaxDegreeOfParallelism(parseInt(newValue) || maxDegreeOfParallelism)}
|
||||
ariaLabel="Max degree of parallelism"
|
||||
label="Max degree of parallelism"
|
||||
/>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShowPriorityLevelOption && (
|
||||
<AccordionItem value="10">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Priority Level</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart">
|
||||
<fieldset>
|
||||
<legend id="priorityLevel" className="settingsSectionLabel legendLabel">
|
||||
Priority Level
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
Sets the priority level for data-plane requests from Data Explorer when using Priority-Based
|
||||
Execution. If "None" is selected, Data Explorer will not specify priority level, and the
|
||||
server-side default priority level will be used.
|
||||
</div>
|
||||
</InfoTooltip>
|
||||
<ChoiceGroup
|
||||
ariaLabelledBy="priorityLevel"
|
||||
selectedKey={priorityLevel}
|
||||
@@ -838,22 +782,21 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
styles={choiceButtonStyles}
|
||||
onChange={handleOnPriorityLevelOptionChange}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
{shouldShowGraphAutoVizOption && (
|
||||
<AccordionItem value="11">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Display Gremlin query results as: </div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Select Graph to automatically visualize the query results as a Graph or JSON to display the results
|
||||
as JSON.
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart">
|
||||
<div className="settingsSectionLabel">
|
||||
Display Gremlin query results as:
|
||||
<InfoTooltip>
|
||||
Select Graph to automatically visualize the query results as a Graph or JSON to display the results as
|
||||
JSON.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
|
||||
<ChoiceGroup
|
||||
selectedKey={graphAutoVizDisabled}
|
||||
options={graphAutoOptionList}
|
||||
@@ -861,22 +804,20 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
aria-label="Graph Auto-visualization"
|
||||
/>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
{shouldShowCopilotSampleDBOption && (
|
||||
<AccordionItem value="12">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Enable sample database</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
This is a sample database and collection with synthetic product data you can use to explore using
|
||||
NoSQL queries and Query Advisor. This will appear as another database in the Data Explorer UI, and
|
||||
is created by, and maintained by Microsoft at no cost to you.
|
||||
</div>
|
||||
)}
|
||||
{shouldShowCopilotSampleDBOption && (
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart settingsSectionInlineCheckbox">
|
||||
<div className="settingsSectionLabel">
|
||||
Enable sample database
|
||||
<InfoTooltip>
|
||||
This is a sample database and collection with synthetic product data you can use to explore using
|
||||
NoSQL queries and Query Advisor. This will appear as another database in the Data Explorer UI, and is
|
||||
created by, and maintained by Microsoft at no cost to you.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
|
||||
<Checkbox
|
||||
styles={{
|
||||
label: { padding: 0 },
|
||||
@@ -885,42 +826,10 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
ariaLabel="Enable sample db for Query Advisor"
|
||||
checked={copilotSampleDBEnabled}
|
||||
onChange={handleSampleDatabaseChange}
|
||||
label="Enable sample database"
|
||||
/>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
)}
|
||||
</Accordion>
|
||||
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart">
|
||||
<DefaultButton
|
||||
onClick={() => {
|
||||
useDialog.getState().showOkCancelModalDialog(
|
||||
"Clear History",
|
||||
undefined,
|
||||
"Are you sure you want to proceed?",
|
||||
() => deleteAllStates(),
|
||||
"Cancel",
|
||||
undefined,
|
||||
<>
|
||||
<span>
|
||||
This action will clear the all customizations for this account in this browser, including:
|
||||
</span>
|
||||
<ul className={styles.bulletList}>
|
||||
<li>Reset your customized tab layout, including the splitter positions</li>
|
||||
<li>Erase your table column preferences, including any custom columns</li>
|
||||
<li>Clear your filter history</li>
|
||||
</ul>
|
||||
</>,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Clear History
|
||||
</DefaultButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart">
|
||||
<div className="settingsSectionLabel">Explorer Version</div>
|
||||
|
||||
@@ -8,30 +8,24 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
submitButtonText="Apply"
|
||||
>
|
||||
<div
|
||||
className="paneMainContent ___133e6fg_0000000 f22iagw f1vx9l62 f1l02sjl"
|
||||
className="paneMainContent"
|
||||
>
|
||||
<Accordion
|
||||
className="___1uf6361_0000000 fz7g6wx"
|
||||
>
|
||||
<AccordionItem
|
||||
value="1"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
className="___15c001r_0000000 fq02s40"
|
||||
className="settingsSection"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionPart"
|
||||
>
|
||||
<fieldset>
|
||||
<legend
|
||||
className="settingsSectionLabel legendLabel"
|
||||
id="pageOptions"
|
||||
>
|
||||
Page Options
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div
|
||||
className="___1dfa554_0000000 fo7qwa0"
|
||||
>
|
||||
<div
|
||||
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
|
||||
>
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
Choose Custom to specify a fixed amount of query results to show, or choose Unlimited to show as many query results per page.
|
||||
</div>
|
||||
</InfoTooltip>
|
||||
<StyledChoiceGroup
|
||||
ariaLabelledBy="pageOptions"
|
||||
onChange={[Function]}
|
||||
@@ -72,21 +66,19 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
}
|
||||
}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div
|
||||
className="tabs ___1dfa554_0000000 fo7qwa0"
|
||||
className="tabs settingsSectionPart"
|
||||
>
|
||||
<div
|
||||
className="tabcontent"
|
||||
>
|
||||
<div
|
||||
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
|
||||
className="settingsSectionLabel"
|
||||
>
|
||||
Query results per page
|
||||
|
||||
<InfoTooltip
|
||||
className="___vtc5hy0_0000000 f10ra9hq f1k6fduh"
|
||||
>
|
||||
<InfoTooltip>
|
||||
Enter the number of query results that should be shown per page.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
@@ -104,71 +96,28 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="3"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
className="___15c001r_0000000 fq02s40"
|
||||
>
|
||||
Query Timeout
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div
|
||||
className="___1dfa554_0000000 fo7qwa0"
|
||||
className="settingsSection"
|
||||
>
|
||||
<div
|
||||
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
|
||||
className="settingsSectionPart"
|
||||
>
|
||||
When a query reaches a specified time limit, a popup with an option to cancel the query will show unless automatic cancellation has been enabled.
|
||||
</div>
|
||||
<StyledToggleBase
|
||||
defaultChecked={false}
|
||||
label="Enable query timeout"
|
||||
onChange={[Function]}
|
||||
styles={
|
||||
{
|
||||
"container": {},
|
||||
"label": {
|
||||
"display": "block",
|
||||
"fontSize": 12,
|
||||
"fontWeight": 400,
|
||||
},
|
||||
"pill": {},
|
||||
"root": {},
|
||||
"text": {},
|
||||
"thumb": {},
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="4"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
className="___15c001r_0000000 fq02s40"
|
||||
>
|
||||
RU Limit
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div
|
||||
className="___1dfa554_0000000 fo7qwa0"
|
||||
>
|
||||
<div
|
||||
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
|
||||
>
|
||||
If a query exceeds a configured RU limit, the query will be aborted.
|
||||
<div>
|
||||
<legend
|
||||
className="settingsSectionLabel legendLabel"
|
||||
id="ruThresholdLabel"
|
||||
>
|
||||
RU Threshold
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
If a query exceeds a configured RU threshold, the query will be aborted.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<div>
|
||||
<StyledToggleBase
|
||||
defaultChecked={true}
|
||||
label="Enable RU limit"
|
||||
label="Enable RU threshold"
|
||||
onChange={[Function]}
|
||||
styles={
|
||||
{
|
||||
@@ -186,14 +135,12 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="___1dfa554_0000000 fo7qwa0"
|
||||
>
|
||||
<div>
|
||||
<StyledSpinButton
|
||||
decrementButtonAriaLabel="Decrease value by 1000"
|
||||
defaultValue="5000"
|
||||
incrementButtonAriaLabel="Increase value by 1000"
|
||||
label="RU Limit (RU)"
|
||||
label="RU Threshold (RU)"
|
||||
labelPosition={0}
|
||||
min={1}
|
||||
onChange={[Function]}
|
||||
@@ -216,27 +163,66 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="5"
|
||||
>
|
||||
<AccordionHeader>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="___15c001r_0000000 fq02s40"
|
||||
className="settingsSection"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionPart"
|
||||
>
|
||||
<div>
|
||||
<legend
|
||||
className="settingsSectionLabel legendLabel"
|
||||
id="queryTimeoutLabel"
|
||||
>
|
||||
Query Timeout
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
When a query reaches a specified time limit, a popup with an option to cancel the query will show unless automatic cancellation has been enabled
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<div>
|
||||
<StyledToggleBase
|
||||
defaultChecked={false}
|
||||
label="Enable query timeout"
|
||||
onChange={[Function]}
|
||||
styles={
|
||||
{
|
||||
"container": {},
|
||||
"label": {
|
||||
"display": "block",
|
||||
"fontSize": 12,
|
||||
"fontWeight": 400,
|
||||
},
|
||||
"pill": {},
|
||||
"root": {},
|
||||
"text": {},
|
||||
"thumb": {},
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="settingsSection"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionPart"
|
||||
>
|
||||
<div>
|
||||
<legend
|
||||
className="settingsSectionLabel legendLabel"
|
||||
id="defaultQueryResultsView"
|
||||
>
|
||||
Default Query Results View
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div
|
||||
className="___1dfa554_0000000 fo7qwa0"
|
||||
>
|
||||
<div
|
||||
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
|
||||
>
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
Select the default view to use when displaying query results.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<div>
|
||||
<StyledChoiceGroup
|
||||
ariaLabelledBy="defaultQueryResultsView"
|
||||
onChange={[Function]}
|
||||
@@ -252,7 +238,7 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
},
|
||||
]
|
||||
}
|
||||
selectedKey="horizontal"
|
||||
selectedKey="vertical"
|
||||
styles={
|
||||
{
|
||||
"flexContainer": [
|
||||
@@ -278,36 +264,30 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="6"
|
||||
>
|
||||
<AccordionHeader>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="___15c001r_0000000 fq02s40"
|
||||
className="settingsSection"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionPart"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionLabel"
|
||||
>
|
||||
Retry Settings
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div
|
||||
className="___1dfa554_0000000 fo7qwa0"
|
||||
>
|
||||
<div
|
||||
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
|
||||
>
|
||||
<InfoTooltip>
|
||||
Retry policy associated with throttled requests during CosmosDB queries.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
className="___zls1px0_0000000 fq02s40 f1ugzwwg"
|
||||
<legend
|
||||
className="settingsSectionLabel legendLabel"
|
||||
id="queryRetryAttemptsLabel"
|
||||
>
|
||||
Max retry attempts
|
||||
</span>
|
||||
<InfoTooltip
|
||||
className="___vtc5hy0_0000000 f10ra9hq f1k6fduh"
|
||||
>
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
Max number of retries to be performed for a request. Default value 9.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
@@ -340,14 +320,13 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
value="9"
|
||||
/>
|
||||
<div>
|
||||
<span
|
||||
className="___zls1px0_0000000 fq02s40 f1ugzwwg"
|
||||
<legend
|
||||
className="settingsSectionLabel legendLabel"
|
||||
id="queryRetryAttemptsLabel"
|
||||
>
|
||||
Fixed retry interval (ms)
|
||||
</span>
|
||||
<InfoTooltip
|
||||
className="___vtc5hy0_0000000 f10ra9hq f1k6fduh"
|
||||
>
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
Fixed retry interval in milliseconds to wait between each retry ignoring the retryAfter returned as part of the response. Default value is 0 milliseconds.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
@@ -380,14 +359,13 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
value="0"
|
||||
/>
|
||||
<div>
|
||||
<span
|
||||
className="___zls1px0_0000000 fq02s40 f1ugzwwg"
|
||||
<legend
|
||||
className="settingsSectionLabel legendLabel"
|
||||
id="queryRetryAttemptsLabel"
|
||||
>
|
||||
Max wait time (s)
|
||||
</span>
|
||||
<InfoTooltip
|
||||
className="___vtc5hy0_0000000 f10ra9hq f1k6fduh"
|
||||
>
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
Max wait time in seconds to wait for a request while the retries are happening. Default value 30 seconds.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
@@ -420,32 +398,25 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
value="30"
|
||||
/>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="7"
|
||||
>
|
||||
<AccordionHeader>
|
||||
</div>
|
||||
<div
|
||||
className="___15c001r_0000000 fq02s40"
|
||||
className="settingsSection"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionPart settingsSectionInlineCheckbox"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionLabel"
|
||||
>
|
||||
Enable container pagination
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div
|
||||
className="___1dfa554_0000000 fo7qwa0"
|
||||
>
|
||||
<div
|
||||
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
|
||||
>
|
||||
<InfoTooltip>
|
||||
Load 50 containers at a time. Currently, containers are not pulled in alphanumeric order.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<StyledCheckboxBase
|
||||
ariaLabel="Enable container pagination"
|
||||
checked={false}
|
||||
className="padding"
|
||||
label="Enable container pagination"
|
||||
onChange={[Function]}
|
||||
styles={
|
||||
{
|
||||
@@ -456,32 +427,25 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="8"
|
||||
>
|
||||
<AccordionHeader>
|
||||
</div>
|
||||
<div
|
||||
className="___15c001r_0000000 fq02s40"
|
||||
className="settingsSection"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionPart settingsSectionInlineCheckbox"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionLabel"
|
||||
>
|
||||
Enable cross-partition query
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div
|
||||
className="___1dfa554_0000000 fo7qwa0"
|
||||
>
|
||||
<div
|
||||
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
|
||||
>
|
||||
<InfoTooltip>
|
||||
Send more than one request while executing a query. More than one request is necessary if the query is not scoped to single partition key value.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<StyledCheckboxBase
|
||||
ariaLabel="Enable cross partition query"
|
||||
checked={false}
|
||||
className="padding"
|
||||
label="Enable cross-partition query"
|
||||
onChange={[Function]}
|
||||
styles={
|
||||
{
|
||||
@@ -492,32 +456,25 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="9"
|
||||
>
|
||||
<AccordionHeader>
|
||||
</div>
|
||||
<div
|
||||
className="___15c001r_0000000 fq02s40"
|
||||
className="settingsSection"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionPart"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionLabel"
|
||||
>
|
||||
Max degree of parallelism
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div
|
||||
className="___1dfa554_0000000 fo7qwa0"
|
||||
>
|
||||
<div
|
||||
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
|
||||
>
|
||||
<InfoTooltip>
|
||||
Gets or sets the number of concurrent operations run client side during parallel query execution. A positive property value limits the number of concurrent operations to the set value. If it is set to less than 0, the system automatically decides the number of concurrent operations to run.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<StyledSpinButton
|
||||
ariaLabel="Max degree of parallelism"
|
||||
className="textfontclr"
|
||||
id="max-degree"
|
||||
label="Max degree of parallelism"
|
||||
min={-1}
|
||||
onDecrement={[Function]}
|
||||
onIncrement={[Function]}
|
||||
@@ -527,21 +484,6 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
value="6"
|
||||
/>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<div
|
||||
className="settingsSection"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionPart"
|
||||
>
|
||||
<CustomizedDefaultButton
|
||||
onClick={[Function]}
|
||||
>
|
||||
Clear History
|
||||
</CustomizedDefaultButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="settingsSection"
|
||||
@@ -569,39 +511,30 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
|
||||
submitButtonText="Apply"
|
||||
>
|
||||
<div
|
||||
className="paneMainContent ___133e6fg_0000000 f22iagw f1vx9l62 f1l02sjl"
|
||||
className="paneMainContent"
|
||||
>
|
||||
<Accordion
|
||||
className="___1uf6361_0000000 fz7g6wx"
|
||||
>
|
||||
<AccordionItem
|
||||
value="6"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
className="___15c001r_0000000 fq02s40"
|
||||
className="settingsSection"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionPart"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionLabel"
|
||||
>
|
||||
Retry Settings
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div
|
||||
className="___1dfa554_0000000 fo7qwa0"
|
||||
>
|
||||
<div
|
||||
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
|
||||
>
|
||||
<InfoTooltip>
|
||||
Retry policy associated with throttled requests during CosmosDB queries.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
className="___zls1px0_0000000 fq02s40 f1ugzwwg"
|
||||
<legend
|
||||
className="settingsSectionLabel legendLabel"
|
||||
id="queryRetryAttemptsLabel"
|
||||
>
|
||||
Max retry attempts
|
||||
</span>
|
||||
<InfoTooltip
|
||||
className="___vtc5hy0_0000000 f10ra9hq f1k6fduh"
|
||||
>
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
Max number of retries to be performed for a request. Default value 9.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
@@ -634,14 +567,13 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
|
||||
value="9"
|
||||
/>
|
||||
<div>
|
||||
<span
|
||||
className="___zls1px0_0000000 fq02s40 f1ugzwwg"
|
||||
<legend
|
||||
className="settingsSectionLabel legendLabel"
|
||||
id="queryRetryAttemptsLabel"
|
||||
>
|
||||
Fixed retry interval (ms)
|
||||
</span>
|
||||
<InfoTooltip
|
||||
className="___vtc5hy0_0000000 f10ra9hq f1k6fduh"
|
||||
>
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
Fixed retry interval in milliseconds to wait between each retry ignoring the retryAfter returned as part of the response. Default value is 0 milliseconds.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
@@ -674,14 +606,13 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
|
||||
value="0"
|
||||
/>
|
||||
<div>
|
||||
<span
|
||||
className="___zls1px0_0000000 fq02s40 f1ugzwwg"
|
||||
<legend
|
||||
className="settingsSectionLabel legendLabel"
|
||||
id="queryRetryAttemptsLabel"
|
||||
>
|
||||
Max wait time (s)
|
||||
</span>
|
||||
<InfoTooltip
|
||||
className="___vtc5hy0_0000000 f10ra9hq f1k6fduh"
|
||||
>
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
Max wait time in seconds to wait for a request while the retries are happening. Default value 30 seconds.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
@@ -714,32 +645,25 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
|
||||
value="30"
|
||||
/>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="7"
|
||||
>
|
||||
<AccordionHeader>
|
||||
</div>
|
||||
<div
|
||||
className="___15c001r_0000000 fq02s40"
|
||||
className="settingsSection"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionPart settingsSectionInlineCheckbox"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionLabel"
|
||||
>
|
||||
Enable container pagination
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div
|
||||
className="___1dfa554_0000000 fo7qwa0"
|
||||
>
|
||||
<div
|
||||
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
|
||||
>
|
||||
<InfoTooltip>
|
||||
Load 50 containers at a time. Currently, containers are not pulled in alphanumeric order.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<StyledCheckboxBase
|
||||
ariaLabel="Enable container pagination"
|
||||
checked={false}
|
||||
className="padding"
|
||||
label="Enable container pagination"
|
||||
onChange={[Function]}
|
||||
styles={
|
||||
{
|
||||
@@ -750,26 +674,20 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="11"
|
||||
>
|
||||
<AccordionHeader>
|
||||
</div>
|
||||
<div
|
||||
className="___15c001r_0000000 fq02s40"
|
||||
className="settingsSection"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionPart"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionLabel"
|
||||
>
|
||||
Display Gremlin query results as:
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div
|
||||
className="___1dfa554_0000000 fo7qwa0"
|
||||
>
|
||||
<div
|
||||
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
|
||||
>
|
||||
<InfoTooltip>
|
||||
Select Graph to automatically visualize the query results as a Graph or JSON to display the results as JSON.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<StyledChoiceGroup
|
||||
aria-label="Graph Auto-visualization"
|
||||
@@ -789,21 +707,6 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
|
||||
selectedKey="false"
|
||||
/>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<div
|
||||
className="settingsSection"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionPart"
|
||||
>
|
||||
<CustomizedDefaultButton
|
||||
onClick={[Function]}
|
||||
>
|
||||
Clear History
|
||||
</CustomizedDefaultButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="settingsSection"
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
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}
|
||||
>
|
||||
<StyledCheckboxBase
|
||||
checked={false}
|
||||
checked={true}
|
||||
label="Share throughput across containers"
|
||||
onChange={[Function]}
|
||||
styles={
|
||||
@@ -137,6 +137,14 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
|
||||
/>
|
||||
</StyledTooltipHostBase>
|
||||
</Stack>
|
||||
<ThroughputInput
|
||||
isDatabase={true}
|
||||
isSharded={true}
|
||||
onCostAcknowledgeChange={[Function]}
|
||||
setIsAutoscale={[Function]}
|
||||
setIsThroughputCapExceeded={[Function]}
|
||||
setThroughputValue={[Function]}
|
||||
/>
|
||||
</Stack>
|
||||
<Separator
|
||||
className="panelSeparator"
|
||||
@@ -255,14 +263,6 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
|
||||
</CustomizedDefaultButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<ThroughputInput
|
||||
isDatabase={false}
|
||||
isSharded={true}
|
||||
onCostAcknowledgeChange={[Function]}
|
||||
setIsAutoscale={[Function]}
|
||||
setIsThroughputCapExceeded={[Function]}
|
||||
setThroughputValue={[Function]}
|
||||
/>
|
||||
<Stack>
|
||||
<Stack
|
||||
horizontal={true}
|
||||
|
||||
@@ -361,11 +361,13 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
|
||||
<span
|
||||
className="css-113"
|
||||
>
|
||||
Confirm by typing the Database id (name)
|
||||
Confirm by typing the
|
||||
Database
|
||||
id
|
||||
</span>
|
||||
</Text>
|
||||
<StyledTextFieldBase
|
||||
ariaLabel="Confirm by typing the Database id (name)"
|
||||
ariaLabel="Confirm by typing the Database id"
|
||||
autoFocus={true}
|
||||
data-test="Input:confirmDatabaseId"
|
||||
id="confirmDatabaseId"
|
||||
@@ -380,7 +382,7 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
|
||||
}
|
||||
>
|
||||
<TextFieldBase
|
||||
ariaLabel="Confirm by typing the Database id (name)"
|
||||
ariaLabel="Confirm by typing the Database id"
|
||||
autoFocus={true}
|
||||
data-test="Input:confirmDatabaseId"
|
||||
deferredValidationTime={200}
|
||||
@@ -675,7 +677,7 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
|
||||
>
|
||||
<input
|
||||
aria-invalid={false}
|
||||
aria-label="Confirm by typing the Database id (name)"
|
||||
aria-label="Confirm by typing the Database id"
|
||||
autoFocus={true}
|
||||
className="ms-TextField-field field-117"
|
||||
data-test="Input:confirmDatabaseId"
|
||||
|
||||
@@ -171,7 +171,7 @@ export const QueryCopilotCarousel: React.FC<QueryCopilotCarouselProps> = ({
|
||||
the query builder.
|
||||
</Text>
|
||||
<Text style={{ fontSize: 13, fontWeight: 600, marginTop: 24 }}>Database Id</Text>
|
||||
<Text style={{ fontSize: 13 }}>CopilotSampleDB</Text>
|
||||
<Text style={{ fontSize: 13 }}>CopilotSampleDb</Text>
|
||||
<Text style={{ fontSize: 13, fontWeight: 600, marginTop: 16 }}>Database throughput (autoscale)</Text>
|
||||
<Text style={{ fontSize: 13 }}>Autoscale</Text>
|
||||
<Text style={{ fontSize: 13, fontWeight: 600, marginTop: 16 }}>Database Max RU/s</Text>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { MinimalQueryIterator } from "Common/IteratorUtilities";
|
||||
import QueryError from "Common/QueryError";
|
||||
import { QueryResults } from "Contracts/ViewModels";
|
||||
import { CopilotMessage } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
|
||||
import { guid } from "Explorer/Tables/Utilities";
|
||||
@@ -29,7 +28,7 @@ const CopilotProvider = ({ children }: { children: React.ReactNode }): JSX.Eleme
|
||||
showSamplePrompts: false,
|
||||
queryIterator: undefined,
|
||||
queryResults: undefined,
|
||||
errors: [],
|
||||
errorMessage: "",
|
||||
isSamplePromptsOpen: false,
|
||||
showPromptTeachingBubble: true,
|
||||
showDeletePopup: false,
|
||||
@@ -65,7 +64,7 @@ const CopilotProvider = ({ children }: { children: React.ReactNode }): JSX.Eleme
|
||||
setShowSamplePrompts: (showSamplePrompts: boolean) => set({ showSamplePrompts }),
|
||||
setQueryIterator: (queryIterator: MinimalQueryIterator | undefined) => set({ queryIterator }),
|
||||
setQueryResults: (queryResults: QueryResults | undefined) => set({ queryResults }),
|
||||
setErrors: (errors: QueryError[]) => set({ errors }),
|
||||
setErrorMessage: (errorMessage: string) => set({ errorMessage }),
|
||||
setIsSamplePromptsOpen: (isSamplePromptsOpen: boolean) => set({ isSamplePromptsOpen }),
|
||||
setShowPromptTeachingBubble: (showPromptTeachingBubble: boolean) => set({ showPromptTeachingBubble }),
|
||||
setShowDeletePopup: (showDeletePopup: boolean) => set({ showDeletePopup }),
|
||||
|
||||
@@ -18,9 +18,8 @@ import {
|
||||
Text,
|
||||
TextField,
|
||||
} from "@fluentui/react";
|
||||
import { HttpStatusCodes, NormalizedEventKey } from "Common/Constants";
|
||||
import { HttpStatusCodes } from "Common/Constants";
|
||||
import { handleError } from "Common/ErrorHandlingUtils";
|
||||
import QueryError, { QueryErrorSeverity } from "Common/QueryError";
|
||||
import { createUri } from "Common/UrlUtility";
|
||||
import { CopyPopup } from "Explorer/QueryCopilot/Popup/CopyPopup";
|
||||
import { DeletePopup } from "Explorer/QueryCopilot/Popup/DeletePopup";
|
||||
@@ -28,8 +27,6 @@ import {
|
||||
SuggestedPrompt,
|
||||
getSampleDatabaseSuggestedPrompts,
|
||||
getSuggestedPrompts,
|
||||
readPromptHistory,
|
||||
savePromptHistory,
|
||||
} from "Explorer/QueryCopilot/QueryCopilotUtilities";
|
||||
import { SubmitFeedback, allocatePhoenixContainer } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
|
||||
import { GenerateSQLQueryResponse, QueryCopilotProps } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
|
||||
@@ -37,7 +34,7 @@ import { SamplePrompts, SamplePromptsProps } from "Explorer/QueryCopilot/Shared/
|
||||
import { Action } from "Shared/Telemetry/TelemetryConstants";
|
||||
import { userContext } from "UserContext";
|
||||
import { useQueryCopilot } from "hooks/useQueryCopilot";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import React, { useRef, useState } from "react";
|
||||
import HintIcon from "../../../images/Hint.svg";
|
||||
import RecentIcon from "../../../images/Recent.svg";
|
||||
import errorIcon from "../../../images/close-black.svg";
|
||||
@@ -73,8 +70,6 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
||||
}: QueryCopilotPromptProps): JSX.Element => {
|
||||
const [copilotTeachingBubbleVisible, setCopilotTeachingBubbleVisible] = useState<boolean>(false);
|
||||
const inputEdited = useRef(false);
|
||||
const itemRefs = useRef([]);
|
||||
const searchInputRef = useRef(null);
|
||||
const {
|
||||
openFeedbackModal,
|
||||
hideFeedbackModalForLikedQueries,
|
||||
@@ -110,10 +105,10 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
||||
setShowErrorMessageBar,
|
||||
setGeneratedQueryComments,
|
||||
setQueryResults,
|
||||
setErrors,
|
||||
errors,
|
||||
setErrorMessage,
|
||||
errorMessage,
|
||||
} = useCopilotStore();
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||
|
||||
const sampleProps: SamplePromptsProps = {
|
||||
isSamplePromptsOpen: isSamplePromptsOpen,
|
||||
setIsSamplePromptsOpen: setIsSamplePromptsOpen,
|
||||
@@ -138,13 +133,14 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
||||
};
|
||||
|
||||
const isSampleCopilotActive = useSelectedNode.getState().isQueryCopilotCollectionSelected();
|
||||
const [histories, setHistories] = useState<string[]>(() => readPromptHistory(userContext.databaseAccount));
|
||||
const cachedHistoriesString = localStorage.getItem(`${userContext.databaseAccount?.id}-queryCopilotHistories`);
|
||||
const cachedHistories = cachedHistoriesString?.split("|");
|
||||
const [histories, setHistories] = useState<string[]>(cachedHistories || []);
|
||||
const suggestedPrompts: SuggestedPrompt[] = isSampleCopilotActive
|
||||
? getSampleDatabaseSuggestedPrompts()
|
||||
: getSuggestedPrompts();
|
||||
const [filteredHistories, setFilteredHistories] = useState<string[]>(histories);
|
||||
const [filteredSuggestedPrompts, setFilteredSuggestedPrompts] = useState<SuggestedPrompt[]>(suggestedPrompts);
|
||||
const { UpArrow, DownArrow, Enter } = NormalizedEventKey;
|
||||
|
||||
const handleUserPromptChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
inputEdited.current = true;
|
||||
@@ -172,7 +168,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
||||
const newHistories = [formattedUserPrompt, ...updatedHistories.slice(0, 2)];
|
||||
|
||||
setHistories(newHistories);
|
||||
savePromptHistory(userContext.databaseAccount, newHistories);
|
||||
localStorage.setItem(`${userContext.databaseAccount.id}-queryCopilotHistories`, newHistories.join("|"));
|
||||
};
|
||||
|
||||
const resetMessageStates = (): void => {
|
||||
@@ -183,7 +179,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
||||
|
||||
const resetQueryResults = (): void => {
|
||||
setQueryResults(null);
|
||||
setErrors([]);
|
||||
setErrorMessage("");
|
||||
};
|
||||
|
||||
const generateSQLQuery = async (): Promise<void> => {
|
||||
@@ -247,12 +243,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
||||
handleError(JSON.stringify(generateSQLQueryResponse), "copilotTooManyRequestError");
|
||||
useTabs.getState().setIsQueryErrorThrown(true);
|
||||
setShowErrorMessageBar(true);
|
||||
setErrors([
|
||||
new QueryError(
|
||||
"Ratelimit exceeded 5 per 1 minute. Please try again after sometime",
|
||||
QueryErrorSeverity.Error,
|
||||
),
|
||||
]);
|
||||
setErrorMessage("Ratelimit exceeded 5 per 1 minute. Please try again after sometime");
|
||||
TelemetryProcessor.traceFailure(Action.QueryGenerationFromCopilotPrompt, {
|
||||
databaseName: databaseId,
|
||||
collectionId: containerId,
|
||||
@@ -310,38 +301,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
||||
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(() => {
|
||||
useTabs.getState().setIsQueryErrorThrown(false);
|
||||
}, []);
|
||||
@@ -371,14 +331,23 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
||||
id="naturalLanguageInput"
|
||||
value={userPrompt}
|
||||
onChange={handleUserPromptChange}
|
||||
onClick={openSamplePrompts}
|
||||
onFocus={() => setShowSamplePrompts(true)}
|
||||
elementRef={searchInputRef}
|
||||
onKeyDown={handleKeyDownForInput}
|
||||
onClick={() => {
|
||||
inputEdited.current = true;
|
||||
setShowSamplePrompts(true);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && userPrompt) {
|
||||
inputEdited.current = true;
|
||||
startGenerateQueryProcess();
|
||||
}
|
||||
}}
|
||||
style={{ lineHeight: 30 }}
|
||||
styles={{
|
||||
root: { width: "100%" },
|
||||
suffix: { background: "none", padding: 0 },
|
||||
suffix: {
|
||||
background: "none",
|
||||
padding: 0,
|
||||
},
|
||||
fieldGroup: {
|
||||
borderRadius: 4,
|
||||
borderColor: "#D1D1D1",
|
||||
@@ -391,8 +360,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
||||
},
|
||||
}}
|
||||
disabled={isGeneratingQuery}
|
||||
autoComplete="list"
|
||||
aria-expanded={showSamplePrompts}
|
||||
autoComplete="off"
|
||||
placeholder="Ask a question in natural language and we’ll generate the query for you."
|
||||
aria-labelledby="copilot-textfield-label"
|
||||
onRenderSuffix={() => {
|
||||
@@ -464,8 +432,6 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
||||
setShowSamplePrompts(false);
|
||||
inputEdited.current = true;
|
||||
}}
|
||||
elementRef={itemRefs.current[i]}
|
||||
onKeyDown={handleKeyDownForItem}
|
||||
onRenderIcon={() => <Image src={RecentIcon} styles={{ root: { overflow: "unset" } }} />}
|
||||
styles={promptStyles}
|
||||
>
|
||||
@@ -488,16 +454,14 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
||||
>
|
||||
Suggested Prompts
|
||||
</Text>
|
||||
{filteredSuggestedPrompts.map((prompt, index) => (
|
||||
{filteredSuggestedPrompts.map((prompt) => (
|
||||
<DefaultButton
|
||||
key={prompt.id}
|
||||
elementRef={itemRefs.current[filteredHistories.length + index]}
|
||||
onClick={() => {
|
||||
setUserPrompt(prompt.text);
|
||||
setShowSamplePrompts(false);
|
||||
inputEdited.current = true;
|
||||
}}
|
||||
onKeyDown={handleKeyDownForItem}
|
||||
onRenderIcon={() => <Image src={HintIcon} />}
|
||||
styles={promptStyles}
|
||||
>
|
||||
@@ -550,9 +514,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
||||
</Link>
|
||||
{showErrorMessageBar && (
|
||||
<MessageBar messageBarType={MessageBarType.error}>
|
||||
{errors.length > 0
|
||||
? errors[0].message
|
||||
: "We ran into an error and were not able to execute query."}
|
||||
{errorMessage ? errorMessage : "We ran into an error and were not able to execute query."}
|
||||
</MessageBar>
|
||||
)}
|
||||
{showInvalidQueryMessageBar && (
|
||||
|
||||
@@ -1,39 +1,10 @@
|
||||
import { shallow } from "enzyme";
|
||||
import { CopilotSubComponentNames } from "Explorer/QueryCopilot/QueryCopilotUtilities";
|
||||
import React from "react";
|
||||
import { AppStateComponentNames, StorePath } from "Shared/AppStatePersistenceUtility";
|
||||
import { updateUserContext } from "UserContext";
|
||||
import Explorer from "../Explorer";
|
||||
import { QueryCopilotTab } from "./QueryCopilotTab";
|
||||
|
||||
describe("Query copilot tab snapshot test", () => {
|
||||
it("should render with initial input", () => {
|
||||
updateUserContext({
|
||||
databaseAccount: {
|
||||
name: "name",
|
||||
properties: undefined,
|
||||
id: "",
|
||||
location: "",
|
||||
type: "",
|
||||
kind: "",
|
||||
},
|
||||
});
|
||||
|
||||
const loadState = (path: StorePath) => {
|
||||
if (
|
||||
path.componentName === AppStateComponentNames.QueryCopilot &&
|
||||
path.subComponentName === CopilotSubComponentNames.toggleStatus
|
||||
) {
|
||||
return { enabled: true };
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
jest.mock("Shared/AppStatePersistenceUtility", () => ({
|
||||
loadState,
|
||||
}));
|
||||
|
||||
const wrapper = shallow(<QueryCopilotTab explorer={new Explorer()} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
|
||||
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
|
||||
import { SaveQueryPane } from "Explorer/Panes/SaveQueryPane/SaveQueryPane";
|
||||
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
|
||||
import { readCopilotToggleStatus, saveCopilotToggleStatus } from "Explorer/QueryCopilot/QueryCopilotUtilities";
|
||||
import { OnExecuteQueryClick } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
|
||||
import { QueryCopilotProps } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
|
||||
import { QueryCopilotResults } from "Explorer/QueryCopilot/Shared/QueryCopilotResults";
|
||||
@@ -19,13 +18,18 @@ import SplitterLayout from "react-splitter-layout";
|
||||
import QueryCommandIcon from "../../../images/CopilotCommand.svg";
|
||||
import ExecuteQueryIcon from "../../../images/ExecuteQuery.svg";
|
||||
import SaveQueryIcon from "../../../images/save-cosmos.svg";
|
||||
import * as StringUtility from "../../Shared/StringUtility";
|
||||
|
||||
export const QueryCopilotTab: React.FC<QueryCopilotProps> = ({ explorer }: QueryCopilotProps): JSX.Element => {
|
||||
const { query, setQuery, selectedQuery, setSelectedQuery, isGeneratingQuery } = useQueryCopilot();
|
||||
|
||||
const [copilotActive, setCopilotActive] = useState<boolean>(() =>
|
||||
readCopilotToggleStatus(userContext.databaseAccount),
|
||||
const cachedCopilotToggleStatus: string = localStorage.getItem(
|
||||
`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`,
|
||||
);
|
||||
const copilotInitialActive: boolean = cachedCopilotToggleStatus
|
||||
? StringUtility.toBoolean(cachedCopilotToggleStatus)
|
||||
: true;
|
||||
const [copilotActive, setCopilotActive] = useState<boolean>(copilotInitialActive);
|
||||
const [tabActive, setTabActive] = useState<boolean>(true);
|
||||
|
||||
const getCommandbarButtons = (): CommandButtonComponentProps[] => {
|
||||
@@ -84,7 +88,7 @@ export const QueryCopilotTab: React.FC<QueryCopilotProps> = ({ explorer }: Query
|
||||
|
||||
const toggleCopilot = (toggle: boolean) => {
|
||||
setCopilotActive(toggle);
|
||||
saveCopilotToggleStatus(userContext.databaseAccount, toggle);
|
||||
localStorage.setItem(`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`, toggle.toString());
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -90,7 +90,7 @@ describe("QueryCopilotUtilities", () => {
|
||||
|
||||
// Mock the items.query method to return the mockResult
|
||||
(
|
||||
sampleDataClient().database("CopilotSampleDB").container("SampleContainer").items.query as jest.Mock
|
||||
sampleDataClient().database("CopilotSampleDb").container("SampleContainer").items.query as jest.Mock
|
||||
).mockReturnValue(mockResult);
|
||||
|
||||
const result = querySampleDocuments(query, options);
|
||||
@@ -119,10 +119,10 @@ describe("QueryCopilotUtilities", () => {
|
||||
const result = await readSampleDocument(documentId);
|
||||
|
||||
expect(sampleDataClient).toHaveBeenCalled();
|
||||
expect(sampleDataClient().database).toHaveBeenCalledWith("CopilotSampleDB");
|
||||
expect(sampleDataClient().database("CopilotSampleDB").container).toHaveBeenCalledWith("SampleContainer");
|
||||
expect(sampleDataClient().database).toHaveBeenCalledWith("CopilotSampleDb");
|
||||
expect(sampleDataClient().database("CopilotSampleDb").container).toHaveBeenCalledWith("SampleContainer");
|
||||
expect(
|
||||
sampleDataClient().database("CopilotSampleDB").container("SampleContainer").item("DocumentId", undefined).read,
|
||||
sampleDataClient().database("CopilotSampleDb").container("SampleContainer").item("DocumentId", undefined).read,
|
||||
).toHaveBeenCalled();
|
||||
expect(result).toEqual(expectedResponse);
|
||||
});
|
||||
@@ -144,10 +144,10 @@ describe("QueryCopilotUtilities", () => {
|
||||
await expect(readSampleDocument(documentId)).rejects.toStrictEqual(errorMock);
|
||||
|
||||
expect(sampleDataClient).toHaveBeenCalled();
|
||||
expect(sampleDataClient().database).toHaveBeenCalledWith("CopilotSampleDB");
|
||||
expect(sampleDataClient().database("CopilotSampleDB").container).toHaveBeenCalledWith("SampleContainer");
|
||||
expect(sampleDataClient().database).toHaveBeenCalledWith("CopilotSampleDb");
|
||||
expect(sampleDataClient().database("CopilotSampleDb").container).toHaveBeenCalledWith("SampleContainer");
|
||||
expect(
|
||||
sampleDataClient().database("CopilotSampleDB").container("SampleContainer").item("DocumentId", undefined).read,
|
||||
sampleDataClient().database("CopilotSampleDb").container("SampleContainer").item("DocumentId", undefined).read,
|
||||
).toHaveBeenCalled();
|
||||
expect(handleError).toHaveBeenCalledWith(errorMock, "ReadDocument", expect.any(String));
|
||||
});
|
||||
|
||||
@@ -4,11 +4,8 @@ import { handleError } from "Common/ErrorHandlingUtils";
|
||||
import { sampleDataClient } from "Common/SampleDataClient";
|
||||
import { getPartitionKeyValue } from "Common/dataAccess/getPartitionKeyValue";
|
||||
import { getCommonQueryOptions } from "Common/dataAccess/queryDocuments";
|
||||
import { DatabaseAccount } from "Contracts/DataModels";
|
||||
import DocumentId from "Explorer/Tree/DocumentId";
|
||||
import { AppStateComponentNames, loadState, saveState } from "Shared/AppStatePersistenceUtility";
|
||||
import { logConsoleProgress } from "Utils/NotificationConsoleUtils";
|
||||
import * as StringUtility from "../../Shared/StringUtility";
|
||||
|
||||
export interface SuggestedPrompt {
|
||||
id: number;
|
||||
@@ -57,110 +54,3 @@ export const getSuggestedPrompts = (): SuggestedPrompt[] => {
|
||||
{ id: 3, text: "Find the oldest item added to my collection" },
|
||||
];
|
||||
};
|
||||
|
||||
// Prompt history persistence
|
||||
export enum CopilotSubComponentNames {
|
||||
promptHistory = "PromptHistory",
|
||||
toggleStatus = "ToggleStatus",
|
||||
}
|
||||
|
||||
const getLegacyHistoryKey = (databaseAccount: DatabaseAccount): string =>
|
||||
`${databaseAccount?.id}-queryCopilotHistories`;
|
||||
const getLegacyToggleStatusKey = (databaseAccount: DatabaseAccount): string =>
|
||||
`${databaseAccount?.id}-queryCopilotToggleStatus`;
|
||||
|
||||
// Migration only needs to run once
|
||||
let hasMigrated = false;
|
||||
// Migrate old prompt history to new format
|
||||
export const migrateCopilotPersistence = (databaseAccount: DatabaseAccount): void => {
|
||||
if (hasMigrated) {
|
||||
return;
|
||||
}
|
||||
|
||||
let key = getLegacyHistoryKey(databaseAccount);
|
||||
let item = localStorage.getItem(key);
|
||||
if (item !== undefined && item !== null) {
|
||||
const historyItems = item.split("|");
|
||||
saveState(
|
||||
{
|
||||
componentName: AppStateComponentNames.QueryCopilot,
|
||||
subComponentName: CopilotSubComponentNames.promptHistory,
|
||||
globalAccountName: databaseAccount.name,
|
||||
databaseName: undefined,
|
||||
containerName: undefined,
|
||||
},
|
||||
historyItems,
|
||||
);
|
||||
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
key = getLegacyToggleStatusKey(databaseAccount);
|
||||
item = localStorage.getItem(key);
|
||||
if (item !== undefined && item !== null) {
|
||||
saveState(
|
||||
{
|
||||
componentName: AppStateComponentNames.QueryCopilot,
|
||||
subComponentName: CopilotSubComponentNames.toggleStatus,
|
||||
globalAccountName: databaseAccount.name,
|
||||
databaseName: undefined,
|
||||
containerName: undefined,
|
||||
},
|
||||
StringUtility.toBoolean(item),
|
||||
);
|
||||
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
hasMigrated = true;
|
||||
};
|
||||
|
||||
export const readPromptHistory = (databaseAccount: DatabaseAccount): string[] => {
|
||||
migrateCopilotPersistence(databaseAccount);
|
||||
return (
|
||||
(loadState({
|
||||
componentName: AppStateComponentNames.QueryCopilot,
|
||||
subComponentName: CopilotSubComponentNames.promptHistory,
|
||||
globalAccountName: databaseAccount.name,
|
||||
databaseName: undefined,
|
||||
containerName: undefined,
|
||||
}) as string[]) || []
|
||||
);
|
||||
};
|
||||
|
||||
export const savePromptHistory = (databaseAccount: DatabaseAccount, historyItems: string[]): void => {
|
||||
saveState(
|
||||
{
|
||||
componentName: AppStateComponentNames.QueryCopilot,
|
||||
subComponentName: CopilotSubComponentNames.promptHistory,
|
||||
globalAccountName: databaseAccount.name,
|
||||
databaseName: undefined,
|
||||
containerName: undefined,
|
||||
},
|
||||
historyItems,
|
||||
);
|
||||
};
|
||||
|
||||
export const readCopilotToggleStatus = (databaseAccount: DatabaseAccount): boolean => {
|
||||
migrateCopilotPersistence(databaseAccount);
|
||||
return !!loadState({
|
||||
componentName: AppStateComponentNames.QueryCopilot,
|
||||
subComponentName: CopilotSubComponentNames.toggleStatus,
|
||||
globalAccountName: databaseAccount.name,
|
||||
databaseName: undefined,
|
||||
containerName: undefined,
|
||||
}) as boolean;
|
||||
};
|
||||
|
||||
export const saveCopilotToggleStatus = (databaseAccount: DatabaseAccount, status: boolean): void => {
|
||||
saveState(
|
||||
{
|
||||
componentName: AppStateComponentNames.QueryCopilot,
|
||||
subComponentName: CopilotSubComponentNames.toggleStatus,
|
||||
globalAccountName: databaseAccount.name,
|
||||
databaseName: undefined,
|
||||
containerName: undefined,
|
||||
},
|
||||
status,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FeedOptions } from "@azure/cosmos";
|
||||
import {
|
||||
Areas,
|
||||
BackendApi,
|
||||
ConnectionStatusType,
|
||||
ContainerStatusType,
|
||||
HttpStatusCodes,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
import { getErrorMessage, getErrorStack, handleError } from "Common/ErrorHandlingUtils";
|
||||
import { shouldEnableCrossPartitionKey } from "Common/HeadersUtility";
|
||||
import { MinimalQueryIterator } from "Common/IteratorUtilities";
|
||||
import QueryError from "Common/QueryError";
|
||||
import { createUri } from "Common/UrlUtility";
|
||||
import { queryDocumentsPage } from "Common/dataAccess/queryDocumentsPage";
|
||||
import { configContext } from "ConfigContext";
|
||||
@@ -25,15 +25,17 @@ import {
|
||||
import { AuthorizationTokenHeaderMetadata, QueryResults } from "Contracts/ViewModels";
|
||||
import { useDialog } from "Explorer/Controls/Dialog";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import { querySampleDocuments, readCopilotToggleStatus } from "Explorer/QueryCopilot/QueryCopilotUtilities";
|
||||
import { querySampleDocuments } from "Explorer/QueryCopilot/QueryCopilotUtilities";
|
||||
import { FeedbackParams, GenerateSQLQueryResponse } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
|
||||
import { Action } from "Shared/Telemetry/TelemetryConstants";
|
||||
import { traceFailure, traceStart, traceSuccess } from "Shared/Telemetry/TelemetryProcessor";
|
||||
import { userContext } from "UserContext";
|
||||
import { getAuthorizationHeader } from "Utils/AuthorizationUtils";
|
||||
import { useNewPortalBackendEndpoint } from "Utils/EndpointUtils";
|
||||
import { queryPagesUntilContentPresent } from "Utils/QueryUtils";
|
||||
import { QueryCopilotState, useQueryCopilot } from "hooks/useQueryCopilot";
|
||||
import { useTabs } from "hooks/useTabs";
|
||||
import * as StringUtility from "../../../Shared/StringUtility";
|
||||
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
@@ -80,7 +82,11 @@ export const isCopilotFeatureRegistered = async (subscriptionId: string): Promis
|
||||
};
|
||||
|
||||
export const getCopilotEnabled = async (): Promise<boolean> => {
|
||||
const url = `${configContext.PORTAL_BACKEND_ENDPOINT}/api/portalsettings/querycopilot`;
|
||||
const backendEndpoint: string = useNewPortalBackendEndpoint(BackendApi.PortalSettings)
|
||||
? configContext.PORTAL_BACKEND_ENDPOINT
|
||||
: configContext.BACKEND_ENDPOINT;
|
||||
|
||||
const url = `${backendEndpoint}/api/portalsettings/querycopilot`;
|
||||
const authorizationHeader: AuthorizationTokenHeaderMetadata = getAuthorizationHeader();
|
||||
const headers = { [authorizationHeader.header]: authorizationHeader.token };
|
||||
|
||||
@@ -348,23 +354,24 @@ export const QueryDocumentsPerPage = async (
|
||||
);
|
||||
|
||||
useQueryCopilot.getState().setQueryResults(queryResults);
|
||||
useQueryCopilot.getState().setErrors([]);
|
||||
useQueryCopilot.getState().setErrorMessage("");
|
||||
useQueryCopilot.getState().setShowErrorMessageBar(false);
|
||||
traceSuccess(Action.ExecuteQueryGeneratedFromQueryCopilot, {
|
||||
correlationId: useQueryCopilot.getState().correlationId,
|
||||
});
|
||||
} catch (error) {
|
||||
const isCopilotActive = readCopilotToggleStatus(userContext.databaseAccount);
|
||||
const isCopilotActive = StringUtility.toBoolean(
|
||||
localStorage.getItem(`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`),
|
||||
);
|
||||
const errorMessage = getErrorMessage(error);
|
||||
traceFailure(Action.ExecuteQueryGeneratedFromQueryCopilot, {
|
||||
correlationId: useQueryCopilot.getState().correlationId,
|
||||
errorMessage,
|
||||
errorMessage: errorMessage,
|
||||
});
|
||||
handleError(errorMessage, "executeQueryCopilotTab");
|
||||
useTabs.getState().setIsQueryErrorThrown(true);
|
||||
if (isCopilotActive) {
|
||||
const queryErrors = QueryError.tryParse(error);
|
||||
useQueryCopilot.getState().setErrors(queryErrors);
|
||||
useQueryCopilot.getState().setErrorMessage(errorMessage);
|
||||
useQueryCopilot.getState().setShowErrorMessageBar(true);
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -8,7 +8,7 @@ export const QueryCopilotResults: React.FC = (): JSX.Element => {
|
||||
<QueryResultSection
|
||||
isMongoDB={false}
|
||||
queryEditorContent={useQueryCopilot.getState().selectedQuery || useQueryCopilot.getState().query}
|
||||
errors={useQueryCopilot.getState().errors}
|
||||
error={useQueryCopilot.getState().errorMessage}
|
||||
queryResults={useQueryCopilot.getState().queryResults}
|
||||
isExecuting={useQueryCopilot.getState().isExecuting}
|
||||
executeQueryDocumentsPage={(firstItemIndex: number) =>
|
||||
|
||||
@@ -17,6 +17,38 @@ exports[`Query copilot tab snapshot test should render with initial input 1`] =
|
||||
}
|
||||
}
|
||||
>
|
||||
<QueryCopilotPromptbar
|
||||
containerId="SampleContainer"
|
||||
databaseId="CopilotSampleDb"
|
||||
explorer={
|
||||
Explorer {
|
||||
"_isInitializingNotebooks": false,
|
||||
"isFixedCollectionWithSharedThroughputSupported": [Function],
|
||||
"isTabsContentExpanded": [Function],
|
||||
"onRefreshDatabasesKeyPress": [Function],
|
||||
"onRefreshResourcesClick": [Function],
|
||||
"phoenixClient": PhoenixClient {
|
||||
"armResourceId": undefined,
|
||||
"retryOptions": {
|
||||
"maxTimeout": 5000,
|
||||
"minTimeout": 5000,
|
||||
"retries": 3,
|
||||
},
|
||||
},
|
||||
"provideFeedbackEmail": [Function],
|
||||
"queriesClient": QueriesClient {
|
||||
"container": [Circular],
|
||||
},
|
||||
"refreshNotebookList": [Function],
|
||||
"resourceTree": ResourceTreeAdapter {
|
||||
"container": [Circular],
|
||||
"copyNotebook": [Function],
|
||||
"parameters": [Function],
|
||||
},
|
||||
}
|
||||
}
|
||||
toggleCopilot={[Function]}
|
||||
/>
|
||||
<Stack
|
||||
className="tabPaneContentContainer"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuButtonProps,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
@@ -26,7 +25,7 @@ import { getCollectionName, getDatabaseName } from "Utils/APITypeUtils";
|
||||
import { Allotment, AllotmentHandle } from "allotment";
|
||||
import { useSidePanel } from "hooks/useSidePanel";
|
||||
import { debounce } from "lodash";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
|
||||
const useSidebarStyles = makeStyles({
|
||||
sidebar: {
|
||||
@@ -61,7 +60,6 @@ const useSidebarStyles = makeStyles({
|
||||
alignItems: "center",
|
||||
justifyItems: "center",
|
||||
width: "100%",
|
||||
containerType: "size", // Use this container for "@container" queries below this.
|
||||
...cosmosShorthands.borderBottom(),
|
||||
},
|
||||
loadingProgressBar: {
|
||||
@@ -85,18 +83,6 @@ const useSidebarStyles = makeStyles({
|
||||
},
|
||||
},
|
||||
},
|
||||
globalCommandsMenuButton: {
|
||||
display: "inline-flex",
|
||||
"@container (min-width: 250px)": {
|
||||
display: "none",
|
||||
},
|
||||
},
|
||||
globalCommandsSplitButton: {
|
||||
display: "none",
|
||||
"@container (min-width: 250px)": {
|
||||
display: "flex",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
interface GlobalCommandsProps {
|
||||
@@ -113,12 +99,6 @@ interface GlobalCommand {
|
||||
|
||||
const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
|
||||
const styles = useSidebarStyles();
|
||||
|
||||
// Since we have two buttons in the DOM (one for small screens and one for larger screens), we wrap the entire thing in a div.
|
||||
// However, that messes with the Menu positioning, so we need to get a reference to the 'div' to pass to the Menu.
|
||||
// We can't use a ref though, because it would be set after the Menu is rendered, so we use a state value to force a re-render.
|
||||
const [globalCommandButton, setGlobalCommandButton] = useState<HTMLElement | null>(null);
|
||||
|
||||
const actions = useMemo<GlobalCommand[]>(() => {
|
||||
if (
|
||||
configContext.platform === Platform.Fabric ||
|
||||
@@ -188,22 +168,16 @@ const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
|
||||
{primaryAction.label}
|
||||
</Button>
|
||||
) : (
|
||||
<Menu positioning={{ target: globalCommandButton, position: "below", align: "end" }}>
|
||||
<Menu positioning="below-end">
|
||||
<MenuTrigger disableButtonEnhancement>
|
||||
{(triggerProps: MenuButtonProps) => (
|
||||
<div ref={setGlobalCommandButton}>
|
||||
<SplitButton
|
||||
menuButton={{ ...triggerProps, "aria-label": "More commands" }}
|
||||
primaryActionButton={{ onClick: onPrimaryActionClick }}
|
||||
className={styles.globalCommandsSplitButton}
|
||||
icon={primaryAction.icon}
|
||||
>
|
||||
{primaryAction.label}
|
||||
</SplitButton>
|
||||
<MenuButton {...triggerProps} icon={primaryAction.icon} className={styles.globalCommandsMenuButton}>
|
||||
New...
|
||||
</MenuButton>
|
||||
</div>
|
||||
)}
|
||||
</MenuTrigger>
|
||||
<MenuPopover>
|
||||
@@ -225,7 +199,7 @@ interface SidebarProps {
|
||||
explorer: Explorer;
|
||||
}
|
||||
|
||||
const CollapseThreshold = 140;
|
||||
const CollapseThreshold = 50;
|
||||
|
||||
export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
|
||||
const styles = useSidebarStyles();
|
||||
@@ -282,12 +256,11 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="sidebarContainer">
|
||||
<Allotment ref={allotment} onChange={onChange} onDragEnd={onDragEnd} className="resourceTreeAndTabs">
|
||||
{/* Collections Tree - Start */}
|
||||
{hasSidebar && (
|
||||
// When collapsed, we force the pane to 24 pixels wide and make it non-resizable.
|
||||
<Allotment.Pane minSize={24} preferredSize={250}>
|
||||
<Allotment.Pane minSize={24} preferredSize={300}>
|
||||
<CosmosFluentProvider className={mergeClasses(styles.sidebar)}>
|
||||
<div className={styles.sidebarContainer}>
|
||||
{loading && (
|
||||
@@ -301,7 +274,6 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
|
||||
<div className={styles.floatingControls}>
|
||||
<button
|
||||
type="button"
|
||||
data-test="Sidebar/RefreshButton"
|
||||
className={styles.floatingControlButton}
|
||||
disabled={loading}
|
||||
title="Refresh"
|
||||
@@ -341,10 +313,9 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
|
||||
</CosmosFluentProvider>
|
||||
</Allotment.Pane>
|
||||
)}
|
||||
<Allotment.Pane minSize={200}>
|
||||
<Allotment.Pane minSize={800}>
|
||||
<Tabs explorer={explorer} />
|
||||
</Allotment.Pane>
|
||||
</Allotment>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -114,7 +114,7 @@ export class SplashScreen extends React.Component<SplashScreenProps> {
|
||||
}
|
||||
|
||||
private clearMostRecent = (): void => {
|
||||
MostRecentActivity.clear(userContext.databaseAccount?.name);
|
||||
MostRecentActivity.mostRecentActivity.clear(userContext.databaseAccount?.id);
|
||||
this.setState({});
|
||||
};
|
||||
|
||||
@@ -498,7 +498,7 @@ export class SplashScreen extends React.Component<SplashScreenProps> {
|
||||
}
|
||||
|
||||
private createRecentItems(): SplashScreenItem[] {
|
||||
return MostRecentActivity.getItems(userContext.databaseAccount?.name).map((activity) => {
|
||||
return MostRecentActivity.mostRecentActivity.getItems(userContext.databaseAccount?.id).map((activity) => {
|
||||
switch (activity.type) {
|
||||
default: {
|
||||
const unknownActivity: never = activity;
|
||||
|
||||
@@ -155,7 +155,6 @@ export const htmlAttributeNames = {
|
||||
dataTableContentTypeAttr: "contentType_attr",
|
||||
dataTableSnapshotAttr: "snapshot_attr",
|
||||
dataTableRowKeyAttr: "rowKey_attr",
|
||||
dataTablePartitionKeyAttr: "partKey_attr",
|
||||
dataTableMessageIdAttr: "messageId_attr",
|
||||
dataTableHeaderIndex: "data-column-index",
|
||||
};
|
||||
|
||||
@@ -193,9 +193,6 @@ function getServerData(sSource: any, aoData: any, fnCallback: any, oSettings: an
|
||||
* from UI elements.
|
||||
*/
|
||||
function bindClientId(nRow: Node, aData: Entities.ITableEntity) {
|
||||
if (aData.PartitionKey && aData.PartitionKey._) {
|
||||
$(nRow).attr(Constants.htmlAttributeNames.dataTablePartitionKeyAttr, aData.PartitionKey._);
|
||||
}
|
||||
$(nRow).attr(Constants.htmlAttributeNames.dataTableRowKeyAttr, aData.RowKey._);
|
||||
return nRow;
|
||||
}
|
||||
@@ -208,10 +205,6 @@ function selectionChanged(element: any, valueAccessor: any, allBindings: any, vi
|
||||
selected &&
|
||||
selected.forEach((b: Entities.ITableEntity) => {
|
||||
var sel = DataTableOperations.getRowSelector([
|
||||
{
|
||||
key: Constants.htmlAttributeNames.dataTablePartitionKeyAttr,
|
||||
value: b.PartitionKey && b.PartitionKey._ && b.PartitionKey._.toString(),
|
||||
},
|
||||
{
|
||||
key: Constants.htmlAttributeNames.dataTableRowKeyAttr,
|
||||
value: b.RowKey && b.RowKey._ && b.RowKey._.toString(),
|
||||
@@ -377,9 +370,8 @@ function updateSelectionStatus(oSettings: any): void {
|
||||
for (var i = 0; i < $dataTableRows.length; i++) {
|
||||
var $row: JQuery = $dataTableRows.eq(i);
|
||||
var rowKey: string = $row.attr(Constants.htmlAttributeNames.dataTableRowKeyAttr);
|
||||
var partitionKey: string = $row.attr(Constants.htmlAttributeNames.dataTablePartitionKeyAttr);
|
||||
var table = tableEntityListViewModelMap[oSettings.ajax].tableViewModel;
|
||||
if (table.isItemSelected(table.getTableEntityKeys(rowKey, partitionKey))) {
|
||||
if (table.isItemSelected(table.getTableEntityKeys(rowKey))) {
|
||||
$row.attr("tabindex", "0");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,10 +56,7 @@ export default class DataTableOperationManager {
|
||||
// Simply select the first item in this case.
|
||||
var lastSelectedItemIndex = lastSelectedItem
|
||||
? this._tableEntityListViewModel.getItemIndexFromCurrentPage(
|
||||
this._tableEntityListViewModel.getTableEntityKeys(
|
||||
lastSelectedItem.RowKey._,
|
||||
lastSelectedItem.PartitionKey && lastSelectedItem.PartitionKey._,
|
||||
),
|
||||
this._tableEntityListViewModel.getTableEntityKeys(lastSelectedItem.RowKey._),
|
||||
)
|
||||
: -1;
|
||||
var nextIndex: number = isUpArrowKey ? lastSelectedItemIndex - 1 : lastSelectedItemIndex + 1;
|
||||
@@ -150,14 +147,13 @@ export default class DataTableOperationManager {
|
||||
private getEntityIdentity($elem: JQuery<Element>): Entities.ITableEntityIdentity {
|
||||
return {
|
||||
RowKey: $elem.attr(Constants.htmlAttributeNames.dataTableRowKeyAttr),
|
||||
PartitionKey: $elem.attr(Constants.htmlAttributeNames.dataTablePartitionKeyAttr),
|
||||
};
|
||||
}
|
||||
|
||||
private updateLastSelectedItem($elem: JQuery<Element>, isShiftSelect: boolean) {
|
||||
var entityIdentity: Entities.ITableEntityIdentity = this.getEntityIdentity($elem);
|
||||
var entity = this._tableEntityListViewModel.getItemFromCurrentPage(
|
||||
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.PartitionKey, entityIdentity.RowKey),
|
||||
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.RowKey),
|
||||
);
|
||||
|
||||
this._tableEntityListViewModel.lastSelectedItem = entity;
|
||||
@@ -172,7 +168,7 @@ export default class DataTableOperationManager {
|
||||
var entityIdentity: Entities.ITableEntityIdentity = this.getEntityIdentity($elem);
|
||||
|
||||
this._tableEntityListViewModel.clearSelection();
|
||||
this.addToSelection(entityIdentity.RowKey, entityIdentity.PartitionKey);
|
||||
this.addToSelection(entityIdentity.RowKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,11 +190,11 @@ export default class DataTableOperationManager {
|
||||
|
||||
if (
|
||||
!this._tableEntityListViewModel.isItemSelected(
|
||||
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.PartitionKey, entityIdentity.RowKey),
|
||||
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.RowKey),
|
||||
)
|
||||
) {
|
||||
// Adding item not previously in selection
|
||||
this.addToSelection(entityIdentity.RowKey, entityIdentity.PartitionKey);
|
||||
this.addToSelection(entityIdentity.RowKey);
|
||||
} else {
|
||||
koSelected.remove((item: Entities.ITableEntity) => item.RowKey._ === entityIdentity.RowKey);
|
||||
}
|
||||
@@ -216,10 +212,10 @@ export default class DataTableOperationManager {
|
||||
if (anchorItem) {
|
||||
var entityIdentity: Entities.ITableEntityIdentity = this.getEntityIdentity($elem);
|
||||
var elementIndex = this._tableEntityListViewModel.getItemIndexFromAllPages(
|
||||
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.PartitionKey, entityIdentity.RowKey),
|
||||
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.RowKey),
|
||||
);
|
||||
var anchorIndex = this._tableEntityListViewModel.getItemIndexFromAllPages(
|
||||
this._tableEntityListViewModel.getTableEntityKeys(anchorItem.PartitionKey._, anchorItem.RowKey._),
|
||||
this._tableEntityListViewModel.getTableEntityKeys(anchorItem.RowKey._),
|
||||
);
|
||||
|
||||
var startIndex = Math.min(elementIndex, anchorIndex);
|
||||
@@ -238,25 +234,24 @@ export default class DataTableOperationManager {
|
||||
|
||||
if (
|
||||
!this._tableEntityListViewModel.isItemSelected(
|
||||
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.PartitionKey, entityIdentity.RowKey),
|
||||
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.RowKey),
|
||||
)
|
||||
) {
|
||||
if (this._tableEntityListViewModel.selected().length) {
|
||||
this._tableEntityListViewModel.clearSelection();
|
||||
}
|
||||
this.addToSelection(entityIdentity.RowKey, entityIdentity.PartitionKey);
|
||||
this.addToSelection(entityIdentity.RowKey);
|
||||
}
|
||||
}
|
||||
|
||||
private addToSelection(rowKey: string, partitionKey?: string) {
|
||||
private addToSelection(rowKey: string) {
|
||||
var selectedEntity: Entities.ITableEntity = this._tableEntityListViewModel.getItemFromCurrentPage(
|
||||
this._tableEntityListViewModel.getTableEntityKeys(rowKey, partitionKey),
|
||||
this._tableEntityListViewModel.getTableEntityKeys(rowKey),
|
||||
);
|
||||
|
||||
if (selectedEntity != null) {
|
||||
this._tableEntityListViewModel.selected.push(selectedEntity);
|
||||
}
|
||||
console.log(this._tableEntityListViewModel.selected().length);
|
||||
}
|
||||
|
||||
// Selecting first row if the selection is empty.
|
||||
@@ -274,7 +269,7 @@ export default class DataTableOperationManager {
|
||||
// Clear last selection: lastSelectedItem and lastSelectedAnchorItem
|
||||
this._tableEntityListViewModel.clearLastSelected();
|
||||
|
||||
this.addToSelection(firstEntity.RowKey._, firstEntity.PartitionKey && firstEntity.PartitionKey._);
|
||||
this.addToSelection(firstEntity.RowKey._);
|
||||
|
||||
// Update last selection
|
||||
this._tableEntityListViewModel.lastSelectedItem = firstEntity;
|
||||
|
||||
@@ -128,14 +128,8 @@ export default class TableEntityListViewModel extends DataTableViewModel {
|
||||
this.sqlQuery = ko.observable<string>("SELECT * FROM c");
|
||||
}
|
||||
|
||||
public getTableEntityKeys(rowKey: string, partitionKey: string): Entities.IProperty[] {
|
||||
const properties: Entities.IProperty[] = [{ key: Constants.EntityKeyNames.RowKey, value: rowKey }];
|
||||
|
||||
if (partitionKey) {
|
||||
properties.push({ key: Constants.EntityKeyNames.PartitionKey, value: partitionKey });
|
||||
}
|
||||
|
||||
return properties;
|
||||
public getTableEntityKeys(rowKey: string): Entities.IProperty[] {
|
||||
return [{ key: Constants.EntityKeyNames.RowKey, value: rowKey }];
|
||||
}
|
||||
|
||||
public reloadTable(useSetting: boolean = true, resetHeaders: boolean = true): DataTables.Api<Element> {
|
||||
@@ -267,8 +261,7 @@ export default class TableEntityListViewModel extends DataTableViewModel {
|
||||
}
|
||||
var oldEntityIndex: number = _.findIndex(
|
||||
this.cache.data,
|
||||
(data: Entities.ITableEntity) =>
|
||||
data.RowKey._ === entity.RowKey._ && data.PartitionKey._ === entity.PartitionKey._,
|
||||
(data: Entities.ITableEntity) => data.RowKey._ === entity.RowKey._,
|
||||
);
|
||||
|
||||
this.cache.data.splice(oldEntityIndex, 1, entity);
|
||||
@@ -292,7 +285,7 @@ export default class TableEntityListViewModel extends DataTableViewModel {
|
||||
entities.forEach((entity: Entities.ITableEntity) => {
|
||||
var cachedIndex: number = _.findIndex(
|
||||
this.cache.data,
|
||||
(e: Entities.ITableEntity) => e.RowKey._ === entity.RowKey._ && e.PartitionKey._ === entity.PartitionKey._,
|
||||
(e: Entities.ITableEntity) => e.RowKey._ === entity.RowKey._,
|
||||
);
|
||||
if (cachedIndex >= 0) {
|
||||
this.cache.data.splice(cachedIndex, 1);
|
||||
@@ -400,16 +393,6 @@ export default class TableEntityListViewModel extends DataTableViewModel {
|
||||
});
|
||||
}
|
||||
|
||||
// Override as Tables can have the same Row key in different Partition keys
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
public getItemFromCurrentPage(itemKeys: Entities.IProperty[]): Entities.ITableEntity {
|
||||
return _.find(this.items(), (item: Entities.ITableEntity) => {
|
||||
return this.matchesKeys(item, itemKeys);
|
||||
});
|
||||
}
|
||||
|
||||
private prefetchAndRender(
|
||||
tableQuery: Entities.ITableQuery,
|
||||
tablePageStartIndex: number,
|
||||
|
||||
@@ -36,5 +36,4 @@ export interface ITableQuery {
|
||||
|
||||
export interface ITableEntityIdentity {
|
||||
RowKey: string;
|
||||
PartitionKey?: string;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as ko from "knockout";
|
||||
import Q from "q";
|
||||
import { AuthType } from "../../AuthType";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import { CassandraProxyAPIs } from "../../Common/Constants";
|
||||
import { CassandraProxyAPIs, CassandraProxyEndpoints } from "../../Common/Constants";
|
||||
import { handleError } from "../../Common/ErrorHandlingUtils";
|
||||
import * as HeadersUtility from "../../Common/HeadersUtility";
|
||||
import { createDocument } from "../../Common/dataAccess/createDocument";
|
||||
@@ -264,6 +264,9 @@ export class CassandraAPIDataClient extends TableDataClient {
|
||||
shouldNotify?: boolean,
|
||||
paginationToken?: string,
|
||||
): Promise<Entities.IListTableEntitiesResult> {
|
||||
if (!this.useCassandraProxyEndpoint("postQuery")) {
|
||||
return this.queryDocuments_ToBeDeprecated(collection, query, shouldNotify, paginationToken);
|
||||
}
|
||||
const clearMessage =
|
||||
shouldNotify && NotificationConsoleUtils.logConsoleProgress(`Querying rows for table ${collection.id()}`);
|
||||
try {
|
||||
@@ -306,6 +309,55 @@ export class CassandraAPIDataClient extends TableDataClient {
|
||||
}
|
||||
}
|
||||
|
||||
public async queryDocuments_ToBeDeprecated(
|
||||
collection: ViewModels.Collection,
|
||||
query: string,
|
||||
shouldNotify?: boolean,
|
||||
paginationToken?: string,
|
||||
): Promise<Entities.IListTableEntitiesResult> {
|
||||
const clearMessage =
|
||||
shouldNotify && NotificationConsoleUtils.logConsoleProgress(`Querying rows for table ${collection.id()}`);
|
||||
try {
|
||||
const { authType, databaseAccount } = userContext;
|
||||
const apiEndpoint: string =
|
||||
authType === AuthType.EncryptedToken
|
||||
? Constants.CassandraBackend.guestQueryApi
|
||||
: Constants.CassandraBackend.queryApi;
|
||||
const data: any = await $.ajax(`${configContext.BACKEND_ENDPOINT}/${apiEndpoint}`, {
|
||||
type: "POST",
|
||||
data: {
|
||||
accountName: databaseAccount?.name,
|
||||
cassandraEndpoint: this.trimCassandraEndpoint(databaseAccount?.properties.cassandraEndpoint),
|
||||
resourceId: databaseAccount?.id,
|
||||
keyspaceId: collection.databaseId,
|
||||
tableId: collection.id(),
|
||||
query,
|
||||
paginationToken,
|
||||
},
|
||||
beforeSend: this.setAuthorizationHeader as any,
|
||||
cache: false,
|
||||
});
|
||||
shouldNotify &&
|
||||
NotificationConsoleUtils.logConsoleInfo(
|
||||
`Successfully fetched ${data.result.length} rows for table ${collection.id()}`,
|
||||
);
|
||||
return {
|
||||
Results: data.result,
|
||||
ContinuationToken: data.paginationToken,
|
||||
};
|
||||
} catch (error) {
|
||||
shouldNotify &&
|
||||
handleError(
|
||||
error,
|
||||
"QueryDocuments_ToBeDeprecated_Cassandra",
|
||||
`Failed to query rows for table ${collection.id()}`,
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
clearMessage?.();
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteDocuments(
|
||||
collection: ViewModels.Collection,
|
||||
entitiesToDelete: Entities.ITableEntity[],
|
||||
@@ -419,6 +471,10 @@ export class CassandraAPIDataClient extends TableDataClient {
|
||||
}
|
||||
|
||||
public getTableKeys(collection: ViewModels.Collection): Q.Promise<CassandraTableKeys> {
|
||||
if (!this.useCassandraProxyEndpoint("getKeys")) {
|
||||
return this.getTableKeys_ToBeDeprecated(collection);
|
||||
}
|
||||
|
||||
if (!!collection.cassandraKeys) {
|
||||
return Q.resolve(collection.cassandraKeys);
|
||||
}
|
||||
@@ -459,7 +515,52 @@ export class CassandraAPIDataClient extends TableDataClient {
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
public getTableKeys_ToBeDeprecated(collection: ViewModels.Collection): Q.Promise<CassandraTableKeys> {
|
||||
if (!!collection.cassandraKeys) {
|
||||
return Q.resolve(collection.cassandraKeys);
|
||||
}
|
||||
const clearInProgressMessage = logConsoleProgress(`Fetching keys for table ${collection.id()}`);
|
||||
const { authType, databaseAccount } = userContext;
|
||||
const apiEndpoint: string =
|
||||
authType === AuthType.EncryptedToken
|
||||
? Constants.CassandraBackend.guestKeysApi
|
||||
: Constants.CassandraBackend.keysApi;
|
||||
let endpoint = `${configContext.BACKEND_ENDPOINT}/${apiEndpoint}`;
|
||||
const deferred = Q.defer<CassandraTableKeys>();
|
||||
|
||||
$.ajax(endpoint, {
|
||||
type: "POST",
|
||||
data: {
|
||||
accountName: databaseAccount?.name,
|
||||
cassandraEndpoint: this.trimCassandraEndpoint(databaseAccount?.properties.cassandraEndpoint),
|
||||
resourceId: databaseAccount?.id,
|
||||
keyspaceId: collection.databaseId,
|
||||
tableId: collection.id(),
|
||||
},
|
||||
beforeSend: this.setAuthorizationHeader as any,
|
||||
cache: false,
|
||||
})
|
||||
.then(
|
||||
(data: CassandraTableKeys) => {
|
||||
collection.cassandraKeys = data;
|
||||
logConsoleInfo(`Successfully fetched keys for table ${collection.id()}`);
|
||||
deferred.resolve(data);
|
||||
},
|
||||
(error: any) => {
|
||||
const errorText = error.responseJSON?.message ?? JSON.stringify(error);
|
||||
handleError(errorText, "FetchKeysCassandra", `Error fetching keys for table ${collection.id()}`);
|
||||
deferred.reject(errorText);
|
||||
},
|
||||
)
|
||||
.done(clearInProgressMessage);
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
public getTableSchema(collection: ViewModels.Collection): Q.Promise<CassandraTableKey[]> {
|
||||
if (!this.useCassandraProxyEndpoint("getSchema")) {
|
||||
return this.getTableSchema_ToBeDeprecated(collection);
|
||||
}
|
||||
|
||||
if (!!collection.cassandraSchema) {
|
||||
return Q.resolve(collection.cassandraSchema);
|
||||
}
|
||||
@@ -501,7 +602,52 @@ export class CassandraAPIDataClient extends TableDataClient {
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
public getTableSchema_ToBeDeprecated(collection: ViewModels.Collection): Q.Promise<CassandraTableKey[]> {
|
||||
if (!!collection.cassandraSchema) {
|
||||
return Q.resolve(collection.cassandraSchema);
|
||||
}
|
||||
const clearInProgressMessage = logConsoleProgress(`Fetching schema for table ${collection.id()}`);
|
||||
const { databaseAccount, authType } = userContext;
|
||||
const apiEndpoint: string =
|
||||
authType === AuthType.EncryptedToken
|
||||
? Constants.CassandraBackend.guestSchemaApi
|
||||
: Constants.CassandraBackend.schemaApi;
|
||||
let endpoint = `${configContext.BACKEND_ENDPOINT}/${apiEndpoint}`;
|
||||
const deferred = Q.defer<CassandraTableKey[]>();
|
||||
|
||||
$.ajax(endpoint, {
|
||||
type: "POST",
|
||||
data: {
|
||||
accountName: databaseAccount?.name,
|
||||
cassandraEndpoint: this.trimCassandraEndpoint(databaseAccount?.properties.cassandraEndpoint),
|
||||
resourceId: databaseAccount?.id,
|
||||
keyspaceId: collection.databaseId,
|
||||
tableId: collection.id(),
|
||||
},
|
||||
beforeSend: this.setAuthorizationHeader as any,
|
||||
cache: false,
|
||||
})
|
||||
.then(
|
||||
(data: any) => {
|
||||
collection.cassandraSchema = data.columns;
|
||||
logConsoleInfo(`Successfully fetched schema for table ${collection.id()}`);
|
||||
deferred.resolve(data.columns);
|
||||
},
|
||||
(error: any) => {
|
||||
const errorText = error.responseJSON?.message ?? JSON.stringify(error);
|
||||
handleError(errorText, "FetchSchemaCassandra", `Error fetching schema for table ${collection.id()}`);
|
||||
deferred.reject(errorText);
|
||||
},
|
||||
)
|
||||
.done(clearInProgressMessage);
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
private createOrDeleteQuery(cassandraEndpoint: string, resourceId: string, query: string): Q.Promise<any> {
|
||||
if (!this.useCassandraProxyEndpoint("createOrDelete")) {
|
||||
return this.createOrDeleteQuery_ToBeDeprecated(cassandraEndpoint, resourceId, query);
|
||||
}
|
||||
|
||||
const deferred = Q.defer();
|
||||
const { authType, databaseAccount } = userContext;
|
||||
const apiEndpoint: string =
|
||||
@@ -531,6 +677,38 @@ export class CassandraAPIDataClient extends TableDataClient {
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
private createOrDeleteQuery_ToBeDeprecated(
|
||||
cassandraEndpoint: string,
|
||||
resourceId: string,
|
||||
query: string,
|
||||
): Q.Promise<any> {
|
||||
const deferred = Q.defer();
|
||||
const { authType, databaseAccount } = userContext;
|
||||
const apiEndpoint: string =
|
||||
authType === AuthType.EncryptedToken
|
||||
? Constants.CassandraBackend.guestCreateOrDeleteApi
|
||||
: Constants.CassandraBackend.createOrDeleteApi;
|
||||
$.ajax(`${configContext.BACKEND_ENDPOINT}/${apiEndpoint}`, {
|
||||
type: "POST",
|
||||
data: {
|
||||
accountName: databaseAccount?.name,
|
||||
cassandraEndpoint: this.trimCassandraEndpoint(cassandraEndpoint),
|
||||
resourceId: resourceId,
|
||||
query: query,
|
||||
},
|
||||
beforeSend: this.setAuthorizationHeader as any,
|
||||
cache: false,
|
||||
}).then(
|
||||
(data: any) => {
|
||||
deferred.resolve();
|
||||
},
|
||||
(reason) => {
|
||||
deferred.reject(reason);
|
||||
},
|
||||
);
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
private trimCassandraEndpoint(cassandraEndpoint: string): string {
|
||||
if (!cassandraEndpoint) {
|
||||
return cassandraEndpoint;
|
||||
@@ -569,4 +747,25 @@ export class CassandraAPIDataClient extends TableDataClient {
|
||||
private getCassandraPartitionKeyProperty(collection: ViewModels.Collection): string {
|
||||
return collection.cassandraKeys.partitionKeys[0].property;
|
||||
}
|
||||
|
||||
private useCassandraProxyEndpoint(api: string): boolean {
|
||||
const activeCassandraProxyEndpoints: string[] = [
|
||||
CassandraProxyEndpoints.Development,
|
||||
CassandraProxyEndpoints.Mpac,
|
||||
CassandraProxyEndpoints.Prod,
|
||||
];
|
||||
let canAccessCassandraProxy: boolean = userContext.databaseAccount.properties.publicNetworkAccess === "Enabled";
|
||||
if (
|
||||
configContext.CASSANDRA_PROXY_ENDPOINT !== CassandraProxyEndpoints.Development &&
|
||||
userContext.databaseAccount.properties.ipRules?.length > 0
|
||||
) {
|
||||
canAccessCassandraProxy = canAccessCassandraProxy && configContext.CASSANDRA_PROXY_OUTBOUND_IPS_ALLOWLISTED;
|
||||
}
|
||||
|
||||
return (
|
||||
canAccessCassandraProxy &&
|
||||
configContext.NEW_CASSANDRA_APIS?.includes(api) &&
|
||||
activeCassandraProxyEndpoints.includes(configContext.CASSANDRA_PROXY_ENDPOINT)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
// 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,10 +1,7 @@
|
||||
import { FeedResponse, ItemDefinition, Resource } from "@azure/cosmos";
|
||||
import { waitFor } from "@testing-library/react";
|
||||
import { deleteDocuments } from "Common/dataAccess/deleteDocument";
|
||||
import { Platform, updateConfigContext } from "ConfigContext";
|
||||
import { useDialog } from "Explorer/Controls/Dialog";
|
||||
import { EditorReactProps } from "Explorer/Controls/Editor/EditorReact";
|
||||
import { ProgressModalDialog } from "Explorer/Controls/ProgressModalDialog";
|
||||
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
|
||||
import {
|
||||
ButtonsDependencies,
|
||||
@@ -16,7 +13,6 @@ import {
|
||||
SAVE_BUTTON_ID,
|
||||
UPDATE_BUTTON_ID,
|
||||
UPLOAD_BUTTON_ID,
|
||||
addStringsNoDuplicate,
|
||||
buildQuery,
|
||||
getDiscardExistingDocumentChangesButtonState,
|
||||
getDiscardNewDocumentChangesButtonState,
|
||||
@@ -68,14 +64,12 @@ jest.mock("Explorer/Controls/Editor/EditorReact", () => ({
|
||||
EditorReact: (props: EditorReactProps) => <>{props.content}</>,
|
||||
}));
|
||||
|
||||
const mockDialogState = {
|
||||
showOkCancelModalDialog: jest.fn((title: string, subText: string, okLabel: string, onOk: () => void) => onOk()),
|
||||
showOkModalDialog: () => {},
|
||||
};
|
||||
|
||||
jest.mock("Explorer/Controls/Dialog", () => ({
|
||||
useDialog: {
|
||||
getState: jest.fn(() => mockDialogState),
|
||||
getState: jest.fn(() => ({
|
||||
showOkCancelModalDialog: (title: string, subText: string, okLabel: string, onOk: () => void) => onOk(),
|
||||
showOkModalDialog: () => {},
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -85,10 +79,6 @@ jest.mock("Common/dataAccess/deleteDocument", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("Explorer/Controls/ProgressModalDialog", () => ({
|
||||
ProgressModalDialog: jest.fn(() => <></>),
|
||||
}));
|
||||
|
||||
async function waitForComponentToPaint<P = unknown>(wrapper: ReactWrapper<P> | ShallowWrapper<P>, amount = 0) {
|
||||
let newWrapper;
|
||||
await act(async () => {
|
||||
@@ -101,13 +91,7 @@ async function waitForComponentToPaint<P = unknown>(wrapper: ReactWrapper<P> | S
|
||||
describe("Documents tab (noSql API)", () => {
|
||||
describe("buildQuery", () => {
|
||||
it("should generate the right select query for SQL API", () => {
|
||||
expect(
|
||||
buildQuery(false, "", ["pk"], {
|
||||
paths: ["pk"],
|
||||
kind: "Hash",
|
||||
version: 2,
|
||||
}),
|
||||
).toContain("select");
|
||||
expect(buildQuery(false, "")).toContain("select");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -355,10 +339,7 @@ describe("Documents tab (noSql API)", () => {
|
||||
const createMockProps = (): IDocumentsTabComponentProps => ({
|
||||
isPreferredApiMongoDB: false,
|
||||
documentIds: [],
|
||||
collection: {
|
||||
id: ko.observable<string>("collectionId"),
|
||||
databaseId: "databaseId",
|
||||
} as ViewModels.CollectionBase,
|
||||
collection: undefined,
|
||||
partitionKey: { kind: "Hash", paths: ["/foo"], version: 2 },
|
||||
onLoadStartKey: 0,
|
||||
tabTitle: "",
|
||||
@@ -399,7 +380,7 @@ describe("Documents tab (noSql API)", () => {
|
||||
.findWhere((node) => node.text() === "Edit Filter")
|
||||
.at(0)
|
||||
.simulate("click");
|
||||
expect(wrapper.find("Input.filterInput").exists()).toBeTruthy();
|
||||
expect(wrapper.find("#filterInput").exists()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -478,29 +459,7 @@ describe("Documents tab (noSql API)", () => {
|
||||
expect(useCommandBar.getState().contextButtons.find((button) => button.id === DISCARD_BUTTON_ID)).toBeDefined();
|
||||
});
|
||||
|
||||
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", () => {
|
||||
it("clicking Delete Document asks for confirmation", () => {
|
||||
const mockDeleteDocuments = deleteDocuments as jest.Mock;
|
||||
mockDeleteDocuments.mockClear();
|
||||
|
||||
@@ -511,18 +470,7 @@ describe("Documents tab (noSql API)", () => {
|
||||
.onCommandClick(undefined);
|
||||
});
|
||||
|
||||
// The implementation uses setTimeout, so wait for it to finish
|
||||
waitFor(() => expect(mockDeleteDocuments).toHaveBeenCalled());
|
||||
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 { deleteDocuments } from "Common/MongoProxyClient";
|
||||
import { deleteDocument } from "Common/MongoProxyClient";
|
||||
import { Platform, updateConfigContext } from "ConfigContext";
|
||||
import { EditorReactProps } from "Explorer/Controls/Editor/EditorReact";
|
||||
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
|
||||
@@ -49,9 +49,7 @@ jest.mock("Common/MongoProxyClient", () => ({
|
||||
id: "id1",
|
||||
}),
|
||||
),
|
||||
deleteDocuments: jest.fn(() => Promise.resolve({ deleteCount: 0, isAcknowledged: true })),
|
||||
ThrottlingError: Error,
|
||||
useMongoProxyEndpoint: jest.fn(() => true),
|
||||
deleteDocument: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock("Explorer/Controls/Editor/EditorReact", () => ({
|
||||
@@ -180,9 +178,9 @@ describe("Documents tab (Mongo API)", () => {
|
||||
expect(useCommandBar.getState().contextButtons.find((button) => button.id === DISCARD_BUTTON_ID)).toBeDefined();
|
||||
});
|
||||
|
||||
it("clicking Delete Document eventually calls delete client api", () => {
|
||||
const mockDeleteDocuments = deleteDocuments as jest.Mock;
|
||||
mockDeleteDocuments.mockClear();
|
||||
it("clicking Delete Document asks for confirmation", () => {
|
||||
const mockDeleteDocument = deleteDocument as jest.Mock;
|
||||
mockDeleteDocument.mockClear();
|
||||
|
||||
act(() => {
|
||||
useCommandBar
|
||||
@@ -191,7 +189,7 @@ describe("Documents tab (Mongo API)", () => {
|
||||
.onCommandClick(undefined);
|
||||
});
|
||||
|
||||
expect(mockDeleteDocuments).toHaveBeenCalled();
|
||||
expect(mockDeleteDocument).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { TableRowId } from "@fluentui/react-components";
|
||||
import { mount } from "enzyme";
|
||||
import React from "react";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import { DocumentsTableComponent, IDocumentsTableComponentProps } from "./DocumentsTableComponent";
|
||||
|
||||
const PARTITION_KEY_HEADER = "partitionKey";
|
||||
@@ -14,25 +13,18 @@ describe("DocumentsTableComponent", () => {
|
||||
{ [ID_HEADER]: "2", [PARTITION_KEY_HEADER]: "pk2" },
|
||||
{ [ID_HEADER]: "3", [PARTITION_KEY_HEADER]: "pk3" },
|
||||
],
|
||||
onItemClicked: (): void => {},
|
||||
onSelectedRowsChange: (): void => {},
|
||||
selectedRows: new Set<TableRowId>(),
|
||||
size: {
|
||||
height: 0,
|
||||
width: 0,
|
||||
},
|
||||
columnDefinitions: [
|
||||
{ id: ID_HEADER, label: "ID", isPartitionKey: false },
|
||||
{ id: PARTITION_KEY_HEADER, label: "Partition Key", isPartitionKey: true },
|
||||
],
|
||||
isRowSelectionDisabled: false,
|
||||
collection: {
|
||||
databaseId: "db",
|
||||
id: ((): string => "coll") as ko.Observable<string>,
|
||||
} as ViewModels.CollectionBase,
|
||||
onRefreshTable: (): void => {
|
||||
throw new Error("Function not implemented.");
|
||||
columnHeaders: {
|
||||
idHeader: ID_HEADER,
|
||||
partitionKeyHeaders: [PARTITION_KEY_HEADER],
|
||||
},
|
||||
selectedColumnIds: [],
|
||||
isSelectionDisabled: false,
|
||||
});
|
||||
|
||||
it("should render documents and partition keys in header", () => {
|
||||
@@ -43,7 +35,7 @@ describe("DocumentsTableComponent", () => {
|
||||
|
||||
it("should not render selection column when isSelectionDisabled is true", () => {
|
||||
const props: IDocumentsTableComponentProps = createMockProps();
|
||||
props.isRowSelectionDisabled = true;
|
||||
props.isSelectionDisabled = true;
|
||||
const wrapper = mount(<DocumentsTableComponent {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -1,84 +1,52 @@
|
||||
import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuDivider,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
MenuPopover,
|
||||
MenuTrigger,
|
||||
TableRowData as RowStateBase,
|
||||
SortDirection,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellLayout,
|
||||
TableColumnDefinition,
|
||||
TableColumnId,
|
||||
TableColumnSizingOptions,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
TableRowId,
|
||||
TableSelectionCell,
|
||||
tokens,
|
||||
createTableColumn,
|
||||
useArrowNavigationGroup,
|
||||
useTableColumnSizing_unstable,
|
||||
useTableFeatures,
|
||||
useTableSelection,
|
||||
useTableSort,
|
||||
} from "@fluentui/react-components";
|
||||
import {
|
||||
ArrowClockwise16Regular,
|
||||
ArrowResetRegular,
|
||||
DeleteRegular,
|
||||
EditRegular,
|
||||
MoreHorizontalRegular,
|
||||
TableResizeColumnRegular,
|
||||
TextSortAscendingRegular,
|
||||
TextSortDescendingRegular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { NormalizedEventKey } from "Common/Constants";
|
||||
import { TableColumnSelectionPane } from "Explorer/Panes/TableColumnSelectionPane/TableColumnSelectionPane";
|
||||
import {
|
||||
ColumnSizesMap,
|
||||
ColumnSort,
|
||||
deleteSubComponentState,
|
||||
readSubComponentState,
|
||||
saveSubComponentState,
|
||||
SubComponentName,
|
||||
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
|
||||
import { INITIAL_SELECTED_ROW_INDEX, useDocumentsTabStyles } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2";
|
||||
import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
|
||||
import { LayoutConstants } from "Explorer/Theme/ThemeUtil";
|
||||
import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils";
|
||||
import { useSidePanel } from "hooks/useSidePanel";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { FixedSizeList as List, ListChildComponentProps } from "react-window";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
|
||||
export type DocumentsTableComponentItem = {
|
||||
id: string;
|
||||
} & Record<string, string | number>;
|
||||
} & Record<string, string>;
|
||||
|
||||
export type ColumnDefinition = {
|
||||
id: string;
|
||||
label: string;
|
||||
isPartitionKey: boolean;
|
||||
export type ColumnHeaders = {
|
||||
idHeader: string;
|
||||
partitionKeyHeaders: string[];
|
||||
};
|
||||
export interface IDocumentsTableComponentProps {
|
||||
onRefreshTable: () => void;
|
||||
items: DocumentsTableComponentItem[];
|
||||
onItemClicked: (index: number) => void;
|
||||
onSelectedRowsChange: (selectedItemsIndices: Set<TableRowId>) => void;
|
||||
selectedRows: Set<TableRowId>;
|
||||
size: { height: number; width: number };
|
||||
selectedColumnIds: string[];
|
||||
columnDefinitions: ColumnDefinition[];
|
||||
columnHeaders: ColumnHeaders;
|
||||
style?: React.CSSProperties;
|
||||
isRowSelectionDisabled?: boolean;
|
||||
collection: ViewModels.CollectionBase;
|
||||
onColumnSelectionChange?: (newSelectedColumnIds: string[]) => void;
|
||||
defaultColumnSelection?: string[];
|
||||
isColumnSelectionDisabled?: boolean;
|
||||
isSelectionDisabled?: boolean;
|
||||
}
|
||||
|
||||
interface TableRowData extends RowStateBase<DocumentsTableComponentItem> {
|
||||
@@ -91,244 +59,92 @@ interface ReactWindowRenderFnProps extends ListChildComponentProps {
|
||||
data: TableRowData[];
|
||||
}
|
||||
|
||||
const COLUMNS_MENU_NAME = "columnsMenu";
|
||||
|
||||
const defaultSize = {
|
||||
idealWidth: 200,
|
||||
minWidth: 50,
|
||||
};
|
||||
|
||||
export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> = ({
|
||||
onRefreshTable,
|
||||
items,
|
||||
onSelectedRowsChange,
|
||||
selectedRows,
|
||||
style,
|
||||
size,
|
||||
selectedColumnIds,
|
||||
columnDefinitions,
|
||||
isRowSelectionDisabled: isSelectionDisabled,
|
||||
collection,
|
||||
onColumnSelectionChange,
|
||||
defaultColumnSelection,
|
||||
isColumnSelectionDisabled,
|
||||
columnHeaders,
|
||||
isSelectionDisabled,
|
||||
}: IDocumentsTableComponentProps) => {
|
||||
const styles = useDocumentsTabStyles();
|
||||
|
||||
const sortedRowsRef = React.useRef(null);
|
||||
|
||||
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(() => {
|
||||
const columnSizesMap: ColumnSizesMap = readSubComponentState(SubComponentName.ColumnSizes, collection, {});
|
||||
const columnSizesPx: TableColumnSizingOptions = {};
|
||||
selectedColumnIds.forEach((columnId) => {
|
||||
if (
|
||||
!columnSizesMap ||
|
||||
!columnSizesMap[columnId] ||
|
||||
columnSizesMap[columnId].widthPx === undefined ||
|
||||
isNaN(columnSizesMap[columnId].widthPx)
|
||||
) {
|
||||
columnSizesPx[columnId] = defaultSize;
|
||||
} else {
|
||||
columnSizesPx[columnId] = {
|
||||
idealWidth: columnSizesMap[columnId].widthPx,
|
||||
const initialSizingOptions: TableColumnSizingOptions = {
|
||||
id: {
|
||||
idealWidth: 280,
|
||||
minWidth: 50,
|
||||
},
|
||||
};
|
||||
columnHeaders.partitionKeyHeaders.forEach((pkHeader) => {
|
||||
initialSizingOptions[pkHeader] = {
|
||||
idealWidth: 200,
|
||||
minWidth: 50,
|
||||
};
|
||||
}
|
||||
});
|
||||
return columnSizesPx;
|
||||
});
|
||||
|
||||
const [sortState, setSortState] = React.useState<{
|
||||
sortDirection: "ascending" | "descending";
|
||||
sortColumn: TableColumnId | undefined;
|
||||
}>(() => {
|
||||
const sort = readSubComponentState<ColumnSort>(SubComponentName.ColumnSort, collection, undefined);
|
||||
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(initialSizingOptions);
|
||||
|
||||
if (!sort) {
|
||||
return {
|
||||
sortDirection: undefined,
|
||||
sortColumn: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sortDirection: sort.direction,
|
||||
sortColumn: sort.columnId,
|
||||
};
|
||||
});
|
||||
|
||||
const onColumnResize = React.useCallback((_, { columnId, width }: { columnId: string; width: number }) => {
|
||||
setColumnSizingOptions((state) => {
|
||||
const newSizingOptions = {
|
||||
const onColumnResize = React.useCallback((_, { columnId, width }) => {
|
||||
setColumnSizingOptions((state) => ({
|
||||
...state,
|
||||
[columnId]: {
|
||||
...state[columnId],
|
||||
idealWidth: width,
|
||||
},
|
||||
};
|
||||
|
||||
const persistentSizes = Object.keys(newSizingOptions).reduce((acc, key) => {
|
||||
acc[key] = {
|
||||
widthPx: newSizingOptions[key].idealWidth,
|
||||
};
|
||||
return acc;
|
||||
}, {} as ColumnSizesMap);
|
||||
|
||||
saveSubComponentState<ColumnSizesMap>(SubComponentName.ColumnSizes, collection, persistentSizes, true);
|
||||
|
||||
return newSizingOptions;
|
||||
});
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// const restoreFocusTargetAttribute = useRestoreFocusTarget();
|
||||
|
||||
const onSortClick = (event: React.SyntheticEvent, columnId: string, direction: SortDirection) => {
|
||||
setColumnSort(event, columnId, direction);
|
||||
|
||||
if (columnId === undefined || direction === undefined) {
|
||||
deleteSubComponentState(SubComponentName.ColumnSort, collection);
|
||||
return;
|
||||
}
|
||||
|
||||
saveSubComponentState<ColumnSort>(SubComponentName.ColumnSort, collection, { columnId, direction });
|
||||
};
|
||||
|
||||
// Columns must be a static object and cannot change on re-renders otherwise React will complain about too many refreshes
|
||||
const columns: TableColumnDefinition<DocumentsTableComponentItem>[] = useMemo(
|
||||
() =>
|
||||
columnDefinitions
|
||||
.filter((column) => selectedColumnIds.includes(column.id))
|
||||
.map((column) => ({
|
||||
columnId: column.id,
|
||||
compare: (a, b) => {
|
||||
if (typeof a[column.id] === "string") {
|
||||
return (a[column.id] as string).localeCompare(b[column.id] as string);
|
||||
} else if (typeof a[column.id] === "number") {
|
||||
return (a[column.id] as number) - (b[column.id] as number);
|
||||
} else {
|
||||
// Should not happen
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
renderHeaderCell: () => (
|
||||
<>
|
||||
<span title={column.label}>{column.label}</span>
|
||||
<Menu>
|
||||
<MenuTrigger disableButtonEnhancement>
|
||||
<Button
|
||||
// {...restoreFocusTargetAttribute}
|
||||
appearance="transparent"
|
||||
aria-label="Select column"
|
||||
size="small"
|
||||
icon={<MoreHorizontalRegular />}
|
||||
style={{ position: "absolute", right: 0, backgroundColor: tokens.colorNeutralBackground1 }}
|
||||
/>
|
||||
</MenuTrigger>
|
||||
<MenuPopover>
|
||||
<MenuList>
|
||||
<MenuItem key="refresh" icon={<ArrowClockwise16Regular />} onClick={onRefreshTable}>
|
||||
Refresh
|
||||
</MenuItem>
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
),
|
||||
[
|
||||
createTableColumn<DocumentsTableComponentItem>({
|
||||
columnId: "id",
|
||||
compare: (a, b) => a.id.localeCompare(b.id),
|
||||
renderHeaderCell: () => columnHeaders.idHeader,
|
||||
renderCell: (item) => (
|
||||
<TableCellLayout truncate title={`${item[column.id]}`}>
|
||||
{item[column.id]}
|
||||
<TableCellLayout truncate title={item.id}>
|
||||
{item.id}
|
||||
</TableCellLayout>
|
||||
),
|
||||
})),
|
||||
[columnDefinitions, onColumnSelectionChange, selectedColumnIds],
|
||||
}),
|
||||
].concat(
|
||||
columnHeaders.partitionKeyHeaders.map((pkHeader) =>
|
||||
createTableColumn<DocumentsTableComponentItem>({
|
||||
columnId: pkHeader,
|
||||
compare: (a, b) => a[pkHeader].localeCompare(b[pkHeader]),
|
||||
// Show Refresh button on last column
|
||||
renderHeaderCell: () => <span title={pkHeader}>{pkHeader}</span>,
|
||||
renderCell: (item) => (
|
||||
<TableCellLayout truncate title={item[pkHeader]}>
|
||||
{item[pkHeader]}
|
||||
</TableCellLayout>
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
[columnHeaders],
|
||||
);
|
||||
|
||||
const [selectionStartIndex, setSelectionStartIndex] = React.useState<number>(INITIAL_SELECTED_ROW_INDEX);
|
||||
const onTableCellClicked = useCallback(
|
||||
(e: React.MouseEvent | undefined, index: number, rowId: TableRowId) => {
|
||||
(e: React.MouseEvent, index: number) => {
|
||||
if (isSelectionDisabled) {
|
||||
// Only allow click
|
||||
onSelectedRowsChange(new Set<TableRowId>([rowId]));
|
||||
onSelectedRowsChange(new Set<TableRowId>([index]));
|
||||
setSelectionStartIndex(index);
|
||||
return;
|
||||
}
|
||||
|
||||
// The selection helper computes in the index space (what's visible to the user in the table, ie the sorted array).
|
||||
// selectedRows is in the rowId space (the index of the original unsorted array), so it must be converted to the index space.
|
||||
const selectedRowsIndex = new Set<number>();
|
||||
selectedRows.forEach((rowId) => {
|
||||
const index = sortedRowsRef.current.findIndex((row: TableRowData) => row.rowId === rowId);
|
||||
if (index !== -1) {
|
||||
selectedRowsIndex.add(index);
|
||||
} else {
|
||||
// This should never happen
|
||||
console.error(`Row with rowId ${rowId} not found in sorted rows`);
|
||||
}
|
||||
});
|
||||
|
||||
const result = selectionHelper(
|
||||
selectedRowsIndex,
|
||||
selectedRows as Set<number>,
|
||||
index,
|
||||
e && isEnvironmentShiftPressed(e),
|
||||
e && isEnvironmentCtrlPressed(e),
|
||||
isEnvironmentShiftPressed(e),
|
||||
isEnvironmentCtrlPressed(e),
|
||||
selectionStartIndex,
|
||||
);
|
||||
|
||||
// Convert selectionHelper result from index space back to rowId space
|
||||
const selectedRowIds = new Set<TableRowId>();
|
||||
result.selection.forEach((index) => {
|
||||
selectedRowIds.add(sortedRowsRef.current[index].rowId);
|
||||
});
|
||||
onSelectedRowsChange(selectedRowIds);
|
||||
|
||||
onSelectedRowsChange(result.selection);
|
||||
if (result.selectionStartIndex !== undefined) {
|
||||
setSelectionStartIndex(result.selectionStartIndex);
|
||||
}
|
||||
@@ -342,20 +158,16 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
|
||||
* - a key is down and the cell is clicked by the mouse
|
||||
*/
|
||||
const onIdClicked = useCallback(
|
||||
(e: React.KeyboardEvent<Element>, rowId: TableRowId) => {
|
||||
(e: React.KeyboardEvent<Element>, index: number) => {
|
||||
if (e.key === NormalizedEventKey.Enter || e.key === NormalizedEventKey.Space) {
|
||||
onSelectedRowsChange(new Set<TableRowId>([rowId]));
|
||||
onSelectedRowsChange(new Set<TableRowId>([index]));
|
||||
}
|
||||
},
|
||||
[onSelectedRowsChange],
|
||||
);
|
||||
|
||||
const RenderRow = ({ index, style, data }: ReactWindowRenderFnProps) => {
|
||||
// WARNING: because the table sorts the data, 'index' is not the same as 'rowId'
|
||||
// The rowId is the index of the item in the original array,
|
||||
// while the index is the index of the item in the sorted array
|
||||
const { item, selected, appearance, onClick, onKeyDown, rowId } = data[index];
|
||||
|
||||
const { item, selected, appearance, onClick, onKeyDown } = data[index];
|
||||
return (
|
||||
<TableRow
|
||||
aria-rowindex={index + 2}
|
||||
@@ -385,8 +197,8 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
|
||||
key={column.columnId}
|
||||
className={styles.tableCell}
|
||||
// When clicking on a cell with shift/ctrl key, onKeyDown is called instead of onClick.
|
||||
onClick={(e: React.MouseEvent<Element, MouseEvent>) => onTableCellClicked(e, index, rowId)}
|
||||
onKeyPress={(e: React.KeyboardEvent<Element>) => onIdClicked(e, rowId)}
|
||||
onClick={(e: React.MouseEvent<Element, MouseEvent>) => onTableCellClicked(e, index)}
|
||||
onKeyPress={(e: React.KeyboardEvent<Element>) => onIdClicked(e, index)}
|
||||
{...columnSizing.getTableCellProps(column.columnId)}
|
||||
tabIndex={column.columnId === "id" ? 0 : -1}
|
||||
>
|
||||
@@ -402,7 +214,6 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
|
||||
columnSizing_unstable: columnSizing,
|
||||
tableRef,
|
||||
selection: { allRowsSelected, someRowsSelected, toggleAllRows, toggleRow, isRowSelected },
|
||||
sort: { getSortDirection, setColumnSort, sort },
|
||||
} = useTableFeatures(
|
||||
{
|
||||
columns,
|
||||
@@ -416,20 +227,10 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
|
||||
// eslint-disable-next-line react/prop-types
|
||||
onSelectionChange: (e, data) => onSelectedRowsChange(data.selectedItems),
|
||||
}),
|
||||
useTableSort({
|
||||
sortState,
|
||||
onSortChange: (e, nextSortState) => setSortState(nextSortState),
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
const headerSortProps = (columnId: TableColumnId) => ({
|
||||
// onClick: (e: React.MouseEvent) => toggleColumnSort(e, columnId),
|
||||
sortDirection: getSortDirection(columnId),
|
||||
});
|
||||
|
||||
const rows: TableRowData[] = sort(
|
||||
getRows((row) => {
|
||||
const rows: TableRowData[] = getRows((row) => {
|
||||
const selected = isRowSelected(row.rowId);
|
||||
return {
|
||||
...row,
|
||||
@@ -443,21 +244,7 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
|
||||
selected,
|
||||
appearance: selected ? ("brand" as const) : ("none" as const),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Store the sorted rows in a ref which won't trigger a re-render (as opposed to a state)
|
||||
sortedRowsRef.current = rows;
|
||||
|
||||
// If there are no selected rows, auto select the first row
|
||||
const [autoSelectFirstDoc, setAutoSelectFirstDoc] = React.useState<boolean>(true);
|
||||
React.useEffect(() => {
|
||||
if (autoSelectFirstDoc && sortedRowsRef.current?.length > 0 && selectedRows.size === 0) {
|
||||
setAutoSelectFirstDoc(false);
|
||||
const DOC_INDEX_TO_SELECT = 0;
|
||||
onTableCellClicked(undefined, DOC_INDEX_TO_SELECT, sortedRowsRef.current[DOC_INDEX_TO_SELECT].rowId);
|
||||
}
|
||||
}, [selectedRows, onTableCellClicked, autoSelectFirstDoc]);
|
||||
});
|
||||
|
||||
const toggleAllKeydown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
@@ -484,53 +271,39 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
|
||||
...style,
|
||||
};
|
||||
|
||||
const checkedValues: { [COLUMNS_MENU_NAME]: string[] } = {
|
||||
[COLUMNS_MENU_NAME]: [],
|
||||
};
|
||||
columnDefinitions.forEach(
|
||||
(columnDefinition) =>
|
||||
selectedColumnIds.includes(columnDefinition.id) && checkedValues[COLUMNS_MENU_NAME].push(columnDefinition.id),
|
||||
);
|
||||
|
||||
const openColumnSelectionPane = (): void => {
|
||||
useSidePanel
|
||||
.getState()
|
||||
.openSidePanel(
|
||||
"Select columns",
|
||||
<TableColumnSelectionPane
|
||||
selectedColumnIds={selectedColumnIds}
|
||||
columnDefinitions={columnDefinitions}
|
||||
onSelectionChange={onColumnSelectionChange}
|
||||
defaultSelection={defaultColumnSelection}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Table noNativeElements {...tableProps}>
|
||||
<TableHeader className={styles.tableHeader}>
|
||||
<TableHeader>
|
||||
<TableRow className={styles.tableRow} style={{ width: size ? size.width - 15 : "100%" }}>
|
||||
{!isSelectionDisabled && (
|
||||
<TableSelectionCell
|
||||
key="selectcell"
|
||||
checked={allRowsSelected ? true : someRowsSelected ? "mixed" : false}
|
||||
onClick={toggleAllRows}
|
||||
onKeyDown={toggleAllKeydown}
|
||||
checkboxIndicator={{ "aria-label": "Select all rows " }}
|
||||
/>
|
||||
)}
|
||||
{columns.map((column) => (
|
||||
{columns.map((column /* index */) => (
|
||||
<Menu openOnContext key={column.columnId}>
|
||||
<MenuTrigger>
|
||||
<TableHeaderCell
|
||||
className={styles.tableCell}
|
||||
key={column.columnId}
|
||||
{...columnSizing.getTableHeaderCellProps(column.columnId)}
|
||||
{...headerSortProps(column.columnId)}
|
||||
>
|
||||
{column.renderHeaderCell()}
|
||||
</TableHeaderCell>
|
||||
</MenuTrigger>
|
||||
<MenuPopover>
|
||||
<MenuList>
|
||||
<MenuItem onClick={columnSizing.enableKeyboardMode(column.columnId)}>
|
||||
Keyboard Column Resizing
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</MenuPopover>
|
||||
</Menu>
|
||||
))}
|
||||
</TableRow>
|
||||
<div className={styles.tableHeaderFiller}></div>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<List
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Utility class to help with selection.
|
||||
* This emulates File Explorer selection behavior.
|
||||
@@ -92,12 +90,3 @@ 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,11 +38,9 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
||||
}
|
||||
}
|
||||
>
|
||||
<Allotment
|
||||
onDragEnd={[Function]}
|
||||
>
|
||||
<Allotment>
|
||||
<Allotment.Pane
|
||||
minSize={55}
|
||||
minSize={175}
|
||||
preferredSize="35%"
|
||||
>
|
||||
<div
|
||||
@@ -55,56 +53,52 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="___9o87uj0_0000000 ffefeo0"
|
||||
className="___77lcry0_0000000 f10pi13n"
|
||||
>
|
||||
<div
|
||||
className="___1rwkz4r_0000000 f1euv43f f1l8gmrm f1e31b4d f150nix6 fy6ml6n f19g0ac"
|
||||
>
|
||||
<Button
|
||||
appearance="transparent"
|
||||
aria-label="Refresh"
|
||||
icon={<ArrowClockwise16Filled />}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
size="small"
|
||||
style={
|
||||
{
|
||||
"height": "100%",
|
||||
"width": "calc(100% + -11px)",
|
||||
"color": undefined,
|
||||
}
|
||||
}
|
||||
>
|
||||
<DocumentsTableComponent
|
||||
collection={
|
||||
{
|
||||
"databaseId": "databaseId",
|
||||
"id": [Function],
|
||||
}
|
||||
}
|
||||
columnDefinitions={
|
||||
[
|
||||
{
|
||||
"id": "id",
|
||||
"isPartitionKey": false,
|
||||
"label": "id",
|
||||
},
|
||||
]
|
||||
}
|
||||
defaultColumnSelection={
|
||||
[
|
||||
"id",
|
||||
]
|
||||
}
|
||||
isColumnSelectionDisabled={false}
|
||||
isRowSelectionDisabled={true}
|
||||
items={[]}
|
||||
onColumnSelectionChange={[Function]}
|
||||
onRefreshTable={[Function]}
|
||||
onSelectedRowsChange={[Function]}
|
||||
selectedColumnIds={
|
||||
[
|
||||
"id",
|
||||
]
|
||||
}
|
||||
selectedRows={Set {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="___9o87uj0_0000000 ffefeo0"
|
||||
>
|
||||
<DocumentsTableComponent
|
||||
columnHeaders={
|
||||
{
|
||||
"idHeader": "id",
|
||||
"partitionKeyHeaders": [],
|
||||
}
|
||||
}
|
||||
isSelectionDisabled={true}
|
||||
items={[]}
|
||||
onItemClicked={[Function]}
|
||||
onSelectedRowsChange={[Function]}
|
||||
selectedRows={
|
||||
Set {
|
||||
0,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Allotment.Pane>
|
||||
<Allotment.Pane
|
||||
minSize={30}
|
||||
minSize={300}
|
||||
preferredSize="65%"
|
||||
>
|
||||
<div
|
||||
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 Explorer from "../../Explorer";
|
||||
import { NewQueryTab } from "../QueryTab/QueryTab";
|
||||
import { IQueryTabComponentProps, ITabAccessor, QueryTabComponent } from "../QueryTab/QueryTabComponent";
|
||||
import QueryTabComponent, { IQueryTabComponentProps, ITabAccessor } from "../QueryTab/QueryTabComponent";
|
||||
|
||||
export interface IMongoQueryTabProps {
|
||||
container: Explorer;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { configContext } from "ConfigContext";
|
||||
import { useMongoProxyEndpoint } from "Common/MongoProxyClient";
|
||||
import React, { Component } from "react";
|
||||
import * as Constants from "../../../Common/Constants";
|
||||
import { configContext } from "../../../ConfigContext";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants";
|
||||
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
|
||||
@@ -48,13 +50,15 @@ export default class MongoShellTabComponent extends Component<
|
||||
IMongoShellTabComponentStates
|
||||
> {
|
||||
private _logTraces: Map<string, number>;
|
||||
private _useMongoProxyEndpoint: boolean;
|
||||
|
||||
constructor(props: IMongoShellTabComponentProps) {
|
||||
super(props);
|
||||
this._logTraces = new Map();
|
||||
this._useMongoProxyEndpoint = useMongoProxyEndpoint("legacyMongoShell");
|
||||
|
||||
this.state = {
|
||||
url: getMongoShellUrl(),
|
||||
url: getMongoShellUrl(this._useMongoProxyEndpoint),
|
||||
};
|
||||
|
||||
props.onMongoShellTabAccessor({
|
||||
@@ -109,8 +113,17 @@ export default class MongoShellTabComponent extends Component<
|
||||
const resourceId = databaseAccount?.id;
|
||||
const accountName = databaseAccount?.name;
|
||||
const documentEndpoint = databaseAccount?.properties.mongoEndpoint || databaseAccount?.properties.documentEndpoint;
|
||||
const mongoEndpoint =
|
||||
documentEndpoint.substr(
|
||||
Constants.MongoDBAccounts.protocol.length + 3,
|
||||
documentEndpoint.length -
|
||||
(Constants.MongoDBAccounts.protocol.length + 2 + Constants.MongoDBAccounts.defaultPort.length),
|
||||
) + Constants.MongoDBAccounts.defaultPort.toString();
|
||||
const databaseId = this.props.collection.databaseId;
|
||||
const collectionId = this.props.collection.id();
|
||||
const apiEndpoint = this._useMongoProxyEndpoint
|
||||
? configContext.MONGO_PROXY_ENDPOINT
|
||||
: configContext.BACKEND_ENDPOINT;
|
||||
const encryptedAuthToken: string = userContext.accessToken;
|
||||
|
||||
shellIframe.contentWindow.postMessage(
|
||||
@@ -119,12 +132,12 @@ export default class MongoShellTabComponent extends Component<
|
||||
data: {
|
||||
resourceId: resourceId,
|
||||
accountName: accountName,
|
||||
mongoEndpoint: documentEndpoint,
|
||||
mongoEndpoint: this._useMongoProxyEndpoint ? documentEndpoint : mongoEndpoint,
|
||||
authorization: authorization,
|
||||
databaseId: databaseId,
|
||||
collectionId: collectionId,
|
||||
encryptedAuthToken: encryptedAuthToken,
|
||||
apiEndpoint: configContext.MONGO_PROXY_ENDPOINT,
|
||||
apiEndpoint: apiEndpoint,
|
||||
},
|
||||
},
|
||||
window.origin,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Platform, resetConfigContext, updateConfigContext } from "../../../Conf
|
||||
import { updateUserContext, userContext } from "../../../UserContext";
|
||||
import { getMongoShellUrl } from "./getMongoShellUrl";
|
||||
|
||||
const mongoBackendEndpoint = "https://localhost:1234";
|
||||
|
||||
describe("getMongoShellUrl", () => {
|
||||
let queryString = "";
|
||||
|
||||
@@ -9,6 +11,7 @@ describe("getMongoShellUrl", () => {
|
||||
resetConfigContext();
|
||||
|
||||
updateConfigContext({
|
||||
BACKEND_ENDPOINT: mongoBackendEndpoint,
|
||||
platform: Platform.Hosted,
|
||||
});
|
||||
|
||||
@@ -34,7 +37,12 @@ describe("getMongoShellUrl", () => {
|
||||
queryString = `resourceId=${userContext.databaseAccount.id}&accountName=${userContext.databaseAccount.name}&mongoEndpoint=${userContext.databaseAccount.properties.documentEndpoint}`;
|
||||
});
|
||||
|
||||
it("should return /index.html by default", () => {
|
||||
expect(getMongoShellUrl().toString()).toContain(`/index.html?${queryString}`);
|
||||
it("should return /indexv2.html by default", () => {
|
||||
expect(getMongoShellUrl().toString()).toContain(`/indexv2.html?${queryString}`);
|
||||
});
|
||||
|
||||
it("should return /index.html when useMongoProxyEndpoint is true", () => {
|
||||
const useMongoProxyEndpoint: boolean = true;
|
||||
expect(getMongoShellUrl(useMongoProxyEndpoint).toString()).toContain(`/index.html?${queryString}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { userContext } from "../../../UserContext";
|
||||
|
||||
export function getMongoShellUrl(): string {
|
||||
export function getMongoShellUrl(useMongoProxyEndpoint?: boolean): string {
|
||||
const { databaseAccount: account } = userContext;
|
||||
const resourceId = account?.id;
|
||||
const accountName = account?.name;
|
||||
const mongoEndpoint = account?.properties?.mongoEndpoint || account?.properties?.documentEndpoint;
|
||||
const queryString = `resourceId=${resourceId}&accountName=${accountName}&mongoEndpoint=${mongoEndpoint}`;
|
||||
|
||||
return `/mongoshell/index.html?${queryString}`;
|
||||
return useMongoProxyEndpoint ? `/mongoshell/index.html?${queryString}` : `/mongoshell/indexv2.html?${queryString}`;
|
||||
}
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
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,93 +1,544 @@
|
||||
import { Link } from "@fluentui/react-components";
|
||||
import QueryError from "Common/QueryError";
|
||||
import { IndeterminateProgressBar } from "Explorer/Controls/IndeterminateProgressBar";
|
||||
import { MessageBanner } from "Explorer/Controls/MessageBanner";
|
||||
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
|
||||
import {
|
||||
DetailsList,
|
||||
DetailsListLayoutMode,
|
||||
IColumn,
|
||||
Icon,
|
||||
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 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 InfoColor from "../../../../images/info_color.svg";
|
||||
import { QueryResults } from "../../../Contracts/ViewModels";
|
||||
import { ErrorList } from "./ErrorList";
|
||||
import { ResultsView } from "./ResultsView";
|
||||
|
||||
export interface ResultsViewProps {
|
||||
interface QueryResultProps {
|
||||
isMongoDB: boolean;
|
||||
queryEditorContent: string;
|
||||
error: string;
|
||||
isExecuting: boolean;
|
||||
queryResults: QueryResults;
|
||||
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> = ({
|
||||
isMongoDB,
|
||||
queryEditorContent,
|
||||
errors,
|
||||
error,
|
||||
queryResults,
|
||||
executeQueryDocumentsPage,
|
||||
isExecuting,
|
||||
executeQueryDocumentsPage,
|
||||
}: QueryResultProps): JSX.Element => {
|
||||
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 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 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 (
|
||||
<div data-test="QueryTab/ResultsPane" className={styles.queryResultsPanel}>
|
||||
{isExecuting && <IndeterminateProgressBar />}
|
||||
<MessageBanner
|
||||
messageId="QueryEditor.EmptyMongoQuery"
|
||||
visible={isMongoDB && queryEditorContent.length === 0}
|
||||
className={styles.queryResultsMessage}
|
||||
>
|
||||
<Stack style={{ height: "100%" }}>
|
||||
{isMongoDB && queryEditorContent.length === 0 && (
|
||||
<div className="mongoQueryHelper">
|
||||
Start by writing a Mongo query, for example: <strong>{"{'id':'foo'}"}</strong> or{" "}
|
||||
<strong>
|
||||
{"{ "}
|
||||
{" }"}
|
||||
</strong>{" "}
|
||||
to get all the documents.
|
||||
</MessageBanner>
|
||||
{/* {maybeSubQuery && ( */}
|
||||
<MessageBanner
|
||||
messageId="QueryEditor.SubQueryWarning"
|
||||
visible={maybeSubQuery}
|
||||
className={styles.queryResultsMessage}
|
||||
>
|
||||
</div>
|
||||
)}
|
||||
{maybeSubQuery && (
|
||||
<div className="warningErrorContainer" aria-live="assertive">
|
||||
<div className="warningErrorContent">
|
||||
<span>
|
||||
<img className="paneErrorIcon" src={InfoColor} alt="Error" />
|
||||
</span>
|
||||
<span className="warningErrorDetailsLinkContainer">
|
||||
We detected you may be using a subquery. To learn more about subqueries effectively,{" "}
|
||||
<Link
|
||||
<a
|
||||
href="https://learn.microsoft.com/azure/cosmos-db/nosql/query/subquery"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
rel="noreferrer"
|
||||
>
|
||||
visit the documentation
|
||||
</Link>
|
||||
</MessageBanner>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* <!-- Query Errors Tab - Start--> */}
|
||||
{error && (
|
||||
<div className="active queryErrorsHeaderContainer">
|
||||
<span className="queryErrors" data-toggle="tab">
|
||||
Errors
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* <!-- Query Errors Tab - End --> */}
|
||||
{/* <!-- Query Results & Errors Content Container - Start--> */}
|
||||
{errors.length > 0 ? (
|
||||
<ErrorList errors={errors} />
|
||||
) : queryResults ? (
|
||||
<ResultsView
|
||||
queryResults={queryResults}
|
||||
executeQueryDocumentsPage={executeQueryDocumentsPage}
|
||||
isMongoDB={isMongoDB}
|
||||
<div className="queryResultErrorContentContainer">
|
||||
{!queryResults && !error && !isExecuting && (
|
||||
<div className="queryEditorWatermark">
|
||||
<p>
|
||||
<img src={RunQuery} alt="Execute Query Watermark" />
|
||||
</p>
|
||||
<p className="queryEditorWatermarkText">Execute a query to see the results</p>
|
||||
</div>
|
||||
)}
|
||||
{(queryResults || !!error) && (
|
||||
<div className="queryResultsErrorsContent">
|
||||
{!error && (
|
||||
<Pivot aria-label="Successful execution" style={{ height: "100%" }}>
|
||||
<PivotItem
|
||||
headerText="Results"
|
||||
headerButtonProps={{
|
||||
"data-order": 1,
|
||||
"data-title": "Results",
|
||||
}}
|
||||
style={{ height: "100%" }}
|
||||
>
|
||||
<div className="result-metadata">
|
||||
<span>
|
||||
<span>
|
||||
{queryResults.itemCount > 0
|
||||
? `${queryResults.firstItemIndex} - ${queryResults.lastItemIndex}`
|
||||
: `0 - 0`}
|
||||
</span>
|
||||
</span>
|
||||
{queryResults.hasMoreResults && (
|
||||
<>
|
||||
<span className="queryResultDivider">|</span>
|
||||
<span className="queryResultNextEnable">
|
||||
<a onClick={() => onFetchNextPageClick()}>
|
||||
<span>Load more</span>
|
||||
<img className="queryResultnextImg" src={QueryEditorNext} alt="Fetch next page" />
|
||||
</a>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<IconButton
|
||||
style={{
|
||||
height: "100%",
|
||||
verticalAlign: "middle",
|
||||
float: "right",
|
||||
}}
|
||||
iconProps={{ imageProps: { src: CopilotCopy } }}
|
||||
title="Copy to Clipboard"
|
||||
ariaLabel="Copy"
|
||||
onClick={onClickCopyResults}
|
||||
/>
|
||||
) : (
|
||||
<ExecuteQueryCallToAction />
|
||||
</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,11 +7,10 @@ import * as DataModels from "../../../Contracts/DataModels";
|
||||
import type { QueryTabOptions } from "../../../Contracts/ViewModels";
|
||||
import { useTabs } from "../../../hooks/useTabs";
|
||||
import Explorer from "../../Explorer";
|
||||
import {
|
||||
import QueryTabComponent, {
|
||||
IQueryTabComponentProps,
|
||||
ITabAccessor,
|
||||
QueryTabComponent,
|
||||
QueryTabCopilotComponent,
|
||||
QueryTabFunctionComponent,
|
||||
} from "../../Tabs/QueryTab/QueryTabComponent";
|
||||
import TabsBase from "../TabsBase";
|
||||
|
||||
@@ -50,7 +49,7 @@ export class NewQueryTab extends TabsBase {
|
||||
public render(): JSX.Element {
|
||||
return userContext.apiType === "SQL" ? (
|
||||
<CopilotProvider>
|
||||
<QueryTabCopilotComponent {...this.iQueryTabComponentProps} />
|
||||
<QueryTabFunctionComponent {...this.iQueryTabComponentProps} />
|
||||
</CopilotProvider>
|
||||
) : (
|
||||
<QueryTabComponent {...this.iQueryTabComponentProps} />
|
||||
|
||||
@@ -2,14 +2,11 @@ import { fireEvent, render } from "@testing-library/react";
|
||||
import { CollectionTabKind } from "Contracts/ViewModels";
|
||||
import { CopilotProvider } from "Explorer/QueryCopilot/QueryCopilotContext";
|
||||
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
|
||||
import { CopilotSubComponentNames } from "Explorer/QueryCopilot/QueryCopilotUtilities";
|
||||
import {
|
||||
import QueryTabComponent, {
|
||||
IQueryTabComponentProps,
|
||||
QueryTabComponent,
|
||||
QueryTabCopilotComponent,
|
||||
QueryTabFunctionComponent,
|
||||
} from "Explorer/Tabs/QueryTab/QueryTabComponent";
|
||||
import TabsBase from "Explorer/Tabs/TabsBase";
|
||||
import { AppStateComponentNames, StorePath } from "Shared/AppStatePersistenceUtility";
|
||||
import { updateUserContext, userContext } from "UserContext";
|
||||
import { mount } from "enzyme";
|
||||
import { useQueryCopilot } from "hooks/useQueryCopilot";
|
||||
@@ -18,24 +15,6 @@ import React from "react";
|
||||
|
||||
jest.mock("Explorer/Controls/Editor/EditorReact");
|
||||
|
||||
const loadState = (path: StorePath) => {
|
||||
if (
|
||||
path.componentName === AppStateComponentNames.QueryCopilot &&
|
||||
path.subComponentName === CopilotSubComponentNames.toggleStatus
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
jest.mock("Shared/AppStatePersistenceUtility", () => ({
|
||||
loadState,
|
||||
AppStateComponentNames: {
|
||||
QueryCopilot: "QueryCopilot",
|
||||
},
|
||||
}));
|
||||
|
||||
describe("QueryTabComponent", () => {
|
||||
const mockStore = useQueryCopilot.getState();
|
||||
beforeEach(() => {
|
||||
@@ -52,7 +31,7 @@ describe("QueryTabComponent", () => {
|
||||
},
|
||||
});
|
||||
const propsMock: Readonly<IQueryTabComponentProps> = {
|
||||
collection: { databaseId: "CopilotSampleDB" },
|
||||
collection: { databaseId: "CopilotSampleDb" },
|
||||
onTabAccessor: () => jest.fn(),
|
||||
isExecutionError: false,
|
||||
tabId: "mockTabId",
|
||||
@@ -63,24 +42,13 @@ describe("QueryTabComponent", () => {
|
||||
|
||||
const { container } = render(<QueryTabComponent {...propsMock} />);
|
||||
|
||||
const launchCopilotButton = container.querySelector('[data-test="QueryTab/ResultsPane/ExecuteCTA"]');
|
||||
const launchCopilotButton = container.querySelector(".queryEditorWatermarkText");
|
||||
fireEvent.keyDown(launchCopilotButton, { key: "c", altKey: true });
|
||||
|
||||
expect(mockStore.setShowCopilotSidebar).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("copilot should be enabled by default when tab is active", () => {
|
||||
updateUserContext({
|
||||
databaseAccount: {
|
||||
name: "name",
|
||||
properties: undefined,
|
||||
id: "",
|
||||
location: "",
|
||||
type: "",
|
||||
kind: "",
|
||||
},
|
||||
});
|
||||
|
||||
useQueryCopilot.getState().setCopilotEnabled(true);
|
||||
useQueryCopilot.getState().setCopilotUserDBEnabled(true);
|
||||
const activeTab = new TabsBase({
|
||||
@@ -102,7 +70,7 @@ describe("QueryTabComponent", () => {
|
||||
|
||||
const container = mount(
|
||||
<CopilotProvider>
|
||||
<QueryTabCopilotComponent {...propsMock} />
|
||||
<QueryTabFunctionComponent {...propsMock} />
|
||||
</CopilotProvider>,
|
||||
);
|
||||
expect(container.find(QueryCopilotPromptbar).exists()).toBe(true);
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable no-console */
|
||||
import { FeedOptions, QueryOperationOptions } from "@azure/cosmos";
|
||||
import QueryError, { createMonacoErrorLocationResolver, createMonacoMarkersForQueryErrors } from "Common/QueryError";
|
||||
import { SplitterDirection } from "Common/Splitter";
|
||||
import { Platform, configContext } from "ConfigContext";
|
||||
import { useDialog } from "Explorer/Controls/Dialog";
|
||||
import { monaco } from "Explorer/LazyMonaco";
|
||||
import { QueryCopilotFeedbackModal } from "Explorer/QueryCopilot/Modal/QueryCopilotFeedbackModal";
|
||||
import { useCopilotStore } from "Explorer/QueryCopilot/QueryCopilotContext";
|
||||
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
|
||||
import { readCopilotToggleStatus, saveCopilotToggleStatus } from "Explorer/QueryCopilot/QueryCopilotUtilities";
|
||||
import { OnExecuteQueryClick, QueryDocumentsPerPage } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
|
||||
import { QueryCopilotSidebar } from "Explorer/QueryCopilot/V2/Sidebar/QueryCopilotSidebar";
|
||||
import { QueryResultSection } from "Explorer/Tabs/QueryTab/QueryResultSection";
|
||||
import { QueryTabStyles, useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
|
||||
import { CosmosFluentProvider } from "Explorer/Theme/ThemeUtil";
|
||||
import { useSelectedNode } from "Explorer/useSelectedNode";
|
||||
import { KeyboardAction } from "KeyboardShortcuts";
|
||||
import { QueryConstants } from "Shared/Constants";
|
||||
@@ -26,10 +21,10 @@ import {
|
||||
ruThresholdEnabled,
|
||||
} from "Shared/StorageUtility";
|
||||
import { Action } from "Shared/Telemetry/TelemetryConstants";
|
||||
import { Allotment } from "allotment";
|
||||
import { QueryCopilotState, useQueryCopilot } from "hooks/useQueryCopilot";
|
||||
import { TabsState, useTabs } from "hooks/useTabs";
|
||||
import React, { Fragment, createRef } from "react";
|
||||
import React, { Fragment } from "react";
|
||||
import SplitterLayout from "react-splitter-layout";
|
||||
import "react-splitter-layout/lib/index.css";
|
||||
import { format } from "react-string-format";
|
||||
import QueryCommandIcon from "../../../../images/CopilotCommand.svg";
|
||||
@@ -40,6 +35,7 @@ import ExecuteQueryIcon from "../../../../images/ExecuteQuery.svg";
|
||||
import CheckIcon from "../../../../images/check-1.svg";
|
||||
import SaveQueryIcon from "../../../../images/save-cosmos.svg";
|
||||
import { NormalizedEventKey } from "../../../Common/Constants";
|
||||
import { getErrorMessage } from "../../../Common/ErrorHandlingUtils";
|
||||
import * as HeadersUtility from "../../../Common/HeadersUtility";
|
||||
import { MinimalQueryIterator } from "../../../Common/IteratorUtilities";
|
||||
import { queryIterator } from "../../../Common/MongoProxyClient";
|
||||
@@ -47,6 +43,7 @@ import { queryDocuments } from "../../../Common/dataAccess/queryDocuments";
|
||||
import { queryDocumentsPage } from "../../../Common/dataAccess/queryDocumentsPage";
|
||||
import * as DataModels from "../../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import * as StringUtility from "../../../Shared/StringUtility";
|
||||
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { userContext } from "../../../UserContext";
|
||||
import * as QueryUtils from "../../../Utils/QueryUtils";
|
||||
@@ -105,9 +102,8 @@ interface IQueryTabStates {
|
||||
toggleState: ToggleState;
|
||||
sqlQueryEditorContent: 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;
|
||||
error: string;
|
||||
isExecutionError: boolean;
|
||||
isExecuting: boolean;
|
||||
showCopilotSidebar: boolean;
|
||||
@@ -116,12 +112,9 @@ interface IQueryTabStates {
|
||||
copilotActive: boolean;
|
||||
currentTabActive: boolean;
|
||||
queryResultsView: SplitterDirection;
|
||||
errors?: QueryError[];
|
||||
modelMarkers?: monaco.editor.IMarkerData[];
|
||||
}
|
||||
|
||||
export const QueryTabCopilotComponent = (props: IQueryTabComponentProps): any => {
|
||||
const styles = useQueryTabStyles();
|
||||
export const QueryTabFunctionComponent = (props: IQueryTabComponentProps): any => {
|
||||
const copilotStore = useCopilotStore();
|
||||
const isSampleCopilotActive = useSelectedNode.getState().isQueryCopilotCollectionSelected();
|
||||
const queryTabProps = {
|
||||
@@ -132,20 +125,10 @@ export const QueryTabCopilotComponent = (props: IQueryTabComponentProps): any =>
|
||||
isSampleCopilotActive: isSampleCopilotActive,
|
||||
copilotStore: copilotStore,
|
||||
};
|
||||
return <QueryTabComponentImpl styles={styles} {...queryTabProps}></QueryTabComponentImpl>;
|
||||
return <QueryTabComponent {...queryTabProps}></QueryTabComponent>;
|
||||
};
|
||||
|
||||
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> {
|
||||
export default class QueryTabComponent extends React.Component<IQueryTabComponentProps, IQueryTabStates> {
|
||||
public queryEditorId: string;
|
||||
public executeQueryButton: Button;
|
||||
public saveQueryButton: Button;
|
||||
@@ -156,19 +139,16 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
public isCopilotTabActive: boolean;
|
||||
private _iterator: MinimalQueryIterator;
|
||||
private queryAbortController: AbortController;
|
||||
queryEditor: React.RefObject<EditorReact>;
|
||||
|
||||
constructor(props: QueryTabComponentImplProps) {
|
||||
constructor(props: IQueryTabComponentProps) {
|
||||
super(props);
|
||||
|
||||
this.queryEditor = createRef<EditorReact>();
|
||||
|
||||
this.state = {
|
||||
toggleState: ToggleState.Result,
|
||||
sqlQueryEditorContent: props.isPreferredApiMongoDB ? "{}" : props.queryText || "SELECT * FROM c",
|
||||
selectedContent: "",
|
||||
queryResults: undefined,
|
||||
errors: [],
|
||||
error: "",
|
||||
isExecutionError: this.props.isExecutionError,
|
||||
isExecuting: false,
|
||||
showCopilotSidebar: useQueryCopilot.getState().showCopilotSidebar,
|
||||
@@ -209,7 +189,13 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
|
||||
private _queryCopilotActive(): boolean {
|
||||
if (this.props.copilotEnabled) {
|
||||
return readCopilotToggleStatus(userContext.databaseAccount);
|
||||
const cachedCopilotToggleStatus: string = localStorage.getItem(
|
||||
`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`,
|
||||
);
|
||||
const copilotInitialActive: boolean = cachedCopilotToggleStatus
|
||||
? StringUtility.toBoolean(cachedCopilotToggleStatus)
|
||||
: true;
|
||||
return copilotInitialActive;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -235,10 +221,9 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
|
||||
public onExecuteQueryClick = async (): Promise<void> => {
|
||||
this._iterator = undefined;
|
||||
|
||||
setTimeout(async () => {
|
||||
await this._executeQueryDocumentsPage(0);
|
||||
}, 100); // TODO: Revert this
|
||||
}, 100);
|
||||
if (this.state.copilotActive) {
|
||||
const query = this.state.sqlQueryEditorContent.split("\r\n")?.pop();
|
||||
const isqueryEdited = this.props.copilotStore.generatedQuery && this.props.copilotStore.generatedQuery !== query;
|
||||
@@ -317,22 +302,23 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
}
|
||||
|
||||
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();
|
||||
if (this._iterator === undefined) {
|
||||
this._iterator = this.props.isPreferredApiMongoDB
|
||||
? queryIterator(this.props.collection.databaseId, this.props.viewModelcollection, query)
|
||||
: queryDocuments(this.props.collection.databaseId, this.props.collection.id(), query, {
|
||||
? queryIterator(
|
||||
this.props.collection.databaseId,
|
||||
this.props.viewModelcollection,
|
||||
this.state.selectedContent || this.state.sqlQueryEditorContent,
|
||||
)
|
||||
: queryDocuments(
|
||||
this.props.collection.databaseId,
|
||||
this.props.collection.id(),
|
||||
this.state.selectedContent || this.state.sqlQueryEditorContent,
|
||||
{
|
||||
enableCrossPartitionQuery: HeadersUtility.shouldEnableCrossPartitionKey(),
|
||||
abortSignal: this.queryAbortController.signal,
|
||||
} as unknown as FeedOptions);
|
||||
} as unknown as FeedOptions,
|
||||
);
|
||||
}
|
||||
|
||||
await this._queryDocumentsPage(firstItemIndex);
|
||||
@@ -397,22 +383,18 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
firstItemIndex,
|
||||
queryDocuments,
|
||||
);
|
||||
this.setState({ queryResults, errors: [] });
|
||||
this.setState({ queryResults, error: "" });
|
||||
} catch (error) {
|
||||
this.props.tabsBaseInstance.isExecutionError(true);
|
||||
this.setState({
|
||||
isExecutionError: true,
|
||||
});
|
||||
|
||||
// Try to parse this as a query error
|
||||
const queryErrors = QueryError.tryParse(
|
||||
error,
|
||||
createMonacoErrorLocationResolver(this.queryEditor.current.editor, this.state.executedSelection),
|
||||
);
|
||||
const errorMessage = getErrorMessage(error);
|
||||
this.setState({
|
||||
errors: queryErrors,
|
||||
modelMarkers: createMonacoMarkersForQueryErrors(queryErrors),
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
document.getElementById("error-display").focus();
|
||||
} finally {
|
||||
this.props.tabsBaseInstance.isExecuting(false);
|
||||
this.setState({
|
||||
@@ -578,7 +560,7 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
private _toggleCopilot = (active: boolean) => {
|
||||
this.setState({ copilotActive: active });
|
||||
useQueryCopilot.getState().setCopilotEnabledforExecution(active);
|
||||
saveCopilotToggleStatus(userContext.databaseAccount, active);
|
||||
localStorage.setItem(`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`, active.toString());
|
||||
|
||||
TelemetryProcessor.traceSuccess(active ? Action.ActivateQueryCopilot : Action.DeactivateQueryCopilot, {
|
||||
databaseName: this.props.collection.databaseId,
|
||||
@@ -602,9 +584,6 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
this.setState({
|
||||
sqlQueryEditorContent: newContent,
|
||||
queryCopilotGeneratedQuery: "",
|
||||
|
||||
// Clear the markers when the user edits the document.
|
||||
modelMarkers: [],
|
||||
});
|
||||
if (this.isPreferredApiMongoDB) {
|
||||
if (newContent.length > 0) {
|
||||
@@ -625,16 +604,14 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
useCommandBar.getState().setContextButtons(this.getTabsButtons());
|
||||
}
|
||||
|
||||
public onSelectedContent(selectedContent: string, selection: monaco.Selection): void {
|
||||
public onSelectedContent(selectedContent: string): void {
|
||||
if (selectedContent.trim().length > 0) {
|
||||
this.setState({
|
||||
selectedContent,
|
||||
selection,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
selectedContent: "",
|
||||
selection: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -691,10 +668,9 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
}
|
||||
|
||||
private getEditorAndQueryResult(): JSX.Element {
|
||||
const vertical = this.state.queryResultsView === SplitterDirection.Horizontal;
|
||||
return (
|
||||
<Fragment>
|
||||
<CosmosFluentProvider id={this.props.tabId} className={this.props.styles.queryTab} role="tabpanel">
|
||||
<div className="tab-pane" id={this.props.tabId} role="tabpanel">
|
||||
{this.props.copilotEnabled && this.state.currentTabActive && this.state.copilotActive && (
|
||||
<QueryCopilotPromptbar
|
||||
explorer={this.props.collection.container}
|
||||
@@ -703,33 +679,40 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
containerId={this.props.collection.id()}
|
||||
></QueryCopilotPromptbar>
|
||||
)}
|
||||
{/* Set 'key' to the value of vertical to force re-rendering when vertical changes, to work around https://github.com/johnwalley/allotment/issues/457 */}
|
||||
<Allotment key={vertical.toString()} vertical={vertical}>
|
||||
<Allotment.Pane data-test="QueryTab/EditorPane">
|
||||
<div className="tabPaneContentContainer">
|
||||
<SplitterLayout
|
||||
primaryIndex={0}
|
||||
primaryMinSize={20}
|
||||
secondaryMinSize={20}
|
||||
// Percentage is a bit better when the splitter flips from vertical to horizontal.
|
||||
percentage={true}
|
||||
// NOTE: It is intentional that this looks reversed!
|
||||
// The 'vertical' property refers to the stacking of the panes so is the opposite of the orientation of the splitter itself
|
||||
// (vertically stacked => horizontal splitter)
|
||||
// Our setting refers to the orientation of the splitter, so we need to reverse it here.
|
||||
vertical={this.state.queryResultsView === SplitterDirection.Horizontal}
|
||||
>
|
||||
<Fragment>
|
||||
<div className="queryEditor" style={{ height: "100%" }}>
|
||||
<EditorReact
|
||||
ref={this.queryEditor}
|
||||
className={this.props.styles.queryEditor}
|
||||
language={"sql"}
|
||||
content={this.getEditorContent()}
|
||||
modelMarkers={this.state.modelMarkers}
|
||||
isReadOnly={false}
|
||||
wordWrap={"on"}
|
||||
ariaLabel={"Editing Query"}
|
||||
lineNumbers={"on"}
|
||||
onContentChanged={(newContent: string) => this.onChangeContent(newContent)}
|
||||
onContentSelected={(selectedContent: string, selection: monaco.Selection) =>
|
||||
this.onSelectedContent(selectedContent, selection)
|
||||
}
|
||||
onContentSelected={(selectedContent: string) => this.onSelectedContent(selectedContent)}
|
||||
/>
|
||||
</Allotment.Pane>
|
||||
<Allotment.Pane>
|
||||
</div>
|
||||
</Fragment>
|
||||
{this.props.isSampleCopilotActive ? (
|
||||
<QueryResultSection
|
||||
isMongoDB={this.props.isPreferredApiMongoDB}
|
||||
queryEditorContent={this.state.sqlQueryEditorContent}
|
||||
errors={this.props.copilotStore?.errors}
|
||||
isExecuting={this.props.copilotStore?.isExecuting}
|
||||
error={this.props.copilotStore?.errorMessage}
|
||||
queryResults={this.props.copilotStore?.queryResults}
|
||||
isExecuting={this.props.copilotStore?.isExecuting}
|
||||
executeQueryDocumentsPage={(firstItemIndex: number) =>
|
||||
QueryDocumentsPerPage(
|
||||
firstItemIndex,
|
||||
@@ -742,17 +725,17 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
<QueryResultSection
|
||||
isMongoDB={this.props.isPreferredApiMongoDB}
|
||||
queryEditorContent={this.state.sqlQueryEditorContent}
|
||||
errors={this.state.errors}
|
||||
isExecuting={this.state.isExecuting}
|
||||
error={this.state.error}
|
||||
queryResults={this.state.queryResults}
|
||||
isExecuting={this.state.isExecuting}
|
||||
executeQueryDocumentsPage={(firstItemIndex: number) =>
|
||||
this._executeQueryDocumentsPage(firstItemIndex)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Allotment.Pane>
|
||||
</Allotment>
|
||||
</CosmosFluentProvider>
|
||||
</SplitterLayout>
|
||||
</div>
|
||||
</div>
|
||||
{this.props.copilotEnabled && this.props.copilotStore?.showFeedbackModal && (
|
||||
<QueryCopilotFeedbackModal
|
||||
explorer={this.props.collection.container}
|
||||
@@ -768,7 +751,7 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
render(): JSX.Element {
|
||||
const shouldScaleElements = this.state.showCopilotSidebar && this.isCopilotTabActive;
|
||||
return (
|
||||
<div data-test="QueryTab" style={{ display: "flex", flexDirection: "row", height: "100%" }}>
|
||||
<div style={{ display: "flex", flexDirection: "row", height: "100%" }}>
|
||||
<div style={{ width: shouldScaleElements ? "70%" : "100%", height: "100%" }}>
|
||||
{this.getEditorAndQueryResult()}
|
||||
</div>
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,96 +0,0 @@
|
||||
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,5 +1,8 @@
|
||||
import { IMessageBarStyles, MessageBar, MessageBarButton, MessageBarType } from "@fluentui/react";
|
||||
import { IMessageBarStyles, Link, MessageBar, MessageBarButton, MessageBarType } from "@fluentui/react";
|
||||
import { CassandraProxyEndpoints, MongoProxyEndpoints } from "Common/Constants";
|
||||
import { sendMessage } from "Common/MessageHandler";
|
||||
import { Platform, configContext, updateConfigContext } from "ConfigContext";
|
||||
import { IpRule } from "Contracts/DataModels";
|
||||
import { MessageTypes } from "Contracts/ExplorerContracts";
|
||||
import { CollectionTabKind } from "Contracts/ViewModels";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
@@ -13,7 +16,9 @@ import { VcoreMongoConnectTab } from "Explorer/Tabs/VCoreMongoConnectTab";
|
||||
import { VcoreMongoQuickstartTab } from "Explorer/Tabs/VCoreMongoQuickstartTab";
|
||||
import { LayoutConstants } from "Explorer/Theme/ThemeUtil";
|
||||
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
|
||||
import { hasRUThresholdBeenConfigured } from "Shared/StorageUtility";
|
||||
import { userContext } from "UserContext";
|
||||
import { CassandraProxyOutboundIPs, MongoProxyOutboundIPs, PortalBackendIPs } from "Utils/EndpointUtils";
|
||||
import { useTeachingBubble } from "hooks/useTeachingBubble";
|
||||
import ko from "knockout";
|
||||
import React, { MutableRefObject, useEffect, useRef, useState } from "react";
|
||||
@@ -32,6 +37,13 @@ interface TabsProps {
|
||||
|
||||
export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
|
||||
const { openedTabs, openedReactTabs, activeTab, activeReactTab, networkSettingsWarning } = useTabs();
|
||||
const [showRUThresholdMessageBar, setShowRUThresholdMessageBar] = useState<boolean>(
|
||||
userContext.apiType === "SQL" && configContext.platform !== Platform.Fabric && !hasRUThresholdBeenConfigured(),
|
||||
);
|
||||
const [
|
||||
showMongoAndCassandraProxiesNetworkSettingsWarningState,
|
||||
setShowMongoAndCassandraProxiesNetworkSettingsWarningState,
|
||||
] = useState<boolean>(showMongoAndCassandraProxiesNetworkSettingsWarning());
|
||||
|
||||
const setKeyboardHandlers = useKeyboardActionGroup(KeyboardActionGroup.TABS);
|
||||
useEffect(() => {
|
||||
@@ -45,8 +57,7 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
|
||||
const defaultMessageBarStyles: IMessageBarStyles = {
|
||||
root: {
|
||||
height: `${LayoutConstants.rowHeight}px`,
|
||||
overflow: "hidden",
|
||||
flexDirection: "row",
|
||||
overflow: "auto",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -75,6 +86,42 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
|
||||
{networkSettingsWarning}
|
||||
</MessageBar>
|
||||
)}
|
||||
{showRUThresholdMessageBar && (
|
||||
<MessageBar
|
||||
messageBarType={MessageBarType.info}
|
||||
onDismiss={() => {
|
||||
setShowRUThresholdMessageBar(false);
|
||||
}}
|
||||
styles={{
|
||||
...defaultMessageBarStyles,
|
||||
innerText: {
|
||||
fontWeight: "bold",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{`To prevent queries from using excessive RUs, Data Explorer has a 5,000 RU default limit. To modify or remove
|
||||
the limit, go to the Settings cog on the right and find "RU Threshold".`}
|
||||
<Link
|
||||
className="underlinedLink"
|
||||
href="https://review.learn.microsoft.com/en-us/azure/cosmos-db/data-explorer?branch=main#configure-request-unit-threshold"
|
||||
target="_blank"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</MessageBar>
|
||||
)}
|
||||
{showMongoAndCassandraProxiesNetworkSettingsWarningState && (
|
||||
<MessageBar
|
||||
messageBarType={MessageBarType.warning}
|
||||
styles={defaultMessageBarStyles}
|
||||
onDismiss={() => {
|
||||
setShowMongoAndCassandraProxiesNetworkSettingsWarningState(false);
|
||||
}}
|
||||
>
|
||||
{`We are moving our middleware to new infrastructure. To avoid future issues with Data Explorer access, please
|
||||
re-enable "Allow access from Azure Portal" on the Networking blade for your account.`}
|
||||
</MessageBar>
|
||||
)}
|
||||
<div className="nav-tabs-margin">
|
||||
<ul className="nav nav-tabs level navTabHeight" id="navTabs" role="tablist">
|
||||
{openedReactTabs.map((tab) => (
|
||||
@@ -251,15 +298,11 @@ function TabPane({ tab, active }: { tab: Tab; active: boolean }) {
|
||||
|
||||
if (tab) {
|
||||
if ("render" in tab) {
|
||||
return (
|
||||
<div data-test={`Tab:${tab.tabId}`} {...attrs}>
|
||||
{tab.render()}
|
||||
</div>
|
||||
);
|
||||
return <div {...attrs}>{tab.render()}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
return <div data-test={`Tab:${tab.tabId}`} {...attrs} ref={ref} data-bind="html:html" />;
|
||||
return <div {...attrs} ref={ref} data-bind="html:html" />;
|
||||
}
|
||||
|
||||
const onKeyPressReactTab = (e: KeyboardEvent, tabKind: ReactTabKind): void => {
|
||||
@@ -319,3 +362,69 @@ const getReactTabContent = (activeReactTab: ReactTabKind, explorer: Explorer): J
|
||||
throw Error(`Unsupported tab kind ${ReactTabKind[activeReactTab]}`);
|
||||
}
|
||||
};
|
||||
|
||||
const showMongoAndCassandraProxiesNetworkSettingsWarning = (): boolean => {
|
||||
const ipRules: IpRule[] = userContext.databaseAccount?.properties?.ipRules;
|
||||
if (
|
||||
((userContext.apiType === "Mongo" && configContext.MONGO_PROXY_ENDPOINT !== MongoProxyEndpoints.Local) ||
|
||||
(userContext.apiType === "Cassandra" &&
|
||||
configContext.CASSANDRA_PROXY_ENDPOINT !== CassandraProxyEndpoints.Development)) &&
|
||||
ipRules?.length
|
||||
) {
|
||||
const legacyPortalBackendIPs: string[] = PortalBackendIPs[configContext.BACKEND_ENDPOINT];
|
||||
const ipAddressesFromIPRules: string[] = ipRules.map((ipRule) => ipRule.ipAddressOrRange);
|
||||
const ipRulesIncludeLegacyPortalBackend: boolean = legacyPortalBackendIPs.every((legacyPortalBackendIP: string) =>
|
||||
ipAddressesFromIPRules.includes(legacyPortalBackendIP),
|
||||
);
|
||||
if (!ipRulesIncludeLegacyPortalBackend) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (userContext.apiType === "Mongo") {
|
||||
const isProdOrMpacMongoProxyEndpoint: boolean = [MongoProxyEndpoints.Mpac, MongoProxyEndpoints.Prod].includes(
|
||||
configContext.MONGO_PROXY_ENDPOINT,
|
||||
);
|
||||
|
||||
const mongoProxyOutboundIPs: string[] = isProdOrMpacMongoProxyEndpoint
|
||||
? [...MongoProxyOutboundIPs[MongoProxyEndpoints.Mpac], ...MongoProxyOutboundIPs[MongoProxyEndpoints.Prod]]
|
||||
: MongoProxyOutboundIPs[configContext.MONGO_PROXY_ENDPOINT];
|
||||
|
||||
const ipRulesIncludeMongoProxy: boolean = mongoProxyOutboundIPs.every((mongoProxyOutboundIP: string) =>
|
||||
ipAddressesFromIPRules.includes(mongoProxyOutboundIP),
|
||||
);
|
||||
|
||||
if (ipRulesIncludeMongoProxy) {
|
||||
updateConfigContext({
|
||||
MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED: true,
|
||||
});
|
||||
}
|
||||
|
||||
return !ipRulesIncludeMongoProxy;
|
||||
} else if (userContext.apiType === "Cassandra") {
|
||||
const isProdOrMpacCassandraProxyEndpoint: boolean = [
|
||||
CassandraProxyEndpoints.Mpac,
|
||||
CassandraProxyEndpoints.Prod,
|
||||
].includes(configContext.CASSANDRA_PROXY_ENDPOINT);
|
||||
|
||||
const cassandraProxyOutboundIPs: string[] = isProdOrMpacCassandraProxyEndpoint
|
||||
? [
|
||||
...CassandraProxyOutboundIPs[CassandraProxyEndpoints.Mpac],
|
||||
...CassandraProxyOutboundIPs[CassandraProxyEndpoints.Prod],
|
||||
]
|
||||
: CassandraProxyOutboundIPs[configContext.CASSANDRA_PROXY_ENDPOINT];
|
||||
|
||||
const ipRulesIncludeCassandraProxy: boolean = cassandraProxyOutboundIPs.every(
|
||||
(cassandraProxyOutboundIP: string) => ipAddressesFromIPRules.includes(cassandraProxyOutboundIP),
|
||||
);
|
||||
|
||||
if (ipRulesIncludeCassandraProxy) {
|
||||
updateConfigContext({
|
||||
CASSANDRA_PROXY_OUTBOUND_IPS_ALLOWLISTED: true,
|
||||
});
|
||||
}
|
||||
|
||||
return !ipRulesIncludeCassandraProxy;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user