Compare commits

..

3 Commits

Author SHA1 Message Date
Sung-Hyun Kang
0b7cbfbf23 move nps survey open dialog call to after explorer initialization 2024-08-13 13:06:01 -05:00
Sung-Hyun Kang
76913d42f1 Merge branch 'master' into NPS_Dialog 2024-08-13 12:44:32 -05:00
Sung-Hyun Kang
7ab2bd38f4 move nps survey open dialog call to after explorer initialization 2024-08-13 12:34:46 -05:00
142 changed files with 6402 additions and 8157 deletions

View File

@@ -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

View File

@@ -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"
}

View File

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

View File

@@ -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

View File

@@ -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>

View File

@@ -174,7 +174,7 @@ module.exports = {
},
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
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,

View File

@@ -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%;
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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: [

View File

@@ -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",

View File

@@ -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 = {

View File

@@ -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 = {};

View File

@@ -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)}`);

View File

@@ -1,3 +0,0 @@
export function getNewDatabaseSharedThroughputDefault(): boolean {
return false;
}

View File

@@ -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,20 +51,15 @@ export const EntityValue: FunctionComponent<TableEntityProps> = ({
}
return (
<>
<span id={entityProperty} className="screenReaderOnly">
Edit Property {entityProperty} {attributeValueLabel}
</span>
<TextField
label={entityValueLabel && entityValueLabel}
className="addEntityTextField"
disabled={isEntityValueDisable}
type={entityValueType}
placeholder={entityValuePlaceholder}
value={typeof entityValue === "string" ? entityValue : ""}
onChange={onEntityValueChange}
aria-labelledby={entityProperty}
/>
</>
<TextField
label={entityValueLabel && entityValueLabel}
className="addEntityTextField"
disabled={isEntityValueDisable}
type={entityValueType}
placeholder={entityValuePlaceholder}
value={typeof entityValue === "string" ? entityValue : ""}
onChange={onEntityValueChange}
ariaLabel={attributeValueLabel}
/>
);
};

View File

@@ -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);
});
});

View File

@@ -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];
};

View File

@@ -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.";
}

View File

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

View File

@@ -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");
});
});
});

View File

@@ -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 idComponents = documentId.self.split("/");
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);
}

View 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[];
};

View File

@@ -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 }),
),
]);
});
});

View File

@@ -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),
};

View File

@@ -135,7 +135,6 @@ export const TableEntity: FunctionComponent<TableEntityProps> = ({
onEntityValueChange={onEntityValueChange}
onSelectDate={onSelectDate}
onEntityTimeValueChange={onEntityTimeValueChange}
entityProperty={entityProperty}
/>
{!isEntityValueDisable && (
<TooltipHost content="Edit property" id="editTooltip">

View File

@@ -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>

View File

@@ -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(

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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),
useSidePanel
.getState()
.openSidePanel(
"Delete " + getDatabaseName(),
<DeleteDatabaseConfirmationPanel refreshDatabases={() => container.refreshAllDatabases()} />,
);
},
onClick: () =>
useSidePanel
.getState()
.openSidePanel(
"Delete " + getDatabaseName(),
<DeleteDatabaseConfirmationPanel refreshDatabases={() => container.refreshAllDatabases()} />,
),
label: `Delete ${getDatabaseName()}`,
styleClass: "deleteDatabaseMenuItem",
});
@@ -148,15 +142,14 @@ 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(
"Delete " + getCollectionName(),
<DeleteCollectionConfirmationPane refreshDatabases={() => container.refreshAllDatabases()} />,
);
useSidePanel
.getState()
.openSidePanel(
"Delete " + getCollectionName(),
<DeleteCollectionConfirmationPane refreshDatabases={() => container.refreshAllDatabases()} />,
);
},
label: `Delete ${getCollectionName()}`,
styleClass: "deleteCollectionMenuItem",

View File

@@ -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,
}),
}));

View File

@@ -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);

View File

@@ -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}
/>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);

View File

@@ -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);

View File

@@ -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,
};

View File

@@ -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);

View File

@@ -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}

View File

@@ -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>
`;

View File

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

View File

