diff --git a/less/documentDB.less b/less/documentDB.less index ee4dba4b6..1abbc9b30 100644 --- a/less/documentDB.less +++ b/less/documentDB.less @@ -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 { diff --git a/package-lock.json b/package-lock.json index afa16806b..db0aa9b88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index ce0042890..d0d3837c2 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -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 = { diff --git a/src/Common/CosmosClient.ts b/src/Common/CosmosClient.ts index 2216e2448..f286f3fbc 100644 --- a/src/Common/CosmosClient.ts +++ b/src/Common/CosmosClient.ts @@ -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 { + 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 { try { const host = configContext.BACKEND_ENDPOINT; diff --git a/src/Common/EnvironmentUtility.test.ts b/src/Common/EnvironmentUtility.test.ts index 6b5b1e218..1c1d9d8da 100644 --- a/src/Common/EnvironmentUtility.test.ts +++ b/src/Common/EnvironmentUtility.test.ts @@ -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); + }); }); diff --git a/src/Common/EnvironmentUtility.ts b/src/Common/EnvironmentUtility.ts index b133cd8b3..cafcbfb3e 100644 --- a/src/Common/EnvironmentUtility.ts +++ b/src/Common/EnvironmentUtility.ts @@ -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]; +}; diff --git a/src/Common/MongoProxyClient.ts b/src/Common/MongoProxyClient.ts index 64c42d875..d3b24928a 100644 --- a/src/Common/MongoProxyClient.ts +++ b/src/Common/MongoProxyClient.ts @@ -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 { @@ -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); } diff --git a/src/Common/PortalNotifications.ts b/src/Common/PortalNotifications.ts deleted file mode 100644 index 774c37cad..000000000 --- a/src/Common/PortalNotifications.ts +++ /dev/null @@ -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 => { - 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[]; -}; diff --git a/src/Common/QueryError.test.ts b/src/Common/QueryError.test.ts new file mode 100644 index 000000000..2eea29a62 --- /dev/null +++ b/src/Common/QueryError.test.ts @@ -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 }), + ), + ]); + }); +}); diff --git a/src/Common/QueryError.ts b/src/Common/QueryError.ts index d7fa8033c..51748d1a8 100644 --- a/src/Common/QueryError.ts +++ b/src/Common/QueryError.ts @@ -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> = { + 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 = { + 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; } diff --git a/src/Common/Tooltip/InfoTooltip.tsx b/src/Common/Tooltip/InfoTooltip.tsx index d85326aa4..abd12385e 100644 --- a/src/Common/Tooltip/InfoTooltip.tsx +++ b/src/Common/Tooltip/InfoTooltip.tsx @@ -3,11 +3,12 @@ import * as React from "react"; export interface TooltipProps { children: string; + className?: string; } -export const InfoTooltip: React.FunctionComponent = ({ children }: TooltipProps) => { +export const InfoTooltip: React.FunctionComponent = ({ children, className }: TooltipProps) => { return ( - + diff --git a/src/Common/dataAccess/deleteDocument.ts b/src/Common/dataAccess/deleteDocument.ts index f20dc9cc8..1f551ee0e 100644 --- a/src/Common/dataAccess/deleteDocument.ts +++ b/src/Common/dataAccess/deleteDocument.ts @@ -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 => { - const nbDocuments = documentIds.length; +export const deleteDocuments = async ( + collection: CollectionBase, + documentIds: DocumentId[], +): Promise => { 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( diff --git a/src/ConfigContext.ts b/src/ConfigContext.ts index 6d15a8a0f..f5a17bbef 100644 --- a/src/ConfigContext.ts +++ b/src/ConfigContext.ts @@ -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 = { `^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 = { "deleteDocument", "createCollectionWithProxy", "legacyMongoShell", - "bulkdelete", + // "bulkdelete", ], MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED: false, CASSANDRA_PROXY_ENDPOINT: CassandraProxyEndpoints.Prod, diff --git a/src/Contracts/ViewModels.ts b/src/Contracts/ViewModels.ts index 04afc10bb..767665686 100644 --- a/src/Contracts/ViewModels.ts +++ b/src/Contracts/ViewModels.ts @@ -98,7 +98,6 @@ export interface Database extends TreeNode { openAddCollection(database: Database, event: MouseEvent): void; onSettingsClick: () => void; loadOffer(): Promise; - getPendingThroughputSplitNotification(): Promise; } 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; } /** diff --git a/src/Explorer/Controls/Dialog.tsx b/src/Explorer/Controls/Dialog.tsx index 87f4edb91..9f69d4761 100644 --- a/src/Explorer/Controls/Dialog.tsx +++ b/src/Explorer/Controls/Dialog.tsx @@ -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 = create((set, get) => ({ @@ -83,7 +83,7 @@ export const useDialog: UseStore = 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 = create((set, get) => ({ get().closeDialog(); }, onSecondaryButtonClick: undefined, + linkProps, }), })); diff --git a/src/Explorer/Controls/ProgressModalDialog.tsx b/src/Explorer/Controls/ProgressModalDialog.tsx new file mode 100644 index 000000000..1d94d66ef --- /dev/null +++ b/src/Explorer/Controls/ProgressModalDialog.tsx @@ -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 = ({ + isOpen, + title, + message, + maxValue, + value, + dismissText, + onCancel, + onDismiss, + children, + mode = "completed", +}) => ( + { + if (!data.open) { + onDismiss(); + } + }} + > + + + {title} + + + + + {children} + + + {mode === "inProgress" || mode === "aborting" ? ( + + ) : ( + + + + )} + + + + +); diff --git a/src/Explorer/Controls/Settings/SettingsComponent.test.tsx b/src/Explorer/Controls/Settings/SettingsComponent.test.tsx index 20c2d027a..a97ee8f45 100644 --- a/src/Explorer/Controls/Settings/SettingsComponent.test.tsx +++ b/src/Explorer/Controls/Settings/SettingsComponent.test.tsx @@ -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); diff --git a/src/Explorer/Controls/Settings/SettingsComponent.tsx b/src/Explorer/Controls/Settings/SettingsComponent.tsx index 04e08753e..74f3fb4f6 100644 --- a/src/Explorer/Controls/Settings/SettingsComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsComponent.tsx @@ -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 { - 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(); - 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(); - 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); diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ScaleComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ScaleComponent.tsx index 761fe899b..251a3b841 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/ScaleComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ScaleComponent.tsx @@ -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 { }; 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 { 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 => ( - - - 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. -
- Database: test, Container: test - , Current autoscale throughput: 100 - 1000 RU/s, Target autoscale throughput: 600 - 6000 RU/s -
-
- - - - -`; diff --git a/src/Explorer/Controls/Settings/SettingsUtils.test.tsx b/src/Explorer/Controls/Settings/SettingsUtils.test.tsx index fd6a71eb9..020a8efef 100644 --- a/src/Explorer/Controls/Settings/SettingsUtils.test.tsx +++ b/src/Explorer/Controls/Settings/SettingsUtils.test.tsx @@ -44,7 +44,6 @@ describe("SettingsUtils", () => { readSettings: undefined, onSettingsClick: undefined, loadOffer: undefined, - getPendingThroughputSplitNotification: undefined, } as ViewModels.Database; }; newCollection.offer(undefined); diff --git a/src/Explorer/Controls/TreeComponent/Styles.ts b/src/Explorer/Controls/TreeComponent/Styles.ts index 14247e657..a81923f35 100644 --- a/src/Explorer/Controls/TreeComponent/Styles.ts +++ b/src/Explorer/Controls/TreeComponent/Styles.ts @@ -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, diff --git a/src/Explorer/Controls/TreeComponent/TreeNodeComponent.tsx b/src/Explorer/Controls/TreeComponent/TreeNodeComponent.tsx index 1a4a5dbae..39dbf3c0f 100644 --- a/src/Explorer/Controls/TreeComponent/TreeNodeComponent.tsx +++ b/src/Explorer/Controls/TreeComponent/TreeNodeComponent.tsx @@ -149,15 +149,16 @@ export const TreeNodeComponent: React.FC = ({ // 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 ? ( - - ) : !isBranch ? ( - typeof node.iconSrc === "string" ? ( + const treeIcon = + node.iconSrc === undefined ? undefined : typeof node.iconSrc === "string" ? ( ) : ( node.iconSrc - ) - ) : openItems.includes(treeNodeId) ? ( + ); + + const expandIcon = isLoading ? ( + + ) : !isBranch ? undefined : openItems.includes(treeNodeId) ? ( ) : ( @@ -174,7 +175,6 @@ export const TreeNodeComponent: React.FC = ({ = ({ ), } } + iconBefore={treeIcon} expandIcon={expandIcon} > {node.label} diff --git a/src/Explorer/Controls/TreeComponent/__snapshots__/TreeNodeComponent.test.tsx.snap b/src/Explorer/Controls/TreeComponent/__snapshots__/TreeNodeComponent.test.tsx.snap index f031061af..80e4d1f19 100644 --- a/src/Explorer/Controls/TreeComponent/__snapshots__/TreeNodeComponent.test.tsx.snap +++ b/src/Explorer/Controls/TreeComponent/__snapshots__/TreeNodeComponent.test.tsx.snap @@ -10,13 +10,20 @@ exports[`TreeNodeComponent does not render children if the node is loading 1`] = > } + iconBefore={ + + } >
+
@@ -208,7 +225,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = ` tabindex="-1" >
+
@@ -242,7 +269,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
@@ -256,7 +283,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = ` tabindex="0" >
+
@@ -300,7 +337,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = ` tabindex="-1" >
+
@@ -343,7 +390,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = ` tabindex="-1" >
+
@@ -383,16 +440,23 @@ exports[`TreeNodeComponent fully renders a tree 1`] = ` > } + iconBefore={ + + } >
+
+ +
@@ -431,7 +505,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
@@ -587,7 +661,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = ` "itemType": "branch", "layoutRef": { "current":
+
@@ -639,7 +723,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = ` tabindex="0" >
+
@@ -680,16 +774,23 @@ exports[`TreeNodeComponent fully renders a tree 1`] = ` > } + iconBefore={ + + } >
+
+ +
@@ -728,7 +839,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
+
@@ -873,7 +994,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = ` tabindex="-1" >
+
@@ -914,16 +1045,23 @@ exports[`TreeNodeComponent fully renders a tree 1`] = ` > } + iconBefore={ + + } >
+
+ +
@@ -1039,7 +1187,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = ` "itemType": "leaf", "layoutRef": { "current":
+
@@ -1087,7 +1245,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = ` tabindex="-1" >
+
@@ -1125,9 +1293,9 @@ exports[`TreeNodeComponent fully renders a tree 1`] = ` >
} + iconBefore={ + + } > } + iconBefore={ + + } > } + iconBefore={ + + } > } + iconBefore={ + + } > ); } public openExecuteSprocParamsPanel(storedProcedure: StoredProcedure): void { @@ -1178,7 +1179,11 @@ export default class Explorer { } public async configureCopilot(): Promise { - if (userContext.apiType !== "SQL" || !userContext.subscriptionId) { + if ( + userContext.apiType !== "SQL" || + !userContext.subscriptionId || + ![Environment.Development, Environment.Mpac, Environment.Prod].includes(getEnvironment()) + ) { return; } const copilotEnabledPromise = getCopilotEnabled(); diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx index a55b0ca68..8c374a4c1 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx +++ b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx @@ -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", ), - commandButtonLabel: undefined, - ariaLabel: "Settings", - tooltipText: "Settings", - hasPopup: true, - disabled: false, - }, - ]; + const buttons: CommandButtonComponentProps[] = [ + { + iconSrc: SettingsIcon, + iconAlt: "Settings", + onCommandClick: () => useSidePanel.getState().openSidePanel("Settings", ), + commandButtonLabel: undefined, + ariaLabel: "Settings", + tooltipText: "Settings", + hasPopup: true, + disabled: false, + }, + ]; const showOpenFullScreen = configContext.platform === Platform.Portal && !isRunningOnNationalCloud() && userContext.apiType !== "Gremlin"; diff --git a/src/Explorer/Notebook/useNotebook.ts b/src/Explorer/Notebook/useNotebook.ts index 734d1cd79..a0f6efbf0 100644 --- a/src/Explorer/Notebook/useNotebook.ts +++ b/src/Explorer/Notebook/useNotebook.ts @@ -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 = 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, { diff --git a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx index 8d07fdad7..92b2f9ad1 100644 --- a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx +++ b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx @@ -1,22 +1,25 @@ import { Checkbox, ChoiceGroup, + DefaultButton, IChoiceGroupOption, ISpinButtonStyles, IToggleStyles, - Icon, MessageBar, MessageBarType, Position, SpinButton, Toggle, - TooltipHost, } from "@fluentui/react"; +import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, makeStyles } from "@fluentui/react-components"; +import { AuthType } from "AuthType"; import * as Constants from "Common/Constants"; import { SplitterDirection } from "Common/Splitter"; import { InfoTooltip } from "Common/Tooltip/InfoTooltip"; import { Platform, configContext } from "ConfigContext"; +import { useDialog } from "Explorer/Controls/Dialog"; import { useDatabases } from "Explorer/useDatabases"; +import { deleteAllStates } from "Shared/AppStatePersistenceUtility"; import { DefaultRUThreshold, LocalStorageUtility, @@ -29,14 +32,13 @@ import * as StringUtility from "Shared/StringUtility"; import { updateUserContext, userContext } from "UserContext"; import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils"; import * as PriorityBasedExecutionUtils from "Utils/PriorityBasedExecutionUtils"; +import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts"; import { useQueryCopilot } from "hooks/useQueryCopilot"; import { useSidePanel } from "hooks/useSidePanel"; import React, { FunctionComponent, useState } from "react"; +import create, { UseStore } from "zustand"; import Explorer from "../../Explorer"; import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm"; -import { AuthType } from "AuthType"; -import create, { UseStore } from "zustand"; -import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts"; export interface DataPlaneRbacState { dataPlaneRbacEnabled: boolean; @@ -50,6 +52,39 @@ export interface DataPlaneRbacState { type DataPlaneRbacStore = UseStore>; +const useStyles = makeStyles({ + bulletList: { + listStyleType: "disc", + paddingLeft: "20px", + }, + container: { + display: "flex", + flexDirection: "column", + height: "100%", + }, + firstItem: { + flex: "1", + }, + header: { + marginRight: "5px", + }, + headerIcon: { + paddingTop: "4px", + cursor: "pointer", + }, + settingsSectionContainer: { + paddingLeft: "15px", + }, + settingsSectionDescription: { + paddingBottom: "10px", + fontSize: "12px", + }, + subHeader: { + marginRight: "5px", + fontSize: "12px", + }, +}); + export const useDataPlaneRbac: DataPlaneRbacStore = create(() => ({ dataPlaneRbacEnabled: false, })); @@ -133,6 +168,9 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ const [copilotSampleDBEnabled, setCopilotSampleDBEnabled] = useState( LocalStorageUtility.getEntryString(StorageKey.CopilotSampleDBEnabled) === "true", ); + + const styles = useStyles(); + const explorerVersion = configContext.gitSha; const shouldShowQueryPageOptions = userContext.apiType === "SQL"; const shouldShowGraphAutoVizOption = userContext.apiType === "Gremlin"; @@ -153,43 +191,45 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ LocalStorageUtility.setEntryNumber(StorageKey.CustomItemPerPage, customItemPerPage); - LocalStorageUtility.setEntryString(StorageKey.DataPlaneRbacEnabled, enableDataPlaneRBACOption); - if ( - enableDataPlaneRBACOption === Constants.RBACOptions.setTrueRBACOption || - (enableDataPlaneRBACOption === Constants.RBACOptions.setAutomaticRBACOption && - userContext.databaseAccount.properties.disableLocalAuth) - ) { - updateUserContext({ - dataPlaneRbacEnabled: true, - hasDataPlaneRbacSettingChanged: true, - }); - useDataPlaneRbac.setState({ dataPlaneRbacEnabled: true }); - } else { - updateUserContext({ - dataPlaneRbacEnabled: false, - hasDataPlaneRbacSettingChanged: true, - }); - const { databaseAccount: account, subscriptionId, resourceGroup } = userContext; - if (!userContext.features.enableAadDataPlane && !userContext.masterKey) { - let keys; - try { - keys = await listKeys(subscriptionId, resourceGroup, account.name); - updateUserContext({ - masterKey: keys.primaryMasterKey, - }); - } catch (error) { - // if listKeys fail because of permissions issue, then make call to get ReadOnlyKeys - if (error.code === "AuthorizationFailed") { - keys = await getReadOnlyKeys(subscriptionId, resourceGroup, account.name); + if (configContext.platform !== Platform.Fabric) { + LocalStorageUtility.setEntryString(StorageKey.DataPlaneRbacEnabled, enableDataPlaneRBACOption); + if ( + enableDataPlaneRBACOption === Constants.RBACOptions.setTrueRBACOption || + (enableDataPlaneRBACOption === Constants.RBACOptions.setAutomaticRBACOption && + userContext.databaseAccount.properties.disableLocalAuth) + ) { + updateUserContext({ + dataPlaneRbacEnabled: true, + hasDataPlaneRbacSettingChanged: true, + }); + useDataPlaneRbac.setState({ dataPlaneRbacEnabled: true }); + } else { + updateUserContext({ + dataPlaneRbacEnabled: false, + hasDataPlaneRbacSettingChanged: true, + }); + const { databaseAccount: account, subscriptionId, resourceGroup } = userContext; + if (!userContext.features.enableAadDataPlane && !userContext.masterKey) { + let keys; + try { + keys = await listKeys(subscriptionId, resourceGroup, account.name); updateUserContext({ - masterKey: keys.primaryReadonlyMasterKey, + masterKey: keys.primaryMasterKey, }); - } else { - logConsoleError(`Error occurred fetching keys for the account." ${error.message}`); - throw error; + } catch (error) { + // if listKeys fail because of permissions issue, then make call to get ReadOnlyKeys + if (error.code === "AuthorizationFailed") { + keys = await getReadOnlyKeys(subscriptionId, resourceGroup, account.name); + updateUserContext({ + masterKey: keys.primaryReadonlyMasterKey, + }); + } else { + logConsoleError(`Error occurred fetching keys for the account." ${error.message}`); + throw error; + } } + useDataPlaneRbac.setState({ dataPlaneRbacEnabled: false }); } - useDataPlaneRbac.setState({ dataPlaneRbacEnabled: false }); } } @@ -428,408 +468,457 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ return ( -
- {shouldShowQueryPageOptions && ( -
-
-
- - Page Options - - - Choose Custom to specify a fixed amount of query results to show, or choose Unlimited to show as many - query results per page. - - -
-
-
- {isCustomPageOptionSelected() && ( -
-
- Query results per page - Enter the number of query results that should be shown per page. +
+ + {shouldShowQueryPageOptions && ( + + +
Page Options
+
+ +
+
+ Choose Custom to specify a fixed amount of query results to show, or choose Unlimited to show as + many query results per page.
- - { - setCustomItemPerPage(parseInt(newValue) + 1 || customItemPerPage); - }} - onDecrement={(newValue) => setCustomItemPerPage(parseInt(newValue) - 1 || customItemPerPage)} - onValidate={(newValue) => setCustomItemPerPage(parseInt(newValue) || customItemPerPage)} - min={1} - step={1} - className="textfontclr" - incrementButtonAriaLabel="Increase value by 1" - decrementButtonAriaLabel="Decrease value by 1" - /> -
- )} -
-
- )} - {userContext.apiType === "SQL" && userContext.authType === AuthType.AAD && ( - <> -
-
-
- - Enable Entra ID RBAC - - - Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable Entra - ID RBAC. - - {" "} - Learn more{" "} - - - } - > - - - {showDataPlaneRBACWarning && configContext.platform === Platform.Portal && ( - setShowDataPlaneRBACWarning(false)} - dismissButtonAriaLabel="Close" - > - Please click on "Login for Entra ID RBAC" button prior to performing Entra ID RBAC - operations - - )} -
-
-
- - )} - {userContext.apiType === "SQL" && ( - <> -
-
-
- - RU Threshold - - If a query exceeds a configured RU threshold, the query will be aborted. -
-
-
- {ruThresholdEnabled && ( -
- + {isCustomPageOptionSelected() && ( +
+
+ Query results per page{" "} + + Enter the number of query results that should be shown per page. + +
+ + { + setCustomItemPerPage(parseInt(newValue) + 1 || customItemPerPage); + }} + onDecrement={(newValue) => setCustomItemPerPage(parseInt(newValue) - 1 || customItemPerPage)} + onValidate={(newValue) => setCustomItemPerPage(parseInt(newValue) || customItemPerPage)} + min={1} + step={1} + className="textfontclr" + incrementButtonAriaLabel="Increase value by 1" + decrementButtonAriaLabel="Decrease value by 1" + /> +
+ )} +
+ + + )} + {userContext.apiType === "SQL" && + userContext.authType === AuthType.AAD && + configContext.platform !== Platform.Fabric && ( + + +
Enable Entra ID RBAC
+
+ +
+ {showDataPlaneRBACWarning && configContext.platform === Platform.Portal && ( + setShowDataPlaneRBACWarning(false)} + dismissButtonAriaLabel="Close" + > + Please click on "Login for Entra ID RBAC" button prior to performing Entra ID RBAC + operations + + )} +
+ Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable Entra + ID RBAC. + + {" "} + Learn more{" "} + +
+
- )} -
-
-
-
+ + + )} + + {userContext.apiType === "SQL" && ( + <> + + +
Query Timeout
+
+ +
+
+ When a query reaches a specified time limit, a popup with an option to cancel the query will show + unless automatic cancellation has been enabled. +
+ +
+ {queryTimeoutEnabled && ( +
+ + +
+ )} +
+
+ + + +
RU Limit
+
+ +
+
+ If a query exceeds a configured RU limit, the query will be aborted. +
+ +
+ {ruThresholdEnabled && ( +
+ +
+ )} +
+
+ + + +
Default Query Results View
+
+ +
+
+ Select the default view to use when displaying query results. +
+ +
+
+
+ + )} + + + +
Retry Settings
+
+ +
+
+ Retry policy associated with throttled requests during CosmosDB queries. +
- - Query Timeout - - - When a query reaches a specified time limit, a popup with an option to cancel the query will show - unless automatic cancellation has been enabled + Max retry attempts + + Max number of retries to be performed for a request. Default value 9.
+ setRetryAttempts(parseInt(newValue) + 1 || retryAttempts)} + onDecrement={(newValue) => setRetryAttempts(parseInt(newValue) - 1 || retryAttempts)} + onValidate={(newValue) => setRetryAttempts(parseInt(newValue) || retryAttempts)} + styles={spinButtonStyles} + />
- + Fixed retry interval (ms) + + Fixed retry interval in milliseconds to wait between each retry ignoring the retryAfter returned as + part of the response. Default value is 0 milliseconds. +
- {queryTimeoutEnabled && ( -
- - + setRetryInterval(parseInt(newValue) + 1000 || retryInterval)} + onDecrement={(newValue) => setRetryInterval(parseInt(newValue) - 1000 || retryInterval)} + onValidate={(newValue) => setRetryInterval(parseInt(newValue) || retryInterval)} + styles={spinButtonStyles} + /> +
+ Max wait time (s) + + Max wait time in seconds to wait for a request while the retries are happening. Default value 30 + seconds. + +
+ setMaxWaitTimeInSeconds(parseInt(newValue) + 1 || MaxWaitTimeInSeconds)} + onDecrement={(newValue) => setMaxWaitTimeInSeconds(parseInt(newValue) - 1 || MaxWaitTimeInSeconds)} + onValidate={(newValue) => setMaxWaitTimeInSeconds(parseInt(newValue) || MaxWaitTimeInSeconds)} + styles={spinButtonStyles} + /> +
+ + + + + +
Enable container pagination
+
+ +
+
+ Load 50 containers at a time. Currently, containers are not pulled in alphanumeric order. +
+ setContainerPaginationEnabled(!containerPaginationEnabled)} + label="Enable container pagination" + /> +
+
+
+ + {shouldShowCrossPartitionOption && ( + + +
Enable cross-partition query
+
+ +
+
+ Send more than one request while executing a query. More than one request is necessary if the query + is not scoped to single partition key value.
- )} -
-
-
-
-
- - Default Query Results View - - Select the default view to use when displaying query results. -
-
- setCrossPartitionQueryEnabled(!crossPartitionQueryEnabled)} + label="Enable cross-partition query" />
-
-
- - )} +
+
+ )} + + {shouldShowParallelismOption && ( + + +
Max degree of parallelism
+
+ +
+
+ Gets or sets the number of concurrent operations run client side during parallel query execution. A + positive property value limits the number of concurrent operations to the set value. If it is set to + less than 0, the system automatically decides the number of concurrent operations to run. +
+ + setMaxDegreeOfParallelism(parseInt(newValue) + 1 || maxDegreeOfParallelism) + } + onDecrement={(newValue) => + setMaxDegreeOfParallelism(parseInt(newValue) - 1 || maxDegreeOfParallelism) + } + onValidate={(newValue) => setMaxDegreeOfParallelism(parseInt(newValue) || maxDegreeOfParallelism)} + ariaLabel="Max degree of parallelism" + label="Max degree of parallelism" + /> +
+
+
+ )} + + {shouldShowPriorityLevelOption && ( + + +
Priority Level
+
+ +
+
+ Sets the priority level for data-plane requests from Data Explorer when using Priority-Based + Execution. If "None" is selected, Data Explorer will not specify priority level, and the + server-side default priority level will be used. +
+ +
+
+
+ )} + + {shouldShowGraphAutoVizOption && ( + + +
Display Gremlin query results as: 
+
+ +
+
+ Select Graph to automatically visualize the query results as a Graph or JSON to display the results + as JSON. +
+ +
+
+
+ )} + + {shouldShowCopilotSampleDBOption && ( + + +
Enable sample database
+
+ +
+
+ This is a sample database and collection with synthetic product data you can use to explore using + NoSQL queries and Query Advisor. This will appear as another database in the Data Explorer UI, and + is created by, and maintained by Microsoft at no cost to you. +
+ +
+
+
+ )} + +
-
- Retry Settings - Retry policy associated with throttled requests during CosmosDB queries. -
-
- - Max retry attempts - - Max number of retries to be performed for a request. Default value 9. -
- setRetryAttempts(parseInt(newValue) + 1 || retryAttempts)} - onDecrement={(newValue) => setRetryAttempts(parseInt(newValue) - 1 || retryAttempts)} - onValidate={(newValue) => setRetryAttempts(parseInt(newValue) || retryAttempts)} - styles={spinButtonStyles} - /> -
- - Fixed retry interval (ms) - - - Fixed retry interval in milliseconds to wait between each retry ignoring the retryAfter returned as part - of the response. Default value is 0 milliseconds. - -
- setRetryInterval(parseInt(newValue) + 1000 || retryInterval)} - onDecrement={(newValue) => setRetryInterval(parseInt(newValue) - 1000 || retryInterval)} - onValidate={(newValue) => setRetryInterval(parseInt(newValue) || retryInterval)} - styles={spinButtonStyles} - /> -
- - Max wait time (s) - - - Max wait time in seconds to wait for a request while the retries are happening. Default value 30 - seconds. - -
- setMaxWaitTimeInSeconds(parseInt(newValue) + 1 || MaxWaitTimeInSeconds)} - onDecrement={(newValue) => setMaxWaitTimeInSeconds(parseInt(newValue) - 1 || MaxWaitTimeInSeconds)} - onValidate={(newValue) => setMaxWaitTimeInSeconds(parseInt(newValue) || MaxWaitTimeInSeconds)} - styles={spinButtonStyles} - /> -
-
-
-
-
- Enable container pagination - - Load 50 containers at a time. Currently, containers are not pulled in alphanumeric order. - -
- { + useDialog.getState().showOkCancelModalDialog( + "Clear History", + undefined, + "Are you sure you want to proceed?", + () => deleteAllStates(), + "Cancel", + undefined, + <> + + This action will clear the all customizations for this account in this browser, including: + +
    +
  • Reset your customized tab layout, including the splitter positions
  • +
  • Erase your table column preferences, including any custom columns
  • +
  • Clear your filter history
  • +
+ , + ); }} - className="padding" - ariaLabel="Enable container pagination" - checked={containerPaginationEnabled} - onChange={() => setContainerPaginationEnabled(!containerPaginationEnabled)} - /> + > + Clear History +
- {shouldShowCrossPartitionOption && ( -
-
-
- Enable cross-partition query - - Send more than one request while executing a query. More than one request is necessary if the query is - not scoped to single partition key value. - -
- - setCrossPartitionQueryEnabled(!crossPartitionQueryEnabled)} - /> -
-
- )} - {shouldShowParallelismOption && ( -
-
-
- Max degree of parallelism - - Gets or sets the number of concurrent operations run client side during parallel query execution. A - positive property value limits the number of concurrent operations to the set value. If it is set to - less than 0, the system automatically decides the number of concurrent operations to run. - -
- - setMaxDegreeOfParallelism(parseInt(newValue) + 1 || maxDegreeOfParallelism)} - onDecrement={(newValue) => setMaxDegreeOfParallelism(parseInt(newValue) - 1 || maxDegreeOfParallelism)} - onValidate={(newValue) => setMaxDegreeOfParallelism(parseInt(newValue) || maxDegreeOfParallelism)} - ariaLabel="Max degree of parallelism" - /> -
-
- )} - {shouldShowPriorityLevelOption && ( -
-
-
- - Priority Level - - - Sets the priority level for data-plane requests from Data Explorer when using Priority-Based - Execution. If "None" is selected, Data Explorer will not specify priority level, and the - server-side default priority level will be used. - - -
-
-
- )} - {shouldShowGraphAutoVizOption && ( -
-
-
- Display Gremlin query results as:  - - Select Graph to automatically visualize the query results as a Graph or JSON to display the results as - JSON. - -
- - -
-
- )} - {shouldShowCopilotSampleDBOption && ( -
-
-
- Enable sample database - - This is a sample database and collection with synthetic product data you can use to explore using - NoSQL queries and Query Advisor. This will appear as another database in the Data Explorer UI, and is - created by, and maintained by Microsoft at no cost to you. - -
- - -
-
- )}
Explorer Version
diff --git a/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap b/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap index 6e2f9e001..8e7ffa387 100644 --- a/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap +++ b/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap @@ -8,481 +8,539 @@ exports[`Settings Pane should render Default properly 1`] = ` submitButtonText="Apply" >
-
-
-
- +
Page Options - - - Choose Custom to specify a fixed amount of query results to show, or choose Unlimited to show as many query results per page. - - + + +
+
+ Choose Custom to specify a fixed amount of query results to show, or choose Unlimited to show as many query results per page. +
+ -
-
-
-
-
- Query results per page - - Enter the number of query results that should be shown per page. - + />
- -
-
-
-
-
-
- - RU Threshold - - - If a query exceeds a configured RU threshold, the query will be aborted. - -
-
- -
-
- -
-
-
-
-
+
+ Query results per page + + + Enter the number of query results that should be shown per page. + +
+ +
+
+ + + -
- +
Query Timeout - - - When a query reaches a specified time limit, a popup with an option to cancel the query will show unless automatic cancellation has been enabled - -
-
- + + +
+
+ When a query reaches a specified time limit, a popup with an option to cancel the query will show unless automatic cancellation has been enabled. +
+ -
-
-
-
-
+
+ + + -
- +
+ RU Limit +
+ + +
+
+ If a query exceeds a configured RU limit, the query will be aborted. +
+ +
+
+ +
+
+ + + +
Default Query Results View - - - Select the default view to use when displaying query results. - -
-
- + + +
+
+ Select the default view to use when displaying query results. +
+ -
-
-
+ /> +
+ +
+ + +
+ Retry Settings +
+
+ +
+
+ Retry policy associated with throttled requests during CosmosDB queries. +
+
+ + Max retry attempts + + + Max number of retries to be performed for a request. Default value 9. + +
+ +
+ + Fixed retry interval (ms) + + + Fixed retry interval in milliseconds to wait between each retry ignoring the retryAfter returned as part of the response. Default value is 0 milliseconds. + +
+ +
+ + Max wait time (s) + + + Max wait time in seconds to wait for a request while the retries are happening. Default value 30 seconds. + +
+ +
+
+
+ + +
+ Enable container pagination +
+
+ +
+
+ Load 50 containers at a time. Currently, containers are not pulled in alphanumeric order. +
+ +
+
+
+ + +
+ Enable cross-partition query +
+
+ +
+
+ Send more than one request while executing a query. More than one request is necessary if the query is not scoped to single partition key value. +
+ +
+
+
+ + +
+ Max degree of parallelism +
+
+ +
+
+ Gets or sets the number of concurrent operations run client side during parallel query execution. A positive property value limits the number of concurrent operations to the set value. If it is set to less than 0, the system automatically decides the number of concurrent operations to run. +
+ +
+
+
+
-
- Retry Settings - - Retry policy associated with throttled requests during CosmosDB queries. - -
-
- - Max retry attempts - - - Max number of retries to be performed for a request. Default value 9. - -
- -
- - Fixed retry interval (ms) - - - Fixed retry interval in milliseconds to wait between each retry ignoring the retryAfter returned as part of the response. Default value is 0 milliseconds. - -
- -
- - Max wait time (s) - - - Max wait time in seconds to wait for a request while the retries are happening. Default value 30 seconds. - -
- -
-
-
-
-
- Enable container pagination - - Load 50 containers at a time. Currently, containers are not pulled in alphanumeric order. - -
- -
-
-
-
-
- Enable cross-partition query - - Send more than one request while executing a query. More than one request is necessary if the query is not scoped to single partition key value. - -
- -
-
-
-
-
- Max degree of parallelism - - Gets or sets the number of concurrent operations run client side during parallel query execution. A positive property value limits the number of concurrent operations to the set value. If it is set to less than 0, the system automatically decides the number of concurrent operations to run. - -
- + Clear History +
+ + + +
+ Retry Settings +
+
+ +
+
+ Retry policy associated with throttled requests during CosmosDB queries. +
+
+ + Max retry attempts + + + Max number of retries to be performed for a request. Default value 9. + +
+ +
+ + Fixed retry interval (ms) + + + Fixed retry interval in milliseconds to wait between each retry ignoring the retryAfter returned as part of the response. Default value is 0 milliseconds. + +
+ +
+ + Max wait time (s) + + + Max wait time in seconds to wait for a request while the retries are happening. Default value 30 seconds. + +
+ +
+
+
+ + +
+ Enable container pagination +
+
+ +
+
+ Load 50 containers at a time. Currently, containers are not pulled in alphanumeric order. +
+ +
+
+
+ + +
+ Display Gremlin query results as:  +
+
+ +
+
+ Select Graph to automatically visualize the query results as a Graph or JSON to display the results as JSON. +
+ +
+
+
+
-
- Retry Settings - - Retry policy associated with throttled requests during CosmosDB queries. - -
-
- - Max retry attempts - - - Max number of retries to be performed for a request. Default value 9. - -
- -
- - Fixed retry interval (ms) - - - Fixed retry interval in milliseconds to wait between each retry ignoring the retryAfter returned as part of the response. Default value is 0 milliseconds. - -
- -
- - Max wait time (s) - - - Max wait time in seconds to wait for a request while the retries are happening. Default value 30 seconds. - -
- -
-
-
-
-
- Enable container pagination - - Load 50 containers at a time. Currently, containers are not pulled in alphanumeric order. - -
- -
-
-
-
-
- Display Gremlin query results as:  - - Select Graph to automatically visualize the query results as a Graph or JSON to display the results as JSON. - -
- + Clear History +
void; + defaultSelection: string[]; +} + +export const TableColumnSelectionPane: React.FC = ({ + columnDefinitions, + selectedColumnIds, + onSelectionChange, + defaultSelection, +}: TableColumnSelectionPaneProps): JSX.Element => { + const closeSidePanel = useSidePanel((state) => state.closeSidePanel); + const originalSelectedColumnIds = React.useMemo(() => selectedColumnIds, []); + const [columnSearchText, setColumnSearchText] = React.useState(""); + const [newSelectedColumnIds, setNewSelectedColumnIds] = React.useState(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 ( +
+ +
+
+ Select which columns to display in your view of items in your container. +
to avoid margin-bottom set by panelMainContent css */> + +
+ +
+ {columnDefinitionList.map((columnDefinition) => ( + onCheckedValueChange(columnDefinition.id, data)} + /> + ))} +
+ +
+
+ + +
+
+
+
+ ); +}; diff --git a/src/Explorer/QueryCopilot/CopilotCarousel.tsx b/src/Explorer/QueryCopilot/CopilotCarousel.tsx index d61924faf..4a73cacb8 100644 --- a/src/Explorer/QueryCopilot/CopilotCarousel.tsx +++ b/src/Explorer/QueryCopilot/CopilotCarousel.tsx @@ -171,7 +171,7 @@ export const QueryCopilotCarousel: React.FC = ({ the query builder. Database Id - CopilotSampleDb + CopilotSampleDB Database throughput (autoscale) Autoscale Database Max RU/s diff --git a/src/Explorer/QueryCopilot/QueryCopilotPromptbar.tsx b/src/Explorer/QueryCopilot/QueryCopilotPromptbar.tsx index c86f1e955..a17f52778 100644 --- a/src/Explorer/QueryCopilot/QueryCopilotPromptbar.tsx +++ b/src/Explorer/QueryCopilot/QueryCopilotPromptbar.tsx @@ -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 = ({ }; const isSampleCopilotActive = useSelectedNode.getState().isQueryCopilotCollectionSelected(); - const cachedHistoriesString = localStorage.getItem(`${userContext.databaseAccount?.id}-queryCopilotHistories`); - const cachedHistories = cachedHistoriesString?.split("|"); - const [histories, setHistories] = useState(cachedHistories || []); + const [histories, setHistories] = useState(() => readPromptHistory(userContext.databaseAccount)); const suggestedPrompts: SuggestedPrompt[] = isSampleCopilotActive ? getSampleDatabaseSuggestedPrompts() : getSuggestedPrompts(); @@ -172,7 +172,7 @@ export const QueryCopilotPromptbar: React.FC = ({ const newHistories = [formattedUserPrompt, ...updatedHistories.slice(0, 2)]; setHistories(newHistories); - localStorage.setItem(`${userContext.databaseAccount.id}-queryCopilotHistories`, newHistories.join("|")); + savePromptHistory(userContext.databaseAccount, newHistories); }; const resetMessageStates = (): void => { diff --git a/src/Explorer/QueryCopilot/QueryCopilotTab.test.tsx b/src/Explorer/QueryCopilot/QueryCopilotTab.test.tsx index eb62cc22c..c5c853fc3 100644 --- a/src/Explorer/QueryCopilot/QueryCopilotTab.test.tsx +++ b/src/Explorer/QueryCopilot/QueryCopilotTab.test.tsx @@ -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(); expect(wrapper).toMatchSnapshot(); }); diff --git a/src/Explorer/QueryCopilot/QueryCopilotTab.tsx b/src/Explorer/QueryCopilot/QueryCopilotTab.tsx index 150f7ec5b..199cf7a8f 100644 --- a/src/Explorer/QueryCopilot/QueryCopilotTab.tsx +++ b/src/Explorer/QueryCopilot/QueryCopilotTab.tsx @@ -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 = ({ explorer }: QueryCopilotProps): JSX.Element => { const { query, setQuery, selectedQuery, setSelectedQuery, isGeneratingQuery } = useQueryCopilot(); - const cachedCopilotToggleStatus: string = localStorage.getItem( - `${userContext.databaseAccount?.id}-queryCopilotToggleStatus`, + const [copilotActive, setCopilotActive] = useState(() => + readCopilotToggleStatus(userContext.databaseAccount), ); - const copilotInitialActive: boolean = cachedCopilotToggleStatus - ? StringUtility.toBoolean(cachedCopilotToggleStatus) - : true; - const [copilotActive, setCopilotActive] = useState(copilotInitialActive); const [tabActive, setTabActive] = useState(true); const getCommandbarButtons = (): CommandButtonComponentProps[] => { @@ -88,7 +84,7 @@ export const QueryCopilotTab: React.FC = ({ explorer }: Query const toggleCopilot = (toggle: boolean) => { setCopilotActive(toggle); - localStorage.setItem(`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`, toggle.toString()); + saveCopilotToggleStatus(userContext.databaseAccount, toggle); }; return ( diff --git a/src/Explorer/QueryCopilot/QueryCopilotUtilities.test.tsx b/src/Explorer/QueryCopilot/QueryCopilotUtilities.test.tsx index c423b35ff..c0e6178eb 100644 --- a/src/Explorer/QueryCopilot/QueryCopilotUtilities.test.tsx +++ b/src/Explorer/QueryCopilot/QueryCopilotUtilities.test.tsx @@ -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)); }); diff --git a/src/Explorer/QueryCopilot/QueryCopilotUtilities.ts b/src/Explorer/QueryCopilot/QueryCopilotUtilities.ts index 7b0290f24..470178285 100644 --- a/src/Explorer/QueryCopilot/QueryCopilotUtilities.ts +++ b/src/Explorer/QueryCopilot/QueryCopilotUtilities.ts @@ -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, + ); +}; diff --git a/src/Explorer/QueryCopilot/Shared/QueryCopilotClient.ts b/src/Explorer/QueryCopilot/Shared/QueryCopilotClient.ts index 63aab6686..5d187be85 100644 --- a/src/Explorer/QueryCopilot/Shared/QueryCopilotClient.ts +++ b/src/Explorer/QueryCopilot/Shared/QueryCopilotClient.ts @@ -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, diff --git a/src/Explorer/QueryCopilot/__snapshots__/QueryCopilotTab.test.tsx.snap b/src/Explorer/QueryCopilot/__snapshots__/QueryCopilotTab.test.tsx.snap index dd199c5c2..c4a4800b9 100644 --- a/src/Explorer/QueryCopilot/__snapshots__/QueryCopilotTab.test.tsx.snap +++ b/src/Explorer/QueryCopilot/__snapshots__/QueryCopilotTab.test.tsx.snap @@ -17,38 +17,6 @@ exports[`Query copilot tab snapshot test should render with initial input 1`] = } } > - diff --git a/src/Explorer/Sidebar.tsx b/src/Explorer/Sidebar.tsx index 7631990a5..b0a0db7e2 100644 --- a/src/Explorer/Sidebar.tsx +++ b/src/Explorer/Sidebar.tsx @@ -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 = ({ 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(null); + const actions = useMemo(() => { if ( configContext.platform === Platform.Fabric || @@ -182,10 +188,10 @@ const GlobalCommands: React.FC = ({ explorer }) => { {primaryAction.label} ) : ( - + {(triggerProps: MenuButtonProps) => ( - <> +
= ({ explorer }) => { New... - +
)}
@@ -280,7 +286,7 @@ export const SidebarContainer: React.FC = ({ explorer }) => { {/* Collections Tree - Start */} {hasSidebar && ( // When collapsed, we force the pane to 24 pixels wide and make it non-resizable. - +
{loading && ( diff --git a/src/Explorer/Tables/Constants.ts b/src/Explorer/Tables/Constants.ts index 58d5b8e87..738b85fdc 100644 --- a/src/Explorer/Tables/Constants.ts +++ b/src/Explorer/Tables/Constants.ts @@ -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", }; diff --git a/src/Explorer/Tables/DataTable/DataTableBindingManager.ts b/src/Explorer/Tables/DataTable/DataTableBindingManager.ts index 3ef0037aa..41a1d49e4 100644 --- a/src/Explorer/Tables/DataTable/DataTableBindingManager.ts +++ b/src/Explorer/Tables/DataTable/DataTableBindingManager.ts @@ -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"); } } diff --git a/src/Explorer/Tables/DataTable/DataTableOperationManager.ts b/src/Explorer/Tables/DataTable/DataTableOperationManager.ts index 67ba1c6a7..787cfa8f4 100644 --- a/src/Explorer/Tables/DataTable/DataTableOperationManager.ts +++ b/src/Explorer/Tables/DataTable/DataTableOperationManager.ts @@ -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): Entities.ITableEntityIdentity { return { RowKey: $elem.attr(Constants.htmlAttributeNames.dataTableRowKeyAttr), + PartitionKey: $elem.attr(Constants.htmlAttributeNames.dataTablePartitionKeyAttr), }; } private updateLastSelectedItem($elem: JQuery, 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; diff --git a/src/Explorer/Tables/DataTable/TableEntityListViewModel.ts b/src/Explorer/Tables/DataTable/TableEntityListViewModel.ts index 99065057f..1c69e0efc 100644 --- a/src/Explorer/Tables/DataTable/TableEntityListViewModel.ts +++ b/src/Explorer/Tables/DataTable/TableEntityListViewModel.ts @@ -128,8 +128,14 @@ export default class TableEntityListViewModel extends DataTableViewModel { this.sqlQuery = ko.observable("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 { @@ -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, diff --git a/src/Explorer/Tables/Entities.ts b/src/Explorer/Tables/Entities.ts index 9584520cb..e65a5f02e 100644 --- a/src/Explorer/Tables/Entities.ts +++ b/src/Explorer/Tables/Entities.ts @@ -36,4 +36,5 @@ export interface ITableQuery { export interface ITableEntityIdentity { RowKey: string; + PartitionKey?: string; } diff --git a/src/Explorer/Tables/TableDataClient.ts b/src/Explorer/Tables/TableDataClient.ts index 25a43f56b..8ba912bc5 100644 --- a/src/Explorer/Tables/TableDataClient.ts +++ b/src/Explorer/Tables/TableDataClient.ts @@ -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) && diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts new file mode 100644 index 000000000..f24f19eb4 --- /dev/null +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts @@ -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 = ( + 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 = ( + 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(), + }); +}; diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx index 267db89b6..14c1f16a8 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx @@ -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

(wrapper: ReactWrapper

| ShallowWrapper

, amount = 0) { let newWrapper; await act(async () => { @@ -91,7 +101,13 @@ async function waitForComponentToPaint

(wrapper: ReactWrapper

| 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("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"]); + }); +}); diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx index 25e52bd2c..19314f005 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx @@ -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 = ({ isPreferredApiMongoDB, @@ -506,7 +588,7 @@ export const DocumentsTabComponent: React.FunctionComponent(false); const [appliedFilter, setAppliedFilter] = useState(""); const [filterContent, setFilterContent] = useState(""); - const [documentIds, setDocumentIds] = useState([]); + const [documentIds, setDocumentIds] = useState([]); const [isExecuting, setIsExecuting] = useState(false); const filterInput = useRef(null); const styles = useDocumentsTabStyles(); @@ -535,6 +617,13 @@ export const DocumentsTabComponent: React.FunctionComponent(() => + readSubComponentState(SubComponentName.MainTabDivider, _collection, { + leftPaneWidthPercent: 35, + }), + ); + const isQueryCopilotSampleContainer = _collection?.isSampleCollection && _collection?.databaseId === QueryCopilotSampleDatabaseId && @@ -543,6 +632,28 @@ export const DocumentsTabComponent: React.FunctionComponent(undefined); + // User's filter history + const [lastFilterContents, setLastFilterContents] = useState(() => + readSubComponentState(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 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 { + const defaultColumnsIds = ["id"]; + if (showPartitionKey(_collection, isPreferredApiMongoDB)) { + defaultColumnsIds.push(...partitionKeyPropertyHeaders); + } + + return defaultColumnsIds; + }; + + const [selectedColumnIds, setSelectedColumnIds] = useState(() => { + const persistedColumnsSelection = readSubComponentState( + 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 { onExecutionErrorChange(true); @@ -881,8 +1119,35 @@ export const DocumentsTabComponent: React.FunctionComponent => + new Promise((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 => { @@ -893,20 +1158,33 @@ export const DocumentsTabComponent: React.FunctionComponent => { - 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 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 - 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 { @@ -1217,16 +1517,6 @@ export const DocumentsTabComponent: React.FunctionComponent = (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 = (event) => { if (event.key === " " || event.key === "Enter") { const focusElement = event.target as HTMLElement; @@ -1239,7 +1529,7 @@ export const DocumentsTabComponent: React.FunctionComponent): 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 { - const item: Record & { 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 { + 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 { initDocumentEditor(documentId, content); + + // Update columns + setColumnDefinitionsFromDocument(content); }, ); @@ -1376,10 +1705,22 @@ export const DocumentsTabComponent: React.FunctionComponent resizeObserver.disconnect(); // clean up }, []); - const columnHeaders = { - idHeader: isPreferredApiMongoDB ? "_id" : "id", - partitionKeyHeaders: (showPartitionKey(_collection, isPreferredApiMongoDB) && partitionKeyPropertyHeaders) || [], - }; + // Column definition is a map to garantee uniqueness + const [columnDefinitions, setColumnDefinitions] = useState(() => { + const persistedColumnsSelection = readSubComponentState( + SubComponentName.ColumnsSelection, + _collection, + undefined, + ); + + if (!persistedColumnsSelection) { + return extractColumnDefinitionsFromDocument({ + id: "id", + }); + } + + return persistedColumnsSelection.columnDefinitions; + }); const onSelectedRowsChange = (selectedRows: Set) => { confirmDiscardingChange(() => { @@ -1442,7 +1783,6 @@ export const DocumentsTabComponent: React.FunctionComponent { if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) { partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, ""); @@ -1612,7 +1952,7 @@ export const DocumentsTabComponent: React.FunctionComponent { + 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(SubComponentName.FilterHistory, _collection, lastFilterContents); + }; + const refreshDocumentsGrid = useCallback( (applyFilterButtonPressed: boolean): void => { // clear documents grid @@ -1693,6 +2051,68 @@ export const DocumentsTabComponent: React.FunctionComponent { + 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(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 (

@@ -1721,12 +2141,11 @@ export const DocumentsTabComponent: React.FunctionComponent {!isPreferredApiMongoDB && SELECT * FROM c } setIsFilterFocused(false)} /> - - {lastFilterContents.map((filter) => ( + + {addStringsNoDuplicate( + lastFilterContents, + isPreferredApiMongoDB ? defaultMongoFilters : getDefaultSqlFilters(partitionKeyProperties), + ).map((filter) => ( @@ -1749,7 +2171,7 @@ export const DocumentsTabComponent: React.FunctionComponent refreshDocumentsGrid(true)} + onClick={onApplyFilterClick} disabled={!applyFilterButton.enabled} aria-label="Apply filter" tabIndex={0} @@ -1780,41 +2202,46 @@ export const DocumentsTabComponent: React.FunctionComponent )} - {/* doesn't like to be a flex child */}
- - + { + tabStateData.leftPaneWidthPercent = (100 * sizes[0]) / (sizes[0] + sizes[1]); + saveSubComponentState(SubComponentName.MainTabDivider, _collection, tabStateData); + setTabStateData(tabStateData); + }} + > +
-
-
-
+ {bulkDeleteOperation && ( + { + 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} + > +
+ {(bulkDeleteMode === "aborting" || bulkDeleteMode === "aborted") && ( +
Deleting document(s) was aborted.
+ )} + {(bulkDeleteProcess.failedIds.length > 0 || + (bulkDeleteProcess.throttledIds.length > 0 && bulkDeleteMode !== "inProgress")) && ( + + + Error + Failed to delete{" "} + {bulkDeleteMode === "inProgress" + ? bulkDeleteProcess.failedIds.length + : bulkDeleteProcess.failedIds.length + bulkDeleteProcess.throttledIds.length}{" "} + document(s). + + + )} + + + Warning + {get429WarningMessageNoSql()}{" "} + + Learn More + + + +
+
+ )} ); }; diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2Mongo.test.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2Mongo.test.tsx index 602184f51..6988f448d 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2Mongo.test.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2Mongo.test.tsx @@ -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(); diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.test.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.test.tsx index 4b396a3fa..4a5439d5e 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.test.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.test.tsx @@ -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, + } 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(); expect(wrapper).toMatchSnapshot(); }); diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx index b6cc25355..c96d63ff5 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx @@ -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; +} & Record; -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) => void; selectedRows: Set; 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 { @@ -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 = ({ + 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(() => { + 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(SubComponentName.ColumnSort, collection, undefined); + + if (!sort) { + return { + sortDirection: undefined, + sortColumn: undefined, + }; + } + + return { + sortDirection: sort.direction, + sortColumn: sort.columnId, }; }); - const [columnSizingOptions, setColumnSizingOptions] = React.useState(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(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(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[] = useMemo( () => - [ - createTableColumn({ - 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: () => ( + <> + {column.label} + + + + + ), renderCell: (item) => ( - - {item.id} + + {item[column.id]} ), - }), - ].concat( - columnHeaders.partitionKeyHeaders.map((pkHeader) => - createTableColumn({ - columnId: pkHeader, - compare: (a, b) => a[pkHeader].localeCompare(b[pkHeader]), - // Show Refresh button on last column - renderHeaderCell: () => {pkHeader}, - renderCell: (item) => ( - - {item[pkHeader]} - - ), - }), - ), - ), - [columnHeaders], + })), + [columnDefinitions, onColumnSelectionChange, selectedColumnIds], ); const [selectionStartIndex, setSelectionStartIndex] = React.useState(INITIAL_SELECTED_ROW_INDEX); @@ -214,6 +379,7 @@ export const DocumentsTableComponent: React.FC = 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 = // 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) => { if (e.key === " ") { @@ -271,39 +448,53 @@ export const DocumentsTableComponent: React.FC = ...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", + , + ); + }; + return ( - + {!isSelectionDisabled && ( )} - {columns.map((column /* index */) => ( - - - - {column.renderHeaderCell()} - - - - - - Keyboard Column Resizing - - - - + {columns.map((column) => ( + + {column.renderHeaderCell()} + ))} +
(value: T): T | undefined => { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }); + return ref.current; +}; diff --git a/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTabV2.test.tsx.snap b/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTabV2.test.tsx.snap index 7b9bae63c..794d609b8 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTabV2.test.tsx.snap +++ b/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTabV2.test.tsx.snap @@ -38,9 +38,11 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = ` } } > - +
-
-
- -
- +
- - - - - , - }, - } - } - > - - - } - className="___16q6g07_0000000 finvdd3 fjik90z fw35ms5" - data-tabster="{"restorer":{"type":1}}" - id="menu17" - key="id" - onContextMenu={[Function]} - onMouseEnter={[Function]} - onMouseLeave={[Function]} - onMouseMove={[Function]} - style={ - { - "maxWidth": 50, - "minWidth": 50, - "width": 50, - } - } - > - - - - -
, - }, - } - } - > - - - - - - - -
+ /> +
@@ -342,7 +111,7 @@ exports[`DocumentsTableComponent should not render selection column when isSelec > + + + + +
+ + + + +
+ + +
+
+
+ +
+ +
+
+`; + +exports[`DocumentsTableComponent should render documents and partition keys in header 1`] = ` + + +
+ +
+ +
+ +
+ + + +
+ + +
+ +
+ +
+
+ + +
+ +
+
+ + +
- -
- -
-
- - 1 - -
-
-
-
-
-
- -
+
- - pk1 - -
-
-
+ aria-hidden={true} + className="fui-Checkbox__indicator rl7ci6d" + /> + +
-
+
@@ -647,11 +865,11 @@ exports[`DocumentsTableComponent should not render selection column when isSelec key="1" style={ { - "height": 36, + "height": 32, "left": 0, "position": "absolute", "right": undefined, - "top": 36, + "top": 32, "width": "100%", } } @@ -665,11 +883,11 @@ exports[`DocumentsTableComponent should not render selection column when isSelec style={ { "cursor": "pointer", - "height": 36, + "height": 32, "left": 0, "position": "absolute", "right": undefined, - "top": 36, + "top": 32, "userSelect": "none", "width": "100%", } @@ -683,855 +901,11 @@ exports[`DocumentsTableComponent should not render selection column when isSelec style={ { "cursor": "pointer", - "height": 36, + "height": 32, "left": 0, "position": "absolute", "right": undefined, - "top": 36, - "userSelect": "none", - "width": "100%", - } - } - > - -
- -
-
- - 2 - -
-
-
-
-
- -
- -
-
- - pk2 - -
-
-
-
-
-
- - - - -
- -
- -
-
- - 3 - -
-
-
-
-
- -
- -
-
- - pk3 - -
-
-
-
-
-
-
-
-
-
- -
- -
-
-
-`; - -exports[`DocumentsTableComponent should render documents and partition keys in header 1`] = ` - - -
- -
- -
- -
- - - -
- - -
- - - - - - , - }, - } - } - > - - - } - className="___16q6g07_0000000 finvdd3 fjik90z fw35ms5" - data-tabster="{"restorer":{"type":1}}" - id="menu3" - key="id" - onContextMenu={[Function]} - onMouseEnter={[Function]} - onMouseLeave={[Function]} - onMouseMove={[Function]} - style={ - { - "maxWidth": 50, - "minWidth": 50, - "width": 50, - } - } - > - - - - -
, - }, - } - } - > - - - - - - - -
-
-
-
- -
- -
-
- - -
- -
- -
-
- - 1 - -
-
-
-
-
- -
- -
-
- - pk1 - -
-
-
-
-
-
- - - - -
- -
- - - -
- - -
- - -
- -
-
- - 2 - -
-
-
-
-
- -
- -
-
- - pk2 - -
-
-
-
-
@@ -1949,11 +996,11 @@ exports[`DocumentsTableComponent should render documents and partition keys in h key="2" style={ { - "height": 36, + "height": 32, "left": 0, "position": "absolute", "right": undefined, - "top": 72, + "top": 64, "width": "100%", } } @@ -1967,11 +1014,11 @@ exports[`DocumentsTableComponent should render documents and partition keys in h style={ { "cursor": "pointer", - "height": 36, + "height": 32, "left": 0, "position": "absolute", "right": undefined, - "top": 72, + "top": 64, "userSelect": "none", "width": "100%", } @@ -1985,11 +1032,11 @@ exports[`DocumentsTableComponent should render documents and partition keys in h style={ { "cursor": "pointer", - "height": 36, + "height": 32, "left": 0, "position": "absolute", "right": undefined, - "top": 72, + "top": 64, "userSelect": "none", "width": "100%", } @@ -2023,7 +1070,7 @@ exports[`DocumentsTableComponent should render documents and partition keys in h aria-label="Select row" checked={false} className="fui-Checkbox__input ruo9svu ___qlal8r0_1xrlghj f1vgc2s3" - id="checkbox-16" + id="checkbox-14" onChange={[Function]} type="checkbox" /> @@ -2035,104 +1082,6 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
- -
- -
-
- - 3 - -
-
-
-
-
- -
- -
-
- - pk3 - -
-
-
-
-
diff --git a/src/Explorer/Tabs/QueryTab/ErrorList.tsx b/src/Explorer/Tabs/QueryTab/ErrorList.tsx index 7bb03d2e3..84e149533 100644 --- a/src/Explorer/Tabs/QueryTab/ErrorList.tsx +++ b/src/Explorer/Tabs/QueryTab/ErrorList.tsx @@ -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({ columnId: "code", compare: (item1, item2) => item1.code.localeCompare(item2.code), - renderHeaderCell: () => null, - renderCell: (item) => item.code, + renderHeaderCell: () => "Code", + renderCell: (item) => {item.code}, }), createTableColumn({ columnId: "severity", compare: (item1, item2) => compareSeverity(item1.severity, item2.severity), - renderHeaderCell: () => null, - renderCell: (item) => {item.severity}, + renderHeaderCell: () => "Severity", + renderCell: (item) => ( + + {item.severity} + + ), }), createTableColumn({ 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}` - : "" - : "", + renderCell: (item) => ( + + {item.location + ? item.location.start.lineNumber + ? `Line ${item.location.start.lineNumber}` + : "" + : ""} + + ), }), createTableColumn({ columnId: "message", @@ -60,8 +67,20 @@ export const ErrorList: React.FC<{ errors: QueryError[] }> = ({ errors }) => { renderHeaderCell: () => "Message", renderCell: (item) => (
-
{item.message}
-
+
+ {item.message} +
+
+ {item.helpLink && ( +