Merge branch 'master' of https://github.com/Azure/cosmos-explorer
This commit is contained in:
commit
bd564c665b
|
@ -1906,8 +1906,14 @@ input::-webkit-calendar-picker-indicator::after {
|
|||
}
|
||||
|
||||
.nav-tabs-margin {
|
||||
padding-top: 5px;
|
||||
height: 32px;
|
||||
background-color: #f2f2f2;
|
||||
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.navTabHeight {
|
||||
|
|
|
@ -2527,13 +2527,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/preset-env/node_modules/babel-plugin-polyfill-corejs3": {
|
||||
"version": "0.10.4",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.4.tgz",
|
||||
"integrity": "sha512-25J6I8NGfa5YkCDogHRID3fVCadIR8/pGl1/spvCkzb6lVn6SR3ojpx9nOn9iEBcUsjY24AmdKm5khcfKdylcg==",
|
||||
"version": "0.10.6",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz",
|
||||
"integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-define-polyfill-provider": "^0.6.1",
|
||||
"core-js-compat": "^3.36.1"
|
||||
"@babel/helper-define-polyfill-provider": "^0.6.2",
|
||||
"core-js-compat": "^3.38.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
|
||||
|
@ -2932,10 +2932,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.6.3",
|
||||
"version": "1.6.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.3"
|
||||
"@floating-ui/utils": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/devtools": {
|
||||
|
@ -2945,15 +2945,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.6.6",
|
||||
"version": "1.6.5",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.0.0",
|
||||
"@floating-ui/utils": "^0.2.3"
|
||||
"@floating-ui/utils": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.2",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fluentui/date-time-utilities": {
|
||||
|
@ -3501,7 +3501,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@fluentui/react-hooks/-/react-hooks-8.8.10.tgz",
|
||||
"integrity": "sha512-Xvnn6uKMsinMg/zo79KBNCDABnl0gpmArQYNQya9FCNRzvmHUCDvuQCqv4IKslvPvuC0Ya8mR2NORm2w0JoZiw==",
|
||||
"dependencies": {
|
||||
"@fluentui/react-window-provider": "^2.2.27",
|
||||
"@fluentui/react-window-provider": "^2.2.28",
|
||||
"@fluentui/set-version": "^8.2.23",
|
||||
"@fluentui/utilities": "^8.15.13",
|
||||
"tslib": "^2.1.0"
|
||||
|
@ -4426,9 +4426,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@fluentui/react-window-provider": {
|
||||
"version": "2.2.27",
|
||||
"resolved": "https://registry.npmjs.org/@fluentui/react-window-provider/-/react-window-provider-2.2.27.tgz",
|
||||
"integrity": "sha512-Dg0G9bizjryV0Q/r0CPtCVTPa2II/EsT9E6JT3jPSALjQADDLlW4/+ZXbcEC7geZ/40+KpZDmhplvk/AJSFBKg==",
|
||||
"version": "2.2.28",
|
||||
"resolved": "https://registry.npmjs.org/@fluentui/react-window-provider/-/react-window-provider-2.2.28.tgz",
|
||||
"integrity": "sha512-YdZ74HTaoDwlvLDzoBST80/17ExIl93tLJpTxnqK5jlJOAUVQ+mxLPF2HQEJq+SZr5IMXHsQ56w/KaZVRn72YA==",
|
||||
"dependencies": {
|
||||
"@fluentui/set-version": "^8.2.23",
|
||||
"tslib": "^2.1.0"
|
||||
|
@ -4512,7 +4512,7 @@
|
|||
"dependencies": {
|
||||
"@fluentui/dom-utilities": "^2.3.7",
|
||||
"@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",
|
||||
"tslib": "^2.1.0"
|
||||
},
|
||||
|
@ -14966,9 +14966,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.23.2",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.2.tgz",
|
||||
"integrity": "sha512-qkqSyistMYdxAcw+CzbZwlBy8AGmS/eEWs+sEV5TnLRGDOL+C5M2EnH6tlZyg0YoAxGJAFKh61En9BR941GnHA==",
|
||||
"version": "4.23.3",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz",
|
||||
"integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
@ -14984,9 +14984,9 @@
|
|||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001640",
|
||||
"electron-to-chromium": "^1.4.820",
|
||||
"node-releases": "^2.0.14",
|
||||
"caniuse-lite": "^1.0.30001646",
|
||||
"electron-to-chromium": "^1.5.4",
|
||||
"node-releases": "^2.0.18",
|
||||
"update-browserslist-db": "^1.1.0"
|
||||
},
|
||||
"bin": {
|
||||
|
@ -15142,9 +15142,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001645",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001645.tgz",
|
||||
"integrity": "sha512-GFtY2+qt91kzyMk6j48dJcwJVq5uTkk71XxE3RtScx7XWRLsO7bU44LOFkOZYR8w9YMS0UhPSYpN/6rAMImmLw==",
|
||||
"version": "1.0.30001651",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz",
|
||||
"integrity": "sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
@ -16063,12 +16063,12 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-js-compat": {
|
||||
"version": "3.37.1",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.37.1.tgz",
|
||||
"integrity": "sha512-9TNiImhKvQqSUkOvk/mMRZzOANTiEVC7WaBNhHcKM7x+/5E1l5NvsysR19zuDQScE8k+kfQXWRN3AtS/eOSHpg==",
|
||||
"version": "3.38.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.38.0.tgz",
|
||||
"integrity": "sha512-75LAicdLa4OJVwFxFbQR3NdnZjNgX6ILpVcVzcC4T2smerB5lELMrJQQQoWV6TiuC/vlaFqgU2tKQx9w5s0e0A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"browserslist": "^4.23.0"
|
||||
"browserslist": "^4.23.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
|
|
|
@ -134,6 +134,9 @@ export class BackendApi {
|
|||
public static readonly GenerateToken: string = "GenerateToken";
|
||||
public static readonly PortalSettings: string = "PortalSettings";
|
||||
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 {
|
||||
|
@ -290,6 +293,7 @@ export class HttpStatusCodes {
|
|||
public static readonly Accepted: number = 202;
|
||||
public static readonly NoContent: number = 204;
|
||||
public static readonly NotModified: number = 304;
|
||||
public static readonly BadRequest: number = 400;
|
||||
public static readonly Unauthorized: number = 401;
|
||||
public static readonly Forbidden: number = 403;
|
||||
public static readonly NotFound: number = 404;
|
||||
|
@ -501,7 +505,7 @@ export class PriorityLevel {
|
|||
public static readonly Default = "low";
|
||||
}
|
||||
|
||||
export const QueryCopilotSampleDatabaseId = "CopilotSampleDb";
|
||||
export const QueryCopilotSampleDatabaseId = "CopilotSampleDB";
|
||||
export const QueryCopilotSampleContainerId = "SampleContainer";
|
||||
|
||||
export const QueryCopilotSampleContainerSchema = {
|
||||
|
|
|
@ -3,15 +3,16 @@ import { getAuthorizationTokenUsingResourceTokens } from "Common/getAuthorizatio
|
|||
import { AuthorizationToken } from "Contracts/FabricMessageTypes";
|
||||
import { checkDatabaseResourceTokensValidity } from "Platform/Fabric/FabricUtil";
|
||||
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
|
||||
import { useNewPortalBackendEndpoint } from "Utils/EndpointUtils";
|
||||
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 { userContext } from "../UserContext";
|
||||
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
|
||||
import * as PriorityBasedExecutionUtils from "../Utils/PriorityBasedExecutionUtils";
|
||||
import { EmulatorMasterKey, HttpHeaders } from "./Constants";
|
||||
import { getErrorMessage } from "./ErrorHandlingUtils";
|
||||
import * as Logger from "../Common/Logger";
|
||||
|
||||
const _global = typeof self === "undefined" ? window : self;
|
||||
|
||||
|
@ -123,6 +124,37 @@ export async function getTokenFromAuthService(
|
|||
verb: string,
|
||||
resourceType: 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> {
|
||||
try {
|
||||
const host = configContext.BACKEND_ENDPOINT;
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { PortalBackendEndpoints } from "Common/Constants";
|
||||
import { updateConfigContext } from "ConfigContext";
|
||||
import * as EnvironmentUtility from "./EnvironmentUtility";
|
||||
|
||||
describe("Environment Utility Test", () => {
|
||||
|
@ -11,4 +13,18 @@ describe("Environment Utility Test", () => {
|
|||
const expectedResult = "test/";
|
||||
expect(EnvironmentUtility.normalizeArmEndpoint(uri)).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it("Detect environment is Mpac", () => {
|
||||
updateConfigContext({
|
||||
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Mpac,
|
||||
});
|
||||
expect(EnvironmentUtility.getEnvironment()).toBe(EnvironmentUtility.Environment.Mpac);
|
||||
});
|
||||
|
||||
it("Detect environment is Development", () => {
|
||||
updateConfigContext({
|
||||
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Development,
|
||||
});
|
||||
expect(EnvironmentUtility.getEnvironment()).toBe(EnvironmentUtility.Environment.Development);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,6 +1,29 @@
|
|||
import { PortalBackendEndpoints } from "Common/Constants";
|
||||
import { configContext } from "ConfigContext";
|
||||
|
||||
export function normalizeArmEndpoint(uri: string): string {
|
||||
if (uri && uri.slice(-1) !== "/") {
|
||||
return `${uri}/`;
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
export enum Environment {
|
||||
Development = "Development",
|
||||
Mpac = "MPAC",
|
||||
Prod = "Prod",
|
||||
Fairfax = "Fairfax",
|
||||
Mooncake = "Mooncake",
|
||||
}
|
||||
|
||||
export const getEnvironment = (): Environment => {
|
||||
const environmentMap: { [key: string]: Environment } = {
|
||||
[PortalBackendEndpoints.Development]: Environment.Development,
|
||||
[PortalBackendEndpoints.Mpac]: Environment.Mpac,
|
||||
[PortalBackendEndpoints.Prod]: Environment.Prod,
|
||||
[PortalBackendEndpoints.Fairfax]: Environment.Fairfax,
|
||||
[PortalBackendEndpoints.Mooncake]: Environment.Mooncake,
|
||||
};
|
||||
|
||||
return environmentMap[configContext.PORTAL_BACKEND_ENDPOINT];
|
||||
};
|
||||
|
|
|
@ -720,8 +720,10 @@ export function useMongoProxyEndpoint(api: string): boolean {
|
|||
MongoProxyEndpoints.Local,
|
||||
MongoProxyEndpoints.Mpac,
|
||||
MongoProxyEndpoints.Prod,
|
||||
// MongoProxyEndpoints.Fairfax,
|
||||
MongoProxyEndpoints.Fairfax,
|
||||
MongoProxyEndpoints.Mooncake,
|
||||
];
|
||||
|
||||
let canAccessMongoProxy: boolean = userContext.databaseAccount.properties.publicNetworkAccess === "Enabled";
|
||||
if (
|
||||
configContext.MONGO_PROXY_ENDPOINT !== MongoProxyEndpoints.Local &&
|
||||
|
@ -729,7 +731,6 @@ export function useMongoProxyEndpoint(api: string): boolean {
|
|||
) {
|
||||
canAccessMongoProxy = canAccessMongoProxy && configContext.MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED;
|
||||
}
|
||||
|
||||
return (
|
||||
canAccessMongoProxy &&
|
||||
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
|
||||
// It causes problems for TypeScript understanding the types
|
||||
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) {
|
||||
sendMessage({ type: MessageTypes.ForbiddenError, reason: errorMessage });
|
||||
return;
|
||||
} else if (
|
||||
response.status === HttpStatusCodes.BadRequest &&
|
||||
errorMessage.includes("Error=16500") &&
|
||||
errorMessage.includes("RetryAfterMs=")
|
||||
) {
|
||||
// If throttling is happening, Cosmos DB will return a 400 with a body of:
|
||||
// A write operation resulted in an error. Error=16500, RetryAfterMs=4, Details='Batch write error.
|
||||
throw new ThrottlingError(errorMessage);
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
|
|
@ -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[];
|
||||
};
|
|
@ -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 }),
|
||||
),
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -1,5 +1,5 @@
|
|||
import { getErrorMessage } from "Common/ErrorHandlingUtils";
|
||||
import { monaco } from "Explorer/LazyMonaco";
|
||||
import { getRUThreshold, ruThresholdEnabled } from "Shared/StorageUtility";
|
||||
|
||||
export enum QueryErrorSeverity {
|
||||
Error = "Error",
|
||||
|
@ -97,13 +97,44 @@ export const createMonacoMarkersForQueryErrors = (errors: QueryError[]) => {
|
|||
.filter((marker) => !!marker);
|
||||
};
|
||||
|
||||
export interface ErrorEnrichment {
|
||||
title?: string;
|
||||
message: string;
|
||||
learnMoreUrl?: string;
|
||||
}
|
||||
|
||||
const REPLACEMENT_MESSAGES: Record<string, (original: string) => string> = {
|
||||
OPERATION_RU_LIMIT_EXCEEDED: (original) => {
|
||||
if (ruThresholdEnabled()) {
|
||||
const threshold = getRUThreshold();
|
||||
return `Query exceeded the Request Unit (RU) limit of ${threshold} RUs. You can change this limit in Data Explorer settings.`;
|
||||
}
|
||||
return original;
|
||||
},
|
||||
};
|
||||
|
||||
const HELP_LINKS: Record<string, string> = {
|
||||
OPERATION_RU_LIMIT_EXCEEDED:
|
||||
"https://learn.microsoft.com/en-us/azure/cosmos-db/data-explorer#configure-request-unit-threshold",
|
||||
};
|
||||
|
||||
export default class QueryError {
|
||||
message: string;
|
||||
helpLink?: string;
|
||||
|
||||
constructor(
|
||||
public message: string,
|
||||
message: string,
|
||||
public severity: QueryErrorSeverity,
|
||||
public code?: string,
|
||||
public location?: QueryErrorLocation,
|
||||
) {}
|
||||
helpLink?: string,
|
||||
) {
|
||||
// Automatically replace the message with a more Data Explorer-specific message if we have for this error code.
|
||||
this.message = REPLACEMENT_MESSAGES[code] ? REPLACEMENT_MESSAGES[code](message) : message;
|
||||
|
||||
// Automatically set the help link if we have one for this error code.
|
||||
this.helpLink = helpLink ?? HELP_LINKS[code];
|
||||
}
|
||||
|
||||
getMonacoSeverity(): monaco.MarkerSeverity {
|
||||
// It's very difficult to use the monaco.MarkerSeverity enum from here, so we'll just use the numbers directly.
|
||||
|
@ -135,7 +166,7 @@ export default class QueryError {
|
|||
return errors;
|
||||
}
|
||||
|
||||
const errorMessage = getErrorMessage(error as string | Error);
|
||||
const errorMessage = error as string;
|
||||
|
||||
// Map some well known messages to richer errors
|
||||
const knownError = knownErrors[errorMessage];
|
||||
|
@ -160,7 +191,9 @@ export default class QueryError {
|
|||
}
|
||||
|
||||
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 =
|
||||
"location" in error && typeof error.location === "object"
|
||||
? locationResolver(error.location as { start: number; end: number })
|
||||
|
@ -173,16 +206,15 @@ export default class QueryError {
|
|||
error: unknown,
|
||||
locationResolver: (location: { start: number; end: number }) => QueryErrorLocation,
|
||||
): QueryError[] | null {
|
||||
if (typeof error === "object" && "message" in error) {
|
||||
error = error.message;
|
||||
}
|
||||
|
||||
if (typeof error !== "string") {
|
||||
let message: string | undefined;
|
||||
if (typeof error === "object" && "message" in error && typeof error.message === "string") {
|
||||
message = error.message;
|
||||
} else {
|
||||
// Unsupported error format.
|
||||
return null;
|
||||
}
|
||||
|
||||
// Assign to a new variable because of a TypeScript flow typing quirk, see below.
|
||||
let message = error;
|
||||
if (message.startsWith("Message: ")) {
|
||||
// Reassigning this to 'error' restores the original type of 'error', which is 'unknown'.
|
||||
// So we use a separate variable to avoid this.
|
||||
|
@ -196,12 +228,15 @@ export default class QueryError {
|
|||
try {
|
||||
parsed = JSON.parse(message);
|
||||
} catch (e) {
|
||||
// Not a query error.
|
||||
return null;
|
||||
// The message doesn't contain a nested error.
|
||||
return [QueryError.read(error, locationResolver)];
|
||||
}
|
||||
|
||||
if (typeof parsed === "object" && "errors" in parsed && Array.isArray(parsed.errors)) {
|
||||
return parsed.errors.map((e) => QueryError.read(e, locationResolver)).filter((e) => e !== null);
|
||||
if (typeof parsed === "object") {
|
||||
if ("errors" in parsed && Array.isArray(parsed.errors)) {
|
||||
return parsed.errors.map((e) => QueryError.read(e, locationResolver)).filter((e) => e !== null);
|
||||
}
|
||||
return [QueryError.read(parsed, locationResolver)];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -3,11 +3,12 @@ import * as React from "react";
|
|||
|
||||
export interface TooltipProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const InfoTooltip: React.FunctionComponent<TooltipProps> = ({ children }: TooltipProps) => {
|
||||
export const InfoTooltip: React.FunctionComponent<TooltipProps> = ({ children, className }: TooltipProps) => {
|
||||
return (
|
||||
<span>
|
||||
<span className={className}>
|
||||
<TooltipHost content={children}>
|
||||
<Icon iconName="Info" ariaLabel={children} className="panelInfoIcon" tabIndex={0} />
|
||||
</TooltipHost>
|
||||
|
|
|
@ -26,14 +26,23 @@ export const deleteDocument = async (collection: CollectionBase, documentId: Doc
|
|||
}
|
||||
};
|
||||
|
||||
export interface IBulkDeleteResult {
|
||||
documentId: DocumentId;
|
||||
requestCharge: number;
|
||||
statusCode: number;
|
||||
retryAfterMilliseconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk delete documents
|
||||
* @param collection
|
||||
* @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[]> => {
|
||||
const nbDocuments = documentIds.length;
|
||||
export const deleteDocuments = async (
|
||||
collection: CollectionBase,
|
||||
documentIds: DocumentId[],
|
||||
): Promise<IBulkDeleteResult[]> => {
|
||||
const clearMessage = logConsoleProgress(`Deleting ${documentIds.length} ${getEntityName(true)}`);
|
||||
try {
|
||||
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,
|
||||
}));
|
||||
|
||||
const promise = v2Container.items.bulk(operations).then((bulkResult) => {
|
||||
return documentIdsChunk.filter((_, index) => bulkResult[index].statusCode === 204);
|
||||
const promise = v2Container.items.bulk(operations).then((bulkResults) => {
|
||||
return bulkResults.map((bulkResult, index) => {
|
||||
const documentId = documentIdsChunk[index];
|
||||
return { ...bulkResult, documentId };
|
||||
});
|
||||
});
|
||||
promiseArray.push(promise);
|
||||
}
|
||||
|
||||
const allResult = await Promise.all(promiseArray);
|
||||
const flatAllResult = Array.prototype.concat.apply([], allResult);
|
||||
logConsoleInfo(
|
||||
`Successfully deleted ${getEntityName(flatAllResult.length > 1)}: ${flatAllResult.length} out of ${nbDocuments}`,
|
||||
);
|
||||
// TODO: handle case result.length != nbDocuments
|
||||
return flatAllResult;
|
||||
} catch (error) {
|
||||
handleError(
|
||||
|
|
|
@ -49,15 +49,15 @@ export interface ConfigContext {
|
|||
ARCADIA_ENDPOINT: string;
|
||||
ARCADIA_LIVY_ENDPOINT_DNS_ZONE: string;
|
||||
BACKEND_ENDPOINT?: string;
|
||||
PORTAL_BACKEND_ENDPOINT?: string;
|
||||
PORTAL_BACKEND_ENDPOINT: string;
|
||||
NEW_BACKEND_APIS?: BackendApi[];
|
||||
MONGO_BACKEND_ENDPOINT?: string;
|
||||
MONGO_PROXY_ENDPOINT?: string;
|
||||
MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED?: boolean;
|
||||
MONGO_PROXY_ENDPOINT: string;
|
||||
NEW_MONGO_APIS?: string[];
|
||||
CASSANDRA_PROXY_ENDPOINT?: string;
|
||||
CASSANDRA_PROXY_OUTBOUND_IPS_ALLOWLISTED: boolean;
|
||||
MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED?: boolean;
|
||||
CASSANDRA_PROXY_ENDPOINT: string;
|
||||
NEW_CASSANDRA_APIS?: string[];
|
||||
CASSANDRA_PROXY_OUTBOUND_IPS_ALLOWLISTED: boolean;
|
||||
PROXY_PATH?: string;
|
||||
JUNO_ENDPOINT: string;
|
||||
GITHUB_CLIENT_ID: string;
|
||||
|
@ -87,7 +87,7 @@ let configContext: Readonly<ConfigContext> = {
|
|||
`^https:\\/\\/.*\\.analysis-df\\.net$`,
|
||||
`^https:\\/\\/.*\\.analysis-df\\.windows\\.net$`,
|
||||
`^https:\\/\\/.*\\.azure-test\\.net$`,
|
||||
`^https:\\/\\/cosmos-explorer-preview\\.azurewebsites\\.net`,
|
||||
`^https:\\/\\/cosmos-explorer-preview\\.azurewebsites\\.net$`,
|
||||
], // Webpack injects this at build time
|
||||
gitSha: process.env.GIT_SHA,
|
||||
hostedExplorerURL: "https://cosmos.azure.com/",
|
||||
|
@ -117,7 +117,7 @@ let configContext: Readonly<ConfigContext> = {
|
|||
"deleteDocument",
|
||||
"createCollectionWithProxy",
|
||||
"legacyMongoShell",
|
||||
"bulkdelete",
|
||||
// "bulkdelete",
|
||||
],
|
||||
MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED: false,
|
||||
CASSANDRA_PROXY_ENDPOINT: CassandraProxyEndpoints.Prod,
|
||||
|
|
|
@ -98,7 +98,6 @@ export interface Database extends TreeNode {
|
|||
openAddCollection(database: Database, event: MouseEvent): void;
|
||||
onSettingsClick: () => void;
|
||||
loadOffer(): Promise<void>;
|
||||
getPendingThroughputSplitNotification(): Promise<DataModels.Notification>;
|
||||
}
|
||||
|
||||
export interface CollectionBase extends TreeNode {
|
||||
|
@ -191,8 +190,6 @@ export interface Collection extends CollectionBase {
|
|||
onDragOver(source: Collection, event: { originalEvent: DragEvent }): void;
|
||||
onDrop(source: Collection, event: { originalEvent: DragEvent }): void;
|
||||
uploadFiles(fileList: FileList): Promise<{ data: UploadDetailsRecord[] }>;
|
||||
|
||||
getPendingThroughputSplitNotification(): Promise<DataModels.Notification>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -35,7 +35,7 @@ export interface DialogState {
|
|||
textFieldProps?: TextFieldProps,
|
||||
primaryButtonDisabled?: boolean,
|
||||
) => void;
|
||||
showOkModalDialog: (title: string, subText: string) => void;
|
||||
showOkModalDialog: (title: string, subText: string, linkProps?: LinkProps) => void;
|
||||
}
|
||||
|
||||
export const useDialog: UseStore<DialogState> = create((set, get) => ({
|
||||
|
@ -83,7 +83,7 @@ export const useDialog: UseStore<DialogState> = create((set, get) => ({
|
|||
textFieldProps,
|
||||
primaryButtonDisabled,
|
||||
}),
|
||||
showOkModalDialog: (title: string, subText: string): void =>
|
||||
showOkModalDialog: (title: string, subText: string, linkProps?: LinkProps): void =>
|
||||
get().openDialog({
|
||||
isModal: true,
|
||||
title,
|
||||
|
@ -94,6 +94,7 @@ export const useDialog: UseStore<DialogState> = create((set, get) => ({
|
|||
get().closeDialog();
|
||||
},
|
||||
onSecondaryButtonClick: undefined,
|
||||
linkProps,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogSurface,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Field,
|
||||
ProgressBar,
|
||||
} from "@fluentui/react-components";
|
||||
import * as React from "react";
|
||||
|
||||
interface ProgressModalDialogProps {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
maxValue: number;
|
||||
value: number;
|
||||
dismissText: string;
|
||||
onDismiss: () => void;
|
||||
onCancel?: () => void;
|
||||
/* mode drives the state of the action buttons
|
||||
* inProgress: Show cancel button
|
||||
* completed: Show close button
|
||||
* aborting: Show cancel button, but disabled
|
||||
* aborted: Show close button
|
||||
*/
|
||||
mode?: "inProgress" | "completed" | "aborting" | "aborted";
|
||||
}
|
||||
|
||||
/**
|
||||
* React component that renders a modal dialog with a progress bar.
|
||||
*/
|
||||
export const ProgressModalDialog: React.FC<ProgressModalDialogProps> = ({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
maxValue,
|
||||
value,
|
||||
dismissText,
|
||||
onCancel,
|
||||
onDismiss,
|
||||
children,
|
||||
mode = "completed",
|
||||
}) => (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(event, data) => {
|
||||
if (!data.open) {
|
||||
onDismiss();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogSurface>
|
||||
<DialogBody>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Field validationMessage={message} validationState="none">
|
||||
<ProgressBar max={maxValue} value={value} />
|
||||
</Field>
|
||||
{children}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{mode === "inProgress" || mode === "aborting" ? (
|
||||
<Button appearance="secondary" onClick={onCancel} disabled={mode === "aborting"}>
|
||||
{dismissText}
|
||||
</Button>
|
||||
) : (
|
||||
<DialogTrigger disableButtonEnhancement>
|
||||
<Button appearance="primary">Close</Button>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
</DialogActions>
|
||||
</DialogBody>
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
);
|
|
@ -134,7 +134,6 @@ describe("SettingsComponent", () => {
|
|||
readSettings: undefined,
|
||||
onSettingsClick: undefined,
|
||||
loadOffer: undefined,
|
||||
getPendingThroughputSplitNotification: undefined,
|
||||
} as ViewModels.Database;
|
||||
newCollection.getDatabase = () => newDatabase;
|
||||
newCollection.offer = ko.observable(undefined);
|
||||
|
|
|
@ -130,7 +130,6 @@ export interface SettingsComponentState {
|
|||
conflictResolutionPolicyProcedureBaseline: string;
|
||||
isConflictResolutionDirty: boolean;
|
||||
|
||||
initialNotification: DataModels.Notification;
|
||||
selectedTab: SettingsV2TabTypes;
|
||||
}
|
||||
|
||||
|
@ -229,7 +228,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||
conflictResolutionPolicyProcedureBaseline: undefined,
|
||||
isConflictResolutionDirty: false,
|
||||
|
||||
initialNotification: undefined,
|
||||
selectedTab: SettingsV2TabTypes.ScaleTab,
|
||||
};
|
||||
|
||||
|
@ -1052,7 +1050,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
|||
onMaxAutoPilotThroughputChange: this.onMaxAutoPilotThroughputChange,
|
||||
onScaleSaveableChange: this.onScaleSaveableChange,
|
||||
onScaleDiscardableChange: this.onScaleDiscardableChange,
|
||||
initialNotification: this.props.settingsTab.pendingNotification(),
|
||||
throughputError: this.state.throughputError,
|
||||
};
|
||||
|
||||
|
|
|
@ -1,18 +1,10 @@
|
|||
import { shallow } from "enzyme";
|
||||
import ko from "knockout";
|
||||
import React from "react";
|
||||
import * as Constants from "../../../../Common/Constants";
|
||||
import * as DataModels from "../../../../Contracts/DataModels";
|
||||
import { updateUserContext } from "../../../../UserContext";
|
||||
import Explorer from "../../../Explorer";
|
||||
import { throughputUnit } from "../SettingsRenderUtils";
|
||||
import { collection } from "../TestUtils";
|
||||
import { ScaleComponent, ScaleComponentProps } from "./ScaleComponent";
|
||||
import { ThroughputInputAutoPilotV3Component } from "./ThroughputInputComponents/ThroughputInputAutoPilotV3Component";
|
||||
|
||||
describe("ScaleComponent", () => {
|
||||
const targetThroughput = 6000;
|
||||
|
||||
const baseProps: ScaleComponentProps = {
|
||||
collection: collection,
|
||||
database: undefined,
|
||||
|
@ -36,39 +28,8 @@ describe("ScaleComponent", () => {
|
|||
onScaleDiscardableChange: () => {
|
||||
return;
|
||||
},
|
||||
initialNotification: {
|
||||
description: `Throughput update for ${targetThroughput} ${throughputUnit}`,
|
||||
} as DataModels.Notification,
|
||||
};
|
||||
|
||||
it("renders with correct initial notification", () => {
|
||||
let wrapper = shallow(<ScaleComponent {...baseProps} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
expect(wrapper.exists(ThroughputInputAutoPilotV3Component)).toEqual(true);
|
||||
expect(wrapper.exists("#throughputApplyLongDelayMessage")).toEqual(true);
|
||||
expect(wrapper.exists("#throughputApplyShortDelayMessage")).toEqual(false);
|
||||
expect(wrapper.find("#throughputApplyLongDelayMessage").html()).toContain(`${targetThroughput}`);
|
||||
|
||||
const newCollection = { ...collection };
|
||||
const maxThroughput = 5000;
|
||||
newCollection.offer = ko.observable({
|
||||
manualThroughput: undefined,
|
||||
autoscaleMaxThroughput: maxThroughput,
|
||||
minimumThroughput: 400,
|
||||
id: "offer",
|
||||
offerReplacePending: true,
|
||||
});
|
||||
const newProps = {
|
||||
...baseProps,
|
||||
initialNotification: undefined as DataModels.Notification,
|
||||
collection: newCollection,
|
||||
};
|
||||
wrapper = shallow(<ScaleComponent {...newProps} />);
|
||||
expect(wrapper.exists("#throughputApplyShortDelayMessage")).toEqual(true);
|
||||
expect(wrapper.exists("#throughputApplyLongDelayMessage")).toEqual(false);
|
||||
expect(wrapper.find("#throughputApplyShortDelayMessage").html()).toContain(`${maxThroughput}`);
|
||||
});
|
||||
|
||||
it("autoScale disabled", () => {
|
||||
const scaleComponent = new ScaleComponent(baseProps);
|
||||
expect(scaleComponent.isAutoScaleEnabled()).toEqual(false);
|
||||
|
|
|
@ -10,7 +10,6 @@ import * as AutoPilotUtils from "../../../../Utils/AutoPilotUtils";
|
|||
import { isRunningOnNationalCloud } from "../../../../Utils/CloudUtils";
|
||||
import {
|
||||
getTextFieldStyles,
|
||||
getThroughputApplyLongDelayMessage,
|
||||
getThroughputApplyShortDelayMessage,
|
||||
subComponentStackProps,
|
||||
throughputUnit,
|
||||
|
@ -34,7 +33,6 @@ export interface ScaleComponentProps {
|
|||
onMaxAutoPilotThroughputChange: (newThroughput: number) => void;
|
||||
onScaleSaveableChange: (isScaleSaveable: boolean) => void;
|
||||
onScaleDiscardableChange: (isScaleDiscardable: boolean) => void;
|
||||
initialNotification: DataModels.Notification;
|
||||
throughputError?: string;
|
||||
}
|
||||
|
||||
|
@ -102,10 +100,6 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
|
|||
};
|
||||
|
||||
public getInitialNotificationElement = (): JSX.Element => {
|
||||
if (this.props.initialNotification) {
|
||||
return this.getLongDelayMessage();
|
||||
}
|
||||
|
||||
if (this.offer?.offerReplacePending) {
|
||||
const throughput = this.offer.manualThroughput || this.offer.autoscaleMaxThroughput;
|
||||
return getThroughputApplyShortDelayMessage(
|
||||
|
@ -120,26 +114,6 @@ export class ScaleComponent extends React.Component<ScaleComponentProps> {
|
|||
return undefined;
|
||||
};
|
||||
|
||||
public getLongDelayMessage = (): JSX.Element => {
|
||||
const matches: string[] = this.props.initialNotification?.description.match(
|
||||
`Throughput update for (.*) ${throughputUnit}`,
|
||||
);
|
||||
|
||||
const throughput = this.props.throughputBaseline;
|
||||
const targetThroughput: number = matches.length > 1 && Number(matches[1]);
|
||||
if (targetThroughput) {
|
||||
return getThroughputApplyLongDelayMessage(
|
||||
this.props.wasAutopilotOriginallySet,
|
||||
throughput,
|
||||
throughputUnit,
|
||||
this.databaseId,
|
||||
this.collectionId,
|
||||
targetThroughput,
|
||||
);
|
||||
}
|
||||
return <></>;
|
||||
};
|
||||
|
||||
private getThroughputInputComponent = (): JSX.Element => (
|
||||
<ThroughputInputAutoPilotV3Component
|
||||
databaseAccount={userContext?.databaseAccount}
|
||||
|
|
|
@ -1,64 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ScaleComponent renders with correct initial notification 1`] = `
|
||||
<Stack
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 20,
|
||||
}
|
||||
}
|
||||
>
|
||||
<StyledMessageBar
|
||||
messageBarType={5}
|
||||
>
|
||||
<Text
|
||||
id="throughputApplyLongDelayMessage"
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"color": "windowtext",
|
||||
"fontSize": 14,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
A request to increase the throughput is currently in progress. This operation will take 1-3 business days to complete. View the latest status in Notifications.
|
||||
<br />
|
||||
Database: test, Container: test
|
||||
, Current autoscale throughput: 100 - 1000 RU/s, Target autoscale throughput: 600 - 6000 RU/s
|
||||
</Text>
|
||||
</StyledMessageBar>
|
||||
<Stack
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 20,
|
||||
}
|
||||
}
|
||||
>
|
||||
<ThroughputInputAutoPilotV3Component
|
||||
canExceedMaximumValue={true}
|
||||
collectionName="test"
|
||||
databaseName="test"
|
||||
isAutoPilotSelected={false}
|
||||
isEmulator={false}
|
||||
isEnabled={true}
|
||||
isFixed={false}
|
||||
label="Throughput (6,000 - unlimited RU/s)"
|
||||
maxAutoPilotThroughput={4000}
|
||||
maxAutoPilotThroughputBaseline={4000}
|
||||
maximum={1000000}
|
||||
minimum={6000}
|
||||
onAutoPilotSelected={[Function]}
|
||||
onMaxAutoPilotThroughputChange={[Function]}
|
||||
onScaleDiscardableChange={[Function]}
|
||||
onScaleSaveableChange={[Function]}
|
||||
onThroughputChange={[Function]}
|
||||
spendAckChecked={false}
|
||||
throughput={1000}
|
||||
throughputBaseline={1000}
|
||||
usageSizeInKB={100}
|
||||
wasAutopilotOriginallySet={true}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
`;
|
|
@ -44,7 +44,6 @@ describe("SettingsUtils", () => {
|
|||
readSettings: undefined,
|
||||
onSettingsClick: undefined,
|
||||
loadOffer: undefined,
|
||||
getPendingThroughputSplitNotification: undefined,
|
||||
} as ViewModels.Database;
|
||||
};
|
||||
newCollection.offer(undefined);
|
||||
|
|
|
@ -17,7 +17,7 @@ export const useTreeStyles = makeStyles({
|
|||
minWidth: "100%",
|
||||
rowGap: "0px",
|
||||
paddingTop: "0px",
|
||||
[treeIconWidth]: "20px",
|
||||
[treeIconWidth]: "16px",
|
||||
[leafNodeSpacing]: "24px",
|
||||
},
|
||||
nodeIcon: {
|
||||
|
@ -32,7 +32,6 @@ export const useTreeStyles = makeStyles({
|
|||
fontSize: tokens.fontSizeBase300,
|
||||
height: tokens.layoutRowHeight,
|
||||
...cosmosShorthands.borderBottom(),
|
||||
paddingLeft: `calc(var(${treeItemLevelToken}, 1) * ${tokens.spacingHorizontalXXL})`,
|
||||
|
||||
// Some sneaky CSS variables stuff to change the background color of the action button on hover.
|
||||
[actionButtonBackground]: tokens.colorNeutralBackground1,
|
||||
|
|
|
@ -149,15 +149,16 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
|
|||
|
||||
// We use the expandIcon slot to hold the node icon too.
|
||||
// We only show a node icon for leaf nodes, even if a branch node has an iconSrc.
|
||||
const expandIcon = isLoading ? (
|
||||
<Spinner size="extra-tiny" />
|
||||
) : !isBranch ? (
|
||||
typeof node.iconSrc === "string" ? (
|
||||
const treeIcon =
|
||||
node.iconSrc === undefined ? undefined : typeof node.iconSrc === "string" ? (
|
||||
<img src={node.iconSrc} className={treeStyles.nodeIcon} alt="" />
|
||||
) : (
|
||||
node.iconSrc
|
||||
)
|
||||
) : openItems.includes(treeNodeId) ? (
|
||||
);
|
||||
|
||||
const expandIcon = isLoading ? (
|
||||
<Spinner size="extra-tiny" />
|
||||
) : !isBranch ? undefined : openItems.includes(treeNodeId) ? (
|
||||
<ChevronDown20Regular data-test="TreeNode/CollapseIcon" />
|
||||
) : (
|
||||
<ChevronRight20Regular data-text="TreeNode/ExpandIcon" />
|
||||
|
@ -174,7 +175,6 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
|
|||
<TreeItemLayout
|
||||
className={mergeClasses(
|
||||
treeStyles.treeItemLayout,
|
||||
expandIcon ? undefined : treeStyles.treeItemLayoutNoIcon,
|
||||
shouldShowAsSelected && treeStyles.selectedItem,
|
||||
node.className && treeStyles[node.className],
|
||||
)}
|
||||
|
@ -200,6 +200,7 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
|
|||
),
|
||||
}
|
||||
}
|
||||
iconBefore={treeIcon}
|
||||
expandIcon={expandIcon}
|
||||
>
|
||||
<span className={treeStyles.nodeLabel}>{node.label}</span>
|
||||
|
|
|
@ -10,13 +10,20 @@ exports[`TreeNodeComponent does not render children if the node is loading 1`] =
|
|||
>
|
||||
<TreeItemLayout
|
||||
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"
|
||||
expandIcon={
|
||||
<ChevronRight20Regular
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
/>
|
||||
}
|
||||
iconBefore={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="rootIcon"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
|
@ -156,7 +163,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
"itemType": "branch",
|
||||
"layoutRef": {
|
||||
"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"
|
||||
>
|
||||
<div
|
||||
|
@ -179,6 +186,16 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="rootIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
|
@ -208,7 +225,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
tabindex="-1"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<div
|
||||
|
@ -231,6 +248,16 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="rootIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
|
@ -242,7 +269,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="fui-Tree rnv2ez3 ___jy13a00_lpffjy0 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
|
||||
class="fui-Tree rnv2ez3 ___17a32do_7zrvj80 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
|
||||
data-test="Tree:root"
|
||||
role="tree"
|
||||
>
|
||||
|
@ -256,7 +283,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
tabindex="0"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<div
|
||||
|
@ -279,6 +306,16 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="child1Icon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
|
@ -300,7 +337,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
tabindex="-1"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<div
|
||||
|
@ -323,6 +360,16 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="child2LoadingIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
|
@ -343,7 +390,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
tabindex="-1"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<div
|
||||
|
@ -363,6 +410,16 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="child3ExpandingIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
|
@ -383,16 +440,23 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
>
|
||||
<TreeItemLayout
|
||||
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"
|
||||
expandIcon={
|
||||
<ChevronRight20Regular
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
/>
|
||||
}
|
||||
iconBefore={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="rootIcon"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<div
|
||||
|
@ -419,6 +483,16 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
</svg>
|
||||
</ChevronRight20Regular>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden={true}
|
||||
className="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="rootIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
|
@ -431,7 +505,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
</div>
|
||||
</TreeItemLayout>
|
||||
<Tree
|
||||
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
|
||||
className="___17a32do_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
|
||||
data-test="Tree:root"
|
||||
>
|
||||
<TreeProvider
|
||||
|
@ -499,7 +573,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
}
|
||||
>
|
||||
<div
|
||||
className="fui-Tree rnv2ez3 ___jy13a00_lpffjy0 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
|
||||
className="fui-Tree rnv2ez3 ___17a32do_7zrvj80 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
|
||||
data-test="Tree:root"
|
||||
role="tree"
|
||||
>
|
||||
|
@ -587,7 +661,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
"itemType": "branch",
|
||||
"layoutRef": {
|
||||
"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"
|
||||
>
|
||||
<div
|
||||
|
@ -610,6 +684,16 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="child1Icon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
|
@ -639,7 +723,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
tabindex="0"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<div
|
||||
|
@ -662,6 +746,16 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="child1Icon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
|
@ -680,16 +774,23 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
>
|
||||
<TreeItemLayout
|
||||
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"
|
||||
expandIcon={
|
||||
<ChevronRight20Regular
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
/>
|
||||
}
|
||||
iconBefore={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="child1Icon"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<div
|
||||
|
@ -716,6 +817,16 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
</svg>
|
||||
</ChevronRight20Regular>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden={true}
|
||||
className="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="child1Icon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
|
@ -728,7 +839,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
</div>
|
||||
</TreeItemLayout>
|
||||
<Tree
|
||||
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
|
||||
className="___17a32do_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
|
||||
data-test="Tree:root/child1Label"
|
||||
>
|
||||
<TreeProvider
|
||||
|
@ -821,7 +932,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
"itemType": "branch",
|
||||
"layoutRef": {
|
||||
"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"
|
||||
>
|
||||
<div
|
||||
|
@ -844,6 +955,16 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="child2LoadingIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
|
@ -873,7 +994,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
tabindex="-1"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<div
|
||||
|
@ -896,6 +1017,16 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="child2LoadingIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
|
@ -914,16 +1045,23 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
>
|
||||
<TreeItemLayout
|
||||
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"
|
||||
expandIcon={
|
||||
<ChevronRight20Regular
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
/>
|
||||
}
|
||||
iconBefore={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="child2LoadingIcon"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<div
|
||||
|
@ -950,6 +1088,16 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
</svg>
|
||||
</ChevronRight20Regular>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden={true}
|
||||
className="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="child2LoadingIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
|
@ -1039,7 +1187,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
"itemType": "leaf",
|
||||
"layoutRef": {
|
||||
"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"
|
||||
>
|
||||
<div
|
||||
|
@ -1059,6 +1207,16 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="child3ExpandingIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
|
@ -1087,7 +1245,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
tabindex="-1"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<div
|
||||
|
@ -1107,6 +1265,16 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="child3ExpandingIcon"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
|
@ -1125,9 +1293,9 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
>
|
||||
<TreeItemLayout
|
||||
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"
|
||||
expandIcon={
|
||||
iconBefore={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
|
@ -1136,12 +1304,12 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
|||
}
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<div
|
||||
aria-hidden={true}
|
||||
className="fui-TreeItemLayout__expandIcon rh4pu5o"
|
||||
className="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
|
@ -1184,9 +1352,9 @@ exports[`TreeNodeComponent renders a loading spinner if the node is loading: loa
|
|||
>
|
||||
<TreeItemLayout
|
||||
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"
|
||||
expandIcon={
|
||||
iconBefore={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
|
@ -1213,13 +1381,20 @@ exports[`TreeNodeComponent renders a loading spinner if the node is loading: loa
|
|||
>
|
||||
<TreeItemLayout
|
||||
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"
|
||||
expandIcon={
|
||||
<Spinner
|
||||
size="extra-tiny"
|
||||
/>
|
||||
}
|
||||
iconBefore={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="rootIcon"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
|
@ -1240,13 +1415,20 @@ exports[`TreeNodeComponent renders a node as expandable if it has empty, but def
|
|||
>
|
||||
<TreeItemLayout
|
||||
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"
|
||||
expandIcon={
|
||||
<ChevronRight20Regular
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
/>
|
||||
}
|
||||
iconBefore={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="rootIcon"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
|
@ -1313,9 +1495,9 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
|
|||
"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"
|
||||
expandIcon={
|
||||
iconBefore={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
|
@ -1363,9 +1545,9 @@ exports[`TreeNodeComponent renders a single node 1`] = `
|
|||
>
|
||||
<TreeItemLayout
|
||||
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"
|
||||
expandIcon={
|
||||
iconBefore={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
|
@ -1392,9 +1574,9 @@ exports[`TreeNodeComponent renders an icon if the node has one 1`] = `
|
|||
>
|
||||
<TreeItemLayout
|
||||
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"
|
||||
expandIcon={
|
||||
iconBefore={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
|
@ -1421,13 +1603,20 @@ exports[`TreeNodeComponent renders selected parent node as selected if no descen
|
|||
>
|
||||
<TreeItemLayout
|
||||
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"
|
||||
expandIcon={
|
||||
<ChevronRight20Regular
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
/>
|
||||
}
|
||||
iconBefore={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="rootIcon"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
|
@ -1436,7 +1625,7 @@ exports[`TreeNodeComponent renders selected parent node as selected if no descen
|
|||
</span>
|
||||
</TreeItemLayout>
|
||||
<Tree
|
||||
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
|
||||
className="___17a32do_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
|
||||
data-test="Tree:root"
|
||||
>
|
||||
<TreeNodeComponent
|
||||
|
@ -1497,13 +1686,20 @@ exports[`TreeNodeComponent renders selected parent node as unselected if any des
|
|||
>
|
||||
<TreeItemLayout
|
||||
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"
|
||||
expandIcon={
|
||||
<ChevronRight20Regular
|
||||
data-text="TreeNode/ExpandIcon"
|
||||
/>
|
||||
}
|
||||
iconBefore={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
src="rootIcon"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
|
@ -1512,7 +1708,7 @@ exports[`TreeNodeComponent renders selected parent node as unselected if any des
|
|||
</span>
|
||||
</TreeItemLayout>
|
||||
<Tree
|
||||
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
|
||||
className="___17a32do_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
|
||||
data-test="Tree:root"
|
||||
>
|
||||
<TreeNodeComponent
|
||||
|
@ -1574,9 +1770,9 @@ exports[`TreeNodeComponent renders single selected leaf node as selected 1`] = `
|
|||
>
|
||||
<TreeItemLayout
|
||||
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"
|
||||
expandIcon={
|
||||
iconBefore={
|
||||
<img
|
||||
alt=""
|
||||
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import * as msal from "@azure/msal-browser";
|
||||
import { Link } from "@fluentui/react/lib/Link";
|
||||
import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility";
|
||||
import { Environment, getEnvironment } from "Common/EnvironmentUtility";
|
||||
import { sendMessage } from "Common/MessageHandler";
|
||||
import { Platform, configContext } from "ConfigContext";
|
||||
import { MessageTypes } from "Contracts/ExplorerContracts";
|
||||
|
@ -1119,7 +1120,7 @@ export default class Explorer {
|
|||
}
|
||||
}
|
||||
|
||||
public openUploadItemsPanePane(): void {
|
||||
public openUploadItemsPane(): void {
|
||||
useSidePanel.getState().openSidePanel("Upload " + getUploadName(), <UploadItemsPane />);
|
||||
}
|
||||
public openExecuteSprocParamsPanel(storedProcedure: StoredProcedure): void {
|
||||
|
@ -1178,7 +1179,11 @@ export default class Explorer {
|
|||
}
|
||||
|
||||
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;
|
||||
}
|
||||
const copilotEnabledPromise = getCopilotEnabled();
|
||||
|
|
|
@ -167,22 +167,18 @@ export function createContextCommandBarButtons(
|
|||
}
|
||||
|
||||
export function createControlCommandBarButtons(container: Explorer): CommandButtonComponentProps[] {
|
||||
const buttons: CommandButtonComponentProps[] =
|
||||
configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly
|
||||
? []
|
||||
: [
|
||||
{
|
||||
iconSrc: SettingsIcon,
|
||||
iconAlt: "Settings",
|
||||
onCommandClick: () =>
|
||||
useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={container} />),
|
||||
commandButtonLabel: undefined,
|
||||
ariaLabel: "Settings",
|
||||
tooltipText: "Settings",
|
||||
hasPopup: true,
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
const buttons: CommandButtonComponentProps[] = [
|
||||
{
|
||||
iconSrc: SettingsIcon,
|
||||
iconAlt: "Settings",
|
||||
onCommandClick: () => useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={container} />),
|
||||
commandButtonLabel: undefined,
|
||||
ariaLabel: "Settings",
|
||||
tooltipText: "Settings",
|
||||
hasPopup: true,
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
const showOpenFullScreen =
|
||||
configContext.platform === Platform.Portal && !isRunningOnNationalCloud() && userContext.apiType !== "Gremlin";
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility";
|
||||
import { PhoenixClient } from "Phoenix/PhoenixClient";
|
||||
import { useNewPortalBackendEndpoint } from "Utils/EndpointUtils";
|
||||
import { cloneDeep } from "lodash";
|
||||
import create, { UseStore } from "zustand";
|
||||
import { AuthType } from "../../AuthType";
|
||||
|
@ -127,7 +128,9 @@ export const useNotebook: UseStore<NotebookState> = create((set, get) => ({
|
|||
userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo"
|
||||
? databaseAccount?.location
|
||||
: 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();
|
||||
try {
|
||||
const response = await fetch(disallowedLocationsUri, {
|
||||
|
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,156 @@
|
|||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
CheckboxOnChangeData,
|
||||
InputOnChangeData,
|
||||
makeStyles,
|
||||
SearchBox,
|
||||
SearchBoxChangeEvent,
|
||||
Text,
|
||||
} from "@fluentui/react-components";
|
||||
import { configContext } from "ConfigContext";
|
||||
import { ColumnDefinition } from "Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent";
|
||||
import { CosmosFluentProvider, getPlatformTheme } from "Explorer/Theme/ThemeUtil";
|
||||
import React from "react";
|
||||
import { useSidePanel } from "../../../hooks/useSidePanel";
|
||||
|
||||
const useColumnSelectionStyles = makeStyles({
|
||||
paneContainer: {
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
},
|
||||
searchBox: {
|
||||
width: "100%",
|
||||
},
|
||||
checkboxContainer: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flex: 1,
|
||||
},
|
||||
checkboxLabel: {
|
||||
padding: "4px 8px",
|
||||
marginBottom: "0px",
|
||||
},
|
||||
});
|
||||
export interface TableColumnSelectionPaneProps {
|
||||
columnDefinitions: ColumnDefinition[];
|
||||
selectedColumnIds: string[];
|
||||
onSelectionChange: (newSelectedColumnIds: string[]) => void;
|
||||
defaultSelection: string[];
|
||||
}
|
||||
|
||||
export const TableColumnSelectionPane: React.FC<TableColumnSelectionPaneProps> = ({
|
||||
columnDefinitions,
|
||||
selectedColumnIds,
|
||||
onSelectionChange,
|
||||
defaultSelection,
|
||||
}: TableColumnSelectionPaneProps): JSX.Element => {
|
||||
const closeSidePanel = useSidePanel((state) => state.closeSidePanel);
|
||||
const originalSelectedColumnIds = React.useMemo(() => selectedColumnIds, []);
|
||||
const [columnSearchText, setColumnSearchText] = React.useState<string>("");
|
||||
const [newSelectedColumnIds, setNewSelectedColumnIds] = React.useState<string[]>(originalSelectedColumnIds);
|
||||
const styles = useColumnSelectionStyles();
|
||||
|
||||
const selectedColumnIdsSet = new Set(newSelectedColumnIds);
|
||||
const onCheckedValueChange = (id: string, checkedData?: CheckboxOnChangeData): void => {
|
||||
const checked = checkedData?.checked;
|
||||
if (checked === "mixed" || checked === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (checked) {
|
||||
selectedColumnIdsSet.add(id);
|
||||
} else {
|
||||
/* selectedColumnIds may contain ids that are not in columnDefinitions, because the selected
|
||||
* ids may have been loaded from persistence, but don't exist in the current retrieved documents.
|
||||
*/
|
||||
|
||||
if (
|
||||
Array.from(selectedColumnIdsSet).filter((id) => columnDefinitions.find((def) => def.id === id) !== undefined)
|
||||
.length === 1 &&
|
||||
selectedColumnIdsSet.has(id)
|
||||
) {
|
||||
// Don't allow unchecking the last column
|
||||
return;
|
||||
}
|
||||
selectedColumnIdsSet.delete(id);
|
||||
}
|
||||
setNewSelectedColumnIds([...selectedColumnIdsSet]);
|
||||
};
|
||||
|
||||
const onSave = (): void => {
|
||||
onSelectionChange(newSelectedColumnIds);
|
||||
closeSidePanel();
|
||||
};
|
||||
|
||||
const onSearchChange: (event: SearchBoxChangeEvent, data: InputOnChangeData) => void = (_, data) =>
|
||||
// eslint-disable-next-line react/prop-types
|
||||
setColumnSearchText(data.value);
|
||||
|
||||
const theme = getPlatformTheme(configContext.platform);
|
||||
|
||||
// Filter and move partition keys to the top
|
||||
const columnDefinitionList = columnDefinitions
|
||||
.filter((def) => !columnSearchText || def.label.toLowerCase().includes(columnSearchText.toLowerCase()))
|
||||
.sort((a, b) => {
|
||||
const ID = "id";
|
||||
// "id" always at the top, then partition keys, then everything else sorted
|
||||
if (a.id === ID) {
|
||||
return b.id === ID ? 0 : -1;
|
||||
} else if (b.id === ID) {
|
||||
return a.id === ID ? 0 : 1;
|
||||
} else if (a.isPartitionKey && !b.isPartitionKey) {
|
||||
return -1;
|
||||
} else if (b.isPartitionKey && !a.isPartitionKey) {
|
||||
return 1;
|
||||
} else {
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.paneContainer}>
|
||||
<CosmosFluentProvider>
|
||||
<div className="panelFormWrapper">
|
||||
<div className="panelMainContent" style={{ display: "flex", flexDirection: "column" }}>
|
||||
<Text>Select which columns to display in your view of items in your container.</Text>
|
||||
<div /* Wrap <SearchBox> to avoid margin-bottom set by panelMainContent css */>
|
||||
<SearchBox
|
||||
className={styles.searchBox}
|
||||
value={columnSearchText}
|
||||
onChange={onSearchChange}
|
||||
placeholder="Search fields"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.checkboxContainer}>
|
||||
{columnDefinitionList.map((columnDefinition) => (
|
||||
<Checkbox
|
||||
style={{ marginBottom: 0 }}
|
||||
key={columnDefinition.id}
|
||||
label={{
|
||||
className: styles.checkboxLabel,
|
||||
children: `${columnDefinition.label}${columnDefinition.isPartitionKey ? " (partition key)" : ""}`,
|
||||
}}
|
||||
checked={selectedColumnIdsSet.has(columnDefinition.id)}
|
||||
onChange={(_, data) => onCheckedValueChange(columnDefinition.id, data)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button appearance="secondary" size="small" onClick={() => setNewSelectedColumnIds(defaultSelection)}>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
<div className="panelFooter" style={{ display: "flex", gap: theme.spacingHorizontalS }}>
|
||||
<Button appearance="primary" onClick={onSave}>
|
||||
Save
|
||||
</Button>
|
||||
<Button appearance="secondary" onClick={closeSidePanel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CosmosFluentProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -171,7 +171,7 @@ export const QueryCopilotCarousel: React.FC<QueryCopilotCarouselProps> = ({
|
|||
the query builder.
|
||||
</Text>
|
||||
<Text style={{ fontSize: 13, fontWeight: 600, marginTop: 24 }}>Database Id</Text>
|
||||
<Text style={{ fontSize: 13 }}>CopilotSampleDb</Text>
|
||||
<Text style={{ fontSize: 13 }}>CopilotSampleDB</Text>
|
||||
<Text style={{ fontSize: 13, fontWeight: 600, marginTop: 16 }}>Database throughput (autoscale)</Text>
|
||||
<Text style={{ fontSize: 13 }}>Autoscale</Text>
|
||||
<Text style={{ fontSize: 13, fontWeight: 600, marginTop: 16 }}>Database Max RU/s</Text>
|
||||
|
|
|
@ -28,6 +28,8 @@ import {
|
|||
SuggestedPrompt,
|
||||
getSampleDatabaseSuggestedPrompts,
|
||||
getSuggestedPrompts,
|
||||
readPromptHistory,
|
||||
savePromptHistory,
|
||||
} from "Explorer/QueryCopilot/QueryCopilotUtilities";
|
||||
import { SubmitFeedback, allocatePhoenixContainer } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
|
||||
import { GenerateSQLQueryResponse, QueryCopilotProps } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
|
||||
|
@ -136,9 +138,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
|||
};
|
||||
|
||||
const isSampleCopilotActive = useSelectedNode.getState().isQueryCopilotCollectionSelected();
|
||||
const cachedHistoriesString = localStorage.getItem(`${userContext.databaseAccount?.id}-queryCopilotHistories`);
|
||||
const cachedHistories = cachedHistoriesString?.split("|");
|
||||
const [histories, setHistories] = useState<string[]>(cachedHistories || []);
|
||||
const [histories, setHistories] = useState<string[]>(() => readPromptHistory(userContext.databaseAccount));
|
||||
const suggestedPrompts: SuggestedPrompt[] = isSampleCopilotActive
|
||||
? getSampleDatabaseSuggestedPrompts()
|
||||
: getSuggestedPrompts();
|
||||
|
@ -172,7 +172,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
|||
const newHistories = [formattedUserPrompt, ...updatedHistories.slice(0, 2)];
|
||||
|
||||
setHistories(newHistories);
|
||||
localStorage.setItem(`${userContext.databaseAccount.id}-queryCopilotHistories`, newHistories.join("|"));
|
||||
savePromptHistory(userContext.databaseAccount, newHistories);
|
||||
};
|
||||
|
||||
const resetMessageStates = (): void => {
|
||||
|
|
|
@ -1,10 +1,39 @@
|
|||
import { shallow } from "enzyme";
|
||||
import { CopilotSubComponentNames } from "Explorer/QueryCopilot/QueryCopilotUtilities";
|
||||
import React from "react";
|
||||
import { AppStateComponentNames, StorePath } from "Shared/AppStatePersistenceUtility";
|
||||
import { updateUserContext } from "UserContext";
|
||||
import Explorer from "../Explorer";
|
||||
import { QueryCopilotTab } from "./QueryCopilotTab";
|
||||
|
||||
describe("Query copilot tab snapshot test", () => {
|
||||
it("should render with initial input", () => {
|
||||
updateUserContext({
|
||||
databaseAccount: {
|
||||
name: "name",
|
||||
properties: undefined,
|
||||
id: "",
|
||||
location: "",
|
||||
type: "",
|
||||
kind: "",
|
||||
},
|
||||
});
|
||||
|
||||
const loadState = (path: StorePath) => {
|
||||
if (
|
||||
path.componentName === AppStateComponentNames.QueryCopilot &&
|
||||
path.subComponentName === CopilotSubComponentNames.toggleStatus
|
||||
) {
|
||||
return { enabled: true };
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
jest.mock("Shared/AppStatePersistenceUtility", () => ({
|
||||
loadState,
|
||||
}));
|
||||
|
||||
const wrapper = shallow(<QueryCopilotTab explorer={new Explorer()} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
|
|
@ -6,6 +6,7 @@ import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
|
|||
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
|
||||
import { SaveQueryPane } from "Explorer/Panes/SaveQueryPane/SaveQueryPane";
|
||||
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
|
||||
import { readCopilotToggleStatus, saveCopilotToggleStatus } from "Explorer/QueryCopilot/QueryCopilotUtilities";
|
||||
import { OnExecuteQueryClick } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
|
||||
import { QueryCopilotProps } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
|
||||
import { QueryCopilotResults } from "Explorer/QueryCopilot/Shared/QueryCopilotResults";
|
||||
|
@ -18,18 +19,13 @@ import SplitterLayout from "react-splitter-layout";
|
|||
import QueryCommandIcon from "../../../images/CopilotCommand.svg";
|
||||
import ExecuteQueryIcon from "../../../images/ExecuteQuery.svg";
|
||||
import SaveQueryIcon from "../../../images/save-cosmos.svg";
|
||||
import * as StringUtility from "../../Shared/StringUtility";
|
||||
|
||||
export const QueryCopilotTab: React.FC<QueryCopilotProps> = ({ explorer }: QueryCopilotProps): JSX.Element => {
|
||||
const { query, setQuery, selectedQuery, setSelectedQuery, isGeneratingQuery } = useQueryCopilot();
|
||||
|
||||
const cachedCopilotToggleStatus: string = localStorage.getItem(
|
||||
`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`,
|
||||
const [copilotActive, setCopilotActive] = useState<boolean>(() =>
|
||||
readCopilotToggleStatus(userContext.databaseAccount),
|
||||
);
|
||||
const copilotInitialActive: boolean = cachedCopilotToggleStatus
|
||||
? StringUtility.toBoolean(cachedCopilotToggleStatus)
|
||||
: true;
|
||||
const [copilotActive, setCopilotActive] = useState<boolean>(copilotInitialActive);
|
||||
const [tabActive, setTabActive] = useState<boolean>(true);
|
||||
|
||||
const getCommandbarButtons = (): CommandButtonComponentProps[] => {
|
||||
|
@ -88,7 +84,7 @@ export const QueryCopilotTab: React.FC<QueryCopilotProps> = ({ explorer }: Query
|
|||
|
||||
const toggleCopilot = (toggle: boolean) => {
|
||||
setCopilotActive(toggle);
|
||||
localStorage.setItem(`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`, toggle.toString());
|
||||
saveCopilotToggleStatus(userContext.databaseAccount, toggle);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
@ -90,7 +90,7 @@ describe("QueryCopilotUtilities", () => {
|
|||
|
||||
// Mock the items.query method to return the mockResult
|
||||
(
|
||||
sampleDataClient().database("CopilotSampleDb").container("SampleContainer").items.query as jest.Mock
|
||||
sampleDataClient().database("CopilotSampleDB").container("SampleContainer").items.query as jest.Mock
|
||||
).mockReturnValue(mockResult);
|
||||
|
||||
const result = querySampleDocuments(query, options);
|
||||
|
@ -119,10 +119,10 @@ describe("QueryCopilotUtilities", () => {
|
|||
const result = await readSampleDocument(documentId);
|
||||
|
||||
expect(sampleDataClient).toHaveBeenCalled();
|
||||
expect(sampleDataClient().database).toHaveBeenCalledWith("CopilotSampleDb");
|
||||
expect(sampleDataClient().database("CopilotSampleDb").container).toHaveBeenCalledWith("SampleContainer");
|
||||
expect(sampleDataClient().database).toHaveBeenCalledWith("CopilotSampleDB");
|
||||
expect(sampleDataClient().database("CopilotSampleDB").container).toHaveBeenCalledWith("SampleContainer");
|
||||
expect(
|
||||
sampleDataClient().database("CopilotSampleDb").container("SampleContainer").item("DocumentId", undefined).read,
|
||||
sampleDataClient().database("CopilotSampleDB").container("SampleContainer").item("DocumentId", undefined).read,
|
||||
).toHaveBeenCalled();
|
||||
expect(result).toEqual(expectedResponse);
|
||||
});
|
||||
|
@ -144,10 +144,10 @@ describe("QueryCopilotUtilities", () => {
|
|||
await expect(readSampleDocument(documentId)).rejects.toStrictEqual(errorMock);
|
||||
|
||||
expect(sampleDataClient).toHaveBeenCalled();
|
||||
expect(sampleDataClient().database).toHaveBeenCalledWith("CopilotSampleDb");
|
||||
expect(sampleDataClient().database("CopilotSampleDb").container).toHaveBeenCalledWith("SampleContainer");
|
||||
expect(sampleDataClient().database).toHaveBeenCalledWith("CopilotSampleDB");
|
||||
expect(sampleDataClient().database("CopilotSampleDB").container).toHaveBeenCalledWith("SampleContainer");
|
||||
expect(
|
||||
sampleDataClient().database("CopilotSampleDb").container("SampleContainer").item("DocumentId", undefined).read,
|
||||
sampleDataClient().database("CopilotSampleDB").container("SampleContainer").item("DocumentId", undefined).read,
|
||||
).toHaveBeenCalled();
|
||||
expect(handleError).toHaveBeenCalledWith(errorMock, "ReadDocument", expect.any(String));
|
||||
});
|
||||
|
|
|
@ -4,8 +4,11 @@ import { handleError } from "Common/ErrorHandlingUtils";
|
|||
import { sampleDataClient } from "Common/SampleDataClient";
|
||||
import { getPartitionKeyValue } from "Common/dataAccess/getPartitionKeyValue";
|
||||
import { getCommonQueryOptions } from "Common/dataAccess/queryDocuments";
|
||||
import { DatabaseAccount } from "Contracts/DataModels";
|
||||
import DocumentId from "Explorer/Tree/DocumentId";
|
||||
import { AppStateComponentNames, loadState, saveState } from "Shared/AppStatePersistenceUtility";
|
||||
import { logConsoleProgress } from "Utils/NotificationConsoleUtils";
|
||||
import * as StringUtility from "../../Shared/StringUtility";
|
||||
|
||||
export interface SuggestedPrompt {
|
||||
id: number;
|
||||
|
@ -54,3 +57,110 @@ export const getSuggestedPrompts = (): SuggestedPrompt[] => {
|
|||
{ id: 3, text: "Find the oldest item added to my collection" },
|
||||
];
|
||||
};
|
||||
|
||||
// Prompt history persistence
|
||||
export enum CopilotSubComponentNames {
|
||||
promptHistory = "PromptHistory",
|
||||
toggleStatus = "ToggleStatus",
|
||||
}
|
||||
|
||||
const getLegacyHistoryKey = (databaseAccount: DatabaseAccount): string =>
|
||||
`${databaseAccount?.id}-queryCopilotHistories`;
|
||||
const getLegacyToggleStatusKey = (databaseAccount: DatabaseAccount): string =>
|
||||
`${databaseAccount?.id}-queryCopilotToggleStatus`;
|
||||
|
||||
// Migration only needs to run once
|
||||
let hasMigrated = false;
|
||||
// Migrate old prompt history to new format
|
||||
export const migrateCopilotPersistence = (databaseAccount: DatabaseAccount): void => {
|
||||
if (hasMigrated) {
|
||||
return;
|
||||
}
|
||||
|
||||
let key = getLegacyHistoryKey(databaseAccount);
|
||||
let item = localStorage.getItem(key);
|
||||
if (item !== undefined && item !== null) {
|
||||
const historyItems = item.split("|");
|
||||
saveState(
|
||||
{
|
||||
componentName: AppStateComponentNames.QueryCopilot,
|
||||
subComponentName: CopilotSubComponentNames.promptHistory,
|
||||
globalAccountName: databaseAccount.name,
|
||||
databaseName: undefined,
|
||||
containerName: undefined,
|
||||
},
|
||||
historyItems,
|
||||
);
|
||||
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
key = getLegacyToggleStatusKey(databaseAccount);
|
||||
item = localStorage.getItem(key);
|
||||
if (item !== undefined && item !== null) {
|
||||
saveState(
|
||||
{
|
||||
componentName: AppStateComponentNames.QueryCopilot,
|
||||
subComponentName: CopilotSubComponentNames.toggleStatus,
|
||||
globalAccountName: databaseAccount.name,
|
||||
databaseName: undefined,
|
||||
containerName: undefined,
|
||||
},
|
||||
StringUtility.toBoolean(item),
|
||||
);
|
||||
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
hasMigrated = true;
|
||||
};
|
||||
|
||||
export const readPromptHistory = (databaseAccount: DatabaseAccount): string[] => {
|
||||
migrateCopilotPersistence(databaseAccount);
|
||||
return (
|
||||
(loadState({
|
||||
componentName: AppStateComponentNames.QueryCopilot,
|
||||
subComponentName: CopilotSubComponentNames.promptHistory,
|
||||
globalAccountName: databaseAccount.name,
|
||||
databaseName: undefined,
|
||||
containerName: undefined,
|
||||
}) as string[]) || []
|
||||
);
|
||||
};
|
||||
|
||||
export const savePromptHistory = (databaseAccount: DatabaseAccount, historyItems: string[]): void => {
|
||||
saveState(
|
||||
{
|
||||
componentName: AppStateComponentNames.QueryCopilot,
|
||||
subComponentName: CopilotSubComponentNames.promptHistory,
|
||||
globalAccountName: databaseAccount.name,
|
||||
databaseName: undefined,
|
||||
containerName: undefined,
|
||||
},
|
||||
historyItems,
|
||||
);
|
||||
};
|
||||
|
||||
export const readCopilotToggleStatus = (databaseAccount: DatabaseAccount): boolean => {
|
||||
migrateCopilotPersistence(databaseAccount);
|
||||
return !!loadState({
|
||||
componentName: AppStateComponentNames.QueryCopilot,
|
||||
subComponentName: CopilotSubComponentNames.toggleStatus,
|
||||
globalAccountName: databaseAccount.name,
|
||||
databaseName: undefined,
|
||||
containerName: undefined,
|
||||
}) as boolean;
|
||||
};
|
||||
|
||||
export const saveCopilotToggleStatus = (databaseAccount: DatabaseAccount, status: boolean): void => {
|
||||
saveState(
|
||||
{
|
||||
componentName: AppStateComponentNames.QueryCopilot,
|
||||
subComponentName: CopilotSubComponentNames.toggleStatus,
|
||||
globalAccountName: databaseAccount.name,
|
||||
databaseName: undefined,
|
||||
containerName: undefined,
|
||||
},
|
||||
status,
|
||||
);
|
||||
};
|
||||
|
|
|
@ -26,7 +26,7 @@ import {
|
|||
import { AuthorizationTokenHeaderMetadata, QueryResults } from "Contracts/ViewModels";
|
||||
import { useDialog } from "Explorer/Controls/Dialog";
|
||||
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 { Action } from "Shared/Telemetry/TelemetryConstants";
|
||||
import { traceFailure, traceStart, traceSuccess } from "Shared/Telemetry/TelemetryProcessor";
|
||||
|
@ -36,7 +36,6 @@ import { useNewPortalBackendEndpoint } from "Utils/EndpointUtils";
|
|||
import { queryPagesUntilContentPresent } from "Utils/QueryUtils";
|
||||
import { QueryCopilotState, useQueryCopilot } from "hooks/useQueryCopilot";
|
||||
import { useTabs } from "hooks/useTabs";
|
||||
import * as StringUtility from "../../../Shared/StringUtility";
|
||||
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
|
@ -361,9 +360,7 @@ export const QueryDocumentsPerPage = async (
|
|||
correlationId: useQueryCopilot.getState().correlationId,
|
||||
});
|
||||
} catch (error) {
|
||||
const isCopilotActive = StringUtility.toBoolean(
|
||||
localStorage.getItem(`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`),
|
||||
);
|
||||
const isCopilotActive = readCopilotToggleStatus(userContext.databaseAccount);
|
||||
const errorMessage = getErrorMessage(error);
|
||||
traceFailure(Action.ExecuteQueryGeneratedFromQueryCopilot, {
|
||||
correlationId: useQueryCopilot.getState().correlationId,
|
||||
|
|
|
@ -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
|
||||
className="tabPaneContentContainer"
|
||||
>
|
||||
|
|
|
@ -26,7 +26,7 @@ import { getCollectionName, getDatabaseName } from "Utils/APITypeUtils";
|
|||
import { Allotment, AllotmentHandle } from "allotment";
|
||||
import { useSidePanel } from "hooks/useSidePanel";
|
||||
import { debounce } from "lodash";
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
const useSidebarStyles = makeStyles({
|
||||
sidebar: {
|
||||
|
@ -86,7 +86,7 @@ const useSidebarStyles = makeStyles({
|
|||
},
|
||||
},
|
||||
globalCommandsMenuButton: {
|
||||
display: "initial",
|
||||
display: "inline-flex",
|
||||
"@container (min-width: 250px)": {
|
||||
display: "none",
|
||||
},
|
||||
|
@ -113,6 +113,12 @@ interface GlobalCommand {
|
|||
|
||||
const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
|
||||
const styles = useSidebarStyles();
|
||||
|
||||
// Since we have two buttons in the DOM (one for small screens and one for larger screens), we wrap the entire thing in a div.
|
||||
// However, that messes with the Menu positioning, so we need to get a reference to the 'div' to pass to the Menu.
|
||||
// We can't use a ref though, because it would be set after the Menu is rendered, so we use a state value to force a re-render.
|
||||
const [globalCommandButton, setGlobalCommandButton] = useState<HTMLElement | null>(null);
|
||||
|
||||
const actions = useMemo<GlobalCommand[]>(() => {
|
||||
if (
|
||||
configContext.platform === Platform.Fabric ||
|
||||
|
@ -182,10 +188,10 @@ const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
|
|||
{primaryAction.label}
|
||||
</Button>
|
||||
) : (
|
||||
<Menu positioning="below-end">
|
||||
<Menu positioning={{ target: globalCommandButton, position: "below", align: "end" }}>
|
||||
<MenuTrigger disableButtonEnhancement>
|
||||
{(triggerProps: MenuButtonProps) => (
|
||||
<>
|
||||
<div ref={setGlobalCommandButton}>
|
||||
<SplitButton
|
||||
menuButton={{ ...triggerProps, "aria-label": "More commands" }}
|
||||
primaryActionButton={{ onClick: onPrimaryActionClick }}
|
||||
|
@ -197,7 +203,7 @@ const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
|
|||
<MenuButton {...triggerProps} icon={primaryAction.icon} className={styles.globalCommandsMenuButton}>
|
||||
New...
|
||||
</MenuButton>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</MenuTrigger>
|
||||
<MenuPopover>
|
||||
|
@ -280,7 +286,7 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
|
|||
{/* Collections Tree - Start */}
|
||||
{hasSidebar && (
|
||||
// When collapsed, we force the pane to 24 pixels wide and make it non-resizable.
|
||||
<Allotment.Pane minSize={24} preferredSize={300}>
|
||||
<Allotment.Pane minSize={24} preferredSize={250}>
|
||||
<CosmosFluentProvider className={mergeClasses(styles.sidebar)}>
|
||||
<div className={styles.sidebarContainer}>
|
||||
{loading && (
|
||||
|
|
|
@ -155,6 +155,7 @@ export const htmlAttributeNames = {
|
|||
dataTableContentTypeAttr: "contentType_attr",
|
||||
dataTableSnapshotAttr: "snapshot_attr",
|
||||
dataTableRowKeyAttr: "rowKey_attr",
|
||||
dataTablePartitionKeyAttr: "partKey_attr",
|
||||
dataTableMessageIdAttr: "messageId_attr",
|
||||
dataTableHeaderIndex: "data-column-index",
|
||||
};
|
||||
|
|
|
@ -193,6 +193,9 @@ function getServerData(sSource: any, aoData: any, fnCallback: any, oSettings: an
|
|||
* from UI elements.
|
||||
*/
|
||||
function bindClientId(nRow: Node, aData: Entities.ITableEntity) {
|
||||
if (aData.PartitionKey && aData.PartitionKey._) {
|
||||
$(nRow).attr(Constants.htmlAttributeNames.dataTablePartitionKeyAttr, aData.PartitionKey._);
|
||||
}
|
||||
$(nRow).attr(Constants.htmlAttributeNames.dataTableRowKeyAttr, aData.RowKey._);
|
||||
return nRow;
|
||||
}
|
||||
|
@ -205,6 +208,10 @@ function selectionChanged(element: any, valueAccessor: any, allBindings: any, vi
|
|||
selected &&
|
||||
selected.forEach((b: Entities.ITableEntity) => {
|
||||
var sel = DataTableOperations.getRowSelector([
|
||||
{
|
||||
key: Constants.htmlAttributeNames.dataTablePartitionKeyAttr,
|
||||
value: b.PartitionKey && b.PartitionKey._ && b.PartitionKey._.toString(),
|
||||
},
|
||||
{
|
||||
key: Constants.htmlAttributeNames.dataTableRowKeyAttr,
|
||||
value: b.RowKey && b.RowKey._ && b.RowKey._.toString(),
|
||||
|
@ -370,8 +377,9 @@ function updateSelectionStatus(oSettings: any): void {
|
|||
for (var i = 0; i < $dataTableRows.length; i++) {
|
||||
var $row: JQuery = $dataTableRows.eq(i);
|
||||
var rowKey: string = $row.attr(Constants.htmlAttributeNames.dataTableRowKeyAttr);
|
||||
var partitionKey: string = $row.attr(Constants.htmlAttributeNames.dataTablePartitionKeyAttr);
|
||||
var table = tableEntityListViewModelMap[oSettings.ajax].tableViewModel;
|
||||
if (table.isItemSelected(table.getTableEntityKeys(rowKey))) {
|
||||
if (table.isItemSelected(table.getTableEntityKeys(rowKey, partitionKey))) {
|
||||
$row.attr("tabindex", "0");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -56,7 +56,10 @@ export default class DataTableOperationManager {
|
|||
// Simply select the first item in this case.
|
||||
var lastSelectedItemIndex = lastSelectedItem
|
||||
? this._tableEntityListViewModel.getItemIndexFromCurrentPage(
|
||||
this._tableEntityListViewModel.getTableEntityKeys(lastSelectedItem.RowKey._),
|
||||
this._tableEntityListViewModel.getTableEntityKeys(
|
||||
lastSelectedItem.RowKey._,
|
||||
lastSelectedItem.PartitionKey && lastSelectedItem.PartitionKey._,
|
||||
),
|
||||
)
|
||||
: -1;
|
||||
var nextIndex: number = isUpArrowKey ? lastSelectedItemIndex - 1 : lastSelectedItemIndex + 1;
|
||||
|
@ -147,13 +150,14 @@ export default class DataTableOperationManager {
|
|||
private getEntityIdentity($elem: JQuery<Element>): Entities.ITableEntityIdentity {
|
||||
return {
|
||||
RowKey: $elem.attr(Constants.htmlAttributeNames.dataTableRowKeyAttr),
|
||||
PartitionKey: $elem.attr(Constants.htmlAttributeNames.dataTablePartitionKeyAttr),
|
||||
};
|
||||
}
|
||||
|
||||
private updateLastSelectedItem($elem: JQuery<Element>, isShiftSelect: boolean) {
|
||||
var entityIdentity: Entities.ITableEntityIdentity = this.getEntityIdentity($elem);
|
||||
var entity = this._tableEntityListViewModel.getItemFromCurrentPage(
|
||||
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.RowKey),
|
||||
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.PartitionKey, entityIdentity.RowKey),
|
||||
);
|
||||
|
||||
this._tableEntityListViewModel.lastSelectedItem = entity;
|
||||
|
@ -168,7 +172,7 @@ export default class DataTableOperationManager {
|
|||
var entityIdentity: Entities.ITableEntityIdentity = this.getEntityIdentity($elem);
|
||||
|
||||
this._tableEntityListViewModel.clearSelection();
|
||||
this.addToSelection(entityIdentity.RowKey);
|
||||
this.addToSelection(entityIdentity.RowKey, entityIdentity.PartitionKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -190,11 +194,11 @@ export default class DataTableOperationManager {
|
|||
|
||||
if (
|
||||
!this._tableEntityListViewModel.isItemSelected(
|
||||
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.RowKey),
|
||||
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.PartitionKey, entityIdentity.RowKey),
|
||||
)
|
||||
) {
|
||||
// Adding item not previously in selection
|
||||
this.addToSelection(entityIdentity.RowKey);
|
||||
this.addToSelection(entityIdentity.RowKey, entityIdentity.PartitionKey);
|
||||
} else {
|
||||
koSelected.remove((item: Entities.ITableEntity) => item.RowKey._ === entityIdentity.RowKey);
|
||||
}
|
||||
|
@ -212,10 +216,10 @@ export default class DataTableOperationManager {
|
|||
if (anchorItem) {
|
||||
var entityIdentity: Entities.ITableEntityIdentity = this.getEntityIdentity($elem);
|
||||
var elementIndex = this._tableEntityListViewModel.getItemIndexFromAllPages(
|
||||
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.RowKey),
|
||||
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.PartitionKey, entityIdentity.RowKey),
|
||||
);
|
||||
var anchorIndex = this._tableEntityListViewModel.getItemIndexFromAllPages(
|
||||
this._tableEntityListViewModel.getTableEntityKeys(anchorItem.RowKey._),
|
||||
this._tableEntityListViewModel.getTableEntityKeys(anchorItem.PartitionKey._, anchorItem.RowKey._),
|
||||
);
|
||||
|
||||
var startIndex = Math.min(elementIndex, anchorIndex);
|
||||
|
@ -234,24 +238,25 @@ export default class DataTableOperationManager {
|
|||
|
||||
if (
|
||||
!this._tableEntityListViewModel.isItemSelected(
|
||||
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.RowKey),
|
||||
this._tableEntityListViewModel.getTableEntityKeys(entityIdentity.PartitionKey, entityIdentity.RowKey),
|
||||
)
|
||||
) {
|
||||
if (this._tableEntityListViewModel.selected().length) {
|
||||
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(
|
||||
this._tableEntityListViewModel.getTableEntityKeys(rowKey),
|
||||
this._tableEntityListViewModel.getTableEntityKeys(rowKey, partitionKey),
|
||||
);
|
||||
|
||||
if (selectedEntity != null) {
|
||||
this._tableEntityListViewModel.selected.push(selectedEntity);
|
||||
}
|
||||
console.log(this._tableEntityListViewModel.selected().length);
|
||||
}
|
||||
|
||||
// Selecting first row if the selection is empty.
|
||||
|
@ -269,7 +274,7 @@ export default class DataTableOperationManager {
|
|||
// Clear last selection: lastSelectedItem and lastSelectedAnchorItem
|
||||
this._tableEntityListViewModel.clearLastSelected();
|
||||
|
||||
this.addToSelection(firstEntity.RowKey._);
|
||||
this.addToSelection(firstEntity.RowKey._, firstEntity.PartitionKey && firstEntity.PartitionKey._);
|
||||
|
||||
// Update last selection
|
||||
this._tableEntityListViewModel.lastSelectedItem = firstEntity;
|
||||
|
|
|
@ -128,8 +128,14 @@ export default class TableEntityListViewModel extends DataTableViewModel {
|
|||
this.sqlQuery = ko.observable<string>("SELECT * FROM c");
|
||||
}
|
||||
|
||||
public getTableEntityKeys(rowKey: string): Entities.IProperty[] {
|
||||
return [{ key: Constants.EntityKeyNames.RowKey, value: rowKey }];
|
||||
public getTableEntityKeys(rowKey: string, partitionKey: string): Entities.IProperty[] {
|
||||
const properties: Entities.IProperty[] = [{ key: Constants.EntityKeyNames.RowKey, value: rowKey }];
|
||||
|
||||
if (partitionKey) {
|
||||
properties.push({ key: Constants.EntityKeyNames.PartitionKey, value: partitionKey });
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
|
||||
public reloadTable(useSetting: boolean = true, resetHeaders: boolean = true): DataTables.Api<Element> {
|
||||
|
@ -261,7 +267,8 @@ export default class TableEntityListViewModel extends DataTableViewModel {
|
|||
}
|
||||
var oldEntityIndex: number = _.findIndex(
|
||||
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);
|
||||
|
@ -285,7 +292,7 @@ export default class TableEntityListViewModel extends DataTableViewModel {
|
|||
entities.forEach((entity: Entities.ITableEntity) => {
|
||||
var cachedIndex: number = _.findIndex(
|
||||
this.cache.data,
|
||||
(e: Entities.ITableEntity) => e.RowKey._ === entity.RowKey._,
|
||||
(e: Entities.ITableEntity) => e.RowKey._ === entity.RowKey._ && e.PartitionKey._ === entity.PartitionKey._,
|
||||
);
|
||||
if (cachedIndex >= 0) {
|
||||
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(
|
||||
tableQuery: Entities.ITableQuery,
|
||||
tablePageStartIndex: number,
|
||||
|
|
|
@ -36,4 +36,5 @@ export interface ITableQuery {
|
|||
|
||||
export interface ITableEntityIdentity {
|
||||
RowKey: string;
|
||||
PartitionKey?: string;
|
||||
}
|
||||
|
|
|
@ -753,7 +753,10 @@ export class CassandraAPIDataClient extends TableDataClient {
|
|||
CassandraProxyEndpoints.Development,
|
||||
CassandraProxyEndpoints.Mpac,
|
||||
CassandraProxyEndpoints.Prod,
|
||||
CassandraProxyEndpoints.Fairfax,
|
||||
CassandraProxyEndpoints.Mooncake,
|
||||
];
|
||||
|
||||
let canAccessCassandraProxy: boolean = userContext.databaseAccount.properties.publicNetworkAccess === "Enabled";
|
||||
if (
|
||||
configContext.CASSANDRA_PROXY_ENDPOINT !== CassandraProxyEndpoints.Development &&
|
||||
|
@ -761,7 +764,6 @@ export class CassandraAPIDataClient extends TableDataClient {
|
|||
) {
|
||||
canAccessCassandraProxy = canAccessCassandraProxy && configContext.CASSANDRA_PROXY_OUTBOUND_IPS_ALLOWLISTED;
|
||||
}
|
||||
|
||||
return (
|
||||
canAccessCassandraProxy &&
|
||||
configContext.NEW_CASSANDRA_APIS?.includes(api) &&
|
||||
|
|
|
@ -0,0 +1,113 @@
|
|||
// Definitions of State data
|
||||
|
||||
import { ColumnDefinition } from "Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent";
|
||||
import {
|
||||
AppStateComponentNames,
|
||||
deleteState,
|
||||
loadState,
|
||||
saveState,
|
||||
saveStateDebounced,
|
||||
} from "Shared/AppStatePersistenceUtility";
|
||||
import { userContext } from "UserContext";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
|
||||
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
|
||||
|
||||
const componentName = AppStateComponentNames.DocumentsTab;
|
||||
|
||||
export enum SubComponentName {
|
||||
ColumnSizes = "ColumnSizes",
|
||||
FilterHistory = "FilterHistory",
|
||||
MainTabDivider = "MainTabDivider",
|
||||
ColumnsSelection = "ColumnsSelection",
|
||||
ColumnSort = "ColumnSort",
|
||||
}
|
||||
|
||||
export type ColumnSizesMap = { [columnId: string]: WidthDefinition };
|
||||
export type FilterHistory = string[];
|
||||
export type WidthDefinition = { widthPx: number };
|
||||
export type TabDivider = { leftPaneWidthPercent: number };
|
||||
export type ColumnsSelection = { selectedColumnIds: string[]; columnDefinitions: ColumnDefinition[] };
|
||||
export type ColumnSort = { columnId: string; direction: "ascending" | "descending" };
|
||||
|
||||
/**
|
||||
*
|
||||
* @param subComponentName
|
||||
* @param collection
|
||||
* @param defaultValue Will be returned if persisted state is not found
|
||||
* @returns
|
||||
*/
|
||||
export const readSubComponentState = <T>(
|
||||
subComponentName: SubComponentName,
|
||||
collection: ViewModels.CollectionBase,
|
||||
defaultValue: T,
|
||||
): T => {
|
||||
const globalAccountName = userContext.databaseAccount?.name;
|
||||
if (!globalAccountName) {
|
||||
const message = "Database account name not found in userContext";
|
||||
console.error(message);
|
||||
TelemetryProcessor.traceFailure(Action.ReadPersistedTabState, { message, componentName });
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const state = loadState({
|
||||
componentName: componentName,
|
||||
subComponentName,
|
||||
globalAccountName,
|
||||
databaseName: collection.databaseId,
|
||||
containerName: collection.id(),
|
||||
}) as T;
|
||||
|
||||
return state || defaultValue;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param subComponentName
|
||||
* @param collection
|
||||
* @param state State to save
|
||||
* @param debounce true for high-frequency calls (e.g mouse drag events)
|
||||
*/
|
||||
export const saveSubComponentState = <T>(
|
||||
subComponentName: SubComponentName,
|
||||
collection: ViewModels.CollectionBase,
|
||||
state: T,
|
||||
debounce?: boolean,
|
||||
): void => {
|
||||
const globalAccountName = userContext.databaseAccount?.name;
|
||||
if (!globalAccountName) {
|
||||
const message = "Database account name not found in userContext";
|
||||
console.error(message);
|
||||
TelemetryProcessor.traceFailure(Action.SavePersistedTabState, { message, componentName });
|
||||
return;
|
||||
}
|
||||
|
||||
(debounce ? saveStateDebounced : saveState)(
|
||||
{
|
||||
componentName: componentName,
|
||||
subComponentName,
|
||||
globalAccountName,
|
||||
databaseName: collection.databaseId,
|
||||
containerName: collection.id(),
|
||||
},
|
||||
state,
|
||||
);
|
||||
};
|
||||
|
||||
export const deleteSubComponentState = (subComponentName: SubComponentName, collection: ViewModels.CollectionBase) => {
|
||||
const globalAccountName = userContext.databaseAccount?.name;
|
||||
if (!globalAccountName) {
|
||||
const message = "Database account name not found in userContext";
|
||||
console.error(message);
|
||||
TelemetryProcessor.traceFailure(Action.DeletePersistedTabState, { message, componentName });
|
||||
return;
|
||||
}
|
||||
|
||||
deleteState({
|
||||
componentName: componentName,
|
||||
subComponentName,
|
||||
globalAccountName,
|
||||
databaseName: collection.databaseId,
|
||||
containerName: collection.id(),
|
||||
});
|
||||
};
|
|
@ -1,7 +1,10 @@
|
|||
import { FeedResponse, ItemDefinition, Resource } from "@azure/cosmos";
|
||||
import { waitFor } from "@testing-library/react";
|
||||
import { deleteDocuments } from "Common/dataAccess/deleteDocument";
|
||||
import { Platform, updateConfigContext } from "ConfigContext";
|
||||
import { useDialog } from "Explorer/Controls/Dialog";
|
||||
import { EditorReactProps } from "Explorer/Controls/Editor/EditorReact";
|
||||
import { ProgressModalDialog } from "Explorer/Controls/ProgressModalDialog";
|
||||
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
|
||||
import {
|
||||
ButtonsDependencies,
|
||||
|
@ -13,6 +16,7 @@ import {
|
|||
SAVE_BUTTON_ID,
|
||||
UPDATE_BUTTON_ID,
|
||||
UPLOAD_BUTTON_ID,
|
||||
addStringsNoDuplicate,
|
||||
buildQuery,
|
||||
getDiscardExistingDocumentChangesButtonState,
|
||||
getDiscardNewDocumentChangesButtonState,
|
||||
|
@ -64,12 +68,14 @@ jest.mock("Explorer/Controls/Editor/EditorReact", () => ({
|
|||
EditorReact: (props: EditorReactProps) => <>{props.content}</>,
|
||||
}));
|
||||
|
||||
const mockDialogState = {
|
||||
showOkCancelModalDialog: jest.fn((title: string, subText: string, okLabel: string, onOk: () => void) => onOk()),
|
||||
showOkModalDialog: () => {},
|
||||
};
|
||||
|
||||
jest.mock("Explorer/Controls/Dialog", () => ({
|
||||
useDialog: {
|
||||
getState: jest.fn(() => ({
|
||||
showOkCancelModalDialog: (title: string, subText: string, okLabel: string, onOk: () => void) => onOk(),
|
||||
showOkModalDialog: () => {},
|
||||
})),
|
||||
getState: jest.fn(() => mockDialogState),
|
||||
},
|
||||
}));
|
||||
|
||||
|
@ -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) {
|
||||
let newWrapper;
|
||||
await act(async () => {
|
||||
|
@ -91,7 +101,13 @@ async function waitForComponentToPaint<P = unknown>(wrapper: ReactWrapper<P> | S
|
|||
describe("Documents tab (noSql API)", () => {
|
||||
describe("buildQuery", () => {
|
||||
it("should generate the right select query for SQL API", () => {
|
||||
expect(buildQuery(false, "")).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 => ({
|
||||
isPreferredApiMongoDB: false,
|
||||
documentIds: [],
|
||||
collection: undefined,
|
||||
collection: {
|
||||
id: ko.observable<string>("collectionId"),
|
||||
databaseId: "databaseId",
|
||||
} as ViewModels.CollectionBase,
|
||||
partitionKey: { kind: "Hash", paths: ["/foo"], version: 2 },
|
||||
onLoadStartKey: 0,
|
||||
tabTitle: "",
|
||||
|
@ -380,7 +399,7 @@ describe("Documents tab (noSql API)", () => {
|
|||
.findWhere((node) => node.text() === "Edit Filter")
|
||||
.at(0)
|
||||
.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();
|
||||
});
|
||||
|
||||
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;
|
||||
mockDeleteDocuments.mockClear();
|
||||
|
||||
|
@ -470,7 +511,18 @@ describe("Documents tab (noSql API)", () => {
|
|||
.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"]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,10 +1,19 @@
|
|||
import { Item, ItemDefinition, PartitionKey, PartitionKeyDefinition, QueryIterator, Resource } from "@azure/cosmos";
|
||||
import { Button, Input, TableRowId, makeStyles, shorthands } from "@fluentui/react-components";
|
||||
import { ArrowClockwise16Filled, Dismiss16Filled } from "@fluentui/react-icons";
|
||||
import {
|
||||
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 { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
|
||||
import MongoUtility from "Common/MongoUtility";
|
||||
import { StyleConstants } from "Common/StyleConstants";
|
||||
import { createDocument } from "Common/dataAccess/createDocument";
|
||||
import {
|
||||
deleteDocument as deleteNoSqlDocument,
|
||||
|
@ -17,9 +26,19 @@ import { Platform, configContext } from "ConfigContext";
|
|||
import { CommandButtonComponentProps } from "Explorer/Controls/CommandButton/CommandButtonComponent";
|
||||
import { useDialog } from "Explorer/Controls/Dialog";
|
||||
import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
|
||||
import { ProgressModalDialog } from "Explorer/Controls/ProgressModalDialog";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
|
||||
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 { useSelectedNode } from "Explorer/useSelectedNode";
|
||||
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
|
||||
|
@ -27,7 +46,7 @@ import { QueryConstants } from "Shared/Constants";
|
|||
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
|
||||
import { Action } from "Shared/Telemetry/TelemetryConstants";
|
||||
import { userContext } from "UserContext";
|
||||
import { logConsoleError } from "Utils/NotificationConsoleUtils";
|
||||
import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils";
|
||||
import { Allotment } from "allotment";
|
||||
import React, { KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { format } from "react-string-format";
|
||||
|
@ -45,11 +64,16 @@ import * as ViewModels from "../../../Contracts/ViewModels";
|
|||
import { CollectionBase } from "../../../Contracts/ViewModels";
|
||||
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
|
||||
import * as QueryUtils from "../../../Utils/QueryUtils";
|
||||
import { extractPartitionKeyValues } from "../../../Utils/QueryUtils";
|
||||
import { defaultQueryFields, extractPartitionKeyValues } from "../../../Utils/QueryUtils";
|
||||
import DocumentId from "../../Tree/DocumentId";
|
||||
import ObjectId from "../../Tree/ObjectId";
|
||||
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;
|
||||
export const useDocumentsTabStyles = makeStyles({
|
||||
|
@ -81,6 +105,13 @@ export const useDocumentsTabStyles = makeStyles({
|
|||
tableCell: {
|
||||
...cosmosShorthands.borderLeft(),
|
||||
},
|
||||
tableHeader: {
|
||||
display: "flex",
|
||||
},
|
||||
tableHeaderFiller: {
|
||||
width: "20px",
|
||||
boxShadow: `0px -1px ${tokens.colorNeutralStroke2} inset`,
|
||||
},
|
||||
loadMore: {
|
||||
...cosmosShorthands.borderTop(),
|
||||
display: "grid",
|
||||
|
@ -104,6 +135,9 @@ export const useDocumentsTabStyles = makeStyles({
|
|||
backgroundColor: "white",
|
||||
zIndex: 1,
|
||||
},
|
||||
deleteProgressContent: {
|
||||
paddingTop: tokens.spacingVerticalL,
|
||||
},
|
||||
});
|
||||
|
||||
export class DocumentsTabV2 extends TabsBase {
|
||||
|
@ -273,7 +307,7 @@ const createUploadButton = (container: Explorer): CommandButtonComponentProps =>
|
|||
iconAlt: label,
|
||||
onCommandClick: () => {
|
||||
const selectedCollection: ViewModels.Collection = useSelectedNode.getState().findSelectedCollection();
|
||||
selectedCollection && container.openUploadItemsPanePane();
|
||||
selectedCollection && container.openUploadItemsPane();
|
||||
},
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
|
@ -461,17 +495,51 @@ export const showPartitionKey = (collection: ViewModels.CollectionBase, isPrefer
|
|||
};
|
||||
|
||||
// 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 = (
|
||||
isMongo: boolean,
|
||||
filter: string,
|
||||
partitionKeyProperties?: string[],
|
||||
partitionKey?: DataModels.PartitionKey,
|
||||
additionalField?: string[],
|
||||
): string => {
|
||||
if (isMongo) {
|
||||
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
|
||||
|
@ -488,6 +556,20 @@ export interface IDocumentsTabComponentProps {
|
|||
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 const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabComponentProps> = ({
|
||||
isPreferredApiMongoDB,
|
||||
|
@ -506,7 +588,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
const [isFilterFocused, setIsFilterFocused] = useState<boolean>(false);
|
||||
const [appliedFilter, setAppliedFilter] = useState<string>("");
|
||||
const [filterContent, setFilterContent] = useState<string>("");
|
||||
const [documentIds, setDocumentIds] = useState<DocumentId[]>([]);
|
||||
const [documentIds, setDocumentIds] = useState<ExtendedDocumentId[]>([]);
|
||||
const [isExecuting, setIsExecuting] = useState<boolean>(false);
|
||||
const filterInput = useRef<HTMLInputElement>(null);
|
||||
const styles = useDocumentsTabStyles();
|
||||
|
@ -535,6 +617,13 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
ViewModels.DocumentExplorerState.noDocumentSelected,
|
||||
);
|
||||
|
||||
// State
|
||||
const [tabStateData, setTabStateData] = useState<TabDivider>(() =>
|
||||
readSubComponentState<TabDivider>(SubComponentName.MainTabDivider, _collection, {
|
||||
leftPaneWidthPercent: 35,
|
||||
}),
|
||||
);
|
||||
|
||||
const isQueryCopilotSampleContainer =
|
||||
_collection?.isSampleCollection &&
|
||||
_collection?.databaseId === QueryCopilotSampleDatabaseId &&
|
||||
|
@ -543,6 +632,28 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
// For Mongo only
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -568,7 +679,96 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
}
|
||||
}, [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 = {
|
||||
enabled: true,
|
||||
|
@ -591,10 +791,37 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
[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
|
||||
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,
|
||||
partitionKeyProperties,
|
||||
|
@ -604,7 +831,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
},
|
||||
rawDocument,
|
||||
partitionKeyValue,
|
||||
),
|
||||
) as ExtendedDocumentId;
|
||||
extendedDocumentId.tableFields = { ...rawDocument };
|
||||
return extendedDocumentId;
|
||||
},
|
||||
[partitionKey],
|
||||
);
|
||||
|
||||
|
@ -766,6 +996,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
|
||||
setDocumentIds(ids);
|
||||
setEditorState(ViewModels.DocumentExplorerState.existingDocumentNoEdits);
|
||||
|
||||
// Update column choices
|
||||
setColumnDefinitionsFromDocument(savedDocument);
|
||||
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.CreateDocument,
|
||||
{
|
||||
|
@ -848,6 +1082,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
},
|
||||
startKey,
|
||||
);
|
||||
|
||||
// Update column choices
|
||||
selectedDocumentId.tableFields = { ...documentContent };
|
||||
setColumnDefinitionsFromDocument(documentContent);
|
||||
},
|
||||
(error) => {
|
||||
onExecutionErrorChange(true);
|
||||
|
@ -881,8 +1119,35 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
setSelectedDocumentContent(selectedDocumentContentBaseline);
|
||||
}, [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
|
||||
* @param list of document ids to delete
|
||||
* @returns Promise of list of deleted document ids
|
||||
*/
|
||||
const _deleteDocuments = useCallback(
|
||||
async (toDeleteDocumentIds: DocumentId[]): Promise<DocumentId[]> => {
|
||||
|
@ -893,20 +1158,33 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
});
|
||||
setIsExecuting(true);
|
||||
|
||||
// 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.
|
||||
const _deleteNoSqlDocuments = async (
|
||||
collection: CollectionBase,
|
||||
toDeleteDocumentIds: DocumentId[],
|
||||
): Promise<DocumentId[]> => {
|
||||
return partitionKey.systemKey
|
||||
? deleteNoSqlDocument(collection, toDeleteDocumentIds[0]).then(() => [toDeleteDocumentIds[0]])
|
||||
: deleteNoSqlDocuments(collection, toDeleteDocumentIds);
|
||||
};
|
||||
|
||||
const deletePromise = !isPreferredApiMongoDB
|
||||
? _deleteNoSqlDocuments(_collection, toDeleteDocumentIds)
|
||||
: MongoProxyClient.deleteDocuments(
|
||||
let deletePromise;
|
||||
if (!isPreferredApiMongoDB) {
|
||||
if (partitionKey.systemKey) {
|
||||
// ----------------------------------------------------------------------------------------------------
|
||||
// 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 for NoSQL.
|
||||
deletePromise = deleteNoSqlDocument(_collection, toDeleteDocumentIds[0]).then(() => {
|
||||
useDialog.getState().showOkModalDialog("Delete document", "Document successfully deleted.");
|
||||
return [toDeleteDocumentIds[0]];
|
||||
});
|
||||
// ----------------------------------------------------------------------------------------------------
|
||||
} else {
|
||||
deletePromise = _bulkDeleteNoSqlDocuments(_collection, toDeleteDocumentIds);
|
||||
}
|
||||
} else {
|
||||
if (isMongoBulkDeleteDisabled) {
|
||||
// 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 as ViewModels.Collection,
|
||||
toDeleteDocumentIds,
|
||||
|
@ -916,6 +1194,8 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
}
|
||||
throw new Error(`Delete failed with deletedCount: ${deletedCount} and isAcknowledged: ${isAcknowledged}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return deletePromise
|
||||
.then(
|
||||
|
@ -946,9 +1226,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
throw error;
|
||||
},
|
||||
)
|
||||
.finally(() => setIsExecuting(false));
|
||||
.finally(() => {
|
||||
setIsExecuting(false);
|
||||
});
|
||||
},
|
||||
[_collection, isPreferredApiMongoDB, onExecutionErrorChange, tabTitle],
|
||||
[_collection, isPreferredApiMongoDB, onExecutionErrorChange, tabTitle, partitionKey.systemKey],
|
||||
);
|
||||
|
||||
const deleteDocuments = useCallback(
|
||||
|
@ -966,14 +1248,25 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
setClickedRowIndex(undefined);
|
||||
setSelectedRows(new Set());
|
||||
setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected);
|
||||
useDialog
|
||||
.getState()
|
||||
.showOkModalDialog("Delete documents", `${deletedIds.length} document(s) successfully deleted.`);
|
||||
},
|
||||
(error: Error) =>
|
||||
useDialog
|
||||
.getState()
|
||||
.showOkModalDialog("Delete documents", `Deleting document(s) failed (${error.message})`),
|
||||
(error: Error) => {
|
||||
if (error instanceof MongoProxyClient.ThrottlingError) {
|
||||
useDialog
|
||||
.getState()
|
||||
.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));
|
||||
},
|
||||
|
@ -1049,7 +1342,13 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
const _queryAbortController = new AbortController();
|
||||
setQueryAbortController(_queryAbortController);
|
||||
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
|
||||
const options: any = {};
|
||||
// TODO: Property 'enableCrossPartitionQuery' does not exist on type 'FeedOptions'.
|
||||
|
@ -1072,6 +1371,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
resourceTokenPartitionKey,
|
||||
isQueryCopilotSampleContainer,
|
||||
_collection,
|
||||
selectedColumnIds,
|
||||
]);
|
||||
|
||||
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
|
||||
]);
|
||||
|
||||
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) => {
|
||||
if (event.key === " " || event.key === "Enter") {
|
||||
const focusElement = event.target as HTMLElement;
|
||||
|
@ -1239,7 +1529,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
|
||||
const onFilterKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === "Enter") {
|
||||
refreshDocumentsGrid(true);
|
||||
onApplyFilterClick();
|
||||
|
||||
// Suppress the default behavior of the key
|
||||
e.preventDefault();
|
||||
|
@ -1258,9 +1548,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
|
||||
// Table config here
|
||||
const tableItems: DocumentsTableComponentItem[] = documentIds.map((documentId) => {
|
||||
const item: Record<string, string> & { id: string } = {
|
||||
id: documentId.id(),
|
||||
};
|
||||
const item: DocumentsTableComponentItem = documentId.tableFields || { id: documentId.id() };
|
||||
|
||||
if (partitionKeyPropertyHeaders && documentId.stringPartitionKeyValues) {
|
||||
for (let i = 0; i < partitionKeyPropertyHeaders.length; i++) {
|
||||
|
@ -1271,6 +1559,44 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
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();
|
||||
* Document has been clicked on in table
|
||||
|
@ -1286,6 +1612,9 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
(_isQueryCopilotSampleContainer ? readSampleDocument(documentId) : readDocument(_collection, documentId)).then(
|
||||
(content) => {
|
||||
initDocumentEditor(documentId, content);
|
||||
|
||||
// Update columns
|
||||
setColumnDefinitionsFromDocument(content);
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -1376,10 +1705,22 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
return () => resizeObserver.disconnect(); // clean up
|
||||
}, []);
|
||||
|
||||
const columnHeaders = {
|
||||
idHeader: isPreferredApiMongoDB ? "_id" : "id",
|
||||
partitionKeyHeaders: (showPartitionKey(_collection, isPreferredApiMongoDB) && partitionKeyPropertyHeaders) || [],
|
||||
};
|
||||
// Column definition is a map<id, ColumnDefinition> to garantee uniqueness
|
||||
const [columnDefinitions, setColumnDefinitions] = useState<ColumnDefinition[]>(() => {
|
||||
const persistedColumnsSelection = readSubComponentState<ColumnsSelection>(
|
||||
SubComponentName.ColumnsSelection,
|
||||
_collection,
|
||||
undefined,
|
||||
);
|
||||
|
||||
if (!persistedColumnsSelection) {
|
||||
return extractColumnDefinitionsFromDocument({
|
||||
id: "id",
|
||||
});
|
||||
}
|
||||
|
||||
return persistedColumnsSelection.columnDefinitions;
|
||||
});
|
||||
|
||||
const onSelectedRowsChange = (selectedRows: Set<TableRowId>) => {
|
||||
confirmDiscardingChange(() => {
|
||||
|
@ -1442,7 +1783,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
return partitionKey;
|
||||
};
|
||||
|
||||
lastFilterContents = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"];
|
||||
partitionKeyProperties = partitionKeyProperties?.map((partitionKeyProperty, i) => {
|
||||
if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) {
|
||||
partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, "");
|
||||
|
@ -1612,7 +1952,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
setIsExecuting(true);
|
||||
onExecutionErrorChange(false);
|
||||
const filter: string = filterContent.trim();
|
||||
const query: string = buildQuery(isPreferredApiMongoDB, filter);
|
||||
const query: string = buildQuery(isPreferredApiMongoDB, filter, selectedColumnIds);
|
||||
|
||||
return MongoProxyClient.queryDocuments(
|
||||
_collection.databaseId,
|
||||
|
@ -1663,6 +2003,24 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
}
|
||||
// ***************** 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(
|
||||
(applyFilterButtonPressed: boolean): void => {
|
||||
// clear documents grid
|
||||
|
@ -1693,6 +2051,68 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
[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 (
|
||||
<CosmosFluentProvider className={styles.container}>
|
||||
<div className="tab-pane active" role="tabpanel" style={{ display: "flex" }}>
|
||||
|
@ -1721,12 +2141,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
<div className={styles.filterRow}>
|
||||
{!isPreferredApiMongoDB && <span> SELECT * FROM c </span>}
|
||||
<Input
|
||||
id="filterInput"
|
||||
ref={filterInput}
|
||||
type="text"
|
||||
size="small"
|
||||
list="filtersList"
|
||||
className={styles.filterInput}
|
||||
list={`filtersList-${getUniqueId(_collection)}`}
|
||||
className={`filterInput ${styles.filterInput}`}
|
||||
title="Type a query predicate or choose one from the list."
|
||||
placeholder={
|
||||
isPreferredApiMongoDB
|
||||
|
@ -1740,8 +2159,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
onBlur={() => setIsFilterFocused(false)}
|
||||
/>
|
||||
|
||||
<datalist id="filtersList">
|
||||
{lastFilterContents.map((filter) => (
|
||||
<datalist id={`filtersList-${getUniqueId(_collection)}`}>
|
||||
{addStringsNoDuplicate(
|
||||
lastFilterContents,
|
||||
isPreferredApiMongoDB ? defaultMongoFilters : getDefaultSqlFilters(partitionKeyProperties),
|
||||
).map((filter) => (
|
||||
<option key={filter} value={filter} />
|
||||
))}
|
||||
</datalist>
|
||||
|
@ -1749,7 +2171,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
<Button
|
||||
appearance="primary"
|
||||
size="small"
|
||||
onClick={() => refreshDocumentsGrid(true)}
|
||||
onClick={onApplyFilterClick}
|
||||
disabled={!applyFilterButton.enabled}
|
||||
aria-label="Apply filter"
|
||||
tabIndex={0}
|
||||
|
@ -1780,41 +2202,46 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* <Split> doesn't like to be a flex child */}
|
||||
<div style={{ overflow: "hidden", height: "100%" }}>
|
||||
<Allotment>
|
||||
<Allotment.Pane preferredSize="35%" minSize={175}>
|
||||
<Allotment
|
||||
onDragEnd={(sizes: number[]) => {
|
||||
tabStateData.leftPaneWidthPercent = (100 * sizes[0]) / (sizes[0] + sizes[1]);
|
||||
saveSubComponentState<TabDivider>(SubComponentName.MainTabDivider, _collection, tabStateData);
|
||||
setTabStateData(tabStateData);
|
||||
}}
|
||||
>
|
||||
<Allotment.Pane preferredSize={`${tabStateData.leftPaneWidthPercent}%`} minSize={55}>
|
||||
<div style={{ height: "100%", width: "100%", overflow: "hidden" }} ref={tableContainerRef}>
|
||||
<div className={styles.floatingControlsContainer}>
|
||||
<div className={styles.floatingControls}>
|
||||
<Button
|
||||
appearance="transparent"
|
||||
aria-label="Refresh"
|
||||
size="small"
|
||||
icon={<ArrowClockwise16Filled />}
|
||||
style={{
|
||||
color: StyleConstants.AccentMedium,
|
||||
}}
|
||||
onClick={() => refreshDocumentsGrid(false)}
|
||||
onKeyDown={onRefreshKeyInput}
|
||||
<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
|
||||
onRefreshTable={() => refreshDocumentsGrid(false)}
|
||||
items={tableItems}
|
||||
onItemClicked={(index) => onDocumentClicked(index, documentIds)}
|
||||
onSelectedRowsChange={onSelectedRowsChange}
|
||||
selectedRows={selectedRows}
|
||||
size={tableContainerSizePx}
|
||||
selectedColumnIds={selectedColumnIds}
|
||||
columnDefinitions={columnDefinitions}
|
||||
isRowSelectionDisabled={
|
||||
isBulkDeleteDisabled ||
|
||||
(configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly)
|
||||
}
|
||||
onColumnSelectionChange={onColumnSelectionChange}
|
||||
defaultColumnSelection={getInitialColumnSelection()}
|
||||
collection={_collection}
|
||||
isColumnSelectionDisabled={isPreferredApiMongoDB}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.tableContainer}>
|
||||
<DocumentsTableComponent
|
||||
items={tableItems}
|
||||
onItemClicked={(index) => onDocumentClicked(index, documentIds)}
|
||||
onSelectedRowsChange={onSelectedRowsChange}
|
||||
selectedRows={selectedRows}
|
||||
size={tableContainerSizePx}
|
||||
columnHeaders={columnHeaders}
|
||||
isSelectionDisabled={
|
||||
(partitionKey.systemKey && !isPreferredApiMongoDB) ||
|
||||
(configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{tableItems.length > 0 && (
|
||||
<a
|
||||
className={styles.loadMore}
|
||||
|
@ -1828,7 +2255,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
)}
|
||||
</div>
|
||||
</Allotment.Pane>
|
||||
<Allotment.Pane preferredSize="65%" minSize={300}>
|
||||
<Allotment.Pane minSize={30}>
|
||||
<div style={{ height: "100%", width: "100%" }}>
|
||||
{isTabActive && selectedDocumentContent && selectedRows.size <= 1 && (
|
||||
<EditorReact
|
||||
|
@ -1850,6 +2277,50 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
|||
</Allotment>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -49,7 +49,9 @@ jest.mock("Common/MongoProxyClient", () => ({
|
|||
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", () => ({
|
||||
|
@ -178,7 +180,7 @@ describe("Documents tab (Mongo API)", () => {
|
|||
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;
|
||||
mockDeleteDocuments.mockClear();
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { TableRowId } from "@fluentui/react-components";
|
||||
import { mount } from "enzyme";
|
||||
import React from "react";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import { DocumentsTableComponent, IDocumentsTableComponentProps } from "./DocumentsTableComponent";
|
||||
|
||||
const PARTITION_KEY_HEADER = "partitionKey";
|
||||
|
@ -20,11 +21,19 @@ describe("DocumentsTableComponent", () => {
|
|||
height: 0,
|
||||
width: 0,
|
||||
},
|
||||
columnHeaders: {
|
||||
idHeader: ID_HEADER,
|
||||
partitionKeyHeaders: [PARTITION_KEY_HEADER],
|
||||
columnDefinitions: [
|
||||
{ id: ID_HEADER, label: "ID", isPartitionKey: false },
|
||||
{ id: PARTITION_KEY_HEADER, label: "Partition Key", isPartitionKey: true },
|
||||
],
|
||||
isRowSelectionDisabled: false,
|
||||
collection: {
|
||||
databaseId: "db",
|
||||
id: ((): string => "coll") as ko.Observable<string>,
|
||||
} as ViewModels.CollectionBase,
|
||||
onRefreshTable: (): void => {
|
||||
throw new Error("Function not implemented.");
|
||||
},
|
||||
isSelectionDisabled: false,
|
||||
selectedColumnIds: [],
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const props: IDocumentsTableComponentProps = createMockProps();
|
||||
props.isSelectionDisabled = true;
|
||||
props.isRowSelectionDisabled = true;
|
||||
const wrapper = mount(<DocumentsTableComponent {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
|
|
@ -1,52 +1,86 @@
|
|||
import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuDivider,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
MenuPopover,
|
||||
MenuTrigger,
|
||||
TableRowData as RowStateBase,
|
||||
SortDirection,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellLayout,
|
||||
TableColumnDefinition,
|
||||
TableColumnId,
|
||||
TableColumnSizingOptions,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
TableRowId,
|
||||
TableSelectionCell,
|
||||
createTableColumn,
|
||||
tokens,
|
||||
useArrowNavigationGroup,
|
||||
useTableColumnSizing_unstable,
|
||||
useTableFeatures,
|
||||
useTableSelection,
|
||||
useTableSort,
|
||||
} from "@fluentui/react-components";
|
||||
import {
|
||||
ArrowClockwise16Regular,
|
||||
ArrowResetRegular,
|
||||
DeleteRegular,
|
||||
EditRegular,
|
||||
MoreHorizontalRegular,
|
||||
TableResizeColumnRegular,
|
||||
TextSortAscendingRegular,
|
||||
TextSortDescendingRegular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { NormalizedEventKey } from "Common/Constants";
|
||||
import { TableColumnSelectionPane } from "Explorer/Panes/TableColumnSelectionPane/TableColumnSelectionPane";
|
||||
import {
|
||||
ColumnSizesMap,
|
||||
ColumnSort,
|
||||
deleteSubComponentState,
|
||||
readSubComponentState,
|
||||
saveSubComponentState,
|
||||
SubComponentName,
|
||||
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
|
||||
import { INITIAL_SELECTED_ROW_INDEX, useDocumentsTabStyles } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2";
|
||||
import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
|
||||
import { LayoutConstants } from "Explorer/Theme/ThemeUtil";
|
||||
import { userContext } from "UserContext";
|
||||
import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils";
|
||||
import { useSidePanel } from "hooks/useSidePanel";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { FixedSizeList as List, ListChildComponentProps } from "react-window";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
|
||||
export type DocumentsTableComponentItem = {
|
||||
id: string;
|
||||
} & Record<string, string>;
|
||||
} & Record<string, string | number>;
|
||||
|
||||
export type ColumnHeaders = {
|
||||
idHeader: string;
|
||||
partitionKeyHeaders: string[];
|
||||
export type ColumnDefinition = {
|
||||
id: string;
|
||||
label: string;
|
||||
isPartitionKey: boolean;
|
||||
};
|
||||
export interface IDocumentsTableComponentProps {
|
||||
onRefreshTable: () => void;
|
||||
items: DocumentsTableComponentItem[];
|
||||
onItemClicked: (index: number) => void;
|
||||
onSelectedRowsChange: (selectedItemsIndices: Set<TableRowId>) => void;
|
||||
selectedRows: Set<TableRowId>;
|
||||
size: { height: number; width: number };
|
||||
columnHeaders: ColumnHeaders;
|
||||
selectedColumnIds: string[];
|
||||
columnDefinitions: ColumnDefinition[];
|
||||
style?: React.CSSProperties;
|
||||
isSelectionDisabled?: boolean;
|
||||
isRowSelectionDisabled?: boolean;
|
||||
collection: ViewModels.CollectionBase;
|
||||
onColumnSelectionChange?: (newSelectedColumnIds: string[]) => void;
|
||||
defaultColumnSelection?: string[];
|
||||
isColumnSelectionDisabled?: boolean;
|
||||
}
|
||||
|
||||
interface TableRowData extends RowStateBase<DocumentsTableComponentItem> {
|
||||
|
@ -59,72 +93,203 @@ interface ReactWindowRenderFnProps extends ListChildComponentProps {
|
|||
data: TableRowData[];
|
||||
}
|
||||
|
||||
const COLUMNS_MENU_NAME = "columnsMenu";
|
||||
|
||||
const defaultSize = {
|
||||
idealWidth: 200,
|
||||
minWidth: 50,
|
||||
};
|
||||
export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> = ({
|
||||
onRefreshTable,
|
||||
items,
|
||||
onSelectedRowsChange,
|
||||
selectedRows,
|
||||
style,
|
||||
size,
|
||||
columnHeaders,
|
||||
isSelectionDisabled,
|
||||
selectedColumnIds,
|
||||
columnDefinitions,
|
||||
isRowSelectionDisabled: isSelectionDisabled,
|
||||
collection,
|
||||
onColumnSelectionChange,
|
||||
defaultColumnSelection,
|
||||
isColumnSelectionDisabled,
|
||||
}: IDocumentsTableComponentProps) => {
|
||||
const styles = useDocumentsTabStyles();
|
||||
|
||||
const initialSizingOptions: TableColumnSizingOptions = {
|
||||
id: {
|
||||
idealWidth: 280,
|
||||
minWidth: 50,
|
||||
},
|
||||
};
|
||||
columnHeaders.partitionKeyHeaders.forEach((pkHeader) => {
|
||||
initialSizingOptions[pkHeader] = {
|
||||
idealWidth: 200,
|
||||
minWidth: 50,
|
||||
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(() => {
|
||||
const columnSizesMap: ColumnSizesMap = readSubComponentState(SubComponentName.ColumnSizes, collection, {});
|
||||
const columnSizesPx: TableColumnSizingOptions = {};
|
||||
selectedColumnIds.forEach((columnId) => {
|
||||
if (
|
||||
!columnSizesMap ||
|
||||
!columnSizesMap[columnId] ||
|
||||
columnSizesMap[columnId].widthPx === undefined ||
|
||||
isNaN(columnSizesMap[columnId].widthPx)
|
||||
) {
|
||||
columnSizesPx[columnId] = defaultSize;
|
||||
} else {
|
||||
columnSizesPx[columnId] = {
|
||||
idealWidth: columnSizesMap[columnId].widthPx,
|
||||
minWidth: 50,
|
||||
};
|
||||
}
|
||||
});
|
||||
return columnSizesPx;
|
||||
});
|
||||
|
||||
const [sortState, setSortState] = React.useState<{
|
||||
sortDirection: "ascending" | "descending";
|
||||
sortColumn: TableColumnId | undefined;
|
||||
}>(() => {
|
||||
const sort = readSubComponentState<ColumnSort>(SubComponentName.ColumnSort, collection, undefined);
|
||||
|
||||
if (!sort) {
|
||||
return {
|
||||
sortDirection: undefined,
|
||||
sortColumn: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sortDirection: sort.direction,
|
||||
sortColumn: sort.columnId,
|
||||
};
|
||||
});
|
||||
|
||||
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(initialSizingOptions);
|
||||
const onColumnResize = React.useCallback((_, { columnId, width }: { columnId: string; width: number }) => {
|
||||
setColumnSizingOptions((state) => {
|
||||
const newSizingOptions = {
|
||||
...state,
|
||||
[columnId]: {
|
||||
...state[columnId],
|
||||
idealWidth: width,
|
||||
},
|
||||
};
|
||||
|
||||
const onColumnResize = React.useCallback((_, { columnId, width }) => {
|
||||
setColumnSizingOptions((state) => ({
|
||||
...state,
|
||||
[columnId]: {
|
||||
...state[columnId],
|
||||
idealWidth: width,
|
||||
},
|
||||
}));
|
||||
const persistentSizes = Object.keys(newSizingOptions).reduce((acc, key) => {
|
||||
acc[key] = {
|
||||
widthPx: newSizingOptions[key].idealWidth,
|
||||
};
|
||||
return acc;
|
||||
}, {} as ColumnSizesMap);
|
||||
|
||||
saveSubComponentState<ColumnSizesMap>(SubComponentName.ColumnSizes, collection, persistentSizes, true);
|
||||
|
||||
return newSizingOptions;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// const restoreFocusTargetAttribute = useRestoreFocusTarget();
|
||||
|
||||
const onSortClick = (event: React.SyntheticEvent, columnId: string, direction: SortDirection) => {
|
||||
setColumnSort(event, columnId, direction);
|
||||
|
||||
if (columnId === undefined || direction === undefined) {
|
||||
deleteSubComponentState(SubComponentName.ColumnSort, collection);
|
||||
return;
|
||||
}
|
||||
|
||||
saveSubComponentState<ColumnSort>(SubComponentName.ColumnSort, collection, { columnId, direction });
|
||||
};
|
||||
|
||||
// Columns must be a static object and cannot change on re-renders otherwise React will complain about too many refreshes
|
||||
const columns: TableColumnDefinition<DocumentsTableComponentItem>[] = useMemo(
|
||||
() =>
|
||||
[
|
||||
createTableColumn<DocumentsTableComponentItem>({
|
||||
columnId: "id",
|
||||
compare: (a, b) => a.id.localeCompare(b.id),
|
||||
renderHeaderCell: () => columnHeaders.idHeader,
|
||||
columnDefinitions
|
||||
.filter((column) => selectedColumnIds.includes(column.id))
|
||||
.map((column) => ({
|
||||
columnId: column.id,
|
||||
compare: (a, b) => {
|
||||
if (typeof a[column.id] === "string") {
|
||||
return (a[column.id] as string).localeCompare(b[column.id] as string);
|
||||
} else if (typeof a[column.id] === "number") {
|
||||
return (a[column.id] as number) - (b[column.id] as number);
|
||||
} else {
|
||||
// Should not happen
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
renderHeaderCell: () => (
|
||||
<>
|
||||
<span title={column.label}>{column.label}</span>
|
||||
<Menu>
|
||||
<MenuTrigger disableButtonEnhancement>
|
||||
<Button
|
||||
// {...restoreFocusTargetAttribute}
|
||||
appearance="transparent"
|
||||
aria-label="Select column"
|
||||
size="small"
|
||||
icon={<MoreHorizontalRegular />}
|
||||
style={{ position: "absolute", right: 0, backgroundColor: tokens.colorNeutralBackground1 }}
|
||||
/>
|
||||
</MenuTrigger>
|
||||
<MenuPopover>
|
||||
<MenuList>
|
||||
<MenuItem key="refresh" icon={<ArrowClockwise16Regular />} onClick={onRefreshTable}>
|
||||
Refresh
|
||||
</MenuItem>
|
||||
{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) => (
|
||||
<TableCellLayout truncate title={item.id}>
|
||||
{item.id}
|
||||
<TableCellLayout truncate title={`${item[column.id]}`}>
|
||||
{item[column.id]}
|
||||
</TableCellLayout>
|
||||
),
|
||||
}),
|
||||
].concat(
|
||||
columnHeaders.partitionKeyHeaders.map((pkHeader) =>
|
||||
createTableColumn<DocumentsTableComponentItem>({
|
||||
columnId: pkHeader,
|
||||
compare: (a, b) => a[pkHeader].localeCompare(b[pkHeader]),
|
||||
// Show Refresh button on last column
|
||||
renderHeaderCell: () => <span title={pkHeader}>{pkHeader}</span>,
|
||||
renderCell: (item) => (
|
||||
<TableCellLayout truncate title={item[pkHeader]}>
|
||||
{item[pkHeader]}
|
||||
</TableCellLayout>
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
[columnHeaders],
|
||||
})),
|
||||
[columnDefinitions, onColumnSelectionChange, selectedColumnIds],
|
||||
);
|
||||
|
||||
const [selectionStartIndex, setSelectionStartIndex] = React.useState<number>(INITIAL_SELECTED_ROW_INDEX);
|
||||
|
@ -214,6 +379,7 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
|
|||
columnSizing_unstable: columnSizing,
|
||||
tableRef,
|
||||
selection: { allRowsSelected, someRowsSelected, toggleAllRows, toggleRow, isRowSelected },
|
||||
sort: { getSortDirection, setColumnSort, sort },
|
||||
} = useTableFeatures(
|
||||
{
|
||||
columns,
|
||||
|
@ -227,25 +393,36 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
|
|||
// eslint-disable-next-line react/prop-types
|
||||
onSelectionChange: (e, data) => onSelectedRowsChange(data.selectedItems),
|
||||
}),
|
||||
useTableSort({
|
||||
sortState,
|
||||
onSortChange: (e, nextSortState) => setSortState(nextSortState),
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
const rows: TableRowData[] = getRows((row) => {
|
||||
const selected = isRowSelected(row.rowId);
|
||||
return {
|
||||
...row,
|
||||
onClick: (e: React.MouseEvent) => toggleRow(e, row.rowId),
|
||||
onKeyDown: (e: React.KeyboardEvent) => {
|
||||
if (e.key === " ") {
|
||||
e.preventDefault();
|
||||
toggleRow(e, row.rowId);
|
||||
}
|
||||
},
|
||||
selected,
|
||||
appearance: selected ? ("brand" as const) : ("none" as const),
|
||||
};
|
||||
const headerSortProps = (columnId: TableColumnId) => ({
|
||||
// onClick: (e: React.MouseEvent) => toggleColumnSort(e, columnId),
|
||||
sortDirection: getSortDirection(columnId),
|
||||
});
|
||||
|
||||
const rows: TableRowData[] = sort(
|
||||
getRows((row) => {
|
||||
const selected = isRowSelected(row.rowId);
|
||||
return {
|
||||
...row,
|
||||
onClick: (e: React.MouseEvent) => toggleRow(e, row.rowId),
|
||||
onKeyDown: (e: React.KeyboardEvent) => {
|
||||
if (e.key === " ") {
|
||||
e.preventDefault();
|
||||
toggleRow(e, row.rowId);
|
||||
}
|
||||
},
|
||||
selected,
|
||||
appearance: selected ? ("brand" as const) : ("none" as const),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const toggleAllKeydown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === " ") {
|
||||
|
@ -271,39 +448,53 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
|
|||
...style,
|
||||
};
|
||||
|
||||
const checkedValues: { [COLUMNS_MENU_NAME]: string[] } = {
|
||||
[COLUMNS_MENU_NAME]: [],
|
||||
};
|
||||
columnDefinitions.forEach(
|
||||
(columnDefinition) =>
|
||||
selectedColumnIds.includes(columnDefinition.id) && checkedValues[COLUMNS_MENU_NAME].push(columnDefinition.id),
|
||||
);
|
||||
|
||||
const openColumnSelectionPane = (): void => {
|
||||
useSidePanel
|
||||
.getState()
|
||||
.openSidePanel(
|
||||
"Select columns",
|
||||
<TableColumnSelectionPane
|
||||
selectedColumnIds={selectedColumnIds}
|
||||
columnDefinitions={columnDefinitions}
|
||||
onSelectionChange={onColumnSelectionChange}
|
||||
defaultSelection={defaultColumnSelection}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Table noNativeElements {...tableProps}>
|
||||
<TableHeader>
|
||||
<TableHeader className={styles.tableHeader}>
|
||||
<TableRow className={styles.tableRow} style={{ width: size ? size.width - 15 : "100%" }}>
|
||||
{!isSelectionDisabled && (
|
||||
<TableSelectionCell
|
||||
key="selectcell"
|
||||
checked={allRowsSelected ? true : someRowsSelected ? "mixed" : false}
|
||||
onClick={toggleAllRows}
|
||||
onKeyDown={toggleAllKeydown}
|
||||
checkboxIndicator={{ "aria-label": "Select all rows " }}
|
||||
/>
|
||||
)}
|
||||
{columns.map((column /* index */) => (
|
||||
<Menu openOnContext key={column.columnId}>
|
||||
<MenuTrigger>
|
||||
<TableHeaderCell
|
||||
className={styles.tableCell}
|
||||
key={column.columnId}
|
||||
{...columnSizing.getTableHeaderCellProps(column.columnId)}
|
||||
>
|
||||
{column.renderHeaderCell()}
|
||||
</TableHeaderCell>
|
||||
</MenuTrigger>
|
||||
<MenuPopover>
|
||||
<MenuList>
|
||||
<MenuItem onClick={columnSizing.enableKeyboardMode(column.columnId)}>
|
||||
Keyboard Column Resizing
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</MenuPopover>
|
||||
</Menu>
|
||||
{columns.map((column) => (
|
||||
<TableHeaderCell
|
||||
className={styles.tableCell}
|
||||
key={column.columnId}
|
||||
{...columnSizing.getTableHeaderCellProps(column.columnId)}
|
||||
{...headerSortProps(column.columnId)}
|
||||
>
|
||||
{column.renderHeaderCell()}
|
||||
</TableHeaderCell>
|
||||
))}
|
||||
</TableRow>
|
||||
<div className={styles.tableHeaderFiller}></div>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<List
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Utility class to help with selection.
|
||||
* This emulates File Explorer selection behavior.
|
||||
|
@ -90,3 +92,12 @@ export const selectionHelper = (
|
|||
}
|
||||
}
|
||||
};
|
||||
|
||||
// To get previous values of a state in useEffect
|
||||
export const usePrevious = <T>(value: T): T | undefined => {
|
||||
const ref = useRef<T>();
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
});
|
||||
return ref.current;
|
||||
};
|
||||
|
|
|
@ -38,9 +38,11 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
|||
}
|
||||
}
|
||||
>
|
||||
<Allotment>
|
||||
<Allotment
|
||||
onDragEnd={[Function]}
|
||||
>
|
||||
<Allotment.Pane
|
||||
minSize={175}
|
||||
minSize={55}
|
||||
preferredSize="35%"
|
||||
>
|
||||
<div
|
||||
|
@ -53,52 +55,61 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
|||
}
|
||||
>
|
||||
<div
|
||||
className="___77lcry0_0000000 f10pi13n"
|
||||
className="___9o87uj0_0000000 ffefeo0"
|
||||
>
|
||||
<div
|
||||
className="___1rwkz4r_0000000 f1euv43f f1l8gmrm f1e31b4d f150nix6 fy6ml6n f19g0ac"
|
||||
style={
|
||||
{
|
||||
"height": "100%",
|
||||
"width": "calc(100% + -11px)",
|
||||
}
|
||||
}
|
||||
>
|
||||
<Button
|
||||
appearance="transparent"
|
||||
aria-label="Refresh"
|
||||
icon={<ArrowClockwise16Filled />}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
size="small"
|
||||
style={
|
||||
<DocumentsTableComponent
|
||||
collection={
|
||||
{
|
||||
"color": undefined,
|
||||
"databaseId": "databaseId",
|
||||
"id": [Function],
|
||||
}
|
||||
}
|
||||
columnDefinitions={
|
||||
[
|
||||
{
|
||||
"id": "id",
|
||||
"isPartitionKey": false,
|
||||
"label": "id",
|
||||
},
|
||||
]
|
||||
}
|
||||
defaultColumnSelection={
|
||||
[
|
||||
"id",
|
||||
]
|
||||
}
|
||||
isColumnSelectionDisabled={false}
|
||||
isRowSelectionDisabled={true}
|
||||
items={[]}
|
||||
onColumnSelectionChange={[Function]}
|
||||
onItemClicked={[Function]}
|
||||
onRefreshTable={[Function]}
|
||||
onSelectedRowsChange={[Function]}
|
||||
selectedColumnIds={
|
||||
[
|
||||
"id",
|
||||
]
|
||||
}
|
||||
selectedRows={
|
||||
Set {
|
||||
0,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="___9o87uj0_0000000 ffefeo0"
|
||||
>
|
||||
<DocumentsTableComponent
|
||||
columnHeaders={
|
||||
{
|
||||
"idHeader": "id",
|
||||
"partitionKeyHeaders": [],
|
||||
}
|
||||
}
|
||||
isSelectionDisabled={true}
|
||||
items={[]}
|
||||
onItemClicked={[Function]}
|
||||
onSelectedRowsChange={[Function]}
|
||||
selectedRows={
|
||||
Set {
|
||||
0,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Allotment.Pane>
|
||||
<Allotment.Pane
|
||||
minSize={300}
|
||||
preferredSize="65%"
|
||||
minSize={30}
|
||||
>
|
||||
<div
|
||||
style={
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -12,7 +12,7 @@ import {
|
|||
createTableColumn,
|
||||
tokens,
|
||||
} 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 { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
|
||||
import { useNotificationConsole } from "hooks/useNotificationConsole";
|
||||
|
@ -34,25 +34,32 @@ export const ErrorList: React.FC<{ errors: QueryError[] }> = ({ errors }) => {
|
|||
createTableColumn<QueryError>({
|
||||
columnId: "code",
|
||||
compare: (item1, item2) => item1.code.localeCompare(item2.code),
|
||||
renderHeaderCell: () => null,
|
||||
renderCell: (item) => item.code,
|
||||
renderHeaderCell: () => "Code",
|
||||
renderCell: (item) => <TableCellLayout truncate>{item.code}</TableCellLayout>,
|
||||
}),
|
||||
createTableColumn<QueryError>({
|
||||
columnId: "severity",
|
||||
compare: (item1, item2) => compareSeverity(item1.severity, item2.severity),
|
||||
renderHeaderCell: () => null,
|
||||
renderCell: (item) => <TableCellLayout media={severityIcons[item.severity]}>{item.severity}</TableCellLayout>,
|
||||
renderHeaderCell: () => "Severity",
|
||||
renderCell: (item) => (
|
||||
<TableCellLayout truncate media={severityIcons[item.severity]}>
|
||||
{item.severity}
|
||||
</TableCellLayout>
|
||||
),
|
||||
}),
|
||||
createTableColumn<QueryError>({
|
||||
columnId: "location",
|
||||
compare: (item1, item2) => item1.location?.start?.offset - item2.location?.start?.offset,
|
||||
renderHeaderCell: () => "Location",
|
||||
renderCell: (item) =>
|
||||
item.location
|
||||
? item.location.start.lineNumber
|
||||
? `Line ${item.location.start.lineNumber}`
|
||||
: "<unknown>"
|
||||
: "<no location>",
|
||||
renderCell: (item) => (
|
||||
<TableCellLayout truncate>
|
||||
{item.location
|
||||
? item.location.start.lineNumber
|
||||
? `Line ${item.location.start.lineNumber}`
|
||||
: "<unknown>"
|
||||
: "<no location>"}
|
||||
</TableCellLayout>
|
||||
),
|
||||
}),
|
||||
createTableColumn<QueryError>({
|
||||
columnId: "message",
|
||||
|
@ -60,8 +67,20 @@ export const ErrorList: React.FC<{ errors: QueryError[] }> = ({ errors }) => {
|
|||
renderHeaderCell: () => "Message",
|
||||
renderCell: (item) => (
|
||||
<div className={styles.errorListMessageCell}>
|
||||
<div className={styles.errorListMessage}>{item.message}</div>
|
||||
<div>
|
||||
<div className={styles.errorListMessage} title={item.message}>
|
||||
{item.message}
|
||||
</div>
|
||||
<div className={styles.errorListMessageActions}>
|
||||
{item.helpLink && (
|
||||
<Button
|
||||
as="a"
|
||||
aria-label="Help"
|
||||
appearance="subtle"
|
||||
icon={<QuestionRegular />}
|
||||
href={item.helpLink}
|
||||
target="_blank"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
aria-label="Details"
|
||||
appearance="subtle"
|
||||
|
@ -76,9 +95,9 @@ export const ErrorList: React.FC<{ errors: QueryError[] }> = ({ errors }) => {
|
|||
|
||||
const columnSizingOptions: TableColumnSizingOptions = {
|
||||
code: {
|
||||
minWidth: 75,
|
||||
idealWidth: 75,
|
||||
defaultWidth: 75,
|
||||
minWidth: 90,
|
||||
idealWidth: 90,
|
||||
defaultWidth: 90,
|
||||
},
|
||||
severity: {
|
||||
minWidth: 100,
|
||||
|
|
|
@ -2,12 +2,14 @@ import { fireEvent, render } from "@testing-library/react";
|
|||
import { CollectionTabKind } from "Contracts/ViewModels";
|
||||
import { CopilotProvider } from "Explorer/QueryCopilot/QueryCopilotContext";
|
||||
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
|
||||
import { CopilotSubComponentNames } from "Explorer/QueryCopilot/QueryCopilotUtilities";
|
||||
import {
|
||||
IQueryTabComponentProps,
|
||||
QueryTabComponent,
|
||||
QueryTabCopilotComponent,
|
||||
} from "Explorer/Tabs/QueryTab/QueryTabComponent";
|
||||
import TabsBase from "Explorer/Tabs/TabsBase";
|
||||
import { AppStateComponentNames, StorePath } from "Shared/AppStatePersistenceUtility";
|
||||
import { updateUserContext, userContext } from "UserContext";
|
||||
import { mount } from "enzyme";
|
||||
import { useQueryCopilot } from "hooks/useQueryCopilot";
|
||||
|
@ -16,6 +18,24 @@ import React from "react";
|
|||
|
||||
jest.mock("Explorer/Controls/Editor/EditorReact");
|
||||
|
||||
const loadState = (path: StorePath) => {
|
||||
if (
|
||||
path.componentName === AppStateComponentNames.QueryCopilot &&
|
||||
path.subComponentName === CopilotSubComponentNames.toggleStatus
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
jest.mock("Shared/AppStatePersistenceUtility", () => ({
|
||||
loadState,
|
||||
AppStateComponentNames: {
|
||||
QueryCopilot: "QueryCopilot",
|
||||
},
|
||||
}));
|
||||
|
||||
describe("QueryTabComponent", () => {
|
||||
const mockStore = useQueryCopilot.getState();
|
||||
beforeEach(() => {
|
||||
|
@ -32,7 +52,7 @@ describe("QueryTabComponent", () => {
|
|||
},
|
||||
});
|
||||
const propsMock: Readonly<IQueryTabComponentProps> = {
|
||||
collection: { databaseId: "CopilotSampleDb" },
|
||||
collection: { databaseId: "CopilotSampleDB" },
|
||||
onTabAccessor: () => jest.fn(),
|
||||
isExecutionError: false,
|
||||
tabId: "mockTabId",
|
||||
|
@ -50,6 +70,17 @@ describe("QueryTabComponent", () => {
|
|||
});
|
||||
|
||||
it("copilot should be enabled by default when tab is active", () => {
|
||||
updateUserContext({
|
||||
databaseAccount: {
|
||||
name: "name",
|
||||
properties: undefined,
|
||||
id: "",
|
||||
location: "",
|
||||
type: "",
|
||||
kind: "",
|
||||
},
|
||||
});
|
||||
|
||||
useQueryCopilot.getState().setCopilotEnabled(true);
|
||||
useQueryCopilot.getState().setCopilotUserDBEnabled(true);
|
||||
const activeTab = new TabsBase({
|
||||
|
|
|
@ -9,6 +9,7 @@ import { monaco } from "Explorer/LazyMonaco";
|
|||
import { QueryCopilotFeedbackModal } from "Explorer/QueryCopilot/Modal/QueryCopilotFeedbackModal";
|
||||
import { useCopilotStore } from "Explorer/QueryCopilot/QueryCopilotContext";
|
||||
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
|
||||
import { readCopilotToggleStatus, saveCopilotToggleStatus } from "Explorer/QueryCopilot/QueryCopilotUtilities";
|
||||
import { OnExecuteQueryClick, QueryDocumentsPerPage } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
|
||||
import { QueryCopilotSidebar } from "Explorer/QueryCopilot/V2/Sidebar/QueryCopilotSidebar";
|
||||
import { QueryResultSection } from "Explorer/Tabs/QueryTab/QueryResultSection";
|
||||
|
@ -46,7 +47,6 @@ import { queryDocuments } from "../../../Common/dataAccess/queryDocuments";
|
|||
import { queryDocumentsPage } from "../../../Common/dataAccess/queryDocumentsPage";
|
||||
import * as DataModels from "../../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import * as StringUtility from "../../../Shared/StringUtility";
|
||||
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { userContext } from "../../../UserContext";
|
||||
import * as QueryUtils from "../../../Utils/QueryUtils";
|
||||
|
@ -209,13 +209,7 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
|||
|
||||
private _queryCopilotActive(): boolean {
|
||||
if (this.props.copilotEnabled) {
|
||||
const cachedCopilotToggleStatus: string = localStorage.getItem(
|
||||
`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`,
|
||||
);
|
||||
const copilotInitialActive: boolean = cachedCopilotToggleStatus
|
||||
? StringUtility.toBoolean(cachedCopilotToggleStatus)
|
||||
: true;
|
||||
return copilotInitialActive;
|
||||
return readCopilotToggleStatus(userContext.databaseAccount);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -584,7 +578,7 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
|||
private _toggleCopilot = (active: boolean) => {
|
||||
this.setState({ copilotActive: active });
|
||||
useQueryCopilot.getState().setCopilotEnabledforExecution(active);
|
||||
localStorage.setItem(`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`, active.toString());
|
||||
saveCopilotToggleStatus(userContext.databaseAccount, active);
|
||||
|
||||
TelemetryProcessor.traceSuccess(active ? Action.ActivateQueryCopilot : Action.DeactivateQueryCopilot, {
|
||||
databaseName: this.props.collection.databaseId,
|
||||
|
|
|
@ -72,6 +72,11 @@ export const useQueryTabStyles = makeStyles({
|
|||
metricsGridButtons: {
|
||||
...cosmosShorthands.borderTop(),
|
||||
},
|
||||
errorListTableCell: {
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
},
|
||||
errorListMessageCell: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
|
@ -80,5 +85,12 @@ export const useQueryTabStyles = makeStyles({
|
|||
},
|
||||
errorListMessage: {
|
||||
flexGrow: 1,
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
},
|
||||
errorListMessageActions: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { IMessageBarStyles, Link, MessageBar, MessageBarButton, MessageBarType } from "@fluentui/react";
|
||||
import { IMessageBarStyles, MessageBar, MessageBarButton, MessageBarType } from "@fluentui/react";
|
||||
import { CassandraProxyEndpoints, MongoProxyEndpoints } from "Common/Constants";
|
||||
import { sendMessage } from "Common/MessageHandler";
|
||||
import { Platform, configContext, updateConfigContext } from "ConfigContext";
|
||||
import { configContext, updateConfigContext } from "ConfigContext";
|
||||
import { IpRule } from "Contracts/DataModels";
|
||||
import { MessageTypes } from "Contracts/ExplorerContracts";
|
||||
import { CollectionTabKind } from "Contracts/ViewModels";
|
||||
|
@ -16,7 +16,6 @@ import { VcoreMongoConnectTab } from "Explorer/Tabs/VCoreMongoConnectTab";
|
|||
import { VcoreMongoQuickstartTab } from "Explorer/Tabs/VCoreMongoQuickstartTab";
|
||||
import { LayoutConstants } from "Explorer/Theme/ThemeUtil";
|
||||
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
|
||||
import { hasRUThresholdBeenConfigured } from "Shared/StorageUtility";
|
||||
import { userContext } from "UserContext";
|
||||
import { CassandraProxyOutboundIPs, MongoProxyOutboundIPs, PortalBackendIPs } from "Utils/EndpointUtils";
|
||||
import { useTeachingBubble } from "hooks/useTeachingBubble";
|
||||
|
@ -37,9 +36,6 @@ interface TabsProps {
|
|||
|
||||
export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
|
||||
const { openedTabs, openedReactTabs, activeTab, activeReactTab, networkSettingsWarning } = useTabs();
|
||||
const [showRUThresholdMessageBar, setShowRUThresholdMessageBar] = useState<boolean>(
|
||||
userContext.apiType === "SQL" && configContext.platform !== Platform.Fabric && !hasRUThresholdBeenConfigured(),
|
||||
);
|
||||
const [
|
||||
showMongoAndCassandraProxiesNetworkSettingsWarningState,
|
||||
setShowMongoAndCassandraProxiesNetworkSettingsWarningState,
|
||||
|
@ -87,30 +83,6 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
|
|||
{networkSettingsWarning}
|
||||
</MessageBar>
|
||||
)}
|
||||
{showRUThresholdMessageBar && (
|
||||
<MessageBar
|
||||
messageBarType={MessageBarType.info}
|
||||
onDismiss={() => {
|
||||
setShowRUThresholdMessageBar(false);
|
||||
}}
|
||||
styles={{
|
||||
...defaultMessageBarStyles,
|
||||
innerText: {
|
||||
fontWeight: "bold",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{`To prevent queries from using excessive RUs, Data Explorer has a 5,000 RU default limit. To modify or remove
|
||||
the limit, go to the Settings cog on the right and find "RU Threshold".`}
|
||||
<Link
|
||||
className="underlinedLink"
|
||||
href="https://review.learn.microsoft.com/en-us/azure/cosmos-db/data-explorer?branch=main#configure-request-unit-threshold"
|
||||
target="_blank"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</MessageBar>
|
||||
)}
|
||||
{showMongoAndCassandraProxiesNetworkSettingsWarningState && (
|
||||
<MessageBar
|
||||
messageBarType={MessageBarType.warning}
|
||||
|
@ -119,7 +91,7 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
|
|||
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.`}
|
||||
</MessageBar>
|
||||
)}
|
||||
|
|
|
@ -2,7 +2,6 @@ import { KeyboardActionGroup, clearKeyboardActionGroup } from "KeyboardShortcuts
|
|||
import * as ko from "knockout";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as ThemeUtility from "../../Common/ThemeUtility";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
|
@ -28,7 +27,6 @@ export default class TabsBase extends WaitsForTemplateViewModel {
|
|||
public tabPath: ko.Observable<string>;
|
||||
public isExecutionError = ko.observable(false);
|
||||
public isExecuting = ko.observable(false);
|
||||
public pendingNotification?: ko.Observable<DataModels.Notification>;
|
||||
protected _theme: string;
|
||||
public onLoadStartKey: number;
|
||||
|
||||
|
@ -45,7 +43,6 @@ export default class TabsBase extends WaitsForTemplateViewModel {
|
|||
this.tabPath =
|
||||
this.collection &&
|
||||
ko.observable<string>(`${this.collection.databaseId}>${this.collection.id()}>${options.title}`);
|
||||
this.pendingNotification = ko.observable<DataModels.Notification>(undefined);
|
||||
this.onLoadStartKey = options.onLoadStartKey;
|
||||
this.closeTabButton = {
|
||||
enabled: ko.computed<boolean>(() => {
|
||||
|
|
|
@ -16,7 +16,7 @@ import React from "react";
|
|||
import { appThemeFabricTealBrandRamp } from "../../Platform/Fabric/FabricTheme";
|
||||
|
||||
export const LayoutConstants = {
|
||||
rowHeight: 36,
|
||||
rowHeight: 32,
|
||||
};
|
||||
|
||||
// Our CosmosFluentProvider has the same props as a FluentProvider.
|
||||
|
@ -91,15 +91,30 @@ const appThemePortalBrandRamp: BrandVariants = {
|
|||
160: "#CDD8EF",
|
||||
};
|
||||
|
||||
const cosmosThemeElements = {
|
||||
layoutRowHeight: `${LayoutConstants.rowHeight}px`,
|
||||
export enum LayoutSize {
|
||||
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",
|
||||
sidebarInitialWidth: "300px",
|
||||
};
|
||||
|
||||
export type CosmosTheme = Theme & typeof cosmosThemeElements;
|
||||
|
||||
export const tokens = themeToTokensObject({ ...webLightTheme, ...cosmosThemeElements });
|
||||
export const tokens = themeToTokensObject({ ...webLightTheme, ...cosmosTheme, ...sizeMappings[LayoutSize.Compact] });
|
||||
|
||||
export const cosmosShorthands = {
|
||||
border: () => shorthands.border("1px", "solid", tokens.colorNeutralStroke2),
|
||||
|
@ -117,6 +132,7 @@ export function getPlatformTheme(platform: Platform): CosmosTheme {
|
|||
|
||||
return {
|
||||
...baseTheme,
|
||||
...cosmosThemeElements,
|
||||
...cosmosTheme,
|
||||
...sizeMappings[LayoutSize.Compact], // TODO: Allow for different layout sizes.
|
||||
};
|
||||
}
|
||||
|
|
|
@ -5,8 +5,6 @@ import * as ko from "knockout";
|
|||
import * as _ from "underscore";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils";
|
||||
import * as Logger from "../../Common/Logger";
|
||||
import { fetchPortalNotifications } from "../../Common/PortalNotifications";
|
||||
import { bulkCreateDocument } from "../../Common/dataAccess/bulkCreateDocument";
|
||||
import { createDocument } from "../../Common/dataAccess/createDocument";
|
||||
import { getCollectionUsageSizeInKB } from "../../Common/dataAccess/getCollectionDataUsageSize";
|
||||
|
@ -1020,41 +1018,6 @@ export default class Collection implements ViewModels.Collection {
|
|||
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[] }> {
|
||||
const data = await Promise.all(Array.from(files).map((file) => this.uploadFile(file)));
|
||||
|
||||
|
|
|
@ -4,8 +4,6 @@ import * as _ from "underscore";
|
|||
import { AuthType } from "../../AuthType";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
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 { readDatabaseOffer } from "../../Common/dataAccess/readDatabaseOffer";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
|
@ -76,7 +74,6 @@ export default class Database implements ViewModels.Database {
|
|||
await useDatabases.getState().loadAllOffers();
|
||||
}
|
||||
|
||||
const pendingNotificationsPromise: Promise<DataModels.Notification> = this.getPendingThroughputSplitNotification();
|
||||
const tabKind = ViewModels.CollectionTabKind.DatabaseSettingsV2;
|
||||
const matchingTabs = useTabs.getState().getTabs(tabKind, (tab) => tab.node?.id() === this.id());
|
||||
let settingsTab = matchingTabs?.[0] as DatabaseSettingsTabV2;
|
||||
|
@ -87,53 +84,39 @@ export default class Database implements ViewModels.Database {
|
|||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: "Scale",
|
||||
});
|
||||
pendingNotificationsPromise.then(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(data: any) => {
|
||||
const pendingNotification: DataModels.Notification = data?.[0];
|
||||
const tabOptions: ViewModels.TabOptions = {
|
||||
tabKind,
|
||||
title: "Scale",
|
||||
tabPath: "",
|
||||
node: this,
|
||||
rid: this.rid,
|
||||
database: this,
|
||||
onLoadStartKey: startKey,
|
||||
};
|
||||
settingsTab = new DatabaseSettingsTabV2(tabOptions);
|
||||
settingsTab.pendingNotification(pendingNotification);
|
||||
useTabs.getState().activateNewTab(settingsTab);
|
||||
},
|
||||
(error) => {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.Tab,
|
||||
{
|
||||
databaseName: this.id(),
|
||||
collectionName: this.id(),
|
||||
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: "Scale",
|
||||
error: errorMessage,
|
||||
errorStack: getErrorStack(error),
|
||||
},
|
||||
startKey,
|
||||
);
|
||||
logConsoleError(`Error while fetching database settings for database ${this.id()}: ${errorMessage}`);
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
try {
|
||||
const tabOptions: ViewModels.TabOptions = {
|
||||
tabKind,
|
||||
title: "Scale",
|
||||
tabPath: "",
|
||||
node: this,
|
||||
rid: this.rid,
|
||||
database: this,
|
||||
onLoadStartKey: startKey,
|
||||
};
|
||||
settingsTab = new DatabaseSettingsTabV2(tabOptions);
|
||||
useTabs.getState().activateNewTab(settingsTab);
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.Tab,
|
||||
{
|
||||
databaseName: this.id(),
|
||||
collectionName: this.id(),
|
||||
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle: "Scale",
|
||||
error: errorMessage,
|
||||
errorStack: getErrorStack(error),
|
||||
},
|
||||
startKey,
|
||||
);
|
||||
logConsoleError(`Error while fetching database settings for database ${this.id()}: ${errorMessage}`);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
pendingNotificationsPromise.then(
|
||||
(pendingNotification: DataModels.Notification) => {
|
||||
settingsTab.pendingNotification(pendingNotification);
|
||||
useTabs.getState().activateTab(settingsTab);
|
||||
},
|
||||
() => {
|
||||
settingsTab.pendingNotification(undefined);
|
||||
useTabs.getState().activateTab(settingsTab);
|
||||
},
|
||||
);
|
||||
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[]): {
|
||||
toAdd: DataModels.Collection[];
|
||||
toDelete: Collection[];
|
||||
|
|
|
@ -30,6 +30,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
|
|||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "standardCollection",
|
||||
|
@ -69,6 +72,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
|
|||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "conflictsCollection",
|
||||
|
@ -92,6 +98,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
|
|||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "standardDb",
|
||||
|
@ -102,6 +111,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
|
|||
{
|
||||
"children": [
|
||||
{
|
||||
"iconSrc": <SettingsRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"id": "",
|
||||
"isSelected": [Function],
|
||||
"label": "Scale",
|
||||
|
@ -133,6 +145,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
|
|||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "sampleItemsCollection",
|
||||
|
@ -156,6 +171,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
|
|||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "sharedDatabase",
|
||||
|
@ -246,6 +264,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
|
|||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "schemaCollection",
|
||||
|
@ -274,6 +295,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
|
|||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "giganticDatabase",
|
||||
|
@ -345,6 +369,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
|
|||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "standardCollection",
|
||||
|
@ -415,6 +442,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
|
|||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "conflictsCollection",
|
||||
|
@ -438,6 +468,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
|
|||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "standardDb",
|
||||
|
@ -448,6 +481,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
|
|||
{
|
||||
"children": [
|
||||
{
|
||||
"iconSrc": <SettingsRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"id": "",
|
||||
"isSelected": [Function],
|
||||
"label": "Scale",
|
||||
|
@ -510,6 +546,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
|
|||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "sampleItemsCollection",
|
||||
|
@ -533,6 +572,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
|
|||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "sharedDatabase",
|
||||
|
@ -654,6 +696,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
|
|||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "schemaCollection",
|
||||
|
@ -682,6 +727,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
|
|||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "giganticDatabase",
|
||||
|
@ -706,6 +754,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
|||
"onClick": [Function],
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "standardCollection",
|
||||
|
@ -724,6 +775,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
|||
"onClick": [Function],
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "conflictsCollection",
|
||||
|
@ -747,6 +801,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
|||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "standardDb",
|
||||
|
@ -766,6 +823,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
|||
"onClick": [Function],
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "sampleItemsCollection",
|
||||
|
@ -789,6 +849,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
|||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "sharedDatabase",
|
||||
|
@ -808,6 +871,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
|||
"onClick": [Function],
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "schemaCollection",
|
||||
|
@ -836,6 +902,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
|||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "giganticDatabase",
|
||||
|
@ -976,6 +1045,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
|||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "standardCollection",
|
||||
|
@ -1076,6 +1148,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
|||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "conflictsCollection",
|
||||
|
@ -1099,6 +1174,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
|||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "standardDb",
|
||||
|
@ -1109,6 +1187,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
|||
{
|
||||
"children": [
|
||||
{
|
||||
"iconSrc": <SettingsRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"id": "",
|
||||
"isSelected": [Function],
|
||||
"label": "Scale",
|
||||
|
@ -1201,6 +1282,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
|||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "sampleItemsCollection",
|
||||
|
@ -1224,6 +1308,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
|||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "sharedDatabase",
|
||||
|
@ -1375,6 +1462,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
|||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "schemaCollection",
|
||||
|
@ -1403,6 +1493,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
|||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "giganticDatabase",
|
||||
|
@ -1543,6 +1636,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
|
|||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "standardCollection",
|
||||
|
@ -1638,6 +1734,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
|
|||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "conflictsCollection",
|
||||
|
@ -1661,6 +1760,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
|
|||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "standardDb",
|
||||
|
@ -1671,6 +1773,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
|
|||
{
|
||||
"children": [
|
||||
{
|
||||
"iconSrc": <SettingsRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"id": "",
|
||||
"isSelected": [Function],
|
||||
"label": "Scale",
|
||||
|
@ -1763,6 +1868,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
|
|||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "sampleItemsCollection",
|
||||
|
@ -1786,6 +1894,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
|
|||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "sharedDatabase",
|
||||
|
@ -1937,6 +2048,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
|
|||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "schemaCollection",
|
||||
|
@ -1965,6 +2079,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
|
|||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "giganticDatabase",
|
||||
|
@ -1986,6 +2103,9 @@ exports[`createResourceTokenTreeNodes creates the expected tree nodes 1`] = `
|
|||
},
|
||||
],
|
||||
"className": "collectionNode",
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "testCollection",
|
||||
|
@ -2021,6 +2141,9 @@ exports[`createSampleDataTreeNodes creates the expected tree nodes 1`] = `
|
|||
"onClick": [Function],
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": false,
|
||||
"isSelected": [Function],
|
||||
"label": "testCollection",
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { DatabaseRegular, DocumentMultipleRegular, SettingsRegular } from "@fluentui/react-icons";
|
||||
import { TreeNode } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
|
||||
import TabsBase from "Explorer/Tabs/TabsBase";
|
||||
import StoredProcedure from "Explorer/Tree/StoredProcedure";
|
||||
|
@ -7,6 +8,7 @@ import { useDatabases } from "Explorer/useDatabases";
|
|||
import { getItemName } from "Utils/APITypeUtils";
|
||||
import { isServerlessAccount } from "Utils/CapabilityUtils";
|
||||
import { useTabs } from "hooks/useTabs";
|
||||
import React from "react";
|
||||
import { isPublicInternetAccessAllowed } from "../../Common/DatabaseAccountUtility";
|
||||
import { Platform, configContext } from "../../ConfigContext";
|
||||
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[] => {
|
||||
const updatedSampleTree: TreeNode = {
|
||||
label: sampleDataResourceTokenCollection.databaseId,
|
||||
|
@ -36,6 +42,7 @@ export const createSampleDataTreeNodes = (sampleDataResourceTokenCollection: Vie
|
|||
isExpanded: false,
|
||||
className: "collectionNode",
|
||||
contextMenu: ResourceTreeContextMenuButtonFactory.createSampleCollectionContextMenuButton(),
|
||||
iconSrc: TreeCollectionIcon,
|
||||
onClick: () => {
|
||||
useSelectedNode.getState().setSelectedNode(sampleDataResourceTokenCollection);
|
||||
useCommandBar.getState().setContextButtons([]);
|
||||
|
@ -104,6 +111,7 @@ export const createResourceTokenTreeNodes = (collection: ViewModels.CollectionBa
|
|||
isExpanded: true,
|
||||
children,
|
||||
className: "collectionNode",
|
||||
iconSrc: TreeCollectionIcon,
|
||||
onClick: () => {
|
||||
// Rewritten version of expandCollapseCollection
|
||||
useSelectedNode.getState().setSelectedNode(collection);
|
||||
|
@ -133,6 +141,7 @@ export const createDatabaseTreeNodes = (
|
|||
databaseNode.children.push({
|
||||
id: database.isSampleDB ? "sampleScaleSettings" : "",
|
||||
label: "Scale",
|
||||
iconSrc: TreeSettingsIcon,
|
||||
isSelected: () =>
|
||||
useSelectedNode
|
||||
.getState()
|
||||
|
@ -169,6 +178,7 @@ export const createDatabaseTreeNodes = (
|
|||
children: [],
|
||||
isSelected: () => useSelectedNode.getState().isDataNodeSelected(database.id()),
|
||||
contextMenu: ResourceTreeContextMenuButtonFactory.createDatabaseContextMenu(container, database.id()),
|
||||
iconSrc: TreeDatabaseIcon,
|
||||
onExpanded: async () => {
|
||||
useSelectedNode.getState().setSelectedNode(database);
|
||||
if (!databaseNode.children || databaseNode.children?.length === 0) {
|
||||
|
@ -219,6 +229,7 @@ export const buildCollectionNode = (
|
|||
children: children,
|
||||
className: "collectionNode",
|
||||
contextMenu: ResourceTreeContextMenuButtonFactory.createCollectionContextMenuButton(container, collection),
|
||||
iconSrc: TreeCollectionIcon,
|
||||
onClick: () => {
|
||||
useSelectedNode.getState().setSelectedNode(collection);
|
||||
collection.openTab();
|
|
@ -52,7 +52,7 @@ export const isAccountRestrictedForConnectionStringLogin = async (connectionStri
|
|||
const headers = new Headers();
|
||||
headers.append(HttpHeaders.connectionString, connectionString);
|
||||
|
||||
const backendEndpoint: string = useNewPortalBackendEndpoint(BackendApi.PortalSettings)
|
||||
const backendEndpoint: string = useNewPortalBackendEndpoint(BackendApi.AccountRestrictions)
|
||||
? configContext.PORTAL_BACKEND_ENDPOINT
|
||||
: configContext.BACKEND_ENDPOINT;
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ describe("parseResourceTokenConnectionString", () => {
|
|||
collectionId: "fakeCollectionId",
|
||||
databaseId: "fakeDatabaseId",
|
||||
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",
|
||||
databaseId: "fakeDatabaseId",
|
||||
partitionKey: "fakePartitionKey",
|
||||
resourceToken: "type=resource&ver=1&sig=2dIP+CdIfT1ScwHWdv5GGw==;fakeToken;",
|
||||
resourceToken: "type=resource&ver=1&sig=2dIP+CdIfT1ScwHWdv5GGw==;fakeToken",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -30,6 +30,10 @@ export function parseResourceTokenConnectionString(connectionString: string): Pa
|
|||
}
|
||||
});
|
||||
|
||||
if (resourceToken && resourceToken.endsWith(";")) {
|
||||
resourceToken = resourceToken.substring(0, resourceToken.length - 1);
|
||||
}
|
||||
|
||||
return {
|
||||
accountEndpoint,
|
||||
collectionId,
|
||||
|
|
|
@ -38,6 +38,7 @@ export type Features = {
|
|||
readonly copilotChatFixedMonacoEditorHeight: boolean;
|
||||
readonly enablePriorityBasedExecution: boolean;
|
||||
readonly disableConnectionStringLogin: boolean;
|
||||
readonly enableDocumentsTableColumnSelection: boolean;
|
||||
|
||||
// can be set via both flight and feature flag
|
||||
autoscaleDefault: boolean;
|
||||
|
@ -108,6 +109,7 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear
|
|||
copilotChatFixedMonacoEditorHeight: "true" === get("copilotchatfixedmonacoeditorheight"),
|
||||
enablePriorityBasedExecution: "true" === get("enableprioritybasedexecution"),
|
||||
disableConnectionStringLogin: "true" === get("disableconnectionstringlogin"),
|
||||
enableDocumentsTableColumnSelection: "true" === get("enabledocumentstablecolumnselection"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
};
|
|
@ -20,3 +20,14 @@ export const setEntryNumber = (key: StorageKey, value: number): void =>
|
|||
|
||||
export const setEntryBoolean = (key: StorageKey, value: boolean): void =>
|
||||
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;
|
||||
};
|
||||
|
|
|
@ -29,7 +29,9 @@ export enum StorageKey {
|
|||
GalleryCalloutDismissed,
|
||||
VisitedAccounts,
|
||||
PriorityLevel,
|
||||
DocumentsTabPrefs,
|
||||
DefaultQueryResultsView,
|
||||
AppState,
|
||||
}
|
||||
|
||||
export const hasRUThresholdBeenConfigured = (): boolean => {
|
||||
|
@ -56,10 +58,10 @@ export const getRUThreshold = (): number => {
|
|||
|
||||
export const getDefaultQueryResultsView = (): SplitterDirection => {
|
||||
const defaultQueryResultsViewRaw = LocalStorageUtility.getEntryString(StorageKey.DefaultQueryResultsView);
|
||||
if (defaultQueryResultsViewRaw === SplitterDirection.Horizontal) {
|
||||
return SplitterDirection.Horizontal;
|
||||
if (defaultQueryResultsViewRaw === SplitterDirection.Vertical) {
|
||||
return SplitterDirection.Vertical;
|
||||
}
|
||||
return SplitterDirection.Vertical;
|
||||
return SplitterDirection.Horizontal;
|
||||
};
|
||||
|
||||
export const DefaultRUThreshold = 5000;
|
||||
|
|
|
@ -139,6 +139,9 @@ export enum Action {
|
|||
QueryEdited,
|
||||
ExecuteQueryGeneratedFromQueryCopilot,
|
||||
DeleteDocuments,
|
||||
ReadPersistedTabState,
|
||||
SavePersistedTabState,
|
||||
DeletePersistedTabState,
|
||||
}
|
||||
|
||||
export const ActionModifiers = {
|
||||
|
|
|
@ -78,6 +78,13 @@ export const PortalBackendIPs: { [key: string]: string[] } = {
|
|||
//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[] } = {
|
||||
[MongoProxyEndpoints.Mpac]: ["20.245.81.54", "40.118.23.126"],
|
||||
[MongoProxyEndpoints.Prod]: ["40.80.152.199", "13.95.130.121"],
|
||||
|
@ -168,7 +175,28 @@ export function useNewPortalBackendEndpoint(backendApi: string): boolean {
|
|||
PortalBackendEndpoints.Mpac,
|
||||
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) {
|
||||
|
|
|
@ -1,20 +1,15 @@
|
|||
import { MongoProxyEndpoints, PortalBackendEndpoints } from "Common/Constants";
|
||||
import { resetConfigContext, updateConfigContext } from "ConfigContext";
|
||||
import { DatabaseAccount, IpRule } from "Contracts/DataModels";
|
||||
import { updateUserContext } from "UserContext";
|
||||
import { PortalBackendIPs } from "Utils/EndpointUtils";
|
||||
import { MongoProxyOutboundIPs, PortalBackendIPs, PortalBackendOutboundIPs } from "Utils/EndpointUtils";
|
||||
import { getNetworkSettingsWarningMessage } from "./NetworkUtility";
|
||||
|
||||
describe("NetworkUtility tests", () => {
|
||||
describe("getNetworkSettingsWarningMessage", () => {
|
||||
const legacyBackendEndpoint: string = "https://main.documentdb.ext.azure.com";
|
||||
const publicAccessMessagePart = "Please enable public access 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;
|
||||
const warningMessageFunc = (msg: string) => (warningMessageResult = msg);
|
||||
|
||||
|
@ -52,52 +47,59 @@ describe("NetworkUtility tests", () => {
|
|||
expect(warningMessageResult).toContain(publicAccessMessagePart);
|
||||
});
|
||||
|
||||
it(`should return no message when the appropriate ip rules are added to mongo/cassandra account per endpoint`, () => {
|
||||
validEndpoints.forEach(async (endpoint) => {
|
||||
updateUserContext({
|
||||
databaseAccount: {
|
||||
kind: "MongoDB",
|
||||
properties: {
|
||||
ipRules: PortalBackendIPs[endpoint].map((ip: string) => ({ ipAddressOrRange: ip }) as IpRule),
|
||||
publicNetworkAccess: "Enabled",
|
||||
},
|
||||
} as DatabaseAccount,
|
||||
});
|
||||
|
||||
updateConfigContext({
|
||||
BACKEND_ENDPOINT: endpoint,
|
||||
});
|
||||
|
||||
let asyncWarningMessageResult: string;
|
||||
const asyncWarningMessageFunc = (msg: string) => (asyncWarningMessageResult = msg);
|
||||
|
||||
await getNetworkSettingsWarningMessage(asyncWarningMessageFunc);
|
||||
expect(asyncWarningMessageResult).toBeUndefined();
|
||||
it(`should return no message when the appropriate ip rules are added to mongo/cassandra account per endpoint`, async () => {
|
||||
const portalBackendOutboundIPsWithLegacyIPs: string[] = [
|
||||
...PortalBackendOutboundIPs[PortalBackendEndpoints.Mpac],
|
||||
...PortalBackendOutboundIPs[PortalBackendEndpoints.Prod],
|
||||
...MongoProxyOutboundIPs[MongoProxyEndpoints.Mpac],
|
||||
...MongoProxyOutboundIPs[MongoProxyEndpoints.Prod],
|
||||
...PortalBackendIPs["https://main.documentdb.ext.azure.com"],
|
||||
];
|
||||
updateUserContext({
|
||||
databaseAccount: {
|
||||
kind: "MongoDB",
|
||||
properties: {
|
||||
ipRules: portalBackendOutboundIPsWithLegacyIPs.map((ip: string) => ({ ipAddressOrRange: ip }) as IpRule),
|
||||
publicNetworkAccess: "Enabled",
|
||||
},
|
||||
} as DatabaseAccount,
|
||||
});
|
||||
|
||||
updateConfigContext({
|
||||
BACKEND_ENDPOINT: legacyBackendEndpoint,
|
||||
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Mpac,
|
||||
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Mpac,
|
||||
});
|
||||
|
||||
let asyncWarningMessageResult: string;
|
||||
const asyncWarningMessageFunc = (msg: string) => (asyncWarningMessageResult = msg);
|
||||
|
||||
await getNetworkSettingsWarningMessage(asyncWarningMessageFunc);
|
||||
expect(asyncWarningMessageResult).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return accessMessage when incorrent ip rule is added to mongo/cassandra account per endpoint", () => {
|
||||
validEndpoints.forEach(async (endpoint) => {
|
||||
updateUserContext({
|
||||
databaseAccount: {
|
||||
kind: "MongoDB",
|
||||
properties: {
|
||||
ipRules: [{ ipAddressOrRange: "1.1.1.1" }],
|
||||
publicNetworkAccess: "Enabled",
|
||||
},
|
||||
} as DatabaseAccount,
|
||||
});
|
||||
|
||||
updateConfigContext({
|
||||
BACKEND_ENDPOINT: endpoint,
|
||||
});
|
||||
|
||||
let asyncWarningMessageResult: string;
|
||||
const asyncWarningMessageFunc = (msg: string) => (asyncWarningMessageResult = msg);
|
||||
|
||||
await getNetworkSettingsWarningMessage(asyncWarningMessageFunc);
|
||||
expect(asyncWarningMessageResult).toContain(accessMessagePart);
|
||||
it("should return accessMessage when incorrent ip rule is added to mongo/cassandra account per endpoint", async () => {
|
||||
updateUserContext({
|
||||
databaseAccount: {
|
||||
kind: "MongoDB",
|
||||
properties: {
|
||||
ipRules: [{ ipAddressOrRange: "1.1.1.1" }],
|
||||
publicNetworkAccess: "Enabled",
|
||||
},
|
||||
} as DatabaseAccount,
|
||||
});
|
||||
|
||||
updateConfigContext({
|
||||
BACKEND_ENDPOINT: legacyBackendEndpoint,
|
||||
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Mpac,
|
||||
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Mpac,
|
||||
});
|
||||
|
||||
let asyncWarningMessageResult: string;
|
||||
const asyncWarningMessageFunc = (msg: string) => (asyncWarningMessageResult = msg);
|
||||
|
||||
await getNetworkSettingsWarningMessage(asyncWarningMessageFunc);
|
||||
expect(asyncWarningMessageResult).toContain(accessMessagePart);
|
||||
});
|
||||
|
||||
// Postgres and vcore mongo account checks basically pass through to CheckFirewallRules so those
|
||||
|
|
|
@ -1,7 +1,13 @@
|
|||
import { CassandraProxyEndpoints, MongoProxyEndpoints, PortalBackendEndpoints } from "Common/Constants";
|
||||
import { configContext } from "ConfigContext";
|
||||
import { checkFirewallRules } from "Explorer/Tabs/Shared/CheckFirewallRules";
|
||||
import { userContext } from "UserContext";
|
||||
import { PortalBackendIPs } from "Utils/EndpointUtils";
|
||||
import {
|
||||
CassandraProxyOutboundIPs,
|
||||
MongoProxyOutboundIPs,
|
||||
PortalBackendIPs,
|
||||
PortalBackendOutboundIPs,
|
||||
} from "Utils/EndpointUtils";
|
||||
|
||||
export const getNetworkSettingsWarningMessage = async (
|
||||
setStateFunc: (warningMessage: string) => void,
|
||||
|
@ -45,18 +51,53 @@ export const getNetworkSettingsWarningMessage = async (
|
|||
const ipRules = accountProperties.ipRules;
|
||||
// public network access is NOT set to "All networks"
|
||||
if (ipRules?.length > 0) {
|
||||
if (userContext.apiType === "Cassandra" || userContext.apiType === "Mongo") {
|
||||
const portalIPs = PortalBackendIPs[configContext.BACKEND_ENDPOINT];
|
||||
let numberOfMatches = 0;
|
||||
ipRules.forEach((ipRule) => {
|
||||
if (portalIPs.indexOf(ipRule.ipAddressOrRange) !== -1) {
|
||||
numberOfMatches++;
|
||||
}
|
||||
});
|
||||
const isProdOrMpacPortalBackendEndpoint: boolean = [
|
||||
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 (numberOfMatches !== portalIPs.length) {
|
||||
setStateFunc(accessMessage);
|
||||
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;
|
||||
ipRules.forEach((ipRule) => {
|
||||
if (portalIPs.indexOf(ipRule.ipAddressOrRange) !== -1) {
|
||||
numberOfMatches++;
|
||||
}
|
||||
});
|
||||
|
||||
if (numberOfMatches !== portalIPs.length) {
|
||||
setStateFunc(accessMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import * as sinon from "sinon";
|
|||
import * as DataModels from "../Contracts/DataModels";
|
||||
import * as ViewModels from "../Contracts/ViewModels";
|
||||
import * as QueryUtils from "./QueryUtils";
|
||||
import { extractPartitionKeyValues } from "./QueryUtils";
|
||||
import { defaultQueryFields, extractPartitionKeyValues } from "./QueryUtils";
|
||||
|
||||
describe("Query Utils", () => {
|
||||
const generatePartitionKeyForPath = (path: string): DataModels.PartitionKey => {
|
||||
|
@ -54,6 +54,20 @@ describe("Query Utils", () => {
|
|||
|
||||
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()", () => {
|
||||
|
@ -147,5 +161,33 @@ describe("Query Utils", () => {
|
|||
expect(expectedPartitionKeyValues).toContain(documentContent["Type"]);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -2,18 +2,29 @@ import { PartitionKey, PartitionKeyDefinition } from "@azure/cosmos";
|
|||
import * as DataModels from "../Contracts/DataModels";
|
||||
import * as ViewModels from "../Contracts/ViewModels";
|
||||
|
||||
export const defaultQueryFields = ["id", "_self", "_rid", "_ts"];
|
||||
|
||||
export function buildDocumentsQuery(
|
||||
filter: string,
|
||||
partitionKeyProperties: string[],
|
||||
partitionKey: DataModels.PartitionKey,
|
||||
additionalField: 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 =
|
||||
partitionKeyProperties && partitionKeyProperties.length > 0
|
||||
? `select c.id, c._self, c._rid, c._ts, [${buildDocumentsQueryPartitionProjections(
|
||||
? `select ${objectListSpec}, [${buildDocumentsQueryPartitionProjections(
|
||||
"c",
|
||||
partitionKey,
|
||||
)}] as _partitionKeyValue from c`
|
||||
: `select c.id, c._self, c._rid, c._ts from c`;
|
||||
: `select ${objectListSpec} from c`;
|
||||
|
||||
if (filter) {
|
||||
query += " " + filter;
|
||||
|
@ -96,7 +107,7 @@ export const extractPartitionKeyValues = (
|
|||
const partitionKeyValues: PartitionKey[] = [];
|
||||
partitionKeyDefinition.paths.forEach((partitionKeyPath: string) => {
|
||||
const partitionKeyPathWithoutSlash: string = partitionKeyPath.substring(1);
|
||||
if (documentContent[partitionKeyPathWithoutSlash]) {
|
||||
if (documentContent[partitionKeyPathWithoutSlash] !== undefined) {
|
||||
partitionKeyValues.push(documentContent[partitionKeyPathWithoutSlash]);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -8,6 +8,7 @@ import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
|
|||
import { useSelectedNode } from "Explorer/useSelectedNode";
|
||||
import { scheduleRefreshDatabaseResourceToken } from "Platform/Fabric/FabricUtil";
|
||||
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
|
||||
import { useNewPortalBackendEndpoint } from "Utils/EndpointUtils";
|
||||
import { getNetworkSettingsWarningMessage } from "Utils/NetworkUtility";
|
||||
import { logConsoleError } from "Utils/NotificationConsoleUtils";
|
||||
import { useQueryCopilot } from "hooks/useQueryCopilot";
|
||||
|
@ -760,11 +761,17 @@ async function updateContextForSampleData(explorer: Explorer): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
const sampleDatabaseEndpoint = useQueryCopilot.getState().copilotUserDBEnabled
|
||||
? `/api/tokens/sampledataconnection/v2`
|
||||
: `/api/tokens/sampledataconnection`;
|
||||
let url: string;
|
||||
if (useNewPortalBackendEndpoint(Constants.BackendApi.SampleData)) {
|
||||
url = createUri(configContext.PORTAL_BACKEND_ENDPOINT, "/api/sampledata");
|
||||
} else {
|
||||
const sampleDatabaseEndpoint = useQueryCopilot.getState().copilotUserDBEnabled
|
||||
? `/api/tokens/sampledataconnection/v2`
|
||||
: `/api/tokens/sampledataconnection`;
|
||||
|
||||
url = createUri(`${configContext.BACKEND_ENDPOINT}`, sampleDatabaseEndpoint);
|
||||
}
|
||||
|
||||
const url = createUri(`${configContext.BACKEND_ENDPOINT}`, sampleDatabaseEndpoint);
|
||||
const authorizationHeader = getAuthorizationHeader();
|
||||
const headers = { [authorizationHeader.header]: authorizationHeader.token };
|
||||
|
||||
|
|
|
@ -1,14 +1,34 @@
|
|||
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 { AccessInputMetadata } from "../Contracts/DataModels";
|
||||
|
||||
const url = `${configContext.BACKEND_ENDPOINT}${ApiEndpoints.guestRuntimeProxy}/accessinputmetadata?_=1609359229955`;
|
||||
|
||||
export async function fetchAccessData(portalToken: string): Promise<AccessInputMetadata> {
|
||||
if (!useNewPortalBackendEndpoint(BackendApi.RuntimeProxy)) {
|
||||
return fetchAccessData_ToBeDeprecated(portalToken);
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
// 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 = {
|
||||
method: "GET",
|
||||
|
|
|
@ -71,6 +71,8 @@ test("Query stats", 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.setText("SELECT\n glarb(c.id),\n blarg(c.id)\nFROM c");
|
||||
|
||||
|
|
Loading…
Reference in New Issue