@@ -17,7 +17,7 @@ export const useTreeStyles = makeStyles({
minWidth: "100%",
rowGap: "0px",
paddingTop: "0px",
[treeIconWidth]: "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,

View File

@@ -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}

View File

@@ -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>

View File

@@ -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();

View File

@@ -167,18 +167,22 @@ export function createContextCommandBarButtons(
}
export function createControlCommandBarButtons(container: Explorer): CommandButtonComponentProps[] {
const buttons: CommandButtonComponentProps[] = [
{
iconSrc: SettingsIcon,
iconAlt: "Settings",
onCommandClick: () => useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={container} />),
commandButtonLabel: undefined,
ariaLabel: "Settings",
tooltipText: "Settings",
hasPopup: true,
disabled: false,
},
];
const buttons: CommandButtonComponentProps[] =
configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly
? []
: [
{
iconSrc: SettingsIcon,
iconAlt: "Settings",
onCommandClick: () =>
useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={container} />),
commandButtonLabel: undefined,
ariaLabel: "Settings",
tooltipText: "Settings",
hasPopup: true,
disabled: false,
},
];
const showOpenFullScreen =
configContext.platform === Platform.Portal && !isRunningOnNationalCloud() && userContext.apiType !== "Gremlin";

View File

@@ -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:"

View File

@@ -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"

View File

@@ -1,13 +1,13 @@
import { clear, collectionWasOpened, getItems, Type } from "Explorer/MostRecentActivity/MostRecentActivity";
import { observable } from "knockout";
import { mostRecentActivity } from "./MostRecentActivity";
describe("MostRecentActivity", () => {
const 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));
});
});

View File

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

View File

@@ -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, {

View File

@@ -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."

View File

@@ -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);

View File

