This commit is contained in:
Senthamil Sindhu 2024-09-17 16:12:01 -07:00
commit bd564c665b
83 changed files with 4774 additions and 3577 deletions

View File

@ -1906,8 +1906,14 @@ input::-webkit-calendar-picker-indicator::after {
} }
.nav-tabs-margin { .nav-tabs-margin {
padding-top: 5px; height: 32px;
background-color: #f2f2f2; background-color: #f2f2f2;
.nav-tabs {
display: flex;
align-items: flex-end;
height: 100%;
}
} }
.navTabHeight { .navTabHeight {

56
package-lock.json generated
View File

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

View File

@ -134,6 +134,9 @@ export class BackendApi {
public static readonly GenerateToken: string = "GenerateToken"; public static readonly GenerateToken: string = "GenerateToken";
public static readonly PortalSettings: string = "PortalSettings"; public static readonly PortalSettings: string = "PortalSettings";
public static readonly AccountRestrictions: string = "AccountRestrictions"; public static readonly AccountRestrictions: string = "AccountRestrictions";
public static readonly RuntimeProxy: string = "RuntimeProxy";
public static readonly DisallowedLocations: string = "DisallowedLocations";
public static readonly SampleData: string = "SampleData";
} }
export class PortalBackendEndpoints { export class PortalBackendEndpoints {
@ -290,6 +293,7 @@ export class HttpStatusCodes {
public static readonly Accepted: number = 202; public static readonly Accepted: number = 202;
public static readonly NoContent: number = 204; public static readonly NoContent: number = 204;
public static readonly NotModified: number = 304; public static readonly NotModified: number = 304;
public static readonly BadRequest: number = 400;
public static readonly Unauthorized: number = 401; public static readonly Unauthorized: number = 401;
public static readonly Forbidden: number = 403; public static readonly Forbidden: number = 403;
public static readonly NotFound: number = 404; public static readonly NotFound: number = 404;
@ -501,7 +505,7 @@ export class PriorityLevel {
public static readonly Default = "low"; public static readonly Default = "low";
} }
export const QueryCopilotSampleDatabaseId = "CopilotSampleDb"; export const QueryCopilotSampleDatabaseId = "CopilotSampleDB";
export const QueryCopilotSampleContainerId = "SampleContainer"; export const QueryCopilotSampleContainerId = "SampleContainer";
export const QueryCopilotSampleContainerSchema = { export const QueryCopilotSampleContainerSchema = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { getErrorMessage } from "Common/ErrorHandlingUtils";
import { monaco } from "Explorer/LazyMonaco"; import { monaco } from "Explorer/LazyMonaco";
import { getRUThreshold, ruThresholdEnabled } from "Shared/StorageUtility";
export enum QueryErrorSeverity { export enum QueryErrorSeverity {
Error = "Error", Error = "Error",
@ -97,13 +97,44 @@ export const createMonacoMarkersForQueryErrors = (errors: QueryError[]) => {
.filter((marker) => !!marker); .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 { export default class QueryError {
message: string;
helpLink?: string;
constructor( constructor(
public message: string, message: string,
public severity: QueryErrorSeverity, public severity: QueryErrorSeverity,
public code?: string, public code?: string,
public location?: QueryErrorLocation, 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 { getMonacoSeverity(): monaco.MarkerSeverity {
// It's very difficult to use the monaco.MarkerSeverity enum from here, so we'll just use the numbers directly. // It's very difficult to use the monaco.MarkerSeverity enum from here, so we'll just use the numbers directly.
@ -135,7 +166,7 @@ export default class QueryError {
return errors; return errors;
} }
const errorMessage = getErrorMessage(error as string | Error); const errorMessage = error as string;
// Map some well known messages to richer errors // Map some well known messages to richer errors
const knownError = knownErrors[errorMessage]; const knownError = knownErrors[errorMessage];
@ -160,7 +191,9 @@ export default class QueryError {
} }
const severity = const severity =
"severity" in error && typeof error.severity === "string" ? (error.severity as QueryErrorSeverity) : undefined; "severity" in error && typeof error.severity === "string"
? (error.severity as QueryErrorSeverity)
: QueryErrorSeverity.Error;
const location = const location =
"location" in error && typeof error.location === "object" "location" in error && typeof error.location === "object"
? locationResolver(error.location as { start: number; end: number }) ? locationResolver(error.location as { start: number; end: number })
@ -173,16 +206,15 @@ export default class QueryError {
error: unknown, error: unknown,
locationResolver: (location: { start: number; end: number }) => QueryErrorLocation, locationResolver: (location: { start: number; end: number }) => QueryErrorLocation,
): QueryError[] | null { ): QueryError[] | null {
if (typeof error === "object" && "message" in error) { let message: string | undefined;
error = error.message; if (typeof error === "object" && "message" in error && typeof error.message === "string") {
} message = error.message;
} else {
if (typeof error !== "string") { // Unsupported error format.
return null; return null;
} }
// Assign to a new variable because of a TypeScript flow typing quirk, see below. // Assign to a new variable because of a TypeScript flow typing quirk, see below.
let message = error;
if (message.startsWith("Message: ")) { if (message.startsWith("Message: ")) {
// Reassigning this to 'error' restores the original type of 'error', which is 'unknown'. // Reassigning this to 'error' restores the original type of 'error', which is 'unknown'.
// So we use a separate variable to avoid this. // So we use a separate variable to avoid this.
@ -196,13 +228,16 @@ export default class QueryError {
try { try {
parsed = JSON.parse(message); parsed = JSON.parse(message);
} catch (e) { } catch (e) {
// Not a query error. // The message doesn't contain a nested error.
return null; return [QueryError.read(error, locationResolver)];
} }
if (typeof parsed === "object" && "errors" in parsed && Array.isArray(parsed.errors)) { 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 parsed.errors.map((e) => QueryError.read(e, locationResolver)).filter((e) => e !== null);
} }
return [QueryError.read(parsed, locationResolver)];
}
return null; return null;
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -130,7 +130,6 @@ export interface SettingsComponentState {
conflictResolutionPolicyProcedureBaseline: string; conflictResolutionPolicyProcedureBaseline: string;
isConflictResolutionDirty: boolean; isConflictResolutionDirty: boolean;
initialNotification: DataModels.Notification;
selectedTab: SettingsV2TabTypes; selectedTab: SettingsV2TabTypes;
} }
@ -229,7 +228,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
conflictResolutionPolicyProcedureBaseline: undefined, conflictResolutionPolicyProcedureBaseline: undefined,
isConflictResolutionDirty: false, isConflictResolutionDirty: false,
initialNotification: undefined,
selectedTab: SettingsV2TabTypes.ScaleTab, selectedTab: SettingsV2TabTypes.ScaleTab,
}; };
@ -1052,7 +1050,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
onMaxAutoPilotThroughputChange: this.onMaxAutoPilotThroughputChange, onMaxAutoPilotThroughputChange: this.onMaxAutoPilotThroughputChange,
onScaleSaveableChange: this.onScaleSaveableChange, onScaleSaveableChange: this.onScaleSaveableChange,
onScaleDiscardableChange: this.onScaleDiscardableChange, onScaleDiscardableChange: this.onScaleDiscardableChange,
initialNotification: this.props.settingsTab.pendingNotification(),
throughputError: this.state.throughputError, throughputError: this.state.throughputError,
}; };

View File

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

View File

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

View File

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

View File

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

View File

@ -17,7 +17,7 @@ export const useTreeStyles = makeStyles({
minWidth: "100%", minWidth: "100%",
rowGap: "0px", rowGap: "0px",
paddingTop: "0px", paddingTop: "0px",
[treeIconWidth]: "20px", [treeIconWidth]: "16px",
[leafNodeSpacing]: "24px", [leafNodeSpacing]: "24px",
}, },
nodeIcon: { nodeIcon: {
@ -32,7 +32,6 @@ export const useTreeStyles = makeStyles({
fontSize: tokens.fontSizeBase300, fontSize: tokens.fontSizeBase300,
height: tokens.layoutRowHeight, height: tokens.layoutRowHeight,
...cosmosShorthands.borderBottom(), ...cosmosShorthands.borderBottom(),
paddingLeft: `calc(var(${treeItemLevelToken}, 1) * ${tokens.spacingHorizontalXXL})`,
// Some sneaky CSS variables stuff to change the background color of the action button on hover. // Some sneaky CSS variables stuff to change the background color of the action button on hover.
[actionButtonBackground]: tokens.colorNeutralBackground1, [actionButtonBackground]: tokens.colorNeutralBackground1,

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import * as msal from "@azure/msal-browser"; import * as msal from "@azure/msal-browser";
import { Link } from "@fluentui/react/lib/Link"; import { Link } from "@fluentui/react/lib/Link";
import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility"; import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility";
import { Environment, getEnvironment } from "Common/EnvironmentUtility";
import { sendMessage } from "Common/MessageHandler"; import { sendMessage } from "Common/MessageHandler";
import { Platform, configContext } from "ConfigContext"; import { Platform, configContext } from "ConfigContext";
import { MessageTypes } from "Contracts/ExplorerContracts"; import { MessageTypes } from "Contracts/ExplorerContracts";
@ -1119,7 +1120,7 @@ export default class Explorer {
} }
} }
public openUploadItemsPanePane(): void { public openUploadItemsPane(): void {
useSidePanel.getState().openSidePanel("Upload " + getUploadName(), <UploadItemsPane />); useSidePanel.getState().openSidePanel("Upload " + getUploadName(), <UploadItemsPane />);
} }
public openExecuteSprocParamsPanel(storedProcedure: StoredProcedure): void { public openExecuteSprocParamsPanel(storedProcedure: StoredProcedure): void {
@ -1178,7 +1179,11 @@ export default class Explorer {
} }
public async configureCopilot(): Promise<void> { public async configureCopilot(): Promise<void> {
if (userContext.apiType !== "SQL" || !userContext.subscriptionId) { if (
userContext.apiType !== "SQL" ||
!userContext.subscriptionId ||
![Environment.Development, Environment.Mpac, Environment.Prod].includes(getEnvironment())
) {
return; return;
} }
const copilotEnabledPromise = getCopilotEnabled(); const copilotEnabledPromise = getCopilotEnabled();

View File

@ -167,15 +167,11 @@ export function createContextCommandBarButtons(
} }
export function createControlCommandBarButtons(container: Explorer): CommandButtonComponentProps[] { export function createControlCommandBarButtons(container: Explorer): CommandButtonComponentProps[] {
const buttons: CommandButtonComponentProps[] = const buttons: CommandButtonComponentProps[] = [
configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly
? []
: [
{ {
iconSrc: SettingsIcon, iconSrc: SettingsIcon,
iconAlt: "Settings", iconAlt: "Settings",
onCommandClick: () => onCommandClick: () => useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={container} />),
useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={container} />),
commandButtonLabel: undefined, commandButtonLabel: undefined,
ariaLabel: "Settings", ariaLabel: "Settings",
tooltipText: "Settings", tooltipText: "Settings",

View File

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

View File

@ -1,22 +1,25 @@
import { import {
Checkbox, Checkbox,
ChoiceGroup, ChoiceGroup,
DefaultButton,
IChoiceGroupOption, IChoiceGroupOption,
ISpinButtonStyles, ISpinButtonStyles,
IToggleStyles, IToggleStyles,
Icon,
MessageBar, MessageBar,
MessageBarType, MessageBarType,
Position, Position,
SpinButton, SpinButton,
Toggle, Toggle,
TooltipHost,
} from "@fluentui/react"; } from "@fluentui/react";
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, makeStyles } from "@fluentui/react-components";
import { AuthType } from "AuthType";
import * as Constants from "Common/Constants"; import * as Constants from "Common/Constants";
import { SplitterDirection } from "Common/Splitter"; import { SplitterDirection } from "Common/Splitter";
import { InfoTooltip } from "Common/Tooltip/InfoTooltip"; import { InfoTooltip } from "Common/Tooltip/InfoTooltip";
import { Platform, configContext } from "ConfigContext"; import { Platform, configContext } from "ConfigContext";
import { useDialog } from "Explorer/Controls/Dialog";
import { useDatabases } from "Explorer/useDatabases"; import { useDatabases } from "Explorer/useDatabases";
import { deleteAllStates } from "Shared/AppStatePersistenceUtility";
import { import {
DefaultRUThreshold, DefaultRUThreshold,
LocalStorageUtility, LocalStorageUtility,
@ -29,14 +32,13 @@ import * as StringUtility from "Shared/StringUtility";
import { updateUserContext, userContext } from "UserContext"; import { updateUserContext, userContext } from "UserContext";
import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils"; import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils";
import * as PriorityBasedExecutionUtils from "Utils/PriorityBasedExecutionUtils"; import * as PriorityBasedExecutionUtils from "Utils/PriorityBasedExecutionUtils";
import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
import { useQueryCopilot } from "hooks/useQueryCopilot"; import { useQueryCopilot } from "hooks/useQueryCopilot";
import { useSidePanel } from "hooks/useSidePanel"; import { useSidePanel } from "hooks/useSidePanel";
import React, { FunctionComponent, useState } from "react"; import React, { FunctionComponent, useState } from "react";
import create, { UseStore } from "zustand";
import Explorer from "../../Explorer"; import Explorer from "../../Explorer";
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm"; import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
import { AuthType } from "AuthType";
import create, { UseStore } from "zustand";
import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
export interface DataPlaneRbacState { export interface DataPlaneRbacState {
dataPlaneRbacEnabled: boolean; dataPlaneRbacEnabled: boolean;
@ -50,6 +52,39 @@ export interface DataPlaneRbacState {
type DataPlaneRbacStore = UseStore<Partial<DataPlaneRbacState>>; type DataPlaneRbacStore = UseStore<Partial<DataPlaneRbacState>>;
const useStyles = makeStyles({
bulletList: {
listStyleType: "disc",
paddingLeft: "20px",
},
container: {
display: "flex",
flexDirection: "column",
height: "100%",
},
firstItem: {
flex: "1",
},
header: {
marginRight: "5px",
},
headerIcon: {
paddingTop: "4px",
cursor: "pointer",
},
settingsSectionContainer: {
paddingLeft: "15px",
},
settingsSectionDescription: {
paddingBottom: "10px",
fontSize: "12px",
},
subHeader: {
marginRight: "5px",
fontSize: "12px",
},
});
export const useDataPlaneRbac: DataPlaneRbacStore = create(() => ({ export const useDataPlaneRbac: DataPlaneRbacStore = create(() => ({
dataPlaneRbacEnabled: false, dataPlaneRbacEnabled: false,
})); }));
@ -133,6 +168,9 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
const [copilotSampleDBEnabled, setCopilotSampleDBEnabled] = useState<boolean>( const [copilotSampleDBEnabled, setCopilotSampleDBEnabled] = useState<boolean>(
LocalStorageUtility.getEntryString(StorageKey.CopilotSampleDBEnabled) === "true", LocalStorageUtility.getEntryString(StorageKey.CopilotSampleDBEnabled) === "true",
); );
const styles = useStyles();
const explorerVersion = configContext.gitSha; const explorerVersion = configContext.gitSha;
const shouldShowQueryPageOptions = userContext.apiType === "SQL"; const shouldShowQueryPageOptions = userContext.apiType === "SQL";
const shouldShowGraphAutoVizOption = userContext.apiType === "Gremlin"; const shouldShowGraphAutoVizOption = userContext.apiType === "Gremlin";
@ -153,6 +191,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
LocalStorageUtility.setEntryNumber(StorageKey.CustomItemPerPage, customItemPerPage); LocalStorageUtility.setEntryNumber(StorageKey.CustomItemPerPage, customItemPerPage);
if (configContext.platform !== Platform.Fabric) {
LocalStorageUtility.setEntryString(StorageKey.DataPlaneRbacEnabled, enableDataPlaneRBACOption); LocalStorageUtility.setEntryString(StorageKey.DataPlaneRbacEnabled, enableDataPlaneRBACOption);
if ( if (
enableDataPlaneRBACOption === Constants.RBACOptions.setTrueRBACOption || enableDataPlaneRBACOption === Constants.RBACOptions.setTrueRBACOption ||
@ -192,6 +231,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: false }); useDataPlaneRbac.setState({ dataPlaneRbacEnabled: false });
} }
} }
}
LocalStorageUtility.setEntryBoolean(StorageKey.RUThresholdEnabled, ruThresholdEnabled); LocalStorageUtility.setEntryBoolean(StorageKey.RUThresholdEnabled, ruThresholdEnabled);
LocalStorageUtility.setEntryBoolean(StorageKey.QueryTimeoutEnabled, queryTimeoutEnabled); LocalStorageUtility.setEntryBoolean(StorageKey.QueryTimeoutEnabled, queryTimeoutEnabled);
@ -428,18 +468,19 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
return ( return (
<RightPaneForm {...genericPaneProps}> <RightPaneForm {...genericPaneProps}>
<div className="paneMainContent"> <div className={`paneMainContent ${styles.container}`}>
<Accordion className={styles.firstItem}>
{shouldShowQueryPageOptions && ( {shouldShowQueryPageOptions && (
<div className="settingsSection"> <AccordionItem value="1">
<div className="settingsSectionPart"> <AccordionHeader>
<fieldset> <div className={styles.header}>Page Options</div>
<legend id="pageOptions" className="settingsSectionLabel legendLabel"> </AccordionHeader>
Page Options <AccordionPanel>
</legend> <div className={styles.settingsSectionContainer}>
<InfoTooltip> <div className={styles.settingsSectionDescription}>
Choose Custom to specify a fixed amount of query results to show, or choose Unlimited to show as many Choose Custom to specify a fixed amount of query results to show, or choose Unlimited to show as
query results per page. many query results per page.
</InfoTooltip> </div>
<ChoiceGroup <ChoiceGroup
ariaLabelledBy="pageOptions" ariaLabelledBy="pageOptions"
selectedKey={pageOption} selectedKey={pageOption}
@ -447,14 +488,15 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
styles={choiceButtonStyles} styles={choiceButtonStyles}
onChange={handleOnPageOptionChange} onChange={handleOnPageOptionChange}
/> />
</fieldset>
</div> </div>
<div className="tabs settingsSectionPart"> <div className={`tabs ${styles.settingsSectionContainer}`}>
{isCustomPageOptionSelected() && ( {isCustomPageOptionSelected() && (
<div className="tabcontent"> <div className="tabcontent">
<div className="settingsSectionLabel"> <div className={styles.settingsSectionDescription}>
Query results per page Query results per page{" "}
<InfoTooltip>Enter the number of query results that should be shown per page.</InfoTooltip> <InfoTooltip className={styles.headerIcon}>
Enter the number of query results that should be shown per page.
</InfoTooltip>
</div> </div>
<SpinButton <SpinButton
@ -474,34 +516,18 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</div> </div>
)} )}
</div> </div>
</div> </AccordionPanel>
</AccordionItem>
)} )}
{userContext.apiType === "SQL" && userContext.authType === AuthType.AAD && ( {userContext.apiType === "SQL" &&
<> userContext.authType === AuthType.AAD &&
<div className="settingsSection"> configContext.platform !== Platform.Fabric && (
<div className="settingsSectionPart"> <AccordionItem value="2">
<fieldset> <AccordionHeader>
<legend id="enableDataPlaneRBACOptions" className="settingsSectionLabel legendLabel"> <div className={styles.header}>Enable Entra ID RBAC</div>
Enable Entra ID RBAC </AccordionHeader>
</legend> <AccordionPanel>
<TooltipHost <div className={styles.settingsSectionContainer}>
content={
<>
Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable Entra
ID RBAC.
<a
href="https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac#use-data-explorer"
target="_blank"
rel="noopener noreferrer"
>
{" "}
Learn more{" "}
</a>
</>
}
>
<Icon iconName="Info" ariaLabel="Info tooltip" className="panelInfoIcon" tabIndex={0} />
</TooltipHost>
{showDataPlaneRBACWarning && configContext.platform === Platform.Portal && ( {showDataPlaneRBACWarning && configContext.platform === Platform.Portal && (
<MessageBar <MessageBar
messageBarType={MessageBarType.warning} messageBarType={MessageBarType.warning}
@ -513,6 +539,18 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
operations operations
</MessageBar> </MessageBar>
)} )}
<div className={styles.settingsSectionDescription}>
Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable Entra
ID RBAC.
<a
href="https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac#use-data-explorer"
target="_blank"
rel="noopener noreferrer"
>
{" "}
Learn more{" "}
</a>
</div>
<ChoiceGroup <ChoiceGroup
ariaLabelledBy="enableDataPlaneRBACOptions" ariaLabelledBy="enableDataPlaneRBACOptions"
options={dataPlaneRBACOptionsList} options={dataPlaneRBACOptionsList}
@ -520,58 +558,23 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
selectedKey={enableDataPlaneRBACOption} selectedKey={enableDataPlaneRBACOption}
onChange={handleOnDataPlaneRBACOptionChange} onChange={handleOnDataPlaneRBACOptionChange}
/> />
</fieldset>
</div> </div>
</div> </AccordionPanel>
</> </AccordionItem>
)} )}
{userContext.apiType === "SQL" && ( {userContext.apiType === "SQL" && (
<> <>
<div className="settingsSection"> <AccordionItem value="3">
<div className="settingsSectionPart"> <AccordionHeader>
<div> <div className={styles.header}>Query Timeout</div>
<legend id="ruThresholdLabel" className="settingsSectionLabel legendLabel"> </AccordionHeader>
RU Threshold <AccordionPanel>
</legend> <div className={styles.settingsSectionContainer}>
<InfoTooltip>If a query exceeds a configured RU threshold, the query will be aborted.</InfoTooltip> <div className={styles.settingsSectionDescription}>
</div>
<div>
<Toggle
styles={toggleStyles}
label="Enable RU threshold"
onChange={handleOnRUThresholdToggleChange}
defaultChecked={ruThresholdEnabled}
/>
</div>
{ruThresholdEnabled && (
<div>
<SpinButton
label="RU Threshold (RU)"
labelPosition={Position.top}
defaultValue={(ruThreshold || DefaultRUThreshold).toString()}
min={1}
step={1000}
onChange={handleOnRUThresholdSpinButtonChange}
incrementButtonAriaLabel="Increase value by 1000"
decrementButtonAriaLabel="Decrease value by 1000"
styles={spinButtonStyles}
/>
</div>
)}
</div>
</div>
<div className="settingsSection">
<div className="settingsSectionPart">
<div>
<legend id="queryTimeoutLabel" className="settingsSectionLabel legendLabel">
Query Timeout
</legend>
<InfoTooltip>
When a query reaches a specified time limit, a popup with an option to cancel the query will show When a query reaches a specified time limit, a popup with an option to cancel the query will show
unless automatic cancellation has been enabled unless automatic cancellation has been enabled.
</InfoTooltip>
</div> </div>
<div>
<Toggle <Toggle
styles={toggleStyles} styles={toggleStyles}
label="Enable query timeout" label="Enable query timeout"
@ -580,7 +583,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
/> />
</div> </div>
{queryTimeoutEnabled && ( {queryTimeoutEnabled && (
<div> <div className={styles.settingsSectionContainer}>
<SpinButton <SpinButton
label="Query timeout (ms)" label="Query timeout (ms)"
labelPosition={Position.top} labelPosition={Position.top}
@ -600,17 +603,52 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
/> />
</div> </div>
)} )}
</AccordionPanel>
</AccordionItem>
<AccordionItem value="4">
<AccordionHeader>
<div className={styles.header}>RU Limit</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
If a query exceeds a configured RU limit, the query will be aborted.
</div> </div>
<Toggle
styles={toggleStyles}
label="Enable RU limit"
onChange={handleOnRUThresholdToggleChange}
defaultChecked={ruThresholdEnabled}
/>
</div> </div>
<div className="settingsSection"> {ruThresholdEnabled && (
<div className="settingsSectionPart"> <div className={styles.settingsSectionContainer}>
<div> <SpinButton
<legend id="defaultQueryResultsView" className="settingsSectionLabel legendLabel"> label="RU Limit (RU)"
Default Query Results View labelPosition={Position.top}
</legend> defaultValue={(ruThreshold || DefaultRUThreshold).toString()}
<InfoTooltip>Select the default view to use when displaying query results.</InfoTooltip> min={1}
step={1000}
onChange={handleOnRUThresholdSpinButtonChange}
incrementButtonAriaLabel="Increase value by 1000"
decrementButtonAriaLabel="Decrease value by 1000"
styles={spinButtonStyles}
/>
</div>
)}
</AccordionPanel>
</AccordionItem>
<AccordionItem value="5">
<AccordionHeader>
<div className={styles.header}>Default Query Results View</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
Select the default view to use when displaying query results.
</div> </div>
<div>
<ChoiceGroup <ChoiceGroup
ariaLabelledBy="defaultQueryResultsView" ariaLabelledBy="defaultQueryResultsView"
selectedKey={defaultQueryResultsView} selectedKey={defaultQueryResultsView}
@ -619,21 +657,25 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
onChange={handleOnDefaultQueryResultsViewChange} onChange={handleOnDefaultQueryResultsViewChange}
/> />
</div> </div>
</div> </AccordionPanel>
</div> </AccordionItem>
</> </>
)} )}
<div className="settingsSection">
<div className="settingsSectionPart"> <AccordionItem value="6">
<div className="settingsSectionLabel"> <AccordionHeader>
Retry Settings <div className={styles.header}>Retry Settings</div>
<InfoTooltip>Retry policy associated with throttled requests during CosmosDB queries.</InfoTooltip> </AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
Retry policy associated with throttled requests during CosmosDB queries.
</div> </div>
<div> <div>
<legend id="queryRetryAttemptsLabel" className="settingsSectionLabel legendLabel"> <span className={styles.subHeader}>Max retry attempts</span>
Max retry attempts <InfoTooltip className={styles.headerIcon}>
</legend> Max number of retries to be performed for a request. Default value 9.
<InfoTooltip>Max number of retries to be performed for a request. Default value 9.</InfoTooltip> </InfoTooltip>
</div> </div>
<SpinButton <SpinButton
labelPosition={Position.top} labelPosition={Position.top}
@ -649,12 +691,10 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
styles={spinButtonStyles} styles={spinButtonStyles}
/> />
<div> <div>
<legend id="queryRetryAttemptsLabel" className="settingsSectionLabel legendLabel"> <span className={styles.subHeader}>Fixed retry interval (ms)</span>
Fixed retry interval (ms) <InfoTooltip className={styles.headerIcon}>
</legend> Fixed retry interval in milliseconds to wait between each retry ignoring the retryAfter returned as
<InfoTooltip> part of the response. Default value is 0 milliseconds.
Fixed retry interval in milliseconds to wait between each retry ignoring the retryAfter returned as part
of the response. Default value is 0 milliseconds.
</InfoTooltip> </InfoTooltip>
</div> </div>
<SpinButton <SpinButton
@ -671,10 +711,8 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
styles={spinButtonStyles} styles={spinButtonStyles}
/> />
<div> <div>
<legend id="queryRetryAttemptsLabel" className="settingsSectionLabel legendLabel"> <span className={styles.subHeader}>Max wait time (s)</span>
Max wait time (s) <InfoTooltip className={styles.headerIcon}>
</legend>
<InfoTooltip>
Max wait time in seconds to wait for a request while the retries are happening. Default value 30 Max wait time in seconds to wait for a request while the retries are happening. Default value 30
seconds. seconds.
</InfoTooltip> </InfoTooltip>
@ -693,14 +731,17 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
styles={spinButtonStyles} styles={spinButtonStyles}
/> />
</div> </div>
</div> </AccordionPanel>
<div className="settingsSection"> </AccordionItem>
<div className="settingsSectionPart settingsSectionInlineCheckbox">
<div className="settingsSectionLabel"> <AccordionItem value="7">
Enable container pagination <AccordionHeader>
<InfoTooltip> <div className={styles.header}>Enable container pagination</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
Load 50 containers at a time. Currently, containers are not pulled in alphanumeric order. Load 50 containers at a time. Currently, containers are not pulled in alphanumeric order.
</InfoTooltip>
</div> </div>
<Checkbox <Checkbox
styles={{ styles={{
@ -710,20 +751,23 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
ariaLabel="Enable container pagination" ariaLabel="Enable container pagination"
checked={containerPaginationEnabled} checked={containerPaginationEnabled}
onChange={() => setContainerPaginationEnabled(!containerPaginationEnabled)} onChange={() => setContainerPaginationEnabled(!containerPaginationEnabled)}
label="Enable container pagination"
/> />
</div> </div>
</div> </AccordionPanel>
{shouldShowCrossPartitionOption && ( </AccordionItem>
<div className="settingsSection">
<div className="settingsSectionPart settingsSectionInlineCheckbox">
<div className="settingsSectionLabel">
Enable cross-partition query
<InfoTooltip>
Send more than one request while executing a query. More than one request is necessary if the query is
not scoped to single partition key value.
</InfoTooltip>
</div>
{shouldShowCrossPartitionOption && (
<AccordionItem value="8">
<AccordionHeader>
<div className={styles.header}>Enable cross-partition query</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
Send more than one request while executing a query. More than one request is necessary if the query
is not scoped to single partition key value.
</div>
<Checkbox <Checkbox
styles={{ styles={{
label: { padding: 0 }, label: { padding: 0 },
@ -732,22 +776,25 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
ariaLabel="Enable cross partition query" ariaLabel="Enable cross partition query"
checked={crossPartitionQueryEnabled} checked={crossPartitionQueryEnabled}
onChange={() => setCrossPartitionQueryEnabled(!crossPartitionQueryEnabled)} onChange={() => setCrossPartitionQueryEnabled(!crossPartitionQueryEnabled)}
label="Enable cross-partition query"
/> />
</div> </div>
</div> </AccordionPanel>
</AccordionItem>
)} )}
{shouldShowParallelismOption && ( {shouldShowParallelismOption && (
<div className="settingsSection"> <AccordionItem value="9">
<div className="settingsSectionPart"> <AccordionHeader>
<div className="settingsSectionLabel"> <div className={styles.header}>Max degree of parallelism</div>
Max degree of parallelism </AccordionHeader>
<InfoTooltip> <AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
Gets or sets the number of concurrent operations run client side during parallel query execution. A Gets or sets the number of concurrent operations run client side during parallel query execution. A
positive property value limits the number of concurrent operations to the set value. If it is set to positive property value limits the number of concurrent operations to the set value. If it is set to
less than 0, the system automatically decides the number of concurrent operations to run. less than 0, the system automatically decides the number of concurrent operations to run.
</InfoTooltip>
</div> </div>
<SpinButton <SpinButton
min={-1} min={-1}
step={1} step={1}
@ -755,26 +802,33 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
role="textbox" role="textbox"
id="max-degree" id="max-degree"
value={"" + maxDegreeOfParallelism} value={"" + maxDegreeOfParallelism}
onIncrement={(newValue) => setMaxDegreeOfParallelism(parseInt(newValue) + 1 || maxDegreeOfParallelism)} onIncrement={(newValue) =>
onDecrement={(newValue) => setMaxDegreeOfParallelism(parseInt(newValue) - 1 || maxDegreeOfParallelism)} setMaxDegreeOfParallelism(parseInt(newValue) + 1 || maxDegreeOfParallelism)
}
onDecrement={(newValue) =>
setMaxDegreeOfParallelism(parseInt(newValue) - 1 || maxDegreeOfParallelism)
}
onValidate={(newValue) => setMaxDegreeOfParallelism(parseInt(newValue) || maxDegreeOfParallelism)} onValidate={(newValue) => setMaxDegreeOfParallelism(parseInt(newValue) || maxDegreeOfParallelism)}
ariaLabel="Max degree of parallelism" ariaLabel="Max degree of parallelism"
label="Max degree of parallelism"
/> />
</div> </div>
</div> </AccordionPanel>
</AccordionItem>
)} )}
{shouldShowPriorityLevelOption && ( {shouldShowPriorityLevelOption && (
<div className="settingsSection"> <AccordionItem value="10">
<div className="settingsSectionPart"> <AccordionHeader>
<fieldset> <div className={styles.header}>Priority Level</div>
<legend id="priorityLevel" className="settingsSectionLabel legendLabel"> </AccordionHeader>
Priority Level <AccordionPanel>
</legend> <div className={styles.settingsSectionContainer}>
<InfoTooltip> <div className={styles.settingsSectionDescription}>
Sets the priority level for data-plane requests from Data Explorer when using Priority-Based Sets the priority level for data-plane requests from Data Explorer when using Priority-Based
Execution. If &quot;None&quot; is selected, Data Explorer will not specify priority level, and the Execution. If &quot;None&quot; is selected, Data Explorer will not specify priority level, and the
server-side default priority level will be used. server-side default priority level will be used.
</InfoTooltip> </div>
<ChoiceGroup <ChoiceGroup
ariaLabelledBy="priorityLevel" ariaLabelledBy="priorityLevel"
selectedKey={priorityLevel} selectedKey={priorityLevel}
@ -782,21 +836,22 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
styles={choiceButtonStyles} styles={choiceButtonStyles}
onChange={handleOnPriorityLevelOptionChange} onChange={handleOnPriorityLevelOptionChange}
/> />
</fieldset>
</div>
</div> </div>
</AccordionPanel>
</AccordionItem>
)} )}
{shouldShowGraphAutoVizOption && (
<div className="settingsSection">
<div className="settingsSectionPart">
<div className="settingsSectionLabel">
Display Gremlin query results as:&nbsp;
<InfoTooltip>
Select Graph to automatically visualize the query results as a Graph or JSON to display the results as
JSON.
</InfoTooltip>
</div>
{shouldShowGraphAutoVizOption && (
<AccordionItem value="11">
<AccordionHeader>
<div className={styles.header}>Display Gremlin query results as:&nbsp;</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
Select Graph to automatically visualize the query results as a Graph or JSON to display the results
as JSON.
</div>
<ChoiceGroup <ChoiceGroup
selectedKey={graphAutoVizDisabled} selectedKey={graphAutoVizDisabled}
options={graphAutoOptionList} options={graphAutoOptionList}
@ -804,20 +859,22 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
aria-label="Graph Auto-visualization" aria-label="Graph Auto-visualization"
/> />
</div> </div>
</div> </AccordionPanel>
</AccordionItem>
)} )}
{shouldShowCopilotSampleDBOption && (
<div className="settingsSection">
<div className="settingsSectionPart settingsSectionInlineCheckbox">
<div className="settingsSectionLabel">
Enable sample database
<InfoTooltip>
This is a sample database and collection with synthetic product data you can use to explore using
NoSQL queries and Query Advisor. This will appear as another database in the Data Explorer UI, and is
created by, and maintained by Microsoft at no cost to you.
</InfoTooltip>
</div>
{shouldShowCopilotSampleDBOption && (
<AccordionItem value="12">
<AccordionHeader>
<div className={styles.header}>Enable sample database</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}>
This is a sample database and collection with synthetic product data you can use to explore using
NoSQL queries and Query Advisor. This will appear as another database in the Data Explorer UI, and
is created by, and maintained by Microsoft at no cost to you.
</div>
<Checkbox <Checkbox
styles={{ styles={{
label: { padding: 0 }, label: { padding: 0 },
@ -826,10 +883,42 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
ariaLabel="Enable sample db for Query Advisor" ariaLabel="Enable sample db for Query Advisor"
checked={copilotSampleDBEnabled} checked={copilotSampleDBEnabled}
onChange={handleSampleDatabaseChange} onChange={handleSampleDatabaseChange}
label="Enable sample database"
/> />
</div> </div>
</div> </AccordionPanel>
</AccordionItem>
)} )}
</Accordion>
<div className="settingsSection">
<div className="settingsSectionPart">
<DefaultButton
onClick={() => {
useDialog.getState().showOkCancelModalDialog(
"Clear History",
undefined,
"Are you sure you want to proceed?",
() => deleteAllStates(),
"Cancel",
undefined,
<>
<span>
This action will clear the all customizations for this account in this browser, including:
</span>
<ul className={styles.bulletList}>
<li>Reset your customized tab layout, including the splitter positions</li>
<li>Erase your table column preferences, including any custom columns</li>
<li>Clear your filter history</li>
</ul>
</>,
);
}}
>
Clear History
</DefaultButton>
</div>
</div>
<div className="settingsSection"> <div className="settingsSection">
<div className="settingsSectionPart"> <div className="settingsSectionPart">
<div className="settingsSectionLabel">Explorer Version</div> <div className="settingsSectionLabel">Explorer Version</div>

View File

@ -8,24 +8,30 @@ exports[`Settings Pane should render Default properly 1`] = `
submitButtonText="Apply" submitButtonText="Apply"
> >
<div <div
className="paneMainContent" className="paneMainContent ___133e6fg_0000000 f22iagw f1vx9l62 f1l02sjl"
> >
<Accordion
className="___1uf6361_0000000 fz7g6wx"
>
<AccordionItem
value="1"
>
<AccordionHeader>
<div <div
className="settingsSection" className="___15c001r_0000000 fq02s40"
>
<div
className="settingsSectionPart"
>
<fieldset>
<legend
className="settingsSectionLabel legendLabel"
id="pageOptions"
> >
Page Options Page Options
</legend> </div>
<InfoTooltip> </AccordionHeader>
<AccordionPanel>
<div
className="___1dfa554_0000000 fo7qwa0"
>
<div
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
>
Choose Custom to specify a fixed amount of query results to show, or choose Unlimited to show as many query results per page. Choose Custom to specify a fixed amount of query results to show, or choose Unlimited to show as many query results per page.
</InfoTooltip> </div>
<StyledChoiceGroup <StyledChoiceGroup
ariaLabelledBy="pageOptions" ariaLabelledBy="pageOptions"
onChange={[Function]} onChange={[Function]}
@ -66,19 +72,21 @@ exports[`Settings Pane should render Default properly 1`] = `
} }
} }
/> />
</fieldset>
</div> </div>
<div <div
className="tabs settingsSectionPart" className="tabs ___1dfa554_0000000 fo7qwa0"
> >
<div <div
className="tabcontent" className="tabcontent"
> >
<div <div
className="settingsSectionLabel" className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
> >
Query results per page Query results per page
<InfoTooltip>
<InfoTooltip
className="___vtc5hy0_0000000 f10ra9hq f1k6fduh"
>
Enter the number of query results that should be shown per page. Enter the number of query results that should be shown per page.
</InfoTooltip> </InfoTooltip>
</div> </div>
@ -96,28 +104,30 @@ exports[`Settings Pane should render Default properly 1`] = `
/> />
</div> </div>
</div> </div>
</div> </AccordionPanel>
</AccordionItem>
<AccordionItem
value="3"
>
<AccordionHeader>
<div <div
className="settingsSection" className="___15c001r_0000000 fq02s40"
>
Query Timeout
</div>
</AccordionHeader>
<AccordionPanel>
<div
className="___1dfa554_0000000 fo7qwa0"
> >
<div <div
className="settingsSectionPart" className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
> >
<div> When a query reaches a specified time limit, a popup with an option to cancel the query will show unless automatic cancellation has been enabled.
<legend
className="settingsSectionLabel legendLabel"
id="ruThresholdLabel"
>
RU Threshold
</legend>
<InfoTooltip>
If a query exceeds a configured RU threshold, the query will be aborted.
</InfoTooltip>
</div> </div>
<div>
<StyledToggleBase <StyledToggleBase
defaultChecked={true} defaultChecked={false}
label="Enable RU threshold" label="Enable query timeout"
onChange={[Function]} onChange={[Function]}
styles={ styles={
{ {
@ -135,12 +145,55 @@ exports[`Settings Pane should render Default properly 1`] = `
} }
/> />
</div> </div>
<div> </AccordionPanel>
</AccordionItem>
<AccordionItem
value="4"
>
<AccordionHeader>
<div
className="___15c001r_0000000 fq02s40"
>
RU Limit
</div>
</AccordionHeader>
<AccordionPanel>
<div
className="___1dfa554_0000000 fo7qwa0"
>
<div
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
>
If a query exceeds a configured RU limit, the query will be aborted.
</div>
<StyledToggleBase
defaultChecked={true}
label="Enable RU limit"
onChange={[Function]}
styles={
{
"container": {},
"label": {
"display": "block",
"fontSize": 12,
"fontWeight": 400,
},
"pill": {},
"root": {},
"text": {},
"thumb": {},
}
}
/>
</div>
<div
className="___1dfa554_0000000 fo7qwa0"
>
<StyledSpinButton <StyledSpinButton
decrementButtonAriaLabel="Decrease value by 1000" decrementButtonAriaLabel="Decrease value by 1000"
defaultValue="5000" defaultValue="5000"
incrementButtonAriaLabel="Increase value by 1000" incrementButtonAriaLabel="Increase value by 1000"
label="RU Threshold (RU)" label="RU Limit (RU)"
labelPosition={0} labelPosition={0}
min={1} min={1}
onChange={[Function]} onChange={[Function]}
@ -163,66 +216,27 @@ exports[`Settings Pane should render Default properly 1`] = `
} }
/> />
</div> </div>
</div> </AccordionPanel>
</div> </AccordionItem>
<AccordionItem
value="5"
>
<AccordionHeader>
<div <div
className="settingsSection" className="___15c001r_0000000 fq02s40"
>
<div
className="settingsSectionPart"
>
<div>
<legend
className="settingsSectionLabel legendLabel"
id="queryTimeoutLabel"
>
Query Timeout
</legend>
<InfoTooltip>
When a query reaches a specified time limit, a popup with an option to cancel the query will show unless automatic cancellation has been enabled
</InfoTooltip>
</div>
<div>
<StyledToggleBase
defaultChecked={false}
label="Enable query timeout"
onChange={[Function]}
styles={
{
"container": {},
"label": {
"display": "block",
"fontSize": 12,
"fontWeight": 400,
},
"pill": {},
"root": {},
"text": {},
"thumb": {},
}
}
/>
</div>
</div>
</div>
<div
className="settingsSection"
>
<div
className="settingsSectionPart"
>
<div>
<legend
className="settingsSectionLabel legendLabel"
id="defaultQueryResultsView"
> >
Default Query Results View Default Query Results View
</legend>
<InfoTooltip>
Select the default view to use when displaying query results.
</InfoTooltip>
</div> </div>
<div> </AccordionHeader>
<AccordionPanel>
<div
className="___1dfa554_0000000 fo7qwa0"
>
<div
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
>
Select the default view to use when displaying query results.
</div>
<StyledChoiceGroup <StyledChoiceGroup
ariaLabelledBy="defaultQueryResultsView" ariaLabelledBy="defaultQueryResultsView"
onChange={[Function]} onChange={[Function]}
@ -238,7 +252,7 @@ exports[`Settings Pane should render Default properly 1`] = `
}, },
] ]
} }
selectedKey="vertical" selectedKey="horizontal"
styles={ styles={
{ {
"flexContainer": [ "flexContainer": [
@ -264,30 +278,36 @@ exports[`Settings Pane should render Default properly 1`] = `
} }
/> />
</div> </div>
</div> </AccordionPanel>
</div> </AccordionItem>
<div <AccordionItem
className="settingsSection" value="6"
> >
<AccordionHeader>
<div <div
className="settingsSectionPart" className="___15c001r_0000000 fq02s40"
>
<div
className="settingsSectionLabel"
> >
Retry Settings Retry Settings
<InfoTooltip> </div>
</AccordionHeader>
<AccordionPanel>
<div
className="___1dfa554_0000000 fo7qwa0"
>
<div
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
>
Retry policy associated with throttled requests during CosmosDB queries. Retry policy associated with throttled requests during CosmosDB queries.
</InfoTooltip>
</div> </div>
<div> <div>
<legend <span
className="settingsSectionLabel legendLabel" className="___zls1px0_0000000 fq02s40 f1ugzwwg"
id="queryRetryAttemptsLabel"
> >
Max retry attempts Max retry attempts
</legend> </span>
<InfoTooltip> <InfoTooltip
className="___vtc5hy0_0000000 f10ra9hq f1k6fduh"
>
Max number of retries to be performed for a request. Default value 9. Max number of retries to be performed for a request. Default value 9.
</InfoTooltip> </InfoTooltip>
</div> </div>
@ -320,13 +340,14 @@ exports[`Settings Pane should render Default properly 1`] = `
value="9" value="9"
/> />
<div> <div>
<legend <span
className="settingsSectionLabel legendLabel" className="___zls1px0_0000000 fq02s40 f1ugzwwg"
id="queryRetryAttemptsLabel"
> >
Fixed retry interval (ms) Fixed retry interval (ms)
</legend> </span>
<InfoTooltip> <InfoTooltip
className="___vtc5hy0_0000000 f10ra9hq f1k6fduh"
>
Fixed retry interval in milliseconds to wait between each retry ignoring the retryAfter returned as part of the response. Default value is 0 milliseconds. Fixed retry interval in milliseconds to wait between each retry ignoring the retryAfter returned as part of the response. Default value is 0 milliseconds.
</InfoTooltip> </InfoTooltip>
</div> </div>
@ -359,13 +380,14 @@ exports[`Settings Pane should render Default properly 1`] = `
value="0" value="0"
/> />
<div> <div>
<legend <span
className="settingsSectionLabel legendLabel" className="___zls1px0_0000000 fq02s40 f1ugzwwg"
id="queryRetryAttemptsLabel"
> >
Max wait time (s) Max wait time (s)
</legend> </span>
<InfoTooltip> <InfoTooltip
className="___vtc5hy0_0000000 f10ra9hq f1k6fduh"
>
Max wait time in seconds to wait for a request while the retries are happening. Default value 30 seconds. Max wait time in seconds to wait for a request while the retries are happening. Default value 30 seconds.
</InfoTooltip> </InfoTooltip>
</div> </div>
@ -398,25 +420,32 @@ exports[`Settings Pane should render Default properly 1`] = `
value="30" value="30"
/> />
</div> </div>
</div> </AccordionPanel>
<div </AccordionItem>
className="settingsSection" <AccordionItem
value="7"
> >
<AccordionHeader>
<div <div
className="settingsSectionPart settingsSectionInlineCheckbox" className="___15c001r_0000000 fq02s40"
>
<div
className="settingsSectionLabel"
> >
Enable container pagination Enable container pagination
<InfoTooltip> </div>
</AccordionHeader>
<AccordionPanel>
<div
className="___1dfa554_0000000 fo7qwa0"
>
<div
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
>
Load 50 containers at a time. Currently, containers are not pulled in alphanumeric order. Load 50 containers at a time. Currently, containers are not pulled in alphanumeric order.
</InfoTooltip>
</div> </div>
<StyledCheckboxBase <StyledCheckboxBase
ariaLabel="Enable container pagination" ariaLabel="Enable container pagination"
checked={false} checked={false}
className="padding" className="padding"
label="Enable container pagination"
onChange={[Function]} onChange={[Function]}
styles={ styles={
{ {
@ -427,25 +456,32 @@ exports[`Settings Pane should render Default properly 1`] = `
} }
/> />
</div> </div>
</div> </AccordionPanel>
<div </AccordionItem>
className="settingsSection" <AccordionItem
value="8"
> >
<AccordionHeader>
<div <div
className="settingsSectionPart settingsSectionInlineCheckbox" className="___15c001r_0000000 fq02s40"
>
<div
className="settingsSectionLabel"
> >
Enable cross-partition query Enable cross-partition query
<InfoTooltip> </div>
</AccordionHeader>
<AccordionPanel>
<div
className="___1dfa554_0000000 fo7qwa0"
>
<div
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
>
Send more than one request while executing a query. More than one request is necessary if the query is not scoped to single partition key value. Send more than one request while executing a query. More than one request is necessary if the query is not scoped to single partition key value.
</InfoTooltip>
</div> </div>
<StyledCheckboxBase <StyledCheckboxBase
ariaLabel="Enable cross partition query" ariaLabel="Enable cross partition query"
checked={false} checked={false}
className="padding" className="padding"
label="Enable cross-partition query"
onChange={[Function]} onChange={[Function]}
styles={ styles={
{ {
@ -456,25 +492,32 @@ exports[`Settings Pane should render Default properly 1`] = `
} }
/> />
</div> </div>
</div> </AccordionPanel>
<div </AccordionItem>
className="settingsSection" <AccordionItem
value="9"
> >
<AccordionHeader>
<div <div
className="settingsSectionPart" className="___15c001r_0000000 fq02s40"
>
<div
className="settingsSectionLabel"
> >
Max degree of parallelism Max degree of parallelism
<InfoTooltip> </div>
</AccordionHeader>
<AccordionPanel>
<div
className="___1dfa554_0000000 fo7qwa0"
>
<div
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
>
Gets or sets the number of concurrent operations run client side during parallel query execution. A positive property value limits the number of concurrent operations to the set value. If it is set to less than 0, the system automatically decides the number of concurrent operations to run. Gets or sets the number of concurrent operations run client side during parallel query execution. A positive property value limits the number of concurrent operations to the set value. If it is set to less than 0, the system automatically decides the number of concurrent operations to run.
</InfoTooltip>
</div> </div>
<StyledSpinButton <StyledSpinButton
ariaLabel="Max degree of parallelism" ariaLabel="Max degree of parallelism"
className="textfontclr" className="textfontclr"
id="max-degree" id="max-degree"
label="Max degree of parallelism"
min={-1} min={-1}
onDecrement={[Function]} onDecrement={[Function]}
onIncrement={[Function]} onIncrement={[Function]}
@ -484,6 +527,21 @@ exports[`Settings Pane should render Default properly 1`] = `
value="6" value="6"
/> />
</div> </div>
</AccordionPanel>
</AccordionItem>
</Accordion>
<div
className="settingsSection"
>
<div
className="settingsSectionPart"
>
<CustomizedDefaultButton
onClick={[Function]}
>
Clear History
</CustomizedDefaultButton>
</div>
</div> </div>
<div <div
className="settingsSection" className="settingsSection"
@ -511,30 +569,39 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
submitButtonText="Apply" submitButtonText="Apply"
> >
<div <div
className="paneMainContent" className="paneMainContent ___133e6fg_0000000 f22iagw f1vx9l62 f1l02sjl"
> >
<div <Accordion
className="settingsSection" className="___1uf6361_0000000 fz7g6wx"
> >
<div <AccordionItem
className="settingsSectionPart" value="6"
> >
<AccordionHeader>
<div <div
className="settingsSectionLabel" className="___15c001r_0000000 fq02s40"
> >
Retry Settings Retry Settings
<InfoTooltip> </div>
</AccordionHeader>
<AccordionPanel>
<div
className="___1dfa554_0000000 fo7qwa0"
>
<div
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
>
Retry policy associated with throttled requests during CosmosDB queries. Retry policy associated with throttled requests during CosmosDB queries.
</InfoTooltip>
</div> </div>
<div> <div>
<legend <span
className="settingsSectionLabel legendLabel" className="___zls1px0_0000000 fq02s40 f1ugzwwg"
id="queryRetryAttemptsLabel"
> >
Max retry attempts Max retry attempts
</legend> </span>
<InfoTooltip> <InfoTooltip
className="___vtc5hy0_0000000 f10ra9hq f1k6fduh"
>
Max number of retries to be performed for a request. Default value 9. Max number of retries to be performed for a request. Default value 9.
</InfoTooltip> </InfoTooltip>
</div> </div>
@ -567,13 +634,14 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
value="9" value="9"
/> />
<div> <div>
<legend <span
className="settingsSectionLabel legendLabel" className="___zls1px0_0000000 fq02s40 f1ugzwwg"
id="queryRetryAttemptsLabel"
> >
Fixed retry interval (ms) Fixed retry interval (ms)
</legend> </span>
<InfoTooltip> <InfoTooltip
className="___vtc5hy0_0000000 f10ra9hq f1k6fduh"
>
Fixed retry interval in milliseconds to wait between each retry ignoring the retryAfter returned as part of the response. Default value is 0 milliseconds. Fixed retry interval in milliseconds to wait between each retry ignoring the retryAfter returned as part of the response. Default value is 0 milliseconds.
</InfoTooltip> </InfoTooltip>
</div> </div>
@ -606,13 +674,14 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
value="0" value="0"
/> />
<div> <div>
<legend <span
className="settingsSectionLabel legendLabel" className="___zls1px0_0000000 fq02s40 f1ugzwwg"
id="queryRetryAttemptsLabel"
> >
Max wait time (s) Max wait time (s)
</legend> </span>
<InfoTooltip> <InfoTooltip
className="___vtc5hy0_0000000 f10ra9hq f1k6fduh"
>
Max wait time in seconds to wait for a request while the retries are happening. Default value 30 seconds. Max wait time in seconds to wait for a request while the retries are happening. Default value 30 seconds.
</InfoTooltip> </InfoTooltip>
</div> </div>
@ -645,25 +714,32 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
value="30" value="30"
/> />
</div> </div>
</div> </AccordionPanel>
<div </AccordionItem>
className="settingsSection" <AccordionItem
value="7"
> >
<AccordionHeader>
<div <div
className="settingsSectionPart settingsSectionInlineCheckbox" className="___15c001r_0000000 fq02s40"
>
<div
className="settingsSectionLabel"
> >
Enable container pagination Enable container pagination
<InfoTooltip> </div>
</AccordionHeader>
<AccordionPanel>
<div
className="___1dfa554_0000000 fo7qwa0"
>
<div
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
>
Load 50 containers at a time. Currently, containers are not pulled in alphanumeric order. Load 50 containers at a time. Currently, containers are not pulled in alphanumeric order.
</InfoTooltip>
</div> </div>
<StyledCheckboxBase <StyledCheckboxBase
ariaLabel="Enable container pagination" ariaLabel="Enable container pagination"
checked={false} checked={false}
className="padding" className="padding"
label="Enable container pagination"
onChange={[Function]} onChange={[Function]}
styles={ styles={
{ {
@ -674,20 +750,26 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
} }
/> />
</div> </div>
</div> </AccordionPanel>
<div </AccordionItem>
className="settingsSection" <AccordionItem
value="11"
> >
<AccordionHeader>
<div <div
className="settingsSectionPart" className="___15c001r_0000000 fq02s40"
>
<div
className="settingsSectionLabel"
> >
Display Gremlin query results as:  Display Gremlin query results as: 
<InfoTooltip> </div>
</AccordionHeader>
<AccordionPanel>
<div
className="___1dfa554_0000000 fo7qwa0"
>
<div
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
>
Select Graph to automatically visualize the query results as a Graph or JSON to display the results as JSON. Select Graph to automatically visualize the query results as a Graph or JSON to display the results as JSON.
</InfoTooltip>
</div> </div>
<StyledChoiceGroup <StyledChoiceGroup
aria-label="Graph Auto-visualization" aria-label="Graph Auto-visualization"
@ -707,6 +789,21 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
selectedKey="false" selectedKey="false"
/> />
</div> </div>
</AccordionPanel>
</AccordionItem>
</Accordion>
<div
className="settingsSection"
>
<div
className="settingsSectionPart"
>
<CustomizedDefaultButton
onClick={[Function]}
>
Clear History
</CustomizedDefaultButton>
</div>
</div> </div>
<div <div
className="settingsSection" className="settingsSection"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,7 +26,7 @@ import { getCollectionName, getDatabaseName } from "Utils/APITypeUtils";
import { Allotment, AllotmentHandle } from "allotment"; import { Allotment, AllotmentHandle } from "allotment";
import { useSidePanel } from "hooks/useSidePanel"; import { useSidePanel } from "hooks/useSidePanel";
import { debounce } from "lodash"; import { debounce } from "lodash";
import React, { useCallback, useEffect, useMemo, useRef } from "react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
const useSidebarStyles = makeStyles({ const useSidebarStyles = makeStyles({
sidebar: { sidebar: {
@ -86,7 +86,7 @@ const useSidebarStyles = makeStyles({
}, },
}, },
globalCommandsMenuButton: { globalCommandsMenuButton: {
display: "initial", display: "inline-flex",
"@container (min-width: 250px)": { "@container (min-width: 250px)": {
display: "none", display: "none",
}, },
@ -113,6 +113,12 @@ interface GlobalCommand {
const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => { const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
const styles = useSidebarStyles(); const styles = useSidebarStyles();
// Since we have two buttons in the DOM (one for small screens and one for larger screens), we wrap the entire thing in a div.
// However, that messes with the Menu positioning, so we need to get a reference to the 'div' to pass to the Menu.
// We can't use a ref though, because it would be set after the Menu is rendered, so we use a state value to force a re-render.
const [globalCommandButton, setGlobalCommandButton] = useState<HTMLElement | null>(null);
const actions = useMemo<GlobalCommand[]>(() => { const actions = useMemo<GlobalCommand[]>(() => {
if ( if (
configContext.platform === Platform.Fabric || configContext.platform === Platform.Fabric ||
@ -182,10 +188,10 @@ const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
{primaryAction.label} {primaryAction.label}
</Button> </Button>
) : ( ) : (
<Menu positioning="below-end"> <Menu positioning={{ target: globalCommandButton, position: "below", align: "end" }}>
<MenuTrigger disableButtonEnhancement> <MenuTrigger disableButtonEnhancement>
{(triggerProps: MenuButtonProps) => ( {(triggerProps: MenuButtonProps) => (
<> <div ref={setGlobalCommandButton}>
<SplitButton <SplitButton
menuButton={{ ...triggerProps, "aria-label": "More commands" }} menuButton={{ ...triggerProps, "aria-label": "More commands" }}
primaryActionButton={{ onClick: onPrimaryActionClick }} primaryActionButton={{ onClick: onPrimaryActionClick }}
@ -197,7 +203,7 @@ const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
<MenuButton {...triggerProps} icon={primaryAction.icon} className={styles.globalCommandsMenuButton}> <MenuButton {...triggerProps} icon={primaryAction.icon} className={styles.globalCommandsMenuButton}>
New... New...
</MenuButton> </MenuButton>
</> </div>
)} )}
</MenuTrigger> </MenuTrigger>
<MenuPopover> <MenuPopover>
@ -280,7 +286,7 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
{/* Collections Tree - Start */} {/* Collections Tree - Start */}
{hasSidebar && ( {hasSidebar && (
// When collapsed, we force the pane to 24 pixels wide and make it non-resizable. // When collapsed, we force the pane to 24 pixels wide and make it non-resizable.
<Allotment.Pane minSize={24} preferredSize={300}> <Allotment.Pane minSize={24} preferredSize={250}>
<CosmosFluentProvider className={mergeClasses(styles.sidebar)}> <CosmosFluentProvider className={mergeClasses(styles.sidebar)}>
<div className={styles.sidebarContainer}> <div className={styles.sidebarContainer}>
{loading && ( {loading && (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -753,7 +753,10 @@ export class CassandraAPIDataClient extends TableDataClient {
CassandraProxyEndpoints.Development, CassandraProxyEndpoints.Development,
CassandraProxyEndpoints.Mpac, CassandraProxyEndpoints.Mpac,
CassandraProxyEndpoints.Prod, CassandraProxyEndpoints.Prod,
CassandraProxyEndpoints.Fairfax,
CassandraProxyEndpoints.Mooncake,
]; ];
let canAccessCassandraProxy: boolean = userContext.databaseAccount.properties.publicNetworkAccess === "Enabled"; let canAccessCassandraProxy: boolean = userContext.databaseAccount.properties.publicNetworkAccess === "Enabled";
if ( if (
configContext.CASSANDRA_PROXY_ENDPOINT !== CassandraProxyEndpoints.Development && configContext.CASSANDRA_PROXY_ENDPOINT !== CassandraProxyEndpoints.Development &&
@ -761,7 +764,6 @@ export class CassandraAPIDataClient extends TableDataClient {
) { ) {
canAccessCassandraProxy = canAccessCassandraProxy && configContext.CASSANDRA_PROXY_OUTBOUND_IPS_ALLOWLISTED; canAccessCassandraProxy = canAccessCassandraProxy && configContext.CASSANDRA_PROXY_OUTBOUND_IPS_ALLOWLISTED;
} }
return ( return (
canAccessCassandraProxy && canAccessCassandraProxy &&
configContext.NEW_CASSANDRA_APIS?.includes(api) && configContext.NEW_CASSANDRA_APIS?.includes(api) &&

View File

@ -0,0 +1,113 @@
// Definitions of State data
import { ColumnDefinition } from "Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent";
import {
AppStateComponentNames,
deleteState,
loadState,
saveState,
saveStateDebounced,
} from "Shared/AppStatePersistenceUtility";
import { userContext } from "UserContext";
import * as ViewModels from "../../../Contracts/ViewModels";
import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
const componentName = AppStateComponentNames.DocumentsTab;
export enum SubComponentName {
ColumnSizes = "ColumnSizes",
FilterHistory = "FilterHistory",
MainTabDivider = "MainTabDivider",
ColumnsSelection = "ColumnsSelection",
ColumnSort = "ColumnSort",
}
export type ColumnSizesMap = { [columnId: string]: WidthDefinition };
export type FilterHistory = string[];
export type WidthDefinition = { widthPx: number };
export type TabDivider = { leftPaneWidthPercent: number };
export type ColumnsSelection = { selectedColumnIds: string[]; columnDefinitions: ColumnDefinition[] };
export type ColumnSort = { columnId: string; direction: "ascending" | "descending" };
/**
*
* @param subComponentName
* @param collection
* @param defaultValue Will be returned if persisted state is not found
* @returns
*/
export const readSubComponentState = <T>(
subComponentName: SubComponentName,
collection: ViewModels.CollectionBase,
defaultValue: T,
): T => {
const globalAccountName = userContext.databaseAccount?.name;
if (!globalAccountName) {
const message = "Database account name not found in userContext";
console.error(message);
TelemetryProcessor.traceFailure(Action.ReadPersistedTabState, { message, componentName });
return defaultValue;
}
const state = loadState({
componentName: componentName,
subComponentName,
globalAccountName,
databaseName: collection.databaseId,
containerName: collection.id(),
}) as T;
return state || defaultValue;
};
/**
*
* @param subComponentName
* @param collection
* @param state State to save
* @param debounce true for high-frequency calls (e.g mouse drag events)
*/
export const saveSubComponentState = <T>(
subComponentName: SubComponentName,
collection: ViewModels.CollectionBase,
state: T,
debounce?: boolean,
): void => {
const globalAccountName = userContext.databaseAccount?.name;
if (!globalAccountName) {
const message = "Database account name not found in userContext";
console.error(message);
TelemetryProcessor.traceFailure(Action.SavePersistedTabState, { message, componentName });
return;
}
(debounce ? saveStateDebounced : saveState)(
{
componentName: componentName,
subComponentName,
globalAccountName,
databaseName: collection.databaseId,
containerName: collection.id(),
},
state,
);
};
export const deleteSubComponentState = (subComponentName: SubComponentName, collection: ViewModels.CollectionBase) => {
const globalAccountName = userContext.databaseAccount?.name;
if (!globalAccountName) {
const message = "Database account name not found in userContext";
console.error(message);
TelemetryProcessor.traceFailure(Action.DeletePersistedTabState, { message, componentName });
return;
}
deleteState({
componentName: componentName,
subComponentName,
globalAccountName,
databaseName: collection.databaseId,
containerName: collection.id(),
});
};

View File

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

View File

@ -1,10 +1,19 @@
import { Item, ItemDefinition, PartitionKey, PartitionKeyDefinition, QueryIterator, Resource } from "@azure/cosmos"; import { Item, ItemDefinition, PartitionKey, PartitionKeyDefinition, QueryIterator, Resource } from "@azure/cosmos";
import { Button, Input, TableRowId, makeStyles, shorthands } from "@fluentui/react-components"; import {
import { ArrowClockwise16Filled, Dismiss16Filled } from "@fluentui/react-icons"; Button,
Input,
Link,
MessageBar,
MessageBarBody,
MessageBarTitle,
TableRowId,
makeStyles,
shorthands,
} from "@fluentui/react-components";
import { Dismiss16Filled } from "@fluentui/react-icons";
import { KeyCodes, QueryCopilotSampleContainerId, QueryCopilotSampleDatabaseId } from "Common/Constants"; import { KeyCodes, QueryCopilotSampleContainerId, QueryCopilotSampleDatabaseId } from "Common/Constants";
import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils"; import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
import MongoUtility from "Common/MongoUtility"; import MongoUtility from "Common/MongoUtility";
import { StyleConstants } from "Common/StyleConstants";
import { createDocument } from "Common/dataAccess/createDocument"; import { createDocument } from "Common/dataAccess/createDocument";
import { import {
deleteDocument as deleteNoSqlDocument, deleteDocument as deleteNoSqlDocument,
@ -17,9 +26,19 @@ import { Platform, configContext } from "ConfigContext";
import { CommandButtonComponentProps } from "Explorer/Controls/CommandButton/CommandButtonComponent"; import { CommandButtonComponentProps } from "Explorer/Controls/CommandButton/CommandButtonComponent";
import { useDialog } from "Explorer/Controls/Dialog"; import { useDialog } from "Explorer/Controls/Dialog";
import { EditorReact } from "Explorer/Controls/Editor/EditorReact"; import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
import { ProgressModalDialog } from "Explorer/Controls/ProgressModalDialog";
import Explorer from "Explorer/Explorer"; import Explorer from "Explorer/Explorer";
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter"; import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities"; import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import {
ColumnsSelection,
FilterHistory,
SubComponentName,
TabDivider,
readSubComponentState,
saveSubComponentState,
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
import { usePrevious } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
import { CosmosFluentProvider, LayoutConstants, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil"; import { CosmosFluentProvider, LayoutConstants, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil";
import { useSelectedNode } from "Explorer/useSelectedNode"; import { useSelectedNode } from "Explorer/useSelectedNode";
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts"; import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
@ -27,7 +46,7 @@ import { QueryConstants } from "Shared/Constants";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility"; import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { Action } from "Shared/Telemetry/TelemetryConstants"; import { Action } from "Shared/Telemetry/TelemetryConstants";
import { userContext } from "UserContext"; import { userContext } from "UserContext";
import { logConsoleError } from "Utils/NotificationConsoleUtils"; import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils";
import { Allotment } from "allotment"; import { Allotment } from "allotment";
import React, { KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from "react"; import React, { KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { format } from "react-string-format"; import { format } from "react-string-format";
@ -45,11 +64,16 @@ import * as ViewModels from "../../../Contracts/ViewModels";
import { CollectionBase } from "../../../Contracts/ViewModels"; import { CollectionBase } from "../../../Contracts/ViewModels";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor"; import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
import * as QueryUtils from "../../../Utils/QueryUtils"; import * as QueryUtils from "../../../Utils/QueryUtils";
import { extractPartitionKeyValues } from "../../../Utils/QueryUtils"; import { defaultQueryFields, extractPartitionKeyValues } from "../../../Utils/QueryUtils";
import DocumentId from "../../Tree/DocumentId"; import DocumentId from "../../Tree/DocumentId";
import ObjectId from "../../Tree/ObjectId"; import ObjectId from "../../Tree/ObjectId";
import TabsBase from "../TabsBase"; import TabsBase from "../TabsBase";
import { DocumentsTableComponent, DocumentsTableComponentItem } from "./DocumentsTableComponent"; import { ColumnDefinition, DocumentsTableComponent, DocumentsTableComponentItem } from "./DocumentsTableComponent";
const MAX_FILTER_HISTORY_COUNT = 100; // Datalist will become scrollable, so we can afford to keep more items than fit on the screen
const NO_SQL_THROTTLING_DOC_URL =
"https://learn.microsoft.com/azure/cosmos-db/nosql/troubleshoot-request-rate-too-large";
const MONGO_THROTTLING_DOC_URL = "https://learn.microsoft.com/azure/cosmos-db/mongodb/prevent-rate-limiting-errors";
const loadMoreHeight = LayoutConstants.rowHeight; const loadMoreHeight = LayoutConstants.rowHeight;
export const useDocumentsTabStyles = makeStyles({ export const useDocumentsTabStyles = makeStyles({
@ -81,6 +105,13 @@ export const useDocumentsTabStyles = makeStyles({
tableCell: { tableCell: {
...cosmosShorthands.borderLeft(), ...cosmosShorthands.borderLeft(),
}, },
tableHeader: {
display: "flex",
},
tableHeaderFiller: {
width: "20px",
boxShadow: `0px -1px ${tokens.colorNeutralStroke2} inset`,
},
loadMore: { loadMore: {
...cosmosShorthands.borderTop(), ...cosmosShorthands.borderTop(),
display: "grid", display: "grid",
@ -104,6 +135,9 @@ export const useDocumentsTabStyles = makeStyles({
backgroundColor: "white", backgroundColor: "white",
zIndex: 1, zIndex: 1,
}, },
deleteProgressContent: {
paddingTop: tokens.spacingVerticalL,
},
}); });
export class DocumentsTabV2 extends TabsBase { export class DocumentsTabV2 extends TabsBase {
@ -273,7 +307,7 @@ const createUploadButton = (container: Explorer): CommandButtonComponentProps =>
iconAlt: label, iconAlt: label,
onCommandClick: () => { onCommandClick: () => {
const selectedCollection: ViewModels.Collection = useSelectedNode.getState().findSelectedCollection(); const selectedCollection: ViewModels.Collection = useSelectedNode.getState().findSelectedCollection();
selectedCollection && container.openUploadItemsPanePane(); selectedCollection && container.openUploadItemsPane();
}, },
commandButtonLabel: label, commandButtonLabel: label,
ariaLabel: label, ariaLabel: label,
@ -461,17 +495,51 @@ export const showPartitionKey = (collection: ViewModels.CollectionBase, isPrefer
}; };
// Export to expose to unit tests // Export to expose to unit tests
/**
* Build default query
* @param isMongo true if mongo api
* @param filter
* @param partitionKeyProperties optional for mongo
* @param partitionKey optional for mongo
* @param additionalField
* @returns
*/
export const buildQuery = ( export const buildQuery = (
isMongo: boolean, isMongo: boolean,
filter: string, filter: string,
partitionKeyProperties?: string[], partitionKeyProperties?: string[],
partitionKey?: DataModels.PartitionKey, partitionKey?: DataModels.PartitionKey,
additionalField?: string[],
): string => { ): string => {
if (isMongo) { if (isMongo) {
return filter || "{}"; return filter || "{}";
} }
return QueryUtils.buildDocumentsQuery(filter, partitionKeyProperties, partitionKey); // Filter out fields starting with "/" (partition keys)
return QueryUtils.buildDocumentsQuery(
filter,
partitionKeyProperties,
partitionKey,
additionalField?.filter((f) => !f.startsWith("/")) || [],
);
};
/**
* Export to expose to unit tests
*
* Add array2 to array1 without duplicates
* @param array1
* @param array2
* @return array1 with array2 added without duplicates
*/
export const addStringsNoDuplicate = (array1: string[], array2: string[]): string[] => {
const result = [...array1];
array2.forEach((item) => {
if (!result.includes(item)) {
result.push(item);
}
});
return result;
}; };
// Export to expose to unit tests // Export to expose to unit tests
@ -488,6 +556,20 @@ export interface IDocumentsTabComponentProps {
isTabActive: boolean; isTabActive: boolean;
} }
const getUniqueId = (collection: ViewModels.CollectionBase): string => `${collection.databaseId}-${collection.id()}`;
const getDefaultSqlFilters = (partitionKeys: string[]) =>
['WHERE c.id = "foo"', "ORDER BY c._ts DESC", 'WHERE c.id = "foo" ORDER BY c._ts DESC', "ORDER BY c._ts ASC"].concat(
partitionKeys.map((partitionKey) => `WHERE c.${partitionKey} = "foo"`),
);
const defaultMongoFilters = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"];
// Extend DocumentId to include fields displayed in the table
type ExtendedDocumentId = DocumentId & { tableFields?: DocumentsTableComponentItem };
// This is based on some heuristics
const calculateOffset = (columnNumber: number): number => columnNumber * 16 - 27;
// Export to expose to unit tests // Export to expose to unit tests
export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabComponentProps> = ({ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabComponentProps> = ({
isPreferredApiMongoDB, isPreferredApiMongoDB,
@ -506,7 +588,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
const [isFilterFocused, setIsFilterFocused] = useState<boolean>(false); const [isFilterFocused, setIsFilterFocused] = useState<boolean>(false);
const [appliedFilter, setAppliedFilter] = useState<string>(""); const [appliedFilter, setAppliedFilter] = useState<string>("");
const [filterContent, setFilterContent] = useState<string>(""); const [filterContent, setFilterContent] = useState<string>("");
const [documentIds, setDocumentIds] = useState<DocumentId[]>([]); const [documentIds, setDocumentIds] = useState<ExtendedDocumentId[]>([]);
const [isExecuting, setIsExecuting] = useState<boolean>(false); const [isExecuting, setIsExecuting] = useState<boolean>(false);
const filterInput = useRef<HTMLInputElement>(null); const filterInput = useRef<HTMLInputElement>(null);
const styles = useDocumentsTabStyles(); const styles = useDocumentsTabStyles();
@ -535,6 +617,13 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
ViewModels.DocumentExplorerState.noDocumentSelected, ViewModels.DocumentExplorerState.noDocumentSelected,
); );
// State
const [tabStateData, setTabStateData] = useState<TabDivider>(() =>
readSubComponentState<TabDivider>(SubComponentName.MainTabDivider, _collection, {
leftPaneWidthPercent: 35,
}),
);
const isQueryCopilotSampleContainer = const isQueryCopilotSampleContainer =
_collection?.isSampleCollection && _collection?.isSampleCollection &&
_collection?.databaseId === QueryCopilotSampleDatabaseId && _collection?.databaseId === QueryCopilotSampleDatabaseId &&
@ -543,6 +632,28 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
// For Mongo only // For Mongo only
const [continuationToken, setContinuationToken] = useState<string>(undefined); const [continuationToken, setContinuationToken] = useState<string>(undefined);
// User's filter history
const [lastFilterContents, setLastFilterContents] = useState<FilterHistory>(() =>
readSubComponentState<FilterHistory>(SubComponentName.FilterHistory, _collection, [] as FilterHistory),
);
// For progress bar for bulk delete (noSql)
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = React.useState(false);
const [bulkDeleteProcess, setBulkDeleteProcess] = useState<{
pendingIds: DocumentId[];
successfulIds: DocumentId[];
throttledIds: DocumentId[];
failedIds: DocumentId[];
beforeExecuteMs: number; // Delay before executing delete. Used for retrying throttling after a specified delay
}>(undefined);
const [bulkDeleteOperation, setBulkDeleteOperation] = useState<{
onCompleted: (documentIds: DocumentId[]) => void;
onFailed: (reason?: unknown) => void;
count: number;
collection: CollectionBase;
}>(undefined);
const [bulkDeleteMode, setBulkDeleteMode] = useState<"inProgress" | "completed" | "aborting" | "aborted">(undefined);
const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB); const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB);
useEffect(() => { useEffect(() => {
@ -568,7 +679,96 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
} }
}, [documentIds, clickedRowIndex, editorState]); }, [documentIds, clickedRowIndex, editorState]);
let lastFilterContents = ['WHERE c.id = "foo"', "ORDER BY c._ts DESC", 'WHERE c.id = "foo" ORDER BY c._ts DESC']; /**
* Recursively delete all documents by retrying throttled requests (429).
* This only works for NoSQL, because the bulk response includes status for each delete document request.
* Recursion is implemented using React useEffect (as opposed to recursively calling setTimeout), because it
* has to update the <ProgressModalDialog> or check if the user is aborting the operation via state React
* variables.
*
* Inputs are the bulkDeleteOperation, bulkDeleteProcess and bulkDeleteMode state variables.
* When the bulkDeleteProcess changes, the function in the useEffect is triggered and checks if the process
* was aborted or completed, which will resolve the promise.
* Otherwise, it will attempt to delete documents of the pending and throttled ids arrays.
* Once deletion is completed, the function updates bulkDeleteProcess with the results, which will trigger
* the function to be called again.
*/
useEffect(() => {
if (!bulkDeleteOperation || !bulkDeleteProcess || !bulkDeleteMode) {
return;
}
if (bulkDeleteMode === "completed" || bulkDeleteMode === "aborted") {
// no op in the case function is called again
return;
}
if (
(bulkDeleteProcess.pendingIds.length === 0 && bulkDeleteProcess.throttledIds.length === 0) ||
bulkDeleteMode === "aborting"
) {
// Successfully deleted all documents or operation was aborted
bulkDeleteOperation.onCompleted(bulkDeleteProcess.successfulIds);
setBulkDeleteMode(bulkDeleteMode === "aborting" ? "aborted" : "completed");
return;
}
// Start deleting documents or retry throttled requests
const newPendingIds = bulkDeleteProcess.pendingIds.concat(bulkDeleteProcess.throttledIds);
const timeout = bulkDeleteProcess.beforeExecuteMs || 0;
setTimeout(() => {
deleteNoSqlDocuments(bulkDeleteOperation.collection, [...newPendingIds])
.then((deleteResult) => {
let retryAfterMilliseconds = 0;
const newSuccessful: DocumentId[] = [];
const newThrottled: DocumentId[] = [];
const newFailed: DocumentId[] = [];
deleteResult.forEach((result) => {
if (result.statusCode === Constants.HttpStatusCodes.NoContent) {
newSuccessful.push(result.documentId);
} else if (result.statusCode === Constants.HttpStatusCodes.TooManyRequests) {
newThrottled.push(result.documentId);
retryAfterMilliseconds = Math.max(result.retryAfterMilliseconds, retryAfterMilliseconds);
} else if (result.statusCode >= 400) {
newFailed.push(result.documentId);
logConsoleError(
`Failed to delete document ${result.documentId.id} with status code ${result.statusCode}`,
);
}
});
logConsoleInfo(`Successfully deleted ${newSuccessful.length} document(s)`);
if (newThrottled.length > 0) {
logConsoleError(
`Failed to delete ${newThrottled.length} document(s) due to "Request too large" (429) error. Retrying...`,
);
}
// Update result of the bulk delete: method is called again, because the state variables changed
// it will decide at the next call what to do
setBulkDeleteProcess((prev) => ({
pendingIds: [],
successfulIds: prev.successfulIds.concat(newSuccessful),
throttledIds: newThrottled,
failedIds: prev.failedIds.concat(newFailed),
beforeExecuteMs: retryAfterMilliseconds,
}));
})
.catch((error) => {
console.error("Error deleting documents", error);
setBulkDeleteProcess((prev) => ({
pendingIds: [],
throttledIds: [],
successfulIds: prev.successfulIds,
failedIds: prev.failedIds.concat(prev.pendingIds),
beforeExecuteMs: undefined,
}));
bulkDeleteOperation.onFailed(error);
});
}, timeout);
}, [bulkDeleteOperation, bulkDeleteProcess, bulkDeleteMode]);
const applyFilterButton = { const applyFilterButton = {
enabled: true, enabled: true,
@ -591,10 +791,37 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
[partitionKeyPropertyHeaders], [partitionKeyPropertyHeaders],
); );
const getInitialColumnSelection = () => {
const defaultColumnsIds = ["id"];
if (showPartitionKey(_collection, isPreferredApiMongoDB)) {
defaultColumnsIds.push(...partitionKeyPropertyHeaders);
}
return defaultColumnsIds;
};
const [selectedColumnIds, setSelectedColumnIds] = useState<string[]>(() => {
const persistedColumnsSelection = readSubComponentState<ColumnsSelection>(
SubComponentName.ColumnsSelection,
_collection,
undefined,
);
if (!persistedColumnsSelection) {
return getInitialColumnSelection();
}
return persistedColumnsSelection.selectedColumnIds;
});
// new DocumentId() requires a DocumentTab which we mock with only the required properties // new DocumentId() requires a DocumentTab which we mock with only the required properties
const newDocumentId = useCallback( const newDocumentId = useCallback(
(rawDocument: DataModels.DocumentId, partitionKeyProperties: string[], partitionKeyValue: string[]) => (
new DocumentId( rawDocument: DataModels.DocumentId,
partitionKeyProperties: string[],
partitionKeyValue: string[],
): ExtendedDocumentId => {
const extendedDocumentId = new DocumentId(
{ {
partitionKey, partitionKey,
partitionKeyProperties, partitionKeyProperties,
@ -604,7 +831,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
}, },
rawDocument, rawDocument,
partitionKeyValue, partitionKeyValue,
), ) as ExtendedDocumentId;
extendedDocumentId.tableFields = { ...rawDocument };
return extendedDocumentId;
},
[partitionKey], [partitionKey],
); );
@ -766,6 +996,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
setDocumentIds(ids); setDocumentIds(ids);
setEditorState(ViewModels.DocumentExplorerState.existingDocumentNoEdits); setEditorState(ViewModels.DocumentExplorerState.existingDocumentNoEdits);
// Update column choices
setColumnDefinitionsFromDocument(savedDocument);
TelemetryProcessor.traceSuccess( TelemetryProcessor.traceSuccess(
Action.CreateDocument, Action.CreateDocument,
{ {
@ -848,6 +1082,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
}, },
startKey, startKey,
); );
// Update column choices
selectedDocumentId.tableFields = { ...documentContent };
setColumnDefinitionsFromDocument(documentContent);
}, },
(error) => { (error) => {
onExecutionErrorChange(true); onExecutionErrorChange(true);
@ -881,8 +1119,35 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
setSelectedDocumentContent(selectedDocumentContentBaseline); setSelectedDocumentContent(selectedDocumentContentBaseline);
}, [initialDocumentContent, selectedDocumentContentBaseline, setSelectedDocumentContent]); }, [initialDocumentContent, selectedDocumentContentBaseline, setSelectedDocumentContent]);
/**
* Trigger a useEffect() to bulk delete noSql documents
* @param collection
* @param documentIds
* @returns
*/
const _bulkDeleteNoSqlDocuments = (collection: CollectionBase, documentIds: DocumentId[]): Promise<DocumentId[]> =>
new Promise<DocumentId[]>((resolve, reject) => {
setBulkDeleteOperation({
onCompleted: resolve,
onFailed: reject,
count: documentIds.length,
collection,
});
setBulkDeleteProcess({
pendingIds: [...documentIds],
throttledIds: [],
successfulIds: [],
failedIds: [],
beforeExecuteMs: 0,
});
setIsBulkDeleteDialogOpen(true);
setBulkDeleteMode("inProgress");
});
/** /**
* Implementation using bulk delete NoSQL API * Implementation using bulk delete NoSQL API
* @param list of document ids to delete
* @returns Promise of list of deleted document ids
*/ */
const _deleteDocuments = useCallback( const _deleteDocuments = useCallback(
async (toDeleteDocumentIds: DocumentId[]): Promise<DocumentId[]> => { async (toDeleteDocumentIds: DocumentId[]): Promise<DocumentId[]> => {
@ -893,20 +1158,33 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
}); });
setIsExecuting(true); setIsExecuting(true);
let deletePromise;
if (!isPreferredApiMongoDB) {
if (partitionKey.systemKey) {
// ----------------------------------------------------------------------------------------------------
// TODO: Once JS SDK Bug fix for bulk deleting legacy containers (whose systemKey==1) is released: // TODO: Once JS SDK Bug fix for bulk deleting legacy containers (whose systemKey==1) is released:
// Remove the check for systemKey, remove call to deleteNoSqlDocument(). deleteNoSqlDocuments() should always be called. // Remove the check for systemKey, remove call to deleteNoSqlDocument(). deleteNoSqlDocuments() should
const _deleteNoSqlDocuments = async ( // always be called for NoSQL.
collection: CollectionBase, deletePromise = deleteNoSqlDocument(_collection, toDeleteDocumentIds[0]).then(() => {
toDeleteDocumentIds: DocumentId[], useDialog.getState().showOkModalDialog("Delete document", "Document successfully deleted.");
): Promise<DocumentId[]> => { return [toDeleteDocumentIds[0]];
return partitionKey.systemKey });
? deleteNoSqlDocument(collection, toDeleteDocumentIds[0]).then(() => [toDeleteDocumentIds[0]]) // ----------------------------------------------------------------------------------------------------
: deleteNoSqlDocuments(collection, toDeleteDocumentIds); } else {
}; deletePromise = _bulkDeleteNoSqlDocuments(_collection, toDeleteDocumentIds);
}
const deletePromise = !isPreferredApiMongoDB } else {
? _deleteNoSqlDocuments(_collection, toDeleteDocumentIds) if (isMongoBulkDeleteDisabled) {
: MongoProxyClient.deleteDocuments( // TODO: Once new mongo proxy is available for all users, remove the call for MongoProxyClient.deleteDocument().
// MongoProxyClient.deleteDocuments() should be called for all users.
deletePromise = MongoProxyClient.deleteDocument(
_collection.databaseId,
_collection as ViewModels.Collection,
toDeleteDocumentIds[0],
).then(() => [toDeleteDocumentIds[0]]);
// ----------------------------------------------------------------------------------------------------
} else {
deletePromise = MongoProxyClient.deleteDocuments(
_collection.databaseId, _collection.databaseId,
_collection as ViewModels.Collection, _collection as ViewModels.Collection,
toDeleteDocumentIds, toDeleteDocumentIds,
@ -916,6 +1194,8 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
} }
throw new Error(`Delete failed with deletedCount: ${deletedCount} and isAcknowledged: ${isAcknowledged}`); throw new Error(`Delete failed with deletedCount: ${deletedCount} and isAcknowledged: ${isAcknowledged}`);
}); });
}
}
return deletePromise return deletePromise
.then( .then(
@ -946,9 +1226,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
throw error; throw error;
}, },
) )
.finally(() => setIsExecuting(false)); .finally(() => {
setIsExecuting(false);
});
}, },
[_collection, isPreferredApiMongoDB, onExecutionErrorChange, tabTitle], [_collection, isPreferredApiMongoDB, onExecutionErrorChange, tabTitle, partitionKey.systemKey],
); );
const deleteDocuments = useCallback( const deleteDocuments = useCallback(
@ -966,14 +1248,25 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
setClickedRowIndex(undefined); setClickedRowIndex(undefined);
setSelectedRows(new Set()); setSelectedRows(new Set());
setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected); setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected);
useDialog
.getState()
.showOkModalDialog("Delete documents", `${deletedIds.length} document(s) successfully deleted.`);
}, },
(error: Error) => (error: Error) => {
if (error instanceof MongoProxyClient.ThrottlingError) {
useDialog useDialog
.getState() .getState()
.showOkModalDialog("Delete documents", `Deleting document(s) failed (${error.message})`), .showOkModalDialog(
"Delete documents",
`Some documents failed to delete due to a rate limiting error. Please try again later. To prevent this in the future, consider increasing the throughput on your container or database.`,
{
linkText: "Learn More",
linkUrl: MONGO_THROTTLING_DOC_URL,
},
);
} else {
useDialog
.getState()
.showOkModalDialog("Delete documents", `Deleting document(s) failed (${error.message})`);
}
},
) )
.finally(() => setIsExecuting(false)); .finally(() => setIsExecuting(false));
}, },
@ -1049,7 +1342,13 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
const _queryAbortController = new AbortController(); const _queryAbortController = new AbortController();
setQueryAbortController(_queryAbortController); setQueryAbortController(_queryAbortController);
const filter: string = filterContent.trim(); const filter: string = filterContent.trim();
const query: string = buildQuery(isPreferredApiMongoDB, filter, partitionKeyProperties, partitionKey); const query: string = buildQuery(
isPreferredApiMongoDB,
filter,
partitionKeyProperties,
partitionKey,
selectedColumnIds,
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const options: any = {}; const options: any = {};
// TODO: Property 'enableCrossPartitionQuery' does not exist on type 'FeedOptions'. // TODO: Property 'enableCrossPartitionQuery' does not exist on type 'FeedOptions'.
@ -1072,6 +1371,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
resourceTokenPartitionKey, resourceTokenPartitionKey,
isQueryCopilotSampleContainer, isQueryCopilotSampleContainer,
_collection, _collection,
selectedColumnIds,
]); ]);
const onHideFilterClick = (): void => { const onHideFilterClick = (): void => {
@ -1217,16 +1517,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
documentsIterator, // loadNextPage: disabled as it will trigger a circular dependency and infinite loop documentsIterator, // loadNextPage: disabled as it will trigger a circular dependency and infinite loop
]); ]);
const onRefreshKeyInput: KeyboardEventHandler<HTMLButtonElement> = (event) => {
if (event.key === " " || event.key === "Enter") {
const focusElement = event.target as HTMLElement;
refreshDocumentsGrid(false);
focusElement && focusElement.focus();
event.stopPropagation();
event.preventDefault();
}
};
const onLoadMoreKeyInput: KeyboardEventHandler<HTMLAnchorElement> = (event) => { const onLoadMoreKeyInput: KeyboardEventHandler<HTMLAnchorElement> = (event) => {
if (event.key === " " || event.key === "Enter") { if (event.key === " " || event.key === "Enter") {
const focusElement = event.target as HTMLElement; const focusElement = event.target as HTMLElement;
@ -1239,7 +1529,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
const onFilterKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => { const onFilterKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.key === "Enter") { if (e.key === "Enter") {
refreshDocumentsGrid(true); onApplyFilterClick();
// Suppress the default behavior of the key // Suppress the default behavior of the key
e.preventDefault(); e.preventDefault();
@ -1258,9 +1548,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
// Table config here // Table config here
const tableItems: DocumentsTableComponentItem[] = documentIds.map((documentId) => { const tableItems: DocumentsTableComponentItem[] = documentIds.map((documentId) => {
const item: Record<string, string> & { id: string } = { const item: DocumentsTableComponentItem = documentId.tableFields || { id: documentId.id() };
id: documentId.id(),
};
if (partitionKeyPropertyHeaders && documentId.stringPartitionKeyValues) { if (partitionKeyPropertyHeaders && documentId.stringPartitionKeyValues) {
for (let i = 0; i < partitionKeyPropertyHeaders.length; i++) { for (let i = 0; i < partitionKeyPropertyHeaders.length; i++) {
@ -1271,6 +1559,44 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
return item; return item;
}); });
const extractColumnDefinitionsFromDocument = (document: unknown): ColumnDefinition[] => {
let columnDefinitions: ColumnDefinition[] = Object.keys(document)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.filter((key) => typeof (document as any)[key] === "string" || typeof (document as any)[key] === "number") // Only allow safe types for displayable React children
.map((key) =>
key === "id"
? { id: key, label: isPreferredApiMongoDB ? "_id" : "id", isPartitionKey: false }
: { id: key, label: key, isPartitionKey: false },
);
if (showPartitionKey(_collection, isPreferredApiMongoDB)) {
columnDefinitions.push(
...partitionKeyPropertyHeaders.map((key) => ({ id: key, label: key, isPartitionKey: true })),
);
// Remove properties that are the partition keys, since they are already included
columnDefinitions = columnDefinitions.filter(
(columnDefinition) => !partitionKeyProperties.includes(columnDefinition.id),
);
}
return columnDefinitions;
};
/**
* Extract column definitions from document and add to the definitions
* @param document
*/
const setColumnDefinitionsFromDocument = (document: unknown): void => {
const currentIds = new Set(columnDefinitions.map((columnDefinition) => columnDefinition.id));
extractColumnDefinitionsFromDocument(document).forEach((columnDefinition) => {
if (!currentIds.has(columnDefinition.id)) {
columnDefinitions.push(columnDefinition);
}
});
setColumnDefinitions([...columnDefinitions]);
};
/** /**
* replicate logic of selectedDocument.click(); * replicate logic of selectedDocument.click();
* Document has been clicked on in table * Document has been clicked on in table
@ -1286,6 +1612,9 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
(_isQueryCopilotSampleContainer ? readSampleDocument(documentId) : readDocument(_collection, documentId)).then( (_isQueryCopilotSampleContainer ? readSampleDocument(documentId) : readDocument(_collection, documentId)).then(
(content) => { (content) => {
initDocumentEditor(documentId, content); initDocumentEditor(documentId, content);
// Update columns
setColumnDefinitionsFromDocument(content);
}, },
); );
@ -1376,10 +1705,22 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
return () => resizeObserver.disconnect(); // clean up return () => resizeObserver.disconnect(); // clean up
}, []); }, []);
const columnHeaders = { // Column definition is a map<id, ColumnDefinition> to garantee uniqueness
idHeader: isPreferredApiMongoDB ? "_id" : "id", const [columnDefinitions, setColumnDefinitions] = useState<ColumnDefinition[]>(() => {
partitionKeyHeaders: (showPartitionKey(_collection, isPreferredApiMongoDB) && partitionKeyPropertyHeaders) || [], const persistedColumnsSelection = readSubComponentState<ColumnsSelection>(
}; SubComponentName.ColumnsSelection,
_collection,
undefined,
);
if (!persistedColumnsSelection) {
return extractColumnDefinitionsFromDocument({
id: "id",
});
}
return persistedColumnsSelection.columnDefinitions;
});
const onSelectedRowsChange = (selectedRows: Set<TableRowId>) => { const onSelectedRowsChange = (selectedRows: Set<TableRowId>) => {
confirmDiscardingChange(() => { confirmDiscardingChange(() => {
@ -1442,7 +1783,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
return partitionKey; return partitionKey;
}; };
lastFilterContents = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"];
partitionKeyProperties = partitionKeyProperties?.map((partitionKeyProperty, i) => { partitionKeyProperties = partitionKeyProperties?.map((partitionKeyProperty, i) => {
if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) { if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) {
partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, ""); partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, "");
@ -1612,7 +1952,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
setIsExecuting(true); setIsExecuting(true);
onExecutionErrorChange(false); onExecutionErrorChange(false);
const filter: string = filterContent.trim(); const filter: string = filterContent.trim();
const query: string = buildQuery(isPreferredApiMongoDB, filter); const query: string = buildQuery(isPreferredApiMongoDB, filter, selectedColumnIds);
return MongoProxyClient.queryDocuments( return MongoProxyClient.queryDocuments(
_collection.databaseId, _collection.databaseId,
@ -1663,6 +2003,24 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
} }
// ***************** Mongo *************************** // ***************** Mongo ***************************
const onApplyFilterClick = (): void => {
refreshDocumentsGrid(true);
// Remove duplicates, but keep order
if (lastFilterContents.includes(filterContent)) {
lastFilterContents.splice(lastFilterContents.indexOf(filterContent), 1);
}
// Save filter content to local storage
lastFilterContents.unshift(filterContent);
// Keep the list size under MAX_FILTER_HISTORY_COUNT. Drop last element if needed.
const limitedLastFilterContents = lastFilterContents.slice(0, MAX_FILTER_HISTORY_COUNT);
setLastFilterContents(limitedLastFilterContents);
saveSubComponentState<FilterHistory>(SubComponentName.FilterHistory, _collection, lastFilterContents);
};
const refreshDocumentsGrid = useCallback( const refreshDocumentsGrid = useCallback(
(applyFilterButtonPressed: boolean): void => { (applyFilterButtonPressed: boolean): void => {
// clear documents grid // clear documents grid
@ -1693,6 +2051,68 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
[createIterator, filterContent], [createIterator, filterContent],
); );
/**
* While retrying, display: retrying now.
* If completed and all documents were deleted, display: all documents deleted.
* @returns 429 warning message
*/
const get429WarningMessageNoSql = (): string => {
let message = 'Some delete requests failed due to a "Request too large" exception (429)';
if (bulkDeleteOperation.count === bulkDeleteProcess.successfulIds.length) {
message += ", but were successfully retried.";
} else if (bulkDeleteMode === "inProgress" || bulkDeleteMode === "aborting") {
message += ". Retrying now.";
} else {
message += ".";
}
return (message +=
" To prevent this in the future, consider increasing the throughput on your container or database.");
};
const onColumnSelectionChange = (newSelectedColumnIds: string[]): void => {
// Do not allow to unselecting all columns
if (newSelectedColumnIds.length === 0) {
return;
}
setSelectedColumnIds(newSelectedColumnIds);
saveSubComponentState<ColumnsSelection>(SubComponentName.ColumnsSelection, _collection, {
selectedColumnIds: newSelectedColumnIds,
columnDefinitions,
});
};
const prevSelectedColumnIds = usePrevious({ selectedColumnIds, setSelectedColumnIds });
useEffect(() => {
// If we are adding a field, let's refresh to include the field in the query
let addedField = false;
for (const field of selectedColumnIds) {
if (
!defaultQueryFields.includes(field) &&
prevSelectedColumnIds &&
!prevSelectedColumnIds.selectedColumnIds.includes(field)
) {
addedField = true;
break;
}
}
if (addedField) {
refreshDocumentsGrid(false);
}
}, [prevSelectedColumnIds, refreshDocumentsGrid, selectedColumnIds]);
// TODO: remove isMongoBulkDeleteDisabled when new mongo proxy is enabled for all users
// TODO: remove partitionKey.systemKey when JS SDK bug is fixed
const isMongoBulkDeleteDisabled = !MongoProxyClient.useMongoProxyEndpoint("bulkdelete");
const isBulkDeleteDisabled =
(partitionKey.systemKey && !isPreferredApiMongoDB) || (isPreferredApiMongoDB && isMongoBulkDeleteDisabled);
// -------------------------------------------------------
return ( return (
<CosmosFluentProvider className={styles.container}> <CosmosFluentProvider className={styles.container}>
<div className="tab-pane active" role="tabpanel" style={{ display: "flex" }}> <div className="tab-pane active" role="tabpanel" style={{ display: "flex" }}>
@ -1721,12 +2141,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
<div className={styles.filterRow}> <div className={styles.filterRow}>
{!isPreferredApiMongoDB && <span> SELECT * FROM c </span>} {!isPreferredApiMongoDB && <span> SELECT * FROM c </span>}
<Input <Input
id="filterInput"
ref={filterInput} ref={filterInput}
type="text" type="text"
size="small" size="small"
list="filtersList" list={`filtersList-${getUniqueId(_collection)}`}
className={styles.filterInput} className={`filterInput ${styles.filterInput}`}
title="Type a query predicate or choose one from the list." title="Type a query predicate or choose one from the list."
placeholder={ placeholder={
isPreferredApiMongoDB isPreferredApiMongoDB
@ -1740,8 +2159,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
onBlur={() => setIsFilterFocused(false)} onBlur={() => setIsFilterFocused(false)}
/> />
<datalist id="filtersList"> <datalist id={`filtersList-${getUniqueId(_collection)}`}>
{lastFilterContents.map((filter) => ( {addStringsNoDuplicate(
lastFilterContents,
isPreferredApiMongoDB ? defaultMongoFilters : getDefaultSqlFilters(partitionKeyProperties),
).map((filter) => (
<option key={filter} value={filter} /> <option key={filter} value={filter} />
))} ))}
</datalist> </datalist>
@ -1749,7 +2171,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
<Button <Button
appearance="primary" appearance="primary"
size="small" size="small"
onClick={() => refreshDocumentsGrid(true)} onClick={onApplyFilterClick}
disabled={!applyFilterButton.enabled} disabled={!applyFilterButton.enabled}
aria-label="Apply filter" aria-label="Apply filter"
tabIndex={0} tabIndex={0}
@ -1780,41 +2202,46 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
)} )}
</> </>
)} )}
{/* <Split> doesn't like to be a flex child */} {/* <Split> doesn't like to be a flex child */}
<div style={{ overflow: "hidden", height: "100%" }}> <div style={{ overflow: "hidden", height: "100%" }}>
<Allotment> <Allotment
<Allotment.Pane preferredSize="35%" minSize={175}> onDragEnd={(sizes: number[]) => {
<div style={{ height: "100%", width: "100%", overflow: "hidden" }} ref={tableContainerRef}> tabStateData.leftPaneWidthPercent = (100 * sizes[0]) / (sizes[0] + sizes[1]);
<div className={styles.floatingControlsContainer}> saveSubComponentState<TabDivider>(SubComponentName.MainTabDivider, _collection, tabStateData);
<div className={styles.floatingControls}> setTabStateData(tabStateData);
<Button
appearance="transparent"
aria-label="Refresh"
size="small"
icon={<ArrowClockwise16Filled />}
style={{
color: StyleConstants.AccentMedium,
}} }}
onClick={() => refreshDocumentsGrid(false)} >
onKeyDown={onRefreshKeyInput} <Allotment.Pane preferredSize={`${tabStateData.leftPaneWidthPercent}%`} minSize={55}>
/> <div style={{ height: "100%", width: "100%", overflow: "hidden" }} ref={tableContainerRef}>
</div>
</div>
<div className={styles.tableContainer}> <div className={styles.tableContainer}>
<div
style={
{
height: "100%",
width: `calc(100% + ${calculateOffset(selectedColumnIds.length)}px)`,
} /* Fix to make table not resize beyond parent's width */
}
>
<DocumentsTableComponent <DocumentsTableComponent
onRefreshTable={() => refreshDocumentsGrid(false)}
items={tableItems} items={tableItems}
onItemClicked={(index) => onDocumentClicked(index, documentIds)} onItemClicked={(index) => onDocumentClicked(index, documentIds)}
onSelectedRowsChange={onSelectedRowsChange} onSelectedRowsChange={onSelectedRowsChange}
selectedRows={selectedRows} selectedRows={selectedRows}
size={tableContainerSizePx} size={tableContainerSizePx}
columnHeaders={columnHeaders} selectedColumnIds={selectedColumnIds}
isSelectionDisabled={ columnDefinitions={columnDefinitions}
(partitionKey.systemKey && !isPreferredApiMongoDB) || isRowSelectionDisabled={
isBulkDeleteDisabled ||
(configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly) (configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly)
} }
onColumnSelectionChange={onColumnSelectionChange}
defaultColumnSelection={getInitialColumnSelection()}
collection={_collection}
isColumnSelectionDisabled={isPreferredApiMongoDB}
/> />
</div> </div>
</div>
{tableItems.length > 0 && ( {tableItems.length > 0 && (
<a <a
className={styles.loadMore} className={styles.loadMore}
@ -1828,7 +2255,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
)} )}
</div> </div>
</Allotment.Pane> </Allotment.Pane>
<Allotment.Pane preferredSize="65%" minSize={300}> <Allotment.Pane minSize={30}>
<div style={{ height: "100%", width: "100%" }}> <div style={{ height: "100%", width: "100%" }}>
{isTabActive && selectedDocumentContent && selectedRows.size <= 1 && ( {isTabActive && selectedDocumentContent && selectedRows.size <= 1 && (
<EditorReact <EditorReact
@ -1850,6 +2277,50 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
</Allotment> </Allotment>
</div> </div>
</div> </div>
{bulkDeleteOperation && (
<ProgressModalDialog
isOpen={isBulkDeleteDialogOpen}
dismissText="Abort"
onDismiss={() => {
setIsBulkDeleteDialogOpen(false);
setBulkDeleteOperation(undefined);
}}
onCancel={() => setBulkDeleteMode("aborting")}
title={`Deleting ${bulkDeleteOperation.count} document(s)`}
message={`Successfully deleted ${bulkDeleteProcess.successfulIds.length} document(s).`}
maxValue={bulkDeleteOperation.count}
value={bulkDeleteProcess.successfulIds.length}
mode={bulkDeleteMode}
>
<div className={styles.deleteProgressContent}>
{(bulkDeleteMode === "aborting" || bulkDeleteMode === "aborted") && (
<div style={{ paddingBottom: tokens.spacingVerticalL }}>Deleting document(s) was aborted.</div>
)}
{(bulkDeleteProcess.failedIds.length > 0 ||
(bulkDeleteProcess.throttledIds.length > 0 && bulkDeleteMode !== "inProgress")) && (
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalL }}>
<MessageBarBody>
<MessageBarTitle>Error</MessageBarTitle>
Failed to delete{" "}
{bulkDeleteMode === "inProgress"
? bulkDeleteProcess.failedIds.length
: bulkDeleteProcess.failedIds.length + bulkDeleteProcess.throttledIds.length}{" "}
document(s).
</MessageBarBody>
</MessageBar>
)}
<MessageBar intent="warning">
<MessageBarBody>
<MessageBarTitle>Warning</MessageBarTitle>
{get429WarningMessageNoSql()}{" "}
<Link href={NO_SQL_THROTTLING_DOC_URL} target="_blank">
Learn More
</Link>
</MessageBarBody>
</MessageBar>
</div>
</ProgressModalDialog>
)}
</CosmosFluentProvider> </CosmosFluentProvider>
); );
}; };

View File

@ -49,7 +49,9 @@ jest.mock("Common/MongoProxyClient", () => ({
id: "id1", id: "id1",
}), }),
), ),
deleteDocuments: jest.fn(() => Promise.resolve()), deleteDocuments: jest.fn(() => Promise.resolve({ deleteCount: 0, isAcknowledged: true })),
ThrottlingError: Error,
useMongoProxyEndpoint: jest.fn(() => true),
})); }));
jest.mock("Explorer/Controls/Editor/EditorReact", () => ({ jest.mock("Explorer/Controls/Editor/EditorReact", () => ({
@ -178,7 +180,7 @@ describe("Documents tab (Mongo API)", () => {
expect(useCommandBar.getState().contextButtons.find((button) => button.id === DISCARD_BUTTON_ID)).toBeDefined(); expect(useCommandBar.getState().contextButtons.find((button) => button.id === DISCARD_BUTTON_ID)).toBeDefined();
}); });
it("clicking Delete Document asks for confirmation", () => { it("clicking Delete Document eventually calls delete client api", () => {
const mockDeleteDocuments = deleteDocuments as jest.Mock; const mockDeleteDocuments = deleteDocuments as jest.Mock;
mockDeleteDocuments.mockClear(); mockDeleteDocuments.mockClear();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import { monaco } from "Explorer/LazyMonaco";
import { QueryCopilotFeedbackModal } from "Explorer/QueryCopilot/Modal/QueryCopilotFeedbackModal"; import { QueryCopilotFeedbackModal } from "Explorer/QueryCopilot/Modal/QueryCopilotFeedbackModal";
import { useCopilotStore } from "Explorer/QueryCopilot/QueryCopilotContext"; import { useCopilotStore } from "Explorer/QueryCopilot/QueryCopilotContext";
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar"; import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
import { readCopilotToggleStatus, saveCopilotToggleStatus } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import { OnExecuteQueryClick, QueryDocumentsPerPage } from "Explorer/QueryCopilot/Shared/QueryCopilotClient"; import { OnExecuteQueryClick, QueryDocumentsPerPage } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { QueryCopilotSidebar } from "Explorer/QueryCopilot/V2/Sidebar/QueryCopilotSidebar"; import { QueryCopilotSidebar } from "Explorer/QueryCopilot/V2/Sidebar/QueryCopilotSidebar";
import { QueryResultSection } from "Explorer/Tabs/QueryTab/QueryResultSection"; import { QueryResultSection } from "Explorer/Tabs/QueryTab/QueryResultSection";
@ -46,7 +47,6 @@ import { queryDocuments } from "../../../Common/dataAccess/queryDocuments";
import { queryDocumentsPage } from "../../../Common/dataAccess/queryDocumentsPage"; import { queryDocumentsPage } from "../../../Common/dataAccess/queryDocumentsPage";
import * as DataModels from "../../../Contracts/DataModels"; import * as DataModels from "../../../Contracts/DataModels";
import * as ViewModels from "../../../Contracts/ViewModels"; import * as ViewModels from "../../../Contracts/ViewModels";
import * as StringUtility from "../../../Shared/StringUtility";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor"; import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
import { userContext } from "../../../UserContext"; import { userContext } from "../../../UserContext";
import * as QueryUtils from "../../../Utils/QueryUtils"; import * as QueryUtils from "../../../Utils/QueryUtils";
@ -209,13 +209,7 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
private _queryCopilotActive(): boolean { private _queryCopilotActive(): boolean {
if (this.props.copilotEnabled) { if (this.props.copilotEnabled) {
const cachedCopilotToggleStatus: string = localStorage.getItem( return readCopilotToggleStatus(userContext.databaseAccount);
`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`,
);
const copilotInitialActive: boolean = cachedCopilotToggleStatus
? StringUtility.toBoolean(cachedCopilotToggleStatus)
: true;
return copilotInitialActive;
} }
return false; return false;
} }
@ -584,7 +578,7 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
private _toggleCopilot = (active: boolean) => { private _toggleCopilot = (active: boolean) => {
this.setState({ copilotActive: active }); this.setState({ copilotActive: active });
useQueryCopilot.getState().setCopilotEnabledforExecution(active); useQueryCopilot.getState().setCopilotEnabledforExecution(active);
localStorage.setItem(`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`, active.toString()); saveCopilotToggleStatus(userContext.databaseAccount, active);
TelemetryProcessor.traceSuccess(active ? Action.ActivateQueryCopilot : Action.DeactivateQueryCopilot, { TelemetryProcessor.traceSuccess(active ? Action.ActivateQueryCopilot : Action.DeactivateQueryCopilot, {
databaseName: this.props.collection.databaseId, databaseName: this.props.collection.databaseId,

View File

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

View File

@ -1,7 +1,7 @@
import { IMessageBarStyles, Link, MessageBar, MessageBarButton, MessageBarType } from "@fluentui/react"; import { IMessageBarStyles, MessageBar, MessageBarButton, MessageBarType } from "@fluentui/react";
import { CassandraProxyEndpoints, MongoProxyEndpoints } from "Common/Constants"; import { CassandraProxyEndpoints, MongoProxyEndpoints } from "Common/Constants";
import { sendMessage } from "Common/MessageHandler"; import { sendMessage } from "Common/MessageHandler";
import { Platform, configContext, updateConfigContext } from "ConfigContext"; import { configContext, updateConfigContext } from "ConfigContext";
import { IpRule } from "Contracts/DataModels"; import { IpRule } from "Contracts/DataModels";
import { MessageTypes } from "Contracts/ExplorerContracts"; import { MessageTypes } from "Contracts/ExplorerContracts";
import { CollectionTabKind } from "Contracts/ViewModels"; import { CollectionTabKind } from "Contracts/ViewModels";
@ -16,7 +16,6 @@ import { VcoreMongoConnectTab } from "Explorer/Tabs/VCoreMongoConnectTab";
import { VcoreMongoQuickstartTab } from "Explorer/Tabs/VCoreMongoQuickstartTab"; import { VcoreMongoQuickstartTab } from "Explorer/Tabs/VCoreMongoQuickstartTab";
import { LayoutConstants } from "Explorer/Theme/ThemeUtil"; import { LayoutConstants } from "Explorer/Theme/ThemeUtil";
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts"; import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
import { hasRUThresholdBeenConfigured } from "Shared/StorageUtility";
import { userContext } from "UserContext"; import { userContext } from "UserContext";
import { CassandraProxyOutboundIPs, MongoProxyOutboundIPs, PortalBackendIPs } from "Utils/EndpointUtils"; import { CassandraProxyOutboundIPs, MongoProxyOutboundIPs, PortalBackendIPs } from "Utils/EndpointUtils";
import { useTeachingBubble } from "hooks/useTeachingBubble"; import { useTeachingBubble } from "hooks/useTeachingBubble";
@ -37,9 +36,6 @@ interface TabsProps {
export const Tabs = ({ explorer }: TabsProps): JSX.Element => { export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
const { openedTabs, openedReactTabs, activeTab, activeReactTab, networkSettingsWarning } = useTabs(); const { openedTabs, openedReactTabs, activeTab, activeReactTab, networkSettingsWarning } = useTabs();
const [showRUThresholdMessageBar, setShowRUThresholdMessageBar] = useState<boolean>(
userContext.apiType === "SQL" && configContext.platform !== Platform.Fabric && !hasRUThresholdBeenConfigured(),
);
const [ const [
showMongoAndCassandraProxiesNetworkSettingsWarningState, showMongoAndCassandraProxiesNetworkSettingsWarningState,
setShowMongoAndCassandraProxiesNetworkSettingsWarningState, setShowMongoAndCassandraProxiesNetworkSettingsWarningState,
@ -87,30 +83,6 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
{networkSettingsWarning} {networkSettingsWarning}
</MessageBar> </MessageBar>
)} )}
{showRUThresholdMessageBar && (
<MessageBar
messageBarType={MessageBarType.info}
onDismiss={() => {
setShowRUThresholdMessageBar(false);
}}
styles={{
...defaultMessageBarStyles,
innerText: {
fontWeight: "bold",
},
}}
>
{`To prevent queries from using excessive RUs, Data Explorer has a 5,000 RU default limit. To modify or remove
the limit, go to the Settings cog on the right and find "RU Threshold".`}
<Link
className="underlinedLink"
href="https://review.learn.microsoft.com/en-us/azure/cosmos-db/data-explorer?branch=main#configure-request-unit-threshold"
target="_blank"
>
Learn More
</Link>
</MessageBar>
)}
{showMongoAndCassandraProxiesNetworkSettingsWarningState && ( {showMongoAndCassandraProxiesNetworkSettingsWarningState && (
<MessageBar <MessageBar
messageBarType={MessageBarType.warning} messageBarType={MessageBarType.warning}
@ -119,7 +91,7 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
setShowMongoAndCassandraProxiesNetworkSettingsWarningState(false); setShowMongoAndCassandraProxiesNetworkSettingsWarningState(false);
}} }}
> >
{`We are moving our middleware to new infrastructure. To avoid future issues with Data Explorer access, please {`We have migrated our middleware to new infrastructure. To avoid issues with Data Explorer access, please
re-enable "Allow access from Azure Portal" on the Networking blade for your account.`} re-enable "Allow access from Azure Portal" on the Networking blade for your account.`}
</MessageBar> </MessageBar>
)} )}

View File

@ -2,7 +2,6 @@ import { KeyboardActionGroup, clearKeyboardActionGroup } from "KeyboardShortcuts
import * as ko from "knockout"; import * as ko from "knockout";
import * as Constants from "../../Common/Constants"; import * as Constants from "../../Common/Constants";
import * as ThemeUtility from "../../Common/ThemeUtility"; import * as ThemeUtility from "../../Common/ThemeUtility";
import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels"; import * as ViewModels from "../../Contracts/ViewModels";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants"; import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
@ -28,7 +27,6 @@ export default class TabsBase extends WaitsForTemplateViewModel {
public tabPath: ko.Observable<string>; public tabPath: ko.Observable<string>;
public isExecutionError = ko.observable(false); public isExecutionError = ko.observable(false);
public isExecuting = ko.observable(false); public isExecuting = ko.observable(false);
public pendingNotification?: ko.Observable<DataModels.Notification>;
protected _theme: string; protected _theme: string;
public onLoadStartKey: number; public onLoadStartKey: number;
@ -45,7 +43,6 @@ export default class TabsBase extends WaitsForTemplateViewModel {
this.tabPath = this.tabPath =
this.collection && this.collection &&
ko.observable<string>(`${this.collection.databaseId}>${this.collection.id()}>${options.title}`); ko.observable<string>(`${this.collection.databaseId}>${this.collection.id()}>${options.title}`);
this.pendingNotification = ko.observable<DataModels.Notification>(undefined);
this.onLoadStartKey = options.onLoadStartKey; this.onLoadStartKey = options.onLoadStartKey;
this.closeTabButton = { this.closeTabButton = {
enabled: ko.computed<boolean>(() => { enabled: ko.computed<boolean>(() => {

View File

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

View File

@ -5,8 +5,6 @@ import * as ko from "knockout";
import * as _ from "underscore"; import * as _ from "underscore";
import * as Constants from "../../Common/Constants"; import * as Constants from "../../Common/Constants";
import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils"; import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils";
import * as Logger from "../../Common/Logger";
import { fetchPortalNotifications } from "../../Common/PortalNotifications";
import { bulkCreateDocument } from "../../Common/dataAccess/bulkCreateDocument"; import { bulkCreateDocument } from "../../Common/dataAccess/bulkCreateDocument";
import { createDocument } from "../../Common/dataAccess/createDocument"; import { createDocument } from "../../Common/dataAccess/createDocument";
import { getCollectionUsageSizeInKB } from "../../Common/dataAccess/getCollectionDataUsageSize"; import { getCollectionUsageSizeInKB } from "../../Common/dataAccess/getCollectionDataUsageSize";
@ -1020,41 +1018,6 @@ export default class Collection implements ViewModels.Collection {
this.uploadFiles(event.originalEvent.dataTransfer.files); this.uploadFiles(event.originalEvent.dataTransfer.files);
} }
public async getPendingThroughputSplitNotification(): Promise<DataModels.Notification> {
if (!this.container) {
return undefined;
}
try {
const notifications: DataModels.Notification[] = await fetchPortalNotifications();
if (!notifications || notifications.length === 0) {
return undefined;
}
return _.find(notifications, (notification: DataModels.Notification) => {
const throughputUpdateRegExp: RegExp = new RegExp("Throughput update (.*) in progress");
return (
notification.kind === "message" &&
notification.collectionName === this.id() &&
notification.description &&
throughputUpdateRegExp.test(notification.description)
);
});
} catch (error) {
Logger.logError(
JSON.stringify({
error: getErrorMessage(error),
accountName: userContext?.databaseAccount,
databaseName: this.databaseId,
collectionName: this.id(),
}),
"Settings tree node",
);
return undefined;
}
}
public async uploadFiles(files: FileList): Promise<{ data: UploadDetailsRecord[] }> { public async uploadFiles(files: FileList): Promise<{ data: UploadDetailsRecord[] }> {
const data = await Promise.all(Array.from(files).map((file) => this.uploadFile(file))); const data = await Promise.all(Array.from(files).map((file) => this.uploadFile(file)));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,200 @@
import {
AppStateComponentNames,
createKeyFromPath,
deleteState,
loadState,
MAX_ENTRY_NB,
PATH_SEPARATOR,
saveState,
} from "Shared/AppStatePersistenceUtility";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
jest.mock("Shared/StorageUtility", () => ({
LocalStorageUtility: {
getEntryObject: jest.fn(),
setEntryObject: jest.fn(),
},
StorageKey: {
AppState: "AppState",
},
}));
describe("AppStatePersistenceUtility", () => {
const storePath = {
componentName: AppStateComponentNames.DocumentsTab,
subComponentName: "b",
globalAccountName: "c",
databaseName: "d",
containerName: "e",
};
const key = createKeyFromPath(storePath);
beforeEach(() => {
jest.clearAllMocks();
});
beforeEach(() => {
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue({
key0: {
schemaVersion: 1,
timestamp: 0,
data: {},
},
});
});
describe("saveState()", () => {
const testState = { aa: 1, bb: "2", cc: [3, 4] };
it("should save state", () => {
saveState(storePath, testState);
expect(LocalStorageUtility.setEntryObject).toHaveBeenCalledTimes(1);
expect(LocalStorageUtility.setEntryObject).toHaveBeenCalledWith(StorageKey.AppState, expect.any(Object));
const passedState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
expect(passedState[key].data).toHaveProperty("aa", 1);
});
it("should save state with timestamp", () => {
saveState(storePath, testState);
const passedState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
expect(passedState[key]).toHaveProperty("timestamp");
expect(passedState[key].timestamp).toBeGreaterThan(0);
});
it("should add state to existing state", () => {
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue({
key0: {
schemaVersion: 1,
timestamp: 0,
data: { dd: 5 },
},
});
saveState(storePath, testState);
const passedState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
expect(passedState["key0"].data).toHaveProperty("dd", 5);
});
it("should remove the oldest entry when the number of entries exceeds the limit", () => {
// Fill up storage with MAX entries
const currentAppState = {};
for (let i = 0; i < MAX_ENTRY_NB; i++) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(currentAppState as any)[`key${i}`] = {
schemaVersion: 1,
timestamp: i,
data: {},
};
}
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue(currentAppState);
saveState(storePath, testState);
// Verify that the new entry is saved
const passedState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
expect(passedState[key].data).toHaveProperty("aa", 1);
// Verify that the oldest entry is removed (smallest timestamp)
const passedAppState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
expect(Object.keys(passedAppState).length).toBe(MAX_ENTRY_NB);
expect(passedAppState).not.toHaveProperty("key0");
});
it("should not remove the oldest entry when the number of entries does not exceed the limit", () => {
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue({
key0: {
schemaVersion: 1,
timestamp: 0,
data: {},
},
key1: {
schemaVersion: 1,
timestamp: 1,
data: {},
},
});
saveState(storePath, testState);
const passedAppState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
expect(Object.keys(passedAppState).length).toBe(3);
});
});
describe("loadState()", () => {
it("should load state", () => {
const data = { aa: 1, bb: "2", cc: [3, 4] };
const testState = {
[key]: {
schemaVersion: 1,
timestamp: 0,
data,
},
};
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue(testState);
const state = loadState(storePath);
expect(state).toEqual(data);
});
it("should return undefined if the state is not found", () => {
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue(null);
const state = loadState(storePath);
expect(state).toBeUndefined();
});
});
describe("deleteState()", () => {
it("should delete state", () => {
const key = createKeyFromPath(storePath);
(LocalStorageUtility.getEntryObject as jest.Mock).mockReturnValue({
[key]: {
schemaVersion: 1,
timestamp: 0,
data: {},
},
otherKey: {
schemaVersion: 2,
timestamp: 0,
data: {},
},
});
deleteState(storePath);
expect(LocalStorageUtility.setEntryObject).toHaveBeenCalledTimes(1);
const passedAppState = (LocalStorageUtility.setEntryObject as jest.Mock).mock.calls[0][1];
expect(passedAppState).not.toHaveProperty(key);
expect(passedAppState).toHaveProperty("otherKey");
});
});
describe("createKeyFromPath()", () => {
it("should create path that contains all components", () => {
const key = createKeyFromPath(storePath);
expect(key).toContain(storePath.componentName);
expect(key).toContain(storePath.subComponentName);
expect(key).toContain(storePath.globalAccountName);
expect(key).toContain(storePath.databaseName);
expect(key).toContain(storePath.containerName);
});
it("should handle components that include special characters", () => {
const storePath = {
componentName: AppStateComponentNames.DocumentsTab,
subComponentName: 'd"e"f',
globalAccountName: "g:hi{j",
databaseName: "a/b/c",
containerName: "https://blahblah.document.azure.com:443/",
};
const key = createKeyFromPath(storePath);
const segments = key.split(PATH_SEPARATOR);
expect(segments.length).toEqual(6); // There should be 5 segments
expect(segments[0]).toBe("");
const expectSubstringsInValue = (value: string, subStrings: string[]): boolean =>
subStrings.every((subString) => value.includes(subString));
expect(expectSubstringsInValue(segments[2], ["d", "e", "f"])).toBe(true);
expect(expectSubstringsInValue(segments[3], ["g", "hi", "j"])).toBe(true);
expect(expectSubstringsInValue(segments[4], ["a", "b", "c"])).toBe(true);
expect(expectSubstringsInValue(segments[5], ["https", "blahblah", "document", "com", "443"])).toBe(true);
});
});
});

View File

@ -0,0 +1,108 @@
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
// The component name whose state is being saved. Component name must not include special characters.
export enum AppStateComponentNames {
DocumentsTab = "DocumentsTab",
QueryCopilot = "QueryCopilot",
}
export const PATH_SEPARATOR = "/"; // export for testing purposes
const SCHEMA_VERSION = 1;
// Export for testing purposes
export const MAX_ENTRY_NB = 100_000; // Limit number of entries to 100k
export interface StateData {
schemaVersion: number;
timestamp: number;
data: unknown;
}
// Export for testing purposes
export type StorePath = {
componentName: AppStateComponentNames;
subComponentName?: string;
globalAccountName?: string;
databaseName?: string;
containerName?: string;
};
// Load and save state data
export const loadState = (path: StorePath): unknown => {
const appState =
LocalStorageUtility.getEntryObject<ApplicationState>(StorageKey.AppState) || ({} as ApplicationState);
const key = createKeyFromPath(path);
return appState[key]?.data;
};
export const saveState = (path: StorePath, state: unknown): void => {
// Retrieve state object
const appState =
LocalStorageUtility.getEntryObject<ApplicationState>(StorageKey.AppState) || ({} as ApplicationState);
const key = createKeyFromPath(path);
appState[key] = {
schemaVersion: SCHEMA_VERSION,
timestamp: Date.now(),
data: state,
};
if (Object.keys(appState).length > MAX_ENTRY_NB) {
// Remove the oldest entry
const oldestKey = Object.keys(appState).reduce((oldest, current) =>
appState[current].timestamp < appState[oldest].timestamp ? current : oldest,
);
delete appState[oldestKey];
}
LocalStorageUtility.setEntryObject(StorageKey.AppState, appState);
};
export const deleteState = (path: StorePath): void => {
// Retrieve state object
const appState =
LocalStorageUtility.getEntryObject<ApplicationState>(StorageKey.AppState) || ({} as ApplicationState);
const key = createKeyFromPath(path);
delete appState[key];
LocalStorageUtility.setEntryObject(StorageKey.AppState, appState);
};
// This is for high-frequency state changes
let timeoutId: NodeJS.Timeout | undefined;
export const saveStateDebounced = (path: StorePath, state: unknown, debounceDelayMs = 1000): void => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => saveState(path, state), debounceDelayMs);
};
interface ApplicationState {
[statePath: string]: StateData;
}
const orderedPathSegments: (keyof StorePath)[] = [
"subComponentName",
"globalAccountName",
"databaseName",
"containerName",
];
/**
* /componentName/subComponentName/globalAccountName/databaseName/containerName/
* Any of the path segments can be "" except componentName
* Export for testing purposes
* @param path
*/
export const createKeyFromPath = (path: StorePath): string => {
let key = `${PATH_SEPARATOR}${encodeURIComponent(path.componentName)}`; // ComponentName is always there
orderedPathSegments.forEach((segment) => {
const segmentValue = path[segment as keyof StorePath];
key += `${PATH_SEPARATOR}${segmentValue !== undefined ? encodeURIComponent(segmentValue) : ""}`;
});
return key;
};
/**
* Remove the entire app state key from local storage
*/
export const deleteAllStates = (): void => {
LocalStorageUtility.removeEntry(StorageKey.AppState);
};

View File

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

View File

@ -29,7 +29,9 @@ export enum StorageKey {
GalleryCalloutDismissed, GalleryCalloutDismissed,
VisitedAccounts, VisitedAccounts,
PriorityLevel, PriorityLevel,
DocumentsTabPrefs,
DefaultQueryResultsView, DefaultQueryResultsView,
AppState,
} }
export const hasRUThresholdBeenConfigured = (): boolean => { export const hasRUThresholdBeenConfigured = (): boolean => {
@ -56,10 +58,10 @@ export const getRUThreshold = (): number => {
export const getDefaultQueryResultsView = (): SplitterDirection => { export const getDefaultQueryResultsView = (): SplitterDirection => {
const defaultQueryResultsViewRaw = LocalStorageUtility.getEntryString(StorageKey.DefaultQueryResultsView); const defaultQueryResultsViewRaw = LocalStorageUtility.getEntryString(StorageKey.DefaultQueryResultsView);
if (defaultQueryResultsViewRaw === SplitterDirection.Horizontal) { if (defaultQueryResultsViewRaw === SplitterDirection.Vertical) {
return SplitterDirection.Horizontal;
}
return SplitterDirection.Vertical; return SplitterDirection.Vertical;
}
return SplitterDirection.Horizontal;
}; };
export const DefaultRUThreshold = 5000; export const DefaultRUThreshold = 5000;

View File

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

View File

@ -78,6 +78,13 @@ export const PortalBackendIPs: { [key: string]: string[] } = {
//usnat: ["7.28.202.68"], //usnat: ["7.28.202.68"],
}; };
export const PortalBackendOutboundIPs: { [key: string]: string[] } = {
[PortalBackendEndpoints.Mpac]: ["13.91.105.215", "4.210.172.107"],
[PortalBackendEndpoints.Prod]: ["13.88.56.148", "40.91.218.243"],
[PortalBackendEndpoints.Fairfax]: ["52.247.163.6", "52.244.134.181"],
[PortalBackendEndpoints.Mooncake]: ["163.228.137.6", "143.64.170.142"],
};
export const MongoProxyOutboundIPs: { [key: string]: string[] } = { export const MongoProxyOutboundIPs: { [key: string]: string[] } = {
[MongoProxyEndpoints.Mpac]: ["20.245.81.54", "40.118.23.126"], [MongoProxyEndpoints.Mpac]: ["20.245.81.54", "40.118.23.126"],
[MongoProxyEndpoints.Prod]: ["40.80.152.199", "13.95.130.121"], [MongoProxyEndpoints.Prod]: ["40.80.152.199", "13.95.130.121"],
@ -168,7 +175,28 @@ export function useNewPortalBackendEndpoint(backendApi: string): boolean {
PortalBackendEndpoints.Mpac, PortalBackendEndpoints.Mpac,
PortalBackendEndpoints.Prod, PortalBackendEndpoints.Prod,
], ],
[BackendApi.AccountRestrictions]: [PortalBackendEndpoints.Development, PortalBackendEndpoints.Mpac], [BackendApi.AccountRestrictions]: [
PortalBackendEndpoints.Development,
PortalBackendEndpoints.Mpac,
PortalBackendEndpoints.Prod,
],
[BackendApi.RuntimeProxy]: [
PortalBackendEndpoints.Development,
PortalBackendEndpoints.Mpac,
PortalBackendEndpoints.Prod,
],
[BackendApi.DisallowedLocations]: [
PortalBackendEndpoints.Development,
PortalBackendEndpoints.Mpac,
PortalBackendEndpoints.Prod,
PortalBackendEndpoints.Fairfax,
PortalBackendEndpoints.Mooncake,
],
[BackendApi.SampleData]: [
PortalBackendEndpoints.Development,
PortalBackendEndpoints.Mpac,
PortalBackendEndpoints.Prod,
],
}; };
if (!newBackendApiEnvironmentMap[backendApi] || !configContext.PORTAL_BACKEND_ENDPOINT) { if (!newBackendApiEnvironmentMap[backendApi] || !configContext.PORTAL_BACKEND_ENDPOINT) {

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import * as sinon from "sinon";
import * as DataModels from "../Contracts/DataModels"; import * as DataModels from "../Contracts/DataModels";
import * as ViewModels from "../Contracts/ViewModels"; import * as ViewModels from "../Contracts/ViewModels";
import * as QueryUtils from "./QueryUtils"; import * as QueryUtils from "./QueryUtils";
import { extractPartitionKeyValues } from "./QueryUtils"; import { defaultQueryFields, extractPartitionKeyValues } from "./QueryUtils";
describe("Query Utils", () => { describe("Query Utils", () => {
const generatePartitionKeyForPath = (path: string): DataModels.PartitionKey => { const generatePartitionKeyForPath = (path: string): DataModels.PartitionKey => {
@ -54,6 +54,20 @@ describe("Query Utils", () => {
expect(partitionProjection).toContain('c["\\\\\\"a\\\\\\""]'); expect(partitionProjection).toContain('c["\\\\\\"a\\\\\\""]');
}); });
it("should always include the default fields", () => {
const query: string = QueryUtils.buildDocumentsQuery("", [], generatePartitionKeyForPath("/a"), []);
defaultQueryFields.forEach((field) => {
expect(query).toContain(`c.${field}`);
});
});
it("should always include the default fields even if they are themselves partition key fields", () => {
const query: string = QueryUtils.buildDocumentsQuery("", ["id"], generatePartitionKeyForPath("/id"), ["id"]);
expect(query).toContain("c.id");
});
}); });
describe("queryPagesUntilContentPresent()", () => { describe("queryPagesUntilContentPresent()", () => {
@ -147,5 +161,33 @@ describe("Query Utils", () => {
expect(expectedPartitionKeyValues).toContain(documentContent["Type"]); expect(expectedPartitionKeyValues).toContain(documentContent["Type"]);
expect(expectedPartitionKeyValues).toContain(documentContent["Status"]); expect(expectedPartitionKeyValues).toContain(documentContent["Status"]);
}); });
it("should extract three partition key values even if one is empty", () => {
const multiPartitionKeyDefinition: PartitionKeyDefinition = {
kind: PartitionKeyKind.MultiHash,
paths: ["/Country", "/Region", "/Category"],
};
const expectedPartitionKeyValues: string[] = ["United States", "US-Washington", ""];
const partitioinKeyValues: PartitionKey[] = extractPartitionKeyValues(
documentContent,
multiPartitionKeyDefinition,
);
expect(partitioinKeyValues.length).toBe(3);
expect(expectedPartitionKeyValues).toContain(documentContent["Country"]);
expect(expectedPartitionKeyValues).toContain(documentContent["Region"]);
expect(expectedPartitionKeyValues).toContain(documentContent["Category"]);
});
it("should extract no partition key values in the case nested partition key", () => {
const singlePartitionKeyDefinition: PartitionKeyDefinition = {
kind: PartitionKeyKind.Hash,
paths: ["/Location.type"],
};
const partitionKeyValues: PartitionKey[] = extractPartitionKeyValues(
documentContent,
singlePartitionKeyDefinition,
);
expect(partitionKeyValues.length).toBe(0);
});
}); });
}); });

View File

@ -2,18 +2,29 @@ import { PartitionKey, PartitionKeyDefinition } from "@azure/cosmos";
import * as DataModels from "../Contracts/DataModels"; import * as DataModels from "../Contracts/DataModels";
import * as ViewModels from "../Contracts/ViewModels"; import * as ViewModels from "../Contracts/ViewModels";
export const defaultQueryFields = ["id", "_self", "_rid", "_ts"];
export function buildDocumentsQuery( export function buildDocumentsQuery(
filter: string, filter: string,
partitionKeyProperties: string[], partitionKeyProperties: string[],
partitionKey: DataModels.PartitionKey, partitionKey: DataModels.PartitionKey,
additionalField: string[] = [],
): string { ): string {
const fieldSet = new Set<string>(defaultQueryFields);
additionalField.forEach((prop) => {
if (!partitionKeyProperties.includes(prop)) {
fieldSet.add(prop);
}
});
const objectListSpec = [...fieldSet].map((prop) => `c.${prop}`).join(",");
let query = let query =
partitionKeyProperties && partitionKeyProperties.length > 0 partitionKeyProperties && partitionKeyProperties.length > 0
? `select c.id, c._self, c._rid, c._ts, [${buildDocumentsQueryPartitionProjections( ? `select ${objectListSpec}, [${buildDocumentsQueryPartitionProjections(
"c", "c",
partitionKey, partitionKey,
)}] as _partitionKeyValue from c` )}] as _partitionKeyValue from c`
: `select c.id, c._self, c._rid, c._ts from c`; : `select ${objectListSpec} from c`;
if (filter) { if (filter) {
query += " " + filter; query += " " + filter;
@ -96,7 +107,7 @@ export const extractPartitionKeyValues = (
const partitionKeyValues: PartitionKey[] = []; const partitionKeyValues: PartitionKey[] = [];
partitionKeyDefinition.paths.forEach((partitionKeyPath: string) => { partitionKeyDefinition.paths.forEach((partitionKeyPath: string) => {
const partitionKeyPathWithoutSlash: string = partitionKeyPath.substring(1); const partitionKeyPathWithoutSlash: string = partitionKeyPath.substring(1);
if (documentContent[partitionKeyPathWithoutSlash]) { if (documentContent[partitionKeyPathWithoutSlash] !== undefined) {
partitionKeyValues.push(documentContent[partitionKeyPathWithoutSlash]); partitionKeyValues.push(documentContent[partitionKeyPathWithoutSlash]);
} }
}); });

View File

@ -8,6 +8,7 @@ import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
import { useSelectedNode } from "Explorer/useSelectedNode"; import { useSelectedNode } from "Explorer/useSelectedNode";
import { scheduleRefreshDatabaseResourceToken } from "Platform/Fabric/FabricUtil"; import { scheduleRefreshDatabaseResourceToken } from "Platform/Fabric/FabricUtil";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility"; import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { useNewPortalBackendEndpoint } from "Utils/EndpointUtils";
import { getNetworkSettingsWarningMessage } from "Utils/NetworkUtility"; import { getNetworkSettingsWarningMessage } from "Utils/NetworkUtility";
import { logConsoleError } from "Utils/NotificationConsoleUtils"; import { logConsoleError } from "Utils/NotificationConsoleUtils";
import { useQueryCopilot } from "hooks/useQueryCopilot"; import { useQueryCopilot } from "hooks/useQueryCopilot";
@ -760,11 +761,17 @@ async function updateContextForSampleData(explorer: Explorer): Promise<void> {
return; return;
} }
let url: string;
if (useNewPortalBackendEndpoint(Constants.BackendApi.SampleData)) {
url = createUri(configContext.PORTAL_BACKEND_ENDPOINT, "/api/sampledata");
} else {
const sampleDatabaseEndpoint = useQueryCopilot.getState().copilotUserDBEnabled const sampleDatabaseEndpoint = useQueryCopilot.getState().copilotUserDBEnabled
? `/api/tokens/sampledataconnection/v2` ? `/api/tokens/sampledataconnection/v2`
: `/api/tokens/sampledataconnection`; : `/api/tokens/sampledataconnection`;
const url = createUri(`${configContext.BACKEND_ENDPOINT}`, sampleDatabaseEndpoint); url = createUri(`${configContext.BACKEND_ENDPOINT}`, sampleDatabaseEndpoint);
}
const authorizationHeader = getAuthorizationHeader(); const authorizationHeader = getAuthorizationHeader();
const headers = { [authorizationHeader.header]: authorizationHeader.token }; const headers = { [authorizationHeader.header]: authorizationHeader.token };

View File

@ -1,14 +1,34 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ApiEndpoints } from "../Common/Constants"; import { useNewPortalBackendEndpoint } from "Utils/EndpointUtils";
import { ApiEndpoints, BackendApi, HttpHeaders } from "../Common/Constants";
import { configContext } from "../ConfigContext"; import { configContext } from "../ConfigContext";
import { AccessInputMetadata } from "../Contracts/DataModels"; import { AccessInputMetadata } from "../Contracts/DataModels";
const url = `${configContext.BACKEND_ENDPOINT}${ApiEndpoints.guestRuntimeProxy}/accessinputmetadata?_=1609359229955`; const url = `${configContext.BACKEND_ENDPOINT}${ApiEndpoints.guestRuntimeProxy}/accessinputmetadata?_=1609359229955`;
export async function fetchAccessData(portalToken: string): Promise<AccessInputMetadata> { export async function fetchAccessData(portalToken: string): Promise<AccessInputMetadata> {
if (!useNewPortalBackendEndpoint(BackendApi.RuntimeProxy)) {
return fetchAccessData_ToBeDeprecated(portalToken);
}
const headers = new Headers(); const headers = new Headers();
// Portal encrypted token API quirk: The token header must be URL encoded // Portal encrypted token API quirk: The token header must be URL encoded
headers.append("x-ms-encrypted-auth-token", encodeURIComponent(portalToken)); headers.append(HttpHeaders.guestAccessToken, encodeURIComponent(portalToken));
const url: string = `${configContext.PORTAL_BACKEND_ENDPOINT}/api/connectionstring/runtimeproxy/accessinputmetadata`;
const options = {
method: "GET",
headers: headers,
};
return fetch(url, options)
.then((response) => response.json())
.catch((error) => console.error(error));
}
export async function fetchAccessData_ToBeDeprecated(portalToken: string): Promise<AccessInputMetadata> {
const headers = new Headers();
// Portal encrypted token API quirk: The token header must be URL encoded
headers.append(HttpHeaders.guestAccessToken, encodeURIComponent(portalToken));
const options = { const options = {
method: "GET", method: "GET",

View File

@ -71,6 +71,8 @@ test("Query stats", async () => {
}); });
test("Query errors", async () => { test("Query errors", async () => {
test.skip(true, "Disabled due to an issue with error reporting in the backend.");
await queryEditor.locator.click(); await queryEditor.locator.click();
await queryEditor.setText("SELECT\n glarb(c.id),\n blarg(c.id)\nFROM c"); await queryEditor.setText("SELECT\n glarb(c.id),\n blarg(c.id)\nFROM c");