@@ -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>
`;

View File

@@ -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"

File diff suppressed because it is too large Load Diff

View File

@@ -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>
);
};

View File

@@ -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}

View File

@@ -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"

View File

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

View File

@@ -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 }),

View File

@@ -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 well 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 && (

View File

@@ -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();
});

View File

@@ -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 (

View File

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

View File

@@ -4,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,
);
};

View File

@@ -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 {

View File

@@ -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) =>

View File

@@ -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"
>

View File

@@ -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>
<SplitButton
menuButton={{ ...triggerProps, "aria-label": "More commands" }}
primaryActionButton={{ onClick: onPrimaryActionClick }}
icon={primaryAction.icon}
>
{primaryAction.label}
</SplitButton>
)}
</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,69 +256,66 @@ 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}>
<CosmosFluentProvider className={mergeClasses(styles.sidebar)}>
<div className={styles.sidebarContainer}>
{loading && (
// The Fluent UI progress bar has some issues in reduced-motion environments so we use a simple CSS animation here.
// https://github.com/microsoft/fluentui/issues/29076
<div className={styles.loadingProgressBar} title="Refreshing tree..." />
)}
{expanded ? (
<>
<div className={styles.floatingControlsContainer}>
<div className={styles.floatingControls}>
<button
type="button"
data-test="Sidebar/RefreshButton"
className={styles.floatingControlButton}
disabled={loading}
title="Refresh"
onClick={onRefreshClick}
>
<ArrowSync12Regular />
</button>
<button
type="button"
className={styles.floatingControlButton}
title="Collapse sidebar"
onClick={() => collapse()}
>
<ChevronLeft12Regular />
</button>
</div>
<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={300}>
<CosmosFluentProvider className={mergeClasses(styles.sidebar)}>
<div className={styles.sidebarContainer}>
{loading && (
// The Fluent UI progress bar has some issues in reduced-motion environments so we use a simple CSS animation here.
// https://github.com/microsoft/fluentui/issues/29076
<div className={styles.loadingProgressBar} title="Refreshing tree..." />
)}
{expanded ? (
<>
<div className={styles.floatingControlsContainer}>
<div className={styles.floatingControls}>
<button
type="button"
className={styles.floatingControlButton}
disabled={loading}
title="Refresh"
onClick={onRefreshClick}
>
<ArrowSync12Regular />
</button>
<button
type="button"
className={styles.floatingControlButton}
title="Collapse sidebar"
onClick={() => collapse()}
>
<ChevronLeft12Regular />
</button>
</div>
<div
className={styles.expandedContent}
style={!hasGlobalCommands ? { gridTemplateRows: "1fr" } : undefined}
>
{hasGlobalCommands && <GlobalCommands explorer={explorer} />}
<ResourceTree explorer={explorer} />
</div>
</>
) : (
<button
type="button"
className={styles.floatingControlButton}
title="Expand sidebar"
onClick={() => expand()}
</div>
<div
className={styles.expandedContent}
style={!hasGlobalCommands ? { gridTemplateRows: "1fr" } : undefined}
>
<ChevronRight12Regular />
</button>
)}
</div>
</CosmosFluentProvider>
</Allotment.Pane>
)}
<Allotment.Pane minSize={200}>
<Tabs explorer={explorer} />
{hasGlobalCommands && <GlobalCommands explorer={explorer} />}
<ResourceTree explorer={explorer} />
</div>
</>
) : (
<button
type="button"
className={styles.floatingControlButton}
title="Expand sidebar"
onClick={() => expand()}
>
<ChevronRight12Regular />
</button>
)}
</div>
</CosmosFluentProvider>
</Allotment.Pane>
</Allotment>
</div>
)}
<Allotment.Pane minSize={800}>
<Tabs explorer={explorer} />
</Allotment.Pane>
</Allotment>
);
};

View File

@@ -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;

View File

@@ -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",
};

View File

@@ -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");
}
}

View File

@@ -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;

View File

@@ -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,

View File

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

View File

@@ -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)
);
}
}

View File

@@ -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(),
});
};

View File

@@ -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

View File

@@ -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();
});
});
});

View File

@@ -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();
});

View File

@@ -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,
minWidth: 50,
};
}
});
return columnSizesPx;
});
const [sortState, setSortState] = React.useState<{
sortDirection: "ascending" | "descending";
sortColumn: TableColumnId | undefined;
}>(() => {
const sort = readSubComponentState<ColumnSort>(SubComponentName.ColumnSort, collection, undefined);
if (!sort) {
return {
sortDirection: undefined,
sortColumn: undefined,
};
}
return {
sortDirection: sort.direction,
sortColumn: sort.columnId,
const initialSizingOptions: TableColumnSizingOptions = {
id: {
idealWidth: 280,
minWidth: 50,
},
};
columnHeaders.partitionKeyHeaders.forEach((pkHeader) => {
initialSizingOptions[pkHeader] = {
idealWidth: 200,
minWidth: 50,
};
});
const onColumnResize = React.useCallback((_, { columnId, width }: { columnId: string; width: number }) => {
setColumnSizingOptions((state) => {
const newSizingOptions = {
...state,
[columnId]: {
...state[columnId],
idealWidth: width,
},
};
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(initialSizingOptions);
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 onColumnResize = React.useCallback((_, { columnId, width }) => {
setColumnSizingOptions((state) => ({
...state,
[columnId]: {
...state[columnId],
idealWidth: width,
},
}));
}, []);
// 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,49 +227,25 @@ 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[] = getRows((row) => {
const selected = isRowSelected(row.rowId);
return {
...row,
onClick: (e: React.MouseEvent) => toggleRow(e, row.rowId),
onKeyDown: (e: React.KeyboardEvent) => {
if (e.key === " ") {
e.preventDefault();
toggleRow(e, row.rowId);
}
},
selected,
appearance: selected ? ("brand" as const) : ("none" as const),
};
});
const rows: TableRowData[] = sort(
getRows((row) => {
const selected = isRowSelected(row.rowId);
return {
...row,
onClick: (e: React.MouseEvent) => toggleRow(e, row.rowId),
onKeyDown: (e: React.KeyboardEvent) => {
if (e.key === " ") {
e.preventDefault();
toggleRow(e, row.rowId);
}
},
selected,
appearance: selected ? ("brand" as const) : ("none" as const),
};
}),
);
// 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>) => {
if (e.key === " ") {
@@ -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) => (
<TableHeaderCell
className={styles.tableCell}
key={column.columnId}
{...columnSizing.getTableHeaderCellProps(column.columnId)}
{...headerSortProps(column.columnId)}
>
{column.renderHeaderCell()}
</TableHeaderCell>
{columns.map((column /* index */) => (
<Menu openOnContext key={column.columnId}>
<MenuTrigger>
<TableHeaderCell
className={styles.tableCell}
key={column.columnId}
{...columnSizing.getTableHeaderCellProps(column.columnId)}
>
{column.renderHeaderCell()}
</TableHeaderCell>
</MenuTrigger>
<MenuPopover>
<MenuList>
<MenuItem onClick={columnSizing.enableKeyboardMode(column.columnId)}>
Keyboard Column Resizing
</MenuItem>
</MenuList>
</MenuPopover>
</Menu>
))}
</TableRow>
<div className={styles.tableHeaderFiller}></div>
</TableHeader>
<TableBody>
<List

View File

@@ -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;
};

View File

@@ -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
style={
{
"height": "100%",
"width": "calc(100% + -11px)",
}
}
className="___1rwkz4r_0000000 f1euv43f f1l8gmrm f1e31b4d f150nix6 fy6ml6n f19g0ac"
>
<DocumentsTableComponent
collection={
<Button
appearance="transparent"
aria-label="Refresh"
icon={<ArrowClockwise16Filled />}
onClick={[Function]}
onKeyDown={[Function]}
size="small"
style={
{
"databaseId": "databaseId",
"id": [Function],
"color": undefined,
}
}
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={

View File

@@ -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;

View File

@@ -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,

View File

@@ -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}`);
});
});

View File

@@ -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}`;
}

View File

@@ -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>
);
};

View File

@@ -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}
>
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}
>
We detected you may be using a subquery. To learn more about subqueries effectively,{" "}
<Link
href="https://learn.microsoft.com/azure/cosmos-db/nosql/query/subquery"
target="_blank"
rel="noreferrer noopener"
>
visit the documentation
</Link>
</MessageBanner>
{/* <!-- Query Results & Errors Content Container - Start--> */}
{errors.length > 0 ? (
<ErrorList errors={errors} />
) : queryResults ? (
<ResultsView
queryResults={queryResults}
executeQueryDocumentsPage={executeQueryDocumentsPage}
isMongoDB={isMongoDB}
/>
) : (
<ExecuteQueryCallToAction />
<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.
</div>
)}
</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,{" "}
<a
href="https://learn.microsoft.com/azure/cosmos-db/nosql/query/subquery"
target="_blank"
rel="noreferrer"
>
visit the documentation
</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--> */}
<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}
/>
</div>
{queryResults && queryResultsString?.length > 0 && !error && (
<div
style={{
paddingBottom: "100px",
height: "100%",
}}
>
<EditorReact
language={"json"}
content={queryResultsString}
isReadOnly={true}
ariaLabel={"Query results"}
/>
</div>
)}
</PivotItem>
<PivotItem
headerText="Query Stats"
headerButtonProps={{
"data-order": 2,
"data-title": "Query Stats",
}}
style={{ height: "100%", overflowY: "scroll" }}
>
{queryResults && !error && (
<div className="queryMetricsSummaryContainer">
<div className="queryMetricsSummary">
<h3>Query Statistics</h3>
<DetailsList
items={generateQueryStatsItems()}
columns={columns}
selectionMode={SelectionMode.none}
layoutMode={DetailsListLayoutMode.justified}
compact={true}
/>
</div>
{userContext.apiType === "SQL" && (
<div className="downloadMetricsLinkContainer">
<a
id="downloadMetricsLink"
role="button"
tabIndex={0}
onClick={() => onDownloadQueryMetricsCsvClick()}
onKeyPress={(event: React.KeyboardEvent<HTMLAnchorElement>) =>
onDownloadQueryMetricsCsvKeyPress(event)
}
>
<img
className="downloadCsvImg"
src={DownloadQueryMetrics}
alt="download query metrics csv"
/>
<span>Per-partition query metrics (CSV)</span>
</a>
</div>
)}
</div>
)}
</PivotItem>
</Pivot>
)}
{/* <!-- Query Errors Content - Start--> */}
{!!error && (
<div className="tab-pane active">
<div className="errorContent">
<span className="errorMessage">{error}</span>
<span className="errorDetailsLink">
<a
onClick={() => onErrorDetailsClick()}
onKeyPress={(event: React.KeyboardEvent<HTMLAnchorElement>) => onErrorDetailsKeyPress(event)}
id="error-display"
tabIndex={0}
aria-label="Error details link"
>
More details
</a>
</span>
</div>
</div>
)}
{/* <!-- Query Errors Content - End--> */}
</div>
)}
</div>
</Stack>
);
};

View File

@@ -7,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} />

View File

@@ -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);

View File

@@ -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, {
enableCrossPartitionQuery: HeadersUtility.shouldEnableCrossPartitionKey(),
abortSignal: this.queryAbortController.signal,
} as unknown as FeedOptions);
? 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,
);
}
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">
<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)
}
/>
</Allotment.Pane>
<Allotment.Pane>
<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
language={"sql"}
content={this.getEditorContent()}
isReadOnly={false}
wordWrap={"on"}
ariaLabel={"Editing Query"}
lineNumbers={"on"}
onContentChanged={(newContent: string) => this.onChangeContent(newContent)}
onContentSelected={(selectedContent: string) => this.onSelectedContent(selectedContent)}
/>
</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>

View File

@@ -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>
);
};

View File

@@ -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",
},
});

View File

@@ -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