Compare commits

...

63 Commits

Author SHA1 Message Date
Laurent Nguyen
04c01256a6 Cleanup checkbox styles 2024-09-05 11:40:14 +02:00
Laurent Nguyen
1795b8e2e9 Merge branch 'master' into users/languy/save-documentstab-prefs 2024-09-05 09:41:58 +02:00
Laurent Nguyen
4296b5ae02 Add more default filters (#1955) 2024-09-05 07:16:48 +02:00
Ashley Stanton-Nurse
e8a5658799 Reduce extra spacing in the new tree and items tab (#1951)
* reduce layout row size and default font size

* icons for the tree

* refmt and update snapshots

* remove commented out code
2024-09-04 13:07:27 -07:00
vchske
b4973e8367 Fixing regex on allowedParentFrameOrigins to address XSS (#1956) 2024-09-04 11:35:32 -07:00
Laurent Nguyen
e23ba02561 Move column selection and sorting behind feature flag enableDocumentsTableColumnSelection 2024-09-02 12:31:39 +02:00
Asier Isayas
4b207f3fa6 Show portal networking banner for new backend (#1952)
* show portal networking banner for new backend

* fixed valid endpoints

* format

* fixed tests

* Fixed tests

* fix tests

* fixed tests

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-08-29 18:42:13 -04:00
sindhuba
c5b7f599b3 Add AAD Endpoints for Data Explorer in Portal (#1943)
* Fix API endpoint for CassandraProxy query API

* activate Mongo Proxy and Cassandra Proxy in Prod

* Add CP Prod endpoint

* Run npm format and tests

* Revert code

* fix bug that blocked local mongo proxy and cassandra proxy development

* Add prod endpoint

* fix pr check tests

* Remove prod

* Remove prod endpoint

* Remove dev endpoint

* Support data plane RBAC

* Support data plane RBAC

* Add additional changes for Portal RBAC functionality

* Remove unnecessary code

* Remove unnecessary code

* Add code to fix VCoreMongo/PG bug

* Address feedback

* Add more logs for RBAC feature

* Add more logs for RBAC features

* Add AAD endpoints for all environments

* Add AAD endpoints

* Run npm format

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-08-28 09:11:21 -07:00
Asier Isayas
6aeac542b1 Runtime Proxy API (#1950)
Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-08-28 09:04:49 -04:00
Ashley Stanton-Nurse
0d22d4ab4d change default splitter orientation when the setting has not been set (#1946) 2024-08-27 14:20:34 -07:00
vchske
0658448b54 Reinstating partition key fix with added check for nested partitions (#1947)
* Reinstating empty hiearchical partition key value fix

* Added use case for nested partitions

* Fix lint issue
2024-08-26 10:00:33 -07:00
Laurent Nguyen
85352b74a3 Update unit test snapshot 2024-08-23 18:07:59 +02:00
Laurent Nguyen
26645f8360 Label to indicate which field is a partition key in Column Selection Pane 2024-08-23 17:59:16 +02:00
Laurent Nguyen
777b695051 Add background color to "..." button for column selection 2024-08-23 17:47:02 +02:00
Laurent Nguyen
1f300e32fe Fix table width 2024-08-23 17:32:00 +02:00
Laurent Nguyen
e81408560e Remove unused function 2024-08-23 17:26:22 +02:00
Laurent Nguyen
ed1e2990d0 Update test snapshots 2024-08-23 17:21:40 +02:00
Laurent Nguyen
5e92a0c5d7 Disable column selection for Mongo. Remove extra refresh button 2024-08-23 17:16:43 +02:00
Laurent Nguyen
26b6de4c53 Merge branch 'master' into users/languy/save-documentstab-prefs 2024-08-23 09:57:52 +02:00
Laurent Nguyen
f308cabeaa Revert "Merge branch 'master' into users/languy/save-documentstab-prefs"
This reverts commit e5a82fd356.
2024-08-23 09:53:45 +02:00
Laurent Nguyen
e5a82fd356 Merge branch 'master' into users/languy/save-documentstab-prefs 2024-08-23 09:47:19 +02:00
Laurent Nguyen
4778183e50 Save columns definition (schema) along with selected columns. 2024-08-23 09:23:38 +02:00
Laurent Nguyen
b1d9570a95 Persist column sorting 2024-08-23 08:21:40 +02:00
Laurent Nguyen
833d677d20 Change persistence format for column width (#1944) 2024-08-22 17:00:49 +02:00
Laurent Nguyen
2397283649 Persist column selection 2024-08-22 16:19:23 +02:00
Laurent Nguyen
905aa26f27 Fix unit test 2024-08-22 14:30:35 +02:00
Laurent Nguyen
a2556dad06 Fix unit tests 2024-08-22 14:24:44 +02:00
Laurent Nguyen
c9398e303b Merge branch 'master' into users/languy/save-documentstab-prefs 2024-08-22 11:48:38 +02:00
Laurent Nguyen
038142c180 Save and restore DocumentsTab state to local storage (#1919)
* Infrastructure to save app state

* Save filters

* Replace read/save methods with more generic ones

* Make datalist for filter unique per database/container combination

* Disable saving middle split position for now

* Fix unit tests

* Turn off confusing auto-complete from input box

* Disable tabStateData for now

* Save and restore split position

* Fix replace autocomplete="off" by removing id on Input tag

* Properly set allotment width

* Fix saved percentage

* Save splitter per collection

* Add error handling and telemetry

* Fix compiling issue

* Add ability to delete filter history. Bug fix when hitting Enter on filter input box.

* Replace delete filter modal with dropdown menu

* Add code to remove oldest record if max limit is reached in app state persistence

* Only save new splitter position on drag end (not onchange)

* Add unit tests

* Add Clear all in settings. Update snapshots

* Fix format

* Remove filter delete and keep filter history to a max. Reword clear button and message in settings pane.

* Fix setting button label

* Update test snapshots

* Reword Clear history button text

* Update unit test snapshot

* Enable Settings pane for Fabric, but turn off Rbac dial for Fabric.

* Change union type to enum

* Update src/Shared/AppStatePersistenceUtility.ts

Assert that path does not include slash char.

Co-authored-by: Ashley Stanton-Nurse <ashleyst@microsoft.com>

* Update src/Shared/AppStatePersistenceUtility.ts

Assert that path does not contain slash.

Co-authored-by: Ashley Stanton-Nurse <ashleyst@microsoft.com>

* Fix format

---------

Co-authored-by: Ashley Stanton-Nurse <ashleyst@microsoft.com>
2024-08-22 07:37:15 +02:00
Ashley Stanton-Nurse
94d3fcb30f disable query error tests due to backend issue (#1942) 2024-08-21 11:30:43 -07:00
Ashley Stanton-Nurse
d3722f2c99 Improve sidebar UI layout when narrow (#1938)
* improve how the sidebar reacts to being a smol lil' guy

* fix snapshots

* shrink minimum sizes to allow small screens to work in some way
2024-08-21 09:55:57 -07:00
Laurent Nguyen
5a5e155205 Implement bulk delete documents for Mongo (#1859)
* Implement bulk delete documents for Mongo

* Fix unit test

* Adding bulkdelete to new mongo apis

* Fix error message

* Fix typo

* Improve error message wording

* Fix format

* Fix format

* Put back old delete for older container with system partition key
2024-08-21 16:59:52 +02:00
Laurent Nguyen
9d4a9c0601 Add reset button to column selection and fix naming of openUploadItemsPanePane() 2024-08-21 16:39:21 +02:00
Laurent Nguyen
2226169a71 Remove database context menu if Fabric and readonly. (#1939)
Co-authored-by: Laurent Nguyen <languye@microsoft.com>
2024-08-20 16:54:58 +02:00
SATYA SB
6f35fb5526 [accessibility-2819223]:Bug 2819223: [Keyboard navigation - Cosmos DB Query Copilot - Copilot]: The suggestions of 'Copilot search' edit field are not accessible with keyboard. (#1893)
Co-authored-by: Satyapriya Bai <v-satybai@microsoft.com>
2024-08-19 10:34:49 +05:30
Ashley Stanton-Nurse
805a4ae168 Error rendering improvements (#1887) 2024-08-15 13:29:57 -07:00
Asier Isayas
cc89691da3 Activate Mongo Proxy in Prod (#1936)
* activate mongo proxy in mpac

* activate mongo proxy in mpac

* activate mongo proxy in prod

* fixed parition key unit test

* remove three part partition key value test

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-08-14 14:15:11 -04:00
sunghyunkang1111
24860a6842 revert extract partition key (#1935) 2024-08-14 02:54:20 -05:00
Asier Isayas
bf6b362610 Activate Mongo Proxy in MPAC (#1934)
* activate mongo proxy in mpac

* activate mongo proxy in mpac

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2024-08-13 16:34:34 -04:00
sunghyunkang1111
baca7922b4 move nps survey open dialog call to after explorer initialization (#1932) 2024-08-13 14:16:20 -05:00
Ashley Stanton-Nurse
b59ba20ed0 fix #1929 by using flex instead of grid to lay out the tabs view (#1930) 2024-08-13 11:19:24 -07:00
Laurent Nguyen
1e10273510 Fix format, update snapshots 2024-08-07 09:36:48 +02:00
Laurent Nguyen
c141e2612b Merge branch 'master' into users/languy/save-documentstab-prefs 2024-08-07 09:27:13 +02:00
Laurent Nguyen
7a179ff34a Fix format 2024-08-06 18:28:52 +02:00
Laurent Nguyen
4e71e340e3 Implement column sorting 2024-07-11 14:33:55 +02:00
Laurent Nguyen
9efbe7d056 Don't allow unselecting last column 2024-07-11 10:04:43 +02:00
Laurent Nguyen
ea2ab19518 Fix heuristic for size update 2024-07-11 10:04:21 +02:00
Laurent Nguyen
5d59c47979 Fix table size issue with some heuristics 2024-07-10 19:14:13 +02:00
Laurent Nguyen
fa460bfba2 Rework column selection UI 2024-07-10 17:20:24 +02:00
Laurent Nguyen
f1dcf1c548 Update choices of column when creating new or updating document 2024-06-25 10:15:41 +02:00
Laurent Nguyen
88f38d6522 Move table values under its own property 2024-06-24 13:38:32 +02:00
Laurent Nguyen
658e2ffe85 Do not allow deselecting all columns 2024-06-21 13:12:50 +02:00
Laurent Nguyen
bea3aa8b55 Accumulate properties rather than replace for column definitions 2024-06-21 12:05:50 +02:00
Laurent Nguyen
ce0cfed128 Only allow data fields that can be rendered (string and numbers) in column selection 2024-06-21 11:42:02 +02:00
Laurent Nguyen
c0a79c1e67 Search uses string includes instead of startsWith 2024-06-20 16:40:04 +02:00
Laurent Nguyen
9945304e18 Switch icons and search compare with lowercase. 2024-06-20 16:36:01 +02:00
Laurent Nguyen
0ce9acdfdf Implement new menu for column selection with search. 2024-06-20 16:23:36 +02:00
Laurent Nguyen
b096fa9bf8 Add column selection from right-click 2024-06-19 13:01:23 +02:00
Laurent Nguyen
55df5cb121 Merge branch 'master' into users/languy/save-documentstab-prefs 2024-06-13 09:14:11 +02:00
Laurent Nguyen
e36853c100 Save column width 2024-06-12 12:26:28 +02:00
Laurent Nguyen
996f785aac Merge branch 'master' into users/languy/save-documentstab-prefs 2024-06-11 17:02:04 +02:00
Laurent Nguyen
6c67f3b2e5 Make table columns generic (no more id and partition keys) 2024-06-11 16:57:17 +02:00
Laurent Nguyen
1ee79881ef Initial implementation of saving split value to local storage 2024-06-10 14:25:58 +02:00
78 changed files with 4754 additions and 3022 deletions

View File

@@ -174,7 +174,7 @@ module.exports = {
},
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
transformIgnorePatterns: ["/node_modules/", "/externals/"],
transformIgnorePatterns: ["/node_modules/(?!@fluentui/react-icons)", "/externals/"],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,

View File

@@ -1906,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 {
@@ -2352,8 +2358,8 @@ a:link {
.tabsManagerContainer {
height: 100%;
display: grid;
grid-template-rows: 36px 36px 1fr;
display: flex;
flex-direction: column;
min-width: 0; // This prevents it to grow past the parent's width if its content is too wide
}
@@ -2610,9 +2616,8 @@ a:link {
}
.tabPanesContainer {
grid-row: span 2; // Fill the remaining space
display: flex;
height: 100%;
flex-grow: 1;
overflow: hidden;
}

938
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,6 @@ export default defineConfig({
reporter: process.env.CI ? "blob" : "html",
timeout: 10 * 60 * 1000,
use: {
actionTimeout: 5 * 60 * 1000,
trace: "off",
video: "off",
screenshot: "on",
@@ -23,7 +22,8 @@ export default defineConfig({
},
expect: {
timeout: 5 * 60 * 1000,
// Many of our expectations take a little longer than the default 5 seconds.
timeout: 15 * 1000,
},
projects: [

View File

@@ -134,6 +134,8 @@ 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";
}
export class PortalBackendEndpoints {
@@ -183,6 +185,12 @@ export class CassandraProxyAPIs {
public static readonly connectionStringSchemaApi: string = "api/connectionstring/cassandra/schema";
}
export class AadEndpoints {
public static readonly Prod: string = "https://login.microsoftonline.com/";
public static readonly Fairfax: string = "https://login.microsoftonline.us/";
public static readonly Mooncake: string = "https://login.partner.microsoftonline.cn/";
}
export class Queries {
public static CustomPageOption: string = "custom";
public static UnlimitedPageOption: string = "unlimited";

View File

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

View File

@@ -53,7 +53,8 @@ const replaceKnownError = (errorMessage: string): string => {
return "Partition key paths must contain only valid characters and not contain a trailing slash or wildcard character.";
} else if (
errorMessage?.indexOf("The user aborted a request") >= 0 ||
errorMessage?.indexOf("The operation was aborted") >= 0
errorMessage?.indexOf("The operation was aborted") >= 0 ||
errorMessage === "signal is aborted without reason"
) {
return "User aborted query.";
}

View File

@@ -550,6 +550,49 @@ export function deleteDocument_ToBeDeprecated(
});
}
export function deleteDocuments(
databaseId: string,
collection: Collection,
documentIds: DocumentId[],
): Promise<{
deletedCount: number;
isAcknowledged: boolean;
}> {
const { databaseAccount } = userContext;
const resourceEndpoint = databaseAccount.properties.mongoEndpoint || databaseAccount.properties.documentEndpoint;
const rids = documentIds.map((documentId) => documentId.id());
const params = {
databaseID: databaseId,
collectionID: collection.id(),
resourceUrl: `${resourceEndpoint}`,
resourceIDs: rids,
subscriptionID: userContext.subscriptionId,
resourceGroup: userContext.resourceGroup,
databaseAccountName: databaseAccount.name,
};
const endpoint = getFeatureEndpointOrDefault("bulkdelete");
return window
.fetch(`${endpoint}/bulkdelete`, {
method: "DELETE",
body: JSON.stringify(params),
headers: {
...defaultHeaders,
...authHeaders(),
[HttpHeaders.contentType]: ContentType.applicationJson,
},
})
.then(async (response) => {
if (response.ok) {
const result = await response.json();
return result;
}
return await errorHandling(response, "deleting documents", params);
});
}
export function createMongoCollectionWithProxy(
params: DataModels.CreateCollectionParams,
): Promise<DataModels.Collection> {
@@ -678,6 +721,7 @@ export function useMongoProxyEndpoint(api: string): boolean {
MongoProxyEndpoints.Mpac,
MongoProxyEndpoints.Prod,
MongoProxyEndpoints.Fairfax,
MongoProxyEndpoints.Mooncake,
];
let canAccessMongoProxy: boolean = userContext.databaseAccount.properties.publicNetworkAccess === "Enabled";
if (

212
src/Common/QueryError.ts Normal file
View File

@@ -0,0 +1,212 @@
import { getErrorMessage } from "Common/ErrorHandlingUtils";
import { monaco } from "Explorer/LazyMonaco";
export enum QueryErrorSeverity {
Error = "Error",
Warning = "Warning",
}
export class QueryErrorLocation {
constructor(
public start: ErrorPosition,
public end: ErrorPosition,
) {}
}
export class ErrorPosition {
constructor(
public offset: number,
public lineNumber?: number,
public column?: number,
) {}
}
// Maps severities to numbers for sorting.
const severityMap: Record<QueryErrorSeverity, number> = {
Error: 1,
Warning: 0,
};
export function compareSeverity(left: QueryErrorSeverity, right: QueryErrorSeverity): number {
return severityMap[left] - severityMap[right];
}
export function createMonacoErrorLocationResolver(
editor: monaco.editor.IStandaloneCodeEditor,
selection?: monaco.Selection,
): (location: { start: number; end: number }) => QueryErrorLocation {
return ({ start, end }) => {
// Start and end are absolute offsets (character index) in the document.
// But we need line numbers and columns for the monaco editor.
// To get those, we use the editor's model to convert the offsets to positions.
const model = editor.getModel();
if (!model) {
return new QueryErrorLocation(new ErrorPosition(start), new ErrorPosition(end));
}
// If the error was found in a selection, adjust the start and end positions to be relative to the document.
if (selection) {
// Get the character index of the start of the selection.
const selectionStartOffset = model.getOffsetAt(selection.getStartPosition());
// Adjust the start and end positions to be relative to the document.
start = selectionStartOffset + start;
end = selectionStartOffset + end;
// Now, when we resolve the positions, they will be relative to the document and appear in the correct location.
}
const startPos = model.getPositionAt(start);
const endPos = model.getPositionAt(end);
return new QueryErrorLocation(
new ErrorPosition(start, startPos.lineNumber, startPos.column),
new ErrorPosition(end, endPos.lineNumber, endPos.column),
);
};
}
export const createMonacoMarkersForQueryErrors = (errors: QueryError[]) => {
if (!errors) {
return [];
}
return errors
.map((error): monaco.editor.IMarkerData => {
// Validate that we have what we need to make a marker
if (
error.location === undefined ||
error.location.start === undefined ||
error.location.end === undefined ||
error.location.start.lineNumber === undefined ||
error.location.end.lineNumber === undefined ||
error.location.start.column === undefined ||
error.location.end.column === undefined
) {
return null;
}
return {
message: error.message,
severity: error.getMonacoSeverity(),
startLineNumber: error.location.start.lineNumber,
startColumn: error.location.start.column,
endLineNumber: error.location.end.lineNumber,
endColumn: error.location.end.column,
};
})
.filter((marker) => !!marker);
};
export default class QueryError {
constructor(
public message: string,
public severity: QueryErrorSeverity,
public code?: string,
public location?: QueryErrorLocation,
) {}
getMonacoSeverity(): monaco.MarkerSeverity {
// It's very difficult to use the monaco.MarkerSeverity enum from here, so we'll just use the numbers directly.
// See: https://microsoft.github.io/monaco-editor/typedoc/enums/MarkerSeverity.html
switch (this.severity) {
case QueryErrorSeverity.Error:
return 8;
case QueryErrorSeverity.Warning:
return 4;
default:
return 2; // Info
}
}
/** Attempts to parse a query error from a string or object.
*
* @param error The error to parse.
* @returns An array of query errors if the error could be parsed, or null otherwise.
*/
static tryParse(
error: unknown,
locationResolver?: (location: { start: number; end: number }) => QueryErrorLocation,
): QueryError[] {
locationResolver =
locationResolver ||
(({ start, end }) => new QueryErrorLocation(new ErrorPosition(start), new ErrorPosition(end)));
const errors = QueryError.tryParseObject(error, locationResolver);
if (errors !== null) {
return errors;
}
const errorMessage = getErrorMessage(error as string | Error);
// Map some well known messages to richer errors
const knownError = knownErrors[errorMessage];
if (knownError) {
return [knownError];
} else {
return [new QueryError(errorMessage, QueryErrorSeverity.Error)];
}
}
static read(
error: unknown,
locationResolver: (location: { start: number; end: number }) => QueryErrorLocation,
): QueryError | null {
if (typeof error !== "object" || error === null) {
return null;
}
const message = "message" in error && typeof error.message === "string" ? error.message : undefined;
if (!message) {
return null; // Invalid error (no message).
}
const severity =
"severity" in error && typeof error.severity === "string" ? (error.severity as QueryErrorSeverity) : undefined;
const location =
"location" in error && typeof error.location === "object"
? locationResolver(error.location as { start: number; end: number })
: undefined;
const code = "code" in error && typeof error.code === "string" ? error.code : undefined;
return new QueryError(message, severity, code, location);
}
private static tryParseObject(
error: unknown,
locationResolver: (location: { start: number; end: number }) => QueryErrorLocation,
): QueryError[] | null {
if (typeof error === "object" && "message" in error) {
error = error.message;
}
if (typeof error !== "string") {
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.
message = message.substring("Message: ".length);
}
const lines = message.split("\n");
message = lines[0].trim();
let parsed: unknown;
try {
parsed = JSON.parse(message);
} catch (e) {
// Not a query error.
return null;
}
if (typeof parsed === "object" && "errors" in parsed && Array.isArray(parsed.errors)) {
return parsed.errors.map((e) => QueryError.read(e, locationResolver)).filter((e) => e !== null);
}
return null;
}
}
const knownErrors: Record<string, QueryError> = {
"User aborted query.": new QueryError("User aborted query.", QueryErrorSeverity.Warning),
};

View File

@@ -87,7 +87,7 @@ let configContext: Readonly<ConfigContext> = {
`^https:\\/\\/.*\\.analysis-df\\.net$`,
`^https:\\/\\/.*\\.analysis-df\\.windows\\.net$`,
`^https:\\/\\/.*\\.azure-test\\.net$`,
`^https:\\/\\/cosmos-explorer-preview\\.azurewebsites\\.net`,
`^https:\\/\\/cosmos-explorer-preview\\.azurewebsites\\.net$`,
], // Webpack injects this at build time
gitSha: process.env.GIT_SHA,
hostedExplorerURL: "https://cosmos.azure.com/",
@@ -109,14 +109,15 @@ let configContext: Readonly<ConfigContext> = {
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Prod,
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Prod,
NEW_MONGO_APIS: [
// "resourcelist",
// "queryDocuments",
// "createDocument",
// "readDocument",
// "updateDocument",
// "deleteDocument",
// "createCollectionWithProxy",
// "legacyMongoShell",
"resourcelist",
"queryDocuments",
"createDocument",
"readDocument",
"updateDocument",
"deleteDocument",
"createCollectionWithProxy",
"legacyMongoShell",
"bulkdelete",
],
MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED: false,
CASSANDRA_PROXY_ENDPOINT: CassandraProxyEndpoints.Prod,

View File

@@ -41,6 +41,10 @@ export interface DatabaseContextMenuButtonParams {
* New resource tree (in ReactJS)
*/
export const createDatabaseContextMenu = (container: Explorer, databaseId: string): TreeNodeMenuItem[] => {
if (configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly) {
return undefined;
}
const items: TreeNodeMenuItem[] = [
{
iconSrc: AddCollectionIcon,

View File

@@ -3,6 +3,37 @@ import * as React from "react";
import { loadMonaco, monaco } from "../../LazyMonaco";
// import "./EditorReact.less";
// In development, add a function to window to allow us to get the editor instance for a given element
if (process.env.NODE_ENV === "development") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const win = window as any;
win._monaco_getEditorForElement =
win._monaco_getEditorForElement ||
((element: HTMLElement) => {
const editorId = element.dataset["monacoEditorId"];
if (!editorId || !win.__monaco_editors || typeof win.__monaco_editors !== "object") {
return null;
}
return win.__monaco_editors[editorId];
});
win._monaco_getEditorContentForElement =
win._monaco_getEditorContentForElement ||
((element: HTMLElement) => {
const editor = win._monaco_getEditorForElement(element);
return editor ? editor.getValue() : null;
});
win._monaco_setEditorContentForElement =
win._monaco_setEditorContentForElement ||
((element: HTMLElement, text: string) => {
const editor = win._monaco_getEditorForElement(element);
if (editor) {
editor.setValue(text);
}
});
}
interface EditorReactStates {
showEditor: boolean;
}
@@ -11,7 +42,7 @@ export interface EditorReactProps {
content: string;
isReadOnly: boolean;
ariaLabel: string; // Sets what will be read to the user to define the control
onContentSelected?: (selectedContent: string) => void; // Called when text is selected
onContentSelected?: (selectedContent: string, selection: monaco.Selection) => void; // Called when text is selected
onContentChanged?: (newContent: string) => void; // Called when text is changed
theme?: string; // Monaco editor theme
wordWrap?: monaco.editor.IEditorOptions["wordWrap"];
@@ -25,6 +56,7 @@ export interface EditorReactProps {
className?: string;
spinnerClassName?: string;
modelMarkers?: monaco.editor.IMarkerData[];
enableWordWrapContextMenuItem?: boolean; // Enable/Disable "Word Wrap" context menu item
onWordWrapChanged?: (wordWrap: "on" | "off") => void; // Called when word wrap is changed
}
@@ -32,10 +64,25 @@ export interface EditorReactProps {
export class EditorReact extends React.Component<EditorReactProps, EditorReactStates> {
private static readonly VIEWING_OPTIONS_GROUP_ID = "viewingoptions"; // Group ID for the context menu group
private rootNode: HTMLElement;
private editor: monaco.editor.IStandaloneCodeEditor;
public editor: monaco.editor.IStandaloneCodeEditor;
private selectionListener: monaco.IDisposable;
private monacoEditorOptionsWordWrap: monaco.editor.EditorOption;
monacoApi: {
default: typeof monaco;
Emitter: typeof monaco.Emitter;
MarkerTag: typeof monaco.MarkerTag;
MarkerSeverity: typeof monaco.MarkerSeverity;
CancellationTokenSource: typeof monaco.CancellationTokenSource;
Uri: typeof monaco.Uri;
KeyCode: typeof monaco.KeyCode;
KeyMod: typeof monaco.KeyMod;
Position: typeof monaco.Position;
Range: typeof monaco.Range;
Selection: typeof monaco.Selection;
SelectionDirection: typeof monaco.SelectionDirection;
Token: typeof monaco.Token;
editor: typeof monaco.editor;
languages: typeof monaco.languages;
};
public constructor(props: EditorReactProps) {
super(props);
@@ -64,7 +111,7 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
if (this.props.content !== existingContent) {
if (this.props.isReadOnly) {
this.editor.setValue(this.props.content);
this.editor.setValue(this.props.content || ""); // Monaco throws an error if you set the value to undefined.
} else {
this.editor.pushUndoStop();
this.editor.executeEdits("", [
@@ -75,6 +122,8 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
]);
}
}
this.monacoApi.editor.setModelMarkers(this.editor.getModel(), "owner", this.props.modelMarkers || []);
}
public componentWillUnmount(): void {
@@ -88,6 +137,7 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
<Spinner size={SpinnerSize.large} className={this.props.spinnerClassName || "spinner"} />
)}
<div
data-test="EditorReact/Host/Unloaded"
className={this.props.className || "jsonEditor"}
style={this.props.monacoContainerStyles}
ref={(elt: HTMLElement) => this.setRef(elt)}
@@ -98,6 +148,18 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
protected configureEditor(editor: monaco.editor.IStandaloneCodeEditor) {
this.editor = editor;
this.rootNode.dataset["test"] = "EditorReact/Host/Loaded";
// In development, we want to be able to access the editor instance from the console
if (process.env.NODE_ENV === "development") {
this.rootNode.dataset["monacoEditorId"] = this.editor.getId();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const win = window as any;
win["__monaco_editors"] = win["__monaco_editors"] || {};
win["__monaco_editors"][this.editor.getId()] = this.editor;
}
if (!this.props.isReadOnly && this.props.onContentChanged) {
// Hooking the model's onDidChangeContent event because of some event ordering issues.
// If a single user input causes BOTH the editor content to change AND the cursor selection to change (which is likely),
@@ -115,7 +177,7 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
this.selectionListener = this.editor.onDidChangeCursorSelection(
(event: monaco.editor.ICursorSelectionChangedEvent) => {
const selectedContent: string = this.editor.getModel().getValueInRange(event.selection);
this.props.onContentSelected(selectedContent);
this.props.onContentSelected(selectedContent, event.selection);
},
);
}
@@ -130,7 +192,7 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
// Method that will be executed when the action is triggered.
// @param editor The editor instance is passed in as a convenience
run: (ed) => {
const newOption = ed.getOption(this.monacoEditorOptionsWordWrap) === "on" ? "off" : "on";
const newOption = ed.getOption(this.monacoApi.editor.EditorOption.wordWrap) === "on" ? "off" : "on";
ed.updateOptions({ wordWrap: newOption });
this.props.onWordWrapChanged(newOption);
},
@@ -156,16 +218,14 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
lineDecorationsWidth: this.props.lineDecorationsWidth,
minimap: this.props.minimap,
scrollBeyondLastLine: this.props.scrollBeyondLastLine,
fixedOverflowWidgets: true,
};
this.rootNode.innerHTML = "";
const lazymonaco = await loadMonaco();
// We can only get this constant after loading monaco lazily
this.monacoEditorOptionsWordWrap = lazymonaco.editor.EditorOption.wordWrap;
this.monacoApi = await loadMonaco();
try {
createCallback(lazymonaco?.editor?.create(this.rootNode, options));
createCallback(this.monacoApi.editor.create(this.rootNode, options));
} catch (error) {
// This could happen if the parent node suddenly disappears during create()
console.error("Unable to create EditorReact", error);

View File

@@ -0,0 +1,37 @@
import { ProgressBar, makeStyles } from "@fluentui/react-components";
import React from "react";
const useStyles = makeStyles({
indeterminateProgressBarRoot: {
"@media screen and (prefers-reduced-motion: reduce)": {
animationIterationCount: "infinite",
animationDuration: "3s",
animationName: {
"0%": {
opacity: ".2", // matches indeterminate bar width
},
"50%": {
opacity: "1",
},
"100%": {
opacity: ".2",
},
},
},
},
indeterminateProgressBarBar: {
"@media screen and (prefers-reduced-motion: reduce)": {
maxWidth: "100%",
},
},
});
export const IndeterminateProgressBar: React.FC = () => {
const styles = useStyles();
return (
<ProgressBar
bar={{ className: styles.indeterminateProgressBarBar }}
className={styles.indeterminateProgressBarRoot}
/>
);
};

View File

@@ -0,0 +1,68 @@
import { Button, MessageBar, MessageBarActions, MessageBarBody } from "@fluentui/react-components";
import { DismissRegular } from "@fluentui/react-icons";
import React, { useState } from "react";
export enum MessageBannerState {
/** The banner should be visible if the triggering conditions are met. */
Allowed = "allowed",
/** The banner has been dismissed by the user and will not be shown until the component is recreated, even if the visibility condition is true. */
Dismissed = "dismissed",
/** The banner has been supressed by the user and will not be shown at all, even if the visibility condition is true. */
Suppressed = "suppressed",
}
export type MessageBannerProps = {
/** A CSS class for the root MessageBar component */
className: string;
/** A unique ID for the message that will be used to store it's dismiss/suppress state across sessions. */
messageId: string;
/** The current visibility state for the banner IGNORING the user's dimiss/suppress preference
*
* If this value is true but the user has dismissed the banner, the banner will NOT be shown.
*/
visible: boolean;
};
/** A component that shows a message banner which can be dismissed by the user.
*
* In the future, this can also support persisting the dismissed state in local storage without requiring changes to all the components that use it.
*
* A message banner can be in three "states":
* - Allowed: The banner should be visible if the triggering conditions are met.
* - Dismissed: The banner has been dismissed by the user and will not be shown until the component is recreated, even if the visibility condition is true.
* - Suppressed: The banner has been supressed by the user and will not be shown at all, even if the visibility condition is true.
*
* The "Dismissed" state represents the user clicking the "x" in the banner to dismiss it.
* The "Suppressed" state represents the user clicking "Don't show this again".
*/
export const MessageBanner: React.FC<MessageBannerProps> = ({ visible, className, children }) => {
const [state, setState] = useState<MessageBannerState>(MessageBannerState.Allowed);
if (state !== MessageBannerState.Allowed) {
return null;
}
if (!visible) {
return null;
}
return (
<MessageBar className={className}>
<MessageBarBody>{children}</MessageBarBody>
<MessageBarActions
containerAction={
<Button
aria-label="dismiss"
appearance="transparent"
icon={<DismissRegular />}
onClick={() => setState(MessageBannerState.Dismissed)}
/>
}
></MessageBarActions>
</MessageBar>
);
};

View File

@@ -17,7 +17,7 @@ export const useTreeStyles = makeStyles({
minWidth: "100%",
rowGap: "0px",
paddingTop: "0px",
[treeIconWidth]: "20px",
[treeIconWidth]: "16px",
[leafNodeSpacing]: "24px",
},
nodeIcon: {
@@ -25,12 +25,13 @@ export const useTreeStyles = makeStyles({
height: `var(${treeIconWidth})`,
},
treeItem: {},
nodeLabel: {},
nodeLabel: {
whiteSpace: "nowrap", // Don't wrap text, there will be a scrollbar.
},
treeItemLayout: {
fontSize: tokens.fontSizeBase300,
height: tokens.layoutRowHeight,
...cosmosShorthands.borderBottom(),
paddingLeft: `calc(var(${treeItemLevelToken}, 1) * ${tokens.spacingHorizontalXXL})`,
// Some sneaky CSS variables stuff to change the background color of the action button on hover.
[actionButtonBackground]: tokens.colorNeutralBackground1,

View File

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

View File

@@ -10,12 +10,23 @@ exports[`TreeNodeComponent does not render children if the node is loading 1`] =
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={<ChevronRight20Regular />}
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -133,6 +144,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg
aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -151,7 +163,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
"itemType": "branch",
"layoutRef": {
"current": <div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
>
<div
@@ -161,6 +173,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg
aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -173,11 +186,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/>
</svg>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -202,7 +225,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="-1"
>
<div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
>
<div
@@ -212,6 +235,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg
aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -224,18 +248,29 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/>
</svg>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
</div>
</div>
<div
class="fui-Tree rnv2ez3 ___jy13a00_lpffjy0 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
class="fui-Tree rnv2ez3 ___17a32do_7zrvj80 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
data-test="Tree:root"
role="tree"
>
<div
@@ -248,7 +283,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="0"
>
<div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child1Label"
>
<div
@@ -258,6 +293,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg
aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -270,11 +306,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/>
</svg>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child1Icon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child1Label
</span>
@@ -291,7 +337,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="-1"
>
<div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child2LoadingLabel"
>
<div
@@ -301,6 +347,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg
aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -313,11 +360,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/>
</svg>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child2LoadingIcon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child2LoadingLabel
</span>
@@ -333,7 +390,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="-1"
>
<div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vrg09j fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_vz3p260 fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child3ExpandingLabel"
>
<div
@@ -353,11 +410,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</span>
</div>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child3ExpandingIcon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child3ExpandingLabel
</span>
@@ -373,22 +440,36 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={<ChevronRight20Regular />}
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
}
>
<div
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
className="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
>
<div
aria-hidden={true}
className="fui-TreeItemLayout__expandIcon rh4pu5o"
>
<ChevronRight20Regular>
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
>
<svg
aria-hidden={true}
className="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -402,11 +483,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</svg>
</ChevronRight20Regular>
</div>
<div
aria-hidden={true}
className="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
</div>
<div
className="fui-TreeItemLayout__main rklbe47"
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -414,7 +505,8 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</div>
</TreeItemLayout>
<Tree
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
className="___17a32do_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
data-test="Tree:root"
>
<TreeProvider
value={
@@ -481,7 +573,8 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
}
>
<div
className="fui-Tree rnv2ez3 ___jy13a00_lpffjy0 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
className="fui-Tree rnv2ez3 ___17a32do_7zrvj80 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
data-test="Tree:root"
role="tree"
>
<TreeNodeComponent
@@ -549,6 +642,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg
aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -567,7 +661,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
"itemType": "branch",
"layoutRef": {
"current": <div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child1Label"
>
<div
@@ -577,6 +671,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg
aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -589,11 +684,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/>
</svg>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child1Icon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child1Label
</span>
@@ -618,7 +723,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="0"
>
<div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child1Label"
>
<div
@@ -628,6 +733,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg
aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -640,11 +746,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/>
</svg>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child1Icon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child1Label
</span>
@@ -658,22 +774,36 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child1Label"
expandIcon={<ChevronRight20Regular />}
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child1Icon"
/>
}
>
<div
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
className="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child1Label"
>
<div
aria-hidden={true}
className="fui-TreeItemLayout__expandIcon rh4pu5o"
>
<ChevronRight20Regular>
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
>
<svg
aria-hidden={true}
className="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -687,11 +817,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</svg>
</ChevronRight20Regular>
</div>
<div
aria-hidden={true}
className="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child1Icon"
/>
</div>
<div
className="fui-TreeItemLayout__main rklbe47"
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
child1Label
</span>
@@ -699,7 +839,8 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</div>
</TreeItemLayout>
<Tree
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
className="___17a32do_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
data-test="Tree:root/child1Label"
>
<TreeProvider
value={
@@ -772,6 +913,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg
aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -790,7 +932,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
"itemType": "branch",
"layoutRef": {
"current": <div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child2LoadingLabel"
>
<div
@@ -800,6 +942,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg
aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -812,11 +955,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/>
</svg>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child2LoadingIcon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child2LoadingLabel
</span>
@@ -841,7 +994,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="-1"
>
<div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child2LoadingLabel"
>
<div
@@ -851,6 +1004,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg
aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -863,11 +1017,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
/>
</svg>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child2LoadingIcon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child2LoadingLabel
</span>
@@ -881,22 +1045,36 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child2LoadingLabel"
expandIcon={<ChevronRight20Regular />}
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child2LoadingIcon"
/>
}
>
<div
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
className="fui-TreeItemLayout r1bx0xiv ___9uolwu0_9b0r4g0 fk6fouc fkhj508 figsok6 f1i3iumi fo100m9 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child2LoadingLabel"
>
<div
aria-hidden={true}
className="fui-TreeItemLayout__expandIcon rh4pu5o"
>
<ChevronRight20Regular>
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
>
<svg
aria-hidden={true}
className="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -910,11 +1088,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</svg>
</ChevronRight20Regular>
</div>
<div
aria-hidden={true}
className="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child2LoadingIcon"
/>
</div>
<div
className="fui-TreeItemLayout__main rklbe47"
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
child2LoadingLabel
</span>
@@ -999,7 +1187,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
"itemType": "leaf",
"layoutRef": {
"current": <div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vrg09j fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_vz3p260 fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child3ExpandingLabel"
>
<div
@@ -1019,11 +1207,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</span>
</div>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child3ExpandingIcon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child3ExpandingLabel
</span>
@@ -1047,7 +1245,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
tabindex="-1"
>
<div
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vrg09j fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
class="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_vz3p260 fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child3ExpandingLabel"
>
<div
@@ -1067,11 +1265,21 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</span>
</div>
</div>
<div
aria-hidden="true"
class="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
class="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="child3ExpandingIcon"
/>
</div>
<div
class="fui-TreeItemLayout__main rklbe47"
>
<span
class=""
class="___1h29e9h_0000000 fz5stix"
>
child3ExpandingLabel
</span>
@@ -1085,9 +1293,9 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child3ExpandingLabel"
expandIcon={
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
@@ -1096,12 +1304,12 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
}
>
<div
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vrg09j fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_vz3p260 fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child3ExpandingLabel"
>
<div
aria-hidden={true}
className="fui-TreeItemLayout__expandIcon rh4pu5o"
className="fui-TreeItemLayout__iconBefore rphzgg1 ___1lqnc2u_1hdcey2 f7x41pl"
>
<img
alt=""
@@ -1113,7 +1321,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
className="fui-TreeItemLayout__main rklbe47"
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
child3ExpandingLabel
</span>
@@ -1144,9 +1352,9 @@ exports[`TreeNodeComponent renders a loading spinner if the node is loading: loa
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
@@ -1155,7 +1363,7 @@ exports[`TreeNodeComponent renders a loading spinner if the node is loading: loa
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -1173,16 +1381,23 @@ exports[`TreeNodeComponent renders a loading spinner if the node is loading: loa
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={
<Spinner
size="extra-tiny"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -1200,12 +1415,23 @@ exports[`TreeNodeComponent renders a node as expandable if it has empty, but def
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={<ChevronRight20Regular />}
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -1269,9 +1495,9 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
"className": "___1r8p62d_0000000 f1xg1ack f1e31b4d",
}
}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
@@ -1280,7 +1506,7 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -1319,9 +1545,9 @@ exports[`TreeNodeComponent renders a single node 1`] = `
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
@@ -1330,7 +1556,7 @@ exports[`TreeNodeComponent renders a single node 1`] = `
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -1348,9 +1574,9 @@ exports[`TreeNodeComponent renders an icon if the node has one 1`] = `
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
@@ -1359,7 +1585,7 @@ exports[`TreeNodeComponent renders an icon if the node has one 1`] = `
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
@@ -1377,18 +1603,30 @@ exports[`TreeNodeComponent renders selected parent node as selected if no descen
>
<TreeItemLayout
actions={false}
className="___kqkdor0_ihxn0o0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1nfm20t f1do9gdl"
className="___rq9vxg0_1ykn2d2 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1nfm20t f1do9gdl"
data-test="TreeNode:root"
expandIcon={<ChevronRight20Regular />}
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
</TreeItemLayout>
<Tree
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
className="___17a32do_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
data-test="Tree:root"
>
<TreeNodeComponent
key="child1Label"
@@ -1448,18 +1686,30 @@ exports[`TreeNodeComponent renders selected parent node as unselected if any des
>
<TreeItemLayout
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
className="___z7owk70_14ep1pe fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={<ChevronRight20Regular />}
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
src="rootIcon"
/>
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>
</TreeItemLayout>
<Tree
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
className="___17a32do_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp f19d5ny4 fzz4f4n"
data-test="Tree:root"
>
<TreeNodeComponent
key="child1Label"
@@ -1520,9 +1770,9 @@ exports[`TreeNodeComponent renders single selected leaf node as selected 1`] = `
>
<TreeItemLayout
actions={false}
className="___kqkdor0_ihxn0o0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1nfm20t f1do9gdl"
className="___rq9vxg0_1ykn2d2 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1nfm20t f1do9gdl"
data-test="TreeNode:root"
expandIcon={
iconBefore={
<img
alt=""
className="___i3nbrx0_0000000 f1do9gdl fbv8p0b"
@@ -1531,7 +1781,7 @@ exports[`TreeNodeComponent renders single selected leaf node as selected 1`] = `
}
>
<span
className=""
className="___1h29e9h_0000000 fz5stix"
>
rootLabel
</span>

View File

@@ -1,19 +1,21 @@
import * as msal from "@azure/msal-browser";
import { Link } from "@fluentui/react/lib/Link";
import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility";
import { sendMessage } from "Common/MessageHandler";
import { Platform, configContext } from "ConfigContext";
import { MessageTypes } from "Contracts/ExplorerContracts";
import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
import { getCopilotEnabled, isCopilotFeatureRegistered } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { IGalleryItem } from "Juno/JunoClient";
import { scheduleRefreshDatabaseResourceToken } from "Platform/Fabric/FabricUtil";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { acquireTokenWithMsal, getMsalInstance } from "Utils/AuthorizationUtils";
import { allowedNotebookServerUrls, validateEndpoint } from "Utils/EndpointUtils";
import { update } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
import { useQueryCopilot } from "hooks/useQueryCopilot";
import * as ko from "knockout";
import React from "react";
import _ from "underscore";
import * as msal from "@azure/msal-browser";
import shallow from "zustand/shallow";
import { AuthType } from "../AuthType";
import { BindingHandlersRegisterer } from "../Bindings/BindingHandlersRegisterer";
@@ -67,8 +69,6 @@ import { ResourceTreeAdapter } from "./Tree/ResourceTreeAdapter";
import StoredProcedure from "./Tree/StoredProcedure";
import { useDatabases } from "./useDatabases";
import { useSelectedNode } from "./useSelectedNode";
import { update } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
BindingHandlersRegisterer.registerBindingHandlers();
@@ -295,7 +295,7 @@ export default class Explorer {
}
public openNPSSurveyDialog(): void {
if (!Platform.Portal) {
if (!Platform.Portal || !["Postgres", "SQL", "Mongo"].includes(userContext.apiType)) {
return;
}
@@ -1119,7 +1119,7 @@ export default class Explorer {
}
}
public openUploadItemsPanePane(): void {
public openUploadItemsPane(): void {
useSidePanel.getState().openSidePanel("Upload " + getUploadName(), <UploadItemsPane />);
}
public openExecuteSprocParamsPanel(storedProcedure: StoredProcedure): void {

View File

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

View File

@@ -131,6 +131,7 @@ export class NotificationConsoleComponent extends React.Component<
</div>
<div
className="expandCollapseButton"
data-test="NotificationConsole/ExpandCollapseButton"
role="button"
tabIndex={0}
aria-label={"console button" + (this.props.isConsoleExpanded ? " expanded" : " collapsed")}
@@ -147,7 +148,7 @@ export class NotificationConsoleComponent extends React.Component<
height={this.props.isConsoleExpanded ? "auto" : 0}
onAnimationEnd={this.onConsoleWasExpanded}
>
<div className="notificationConsoleContents">
<div data-test="NotificationConsole/Contents" className="notificationConsoleContents">
<div className="notificationConsoleControls">
<Dropdown
label="Filter:"

View File

@@ -74,6 +74,7 @@ exports[`NotificationConsoleComponent renders the console 1`] = `
aria-expanded={true}
aria-label="console button collapsed"
className="expandCollapseButton"
data-test="NotificationConsole/ExpandCollapseButton"
role="button"
tabIndex={0}
>
@@ -109,6 +110,7 @@ exports[`NotificationConsoleComponent renders the console 1`] = `
>
<div
className="notificationConsoleContents"
data-test="NotificationConsole/Contents"
>
<div
className="notificationConsoleControls"
@@ -245,6 +247,7 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
aria-expanded={true}
aria-label="console button collapsed"
className="expandCollapseButton"
data-test="NotificationConsole/ExpandCollapseButton"
role="button"
tabIndex={0}
>
@@ -280,6 +283,7 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
>
<div
className="notificationConsoleContents"
data-test="NotificationConsole/Contents"
>
<div
className="notificationConsoleControls"

View File

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

View File

@@ -1,6 +1,7 @@
import {
Checkbox,
ChoiceGroup,
DefaultButton,
IChoiceGroupOption,
ISpinButtonStyles,
IToggleStyles,
@@ -12,11 +13,15 @@ import {
Toggle,
TooltipHost,
} from "@fluentui/react";
import { 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 +34,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 +54,13 @@ export interface DataPlaneRbacState {
type DataPlaneRbacStore = UseStore<Partial<DataPlaneRbacState>>;
const useStyles = makeStyles({
bulletList: {
listStyleType: "disc",
paddingLeft: "20px",
},
});
export const useDataPlaneRbac: DataPlaneRbacStore = create(() => ({
dataPlaneRbacEnabled: false,
}));
@@ -133,6 +144,9 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
const [copilotSampleDBEnabled, setCopilotSampleDBEnabled] = useState<boolean>(
LocalStorageUtility.getEntryString(StorageKey.CopilotSampleDBEnabled) === "true",
);
const styles = useStyles();
const explorerVersion = configContext.gitSha;
const shouldShowQueryPageOptions = userContext.apiType === "SQL";
const shouldShowGraphAutoVizOption = userContext.apiType === "Gremlin";
@@ -153,43 +167,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 });
}
}
@@ -476,55 +492,57 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</div>
</div>
)}
{userContext.apiType === "SQL" && userContext.authType === AuthType.AAD && (
<>
<div className="settingsSection">
<div className="settingsSectionPart">
<fieldset>
<legend id="enableDataPlaneRBACOptions" className="settingsSectionLabel legendLabel">
Enable Entra ID RBAC
</legend>
<TooltipHost
content={
<>
Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable Entra
ID RBAC.
<a
href="https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac#use-data-explorer"
target="_blank"
rel="noopener noreferrer"
>
{" "}
Learn more{" "}
</a>
</>
}
>
<Icon iconName="Info" ariaLabel="Info tooltip" className="panelInfoIcon" tabIndex={0} />
</TooltipHost>
{showDataPlaneRBACWarning && configContext.platform === Platform.Portal && (
<MessageBar
messageBarType={MessageBarType.warning}
isMultiline={true}
onDismiss={() => setShowDataPlaneRBACWarning(false)}
dismissButtonAriaLabel="Close"
{userContext.apiType === "SQL" &&
userContext.authType === AuthType.AAD &&
configContext.platform !== Platform.Fabric && (
<>
<div className="settingsSection">
<div className="settingsSectionPart">
<fieldset>
<legend id="enableDataPlaneRBACOptions" className="settingsSectionLabel legendLabel">
Enable Entra ID RBAC
</legend>
<TooltipHost
content={
<>
Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable
Entra ID RBAC.
<a
href="https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac#use-data-explorer"
target="_blank"
rel="noopener noreferrer"
>
{" "}
Learn more{" "}
</a>
</>
}
>
Please click on &quot;Login for Entra ID RBAC&quot; button prior to performing Entra ID RBAC
operations
</MessageBar>
)}
<ChoiceGroup
ariaLabelledBy="enableDataPlaneRBACOptions"
options={dataPlaneRBACOptionsList}
styles={choiceButtonStyles}
selectedKey={enableDataPlaneRBACOption}
onChange={handleOnDataPlaneRBACOptionChange}
/>
</fieldset>
<Icon iconName="Info" ariaLabel="Info tooltip" className="panelInfoIcon" tabIndex={0} />
</TooltipHost>
{showDataPlaneRBACWarning && configContext.platform === Platform.Portal && (
<MessageBar
messageBarType={MessageBarType.warning}
isMultiline={true}
onDismiss={() => setShowDataPlaneRBACWarning(false)}
dismissButtonAriaLabel="Close"
>
Please click on &quot;Login for Entra ID RBAC&quot; button prior to performing Entra ID RBAC
operations
</MessageBar>
)}
<ChoiceGroup
ariaLabelledBy="enableDataPlaneRBACOptions"
options={dataPlaneRBACOptionsList}
styles={choiceButtonStyles}
selectedKey={enableDataPlaneRBACOption}
onChange={handleOnDataPlaneRBACOptionChange}
/>
</fieldset>
</div>
</div>
</div>
</>
)}
</>
)}
{userContext.apiType === "SQL" && (
<>
<div className="settingsSection">
@@ -830,6 +848,34 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</div>
</div>
)}
<div className="settingsSection">
<div className="settingsSectionPart">
<DefaultButton
onClick={() => {
useDialog.getState().showOkCancelModalDialog(
"Clear History",
undefined,
"Are you sure you want to proceed?",
() => deleteAllStates(),
"Cancel",
undefined,
<>
<span>
This action will clear the all customizations for this account in this browser, including:
</span>
<ul className={styles.bulletList}>
<li>Reset your customized tab layout, including the splitter positions</li>
<li>Erase your table column preferences, including any custom columns</li>
<li>Clear your filter history</li>
</ul>
</>,
);
}}
>
Clear History
</DefaultButton>
</div>
</div>
<div className="settingsSection">
<div className="settingsSectionPart">
<div className="settingsSectionLabel">Explorer Version</div>

View File

@@ -238,7 +238,7 @@ exports[`Settings Pane should render Default properly 1`] = `
},
]
}
selectedKey="vertical"
selectedKey="horizontal"
styles={
{
"flexContainer": [
@@ -485,6 +485,19 @@ exports[`Settings Pane should render Default properly 1`] = `
/>
</div>
</div>
<div
className="settingsSection"
>
<div
className="settingsSectionPart"
>
<CustomizedDefaultButton
onClick={[Function]}
>
Clear History
</CustomizedDefaultButton>
</div>
</div>
<div
className="settingsSection"
>
@@ -708,6 +721,19 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
/>
</div>
</div>
<div
className="settingsSection"
>
<div
className="settingsSectionPart"
>
<CustomizedDefaultButton
onClick={[Function]}
>
Clear History
</CustomizedDefaultButton>
</div>
</div>
<div
className="settingsSection"
>

View File

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

View File

@@ -1,4 +1,5 @@
import { MinimalQueryIterator } from "Common/IteratorUtilities";
import QueryError from "Common/QueryError";
import { QueryResults } from "Contracts/ViewModels";
import { CopilotMessage } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
import { guid } from "Explorer/Tables/Utilities";
@@ -28,7 +29,7 @@ const CopilotProvider = ({ children }: { children: React.ReactNode }): JSX.Eleme
showSamplePrompts: false,
queryIterator: undefined,
queryResults: undefined,
errorMessage: "",
errors: [],
isSamplePromptsOpen: false,
showPromptTeachingBubble: true,
showDeletePopup: false,
@@ -64,7 +65,7 @@ const CopilotProvider = ({ children }: { children: React.ReactNode }): JSX.Eleme
setShowSamplePrompts: (showSamplePrompts: boolean) => set({ showSamplePrompts }),
setQueryIterator: (queryIterator: MinimalQueryIterator | undefined) => set({ queryIterator }),
setQueryResults: (queryResults: QueryResults | undefined) => set({ queryResults }),
setErrorMessage: (errorMessage: string) => set({ errorMessage }),
setErrors: (errors: QueryError[]) => set({ errors }),
setIsSamplePromptsOpen: (isSamplePromptsOpen: boolean) => set({ isSamplePromptsOpen }),
setShowPromptTeachingBubble: (showPromptTeachingBubble: boolean) => set({ showPromptTeachingBubble }),
setShowDeletePopup: (showDeletePopup: boolean) => set({ showDeletePopup }),

View File

@@ -18,8 +18,9 @@ import {
Text,
TextField,
} from "@fluentui/react";
import { HttpStatusCodes } from "Common/Constants";
import { HttpStatusCodes, NormalizedEventKey } from "Common/Constants";
import { handleError } from "Common/ErrorHandlingUtils";
import QueryError, { QueryErrorSeverity } from "Common/QueryError";
import { createUri } from "Common/UrlUtility";
import { CopyPopup } from "Explorer/QueryCopilot/Popup/CopyPopup";
import { DeletePopup } from "Explorer/QueryCopilot/Popup/DeletePopup";
@@ -34,7 +35,7 @@ import { SamplePrompts, SamplePromptsProps } from "Explorer/QueryCopilot/Shared/
import { Action } from "Shared/Telemetry/TelemetryConstants";
import { userContext } from "UserContext";
import { useQueryCopilot } from "hooks/useQueryCopilot";
import React, { useRef, useState } from "react";
import React, { useMemo, useRef, useState } from "react";
import HintIcon from "../../../images/Hint.svg";
import RecentIcon from "../../../images/Recent.svg";
import errorIcon from "../../../images/close-black.svg";
@@ -70,6 +71,8 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
}: QueryCopilotPromptProps): JSX.Element => {
const [copilotTeachingBubbleVisible, setCopilotTeachingBubbleVisible] = useState<boolean>(false);
const inputEdited = useRef(false);
const itemRefs = useRef([]);
const searchInputRef = useRef(null);
const {
openFeedbackModal,
hideFeedbackModalForLikedQueries,
@@ -105,10 +108,10 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
setShowErrorMessageBar,
setGeneratedQueryComments,
setQueryResults,
setErrorMessage,
errorMessage,
setErrors,
errors,
} = useCopilotStore();
const [focusedIndex, setFocusedIndex] = useState(-1);
const sampleProps: SamplePromptsProps = {
isSamplePromptsOpen: isSamplePromptsOpen,
setIsSamplePromptsOpen: setIsSamplePromptsOpen,
@@ -141,6 +144,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
: getSuggestedPrompts();
const [filteredHistories, setFilteredHistories] = useState<string[]>(histories);
const [filteredSuggestedPrompts, setFilteredSuggestedPrompts] = useState<SuggestedPrompt[]>(suggestedPrompts);
const { UpArrow, DownArrow, Enter } = NormalizedEventKey;
const handleUserPromptChange = (event: React.ChangeEvent<HTMLInputElement>) => {
inputEdited.current = true;
@@ -179,7 +183,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
const resetQueryResults = (): void => {
setQueryResults(null);
setErrorMessage("");
setErrors([]);
};
const generateSQLQuery = async (): Promise<void> => {
@@ -243,7 +247,12 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
handleError(JSON.stringify(generateSQLQueryResponse), "copilotTooManyRequestError");
useTabs.getState().setIsQueryErrorThrown(true);
setShowErrorMessageBar(true);
setErrorMessage("Ratelimit exceeded 5 per 1 minute. Please try again after sometime");
setErrors([
new QueryError(
"Ratelimit exceeded 5 per 1 minute. Please try again after sometime",
QueryErrorSeverity.Error,
),
]);
TelemetryProcessor.traceFailure(Action.QueryGenerationFromCopilotPrompt, {
databaseName: databaseId,
collectionId: containerId,
@@ -301,7 +310,38 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
return "Content is updated";
}
};
const openSamplePrompts = () => {
inputEdited.current = true;
setShowSamplePrompts(true);
};
const totalSuggestions = useMemo(
() => [...filteredSuggestedPrompts, ...filteredHistories],
[filteredSuggestedPrompts, filteredHistories],
);
const handleKeyDownForInput = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === DownArrow) {
setFocusedIndex(0);
itemRefs.current[0]?.current?.focus();
} else if (event.key === Enter && userPrompt) {
inputEdited.current = true;
startGenerateQueryProcess();
}
};
const handleKeyDownForItem = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === UpArrow && focusedIndex > 0) {
itemRefs.current[focusedIndex - 1].current?.focus();
setFocusedIndex((prevIndex) => prevIndex - 1);
} else if (event.key === DownArrow && focusedIndex < totalSuggestions.length - 1) {
itemRefs.current[focusedIndex + 1].current?.focus();
setFocusedIndex((prevIndex) => prevIndex + 1);
}
};
React.useEffect(() => {
itemRefs.current = totalSuggestions.map(() => React.createRef());
}, [totalSuggestions]);
React.useEffect(() => {
useTabs.getState().setIsQueryErrorThrown(false);
}, []);
@@ -331,23 +371,14 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
id="naturalLanguageInput"
value={userPrompt}
onChange={handleUserPromptChange}
onClick={() => {
inputEdited.current = true;
setShowSamplePrompts(true);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && userPrompt) {
inputEdited.current = true;
startGenerateQueryProcess();
}
}}
onClick={openSamplePrompts}
onFocus={() => setShowSamplePrompts(true)}
elementRef={searchInputRef}
onKeyDown={handleKeyDownForInput}
style={{ lineHeight: 30 }}
styles={{
root: { width: "100%" },
suffix: {
background: "none",
padding: 0,
},
suffix: { background: "none", padding: 0 },
fieldGroup: {
borderRadius: 4,
borderColor: "#D1D1D1",
@@ -360,7 +391,8 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
},
}}
disabled={isGeneratingQuery}
autoComplete="off"
autoComplete="list"
aria-expanded={showSamplePrompts}
placeholder="Ask a question in natural language and well generate the query for you."
aria-labelledby="copilot-textfield-label"
onRenderSuffix={() => {
@@ -432,6 +464,8 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
setShowSamplePrompts(false);
inputEdited.current = true;
}}
elementRef={itemRefs.current[i]}
onKeyDown={handleKeyDownForItem}
onRenderIcon={() => <Image src={RecentIcon} styles={{ root: { overflow: "unset" } }} />}
styles={promptStyles}
>
@@ -454,14 +488,16 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
>
Suggested Prompts
</Text>
{filteredSuggestedPrompts.map((prompt) => (
{filteredSuggestedPrompts.map((prompt, index) => (
<DefaultButton
key={prompt.id}
elementRef={itemRefs.current[filteredHistories.length + index]}
onClick={() => {
setUserPrompt(prompt.text);
setShowSamplePrompts(false);
inputEdited.current = true;
}}
onKeyDown={handleKeyDownForItem}
onRenderIcon={() => <Image src={HintIcon} />}
styles={promptStyles}
>
@@ -514,7 +550,9 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
</Link>
{showErrorMessageBar && (
<MessageBar messageBarType={MessageBarType.error}>
{errorMessage ? errorMessage : "We ran into an error and were not able to execute query."}
{errors.length > 0
? errors[0].message
: "We ran into an error and were not able to execute query."}
</MessageBar>
)}
{showInvalidQueryMessageBar && (

View File

@@ -13,6 +13,7 @@ import {
import { getErrorMessage, getErrorStack, handleError } from "Common/ErrorHandlingUtils";
import { shouldEnableCrossPartitionKey } from "Common/HeadersUtility";
import { MinimalQueryIterator } from "Common/IteratorUtilities";
import QueryError from "Common/QueryError";
import { createUri } from "Common/UrlUtility";
import { queryDocumentsPage } from "Common/dataAccess/queryDocumentsPage";
import { configContext } from "ConfigContext";
@@ -354,7 +355,7 @@ export const QueryDocumentsPerPage = async (
);
useQueryCopilot.getState().setQueryResults(queryResults);
useQueryCopilot.getState().setErrorMessage("");
useQueryCopilot.getState().setErrors([]);
useQueryCopilot.getState().setShowErrorMessageBar(false);
traceSuccess(Action.ExecuteQueryGeneratedFromQueryCopilot, {
correlationId: useQueryCopilot.getState().correlationId,
@@ -366,12 +367,13 @@ export const QueryDocumentsPerPage = async (
const errorMessage = getErrorMessage(error);
traceFailure(Action.ExecuteQueryGeneratedFromQueryCopilot, {
correlationId: useQueryCopilot.getState().correlationId,
errorMessage: errorMessage,
errorMessage,
});
handleError(errorMessage, "executeQueryCopilotTab");
useTabs.getState().setIsQueryErrorThrown(true);
if (isCopilotActive) {
useQueryCopilot.getState().setErrorMessage(errorMessage);
const queryErrors = QueryError.tryParse(error);
useQueryCopilot.getState().setErrors(queryErrors);
useQueryCopilot.getState().setShowErrorMessageBar(true);
}
} finally {

View File

@@ -8,7 +8,7 @@ export const QueryCopilotResults: React.FC = (): JSX.Element => {
<QueryResultSection
isMongoDB={false}
queryEditorContent={useQueryCopilot.getState().selectedQuery || useQueryCopilot.getState().query}
error={useQueryCopilot.getState().errorMessage}
errors={useQueryCopilot.getState().errors}
queryResults={useQueryCopilot.getState().queryResults}
isExecuting={useQueryCopilot.getState().isExecuting}
executeQueryDocumentsPage={(firstItemIndex: number) =>

View File

@@ -1,6 +1,7 @@
import {
Button,
Menu,
MenuButton,
MenuButtonProps,
MenuItem,
MenuList,
@@ -60,6 +61,7 @@ const useSidebarStyles = makeStyles({
alignItems: "center",
justifyItems: "center",
width: "100%",
containerType: "size", // Use this container for "@container" queries below this.
...cosmosShorthands.borderBottom(),
},
loadingProgressBar: {
@@ -83,6 +85,18 @@ const useSidebarStyles = makeStyles({
},
},
},
globalCommandsMenuButton: {
display: "inline-flex",
"@container (min-width: 250px)": {
display: "none",
},
},
globalCommandsSplitButton: {
display: "none",
"@container (min-width: 250px)": {
display: "flex",
},
},
});
interface GlobalCommandsProps {
@@ -171,13 +185,19 @@ const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
<Menu positioning="below-end">
<MenuTrigger disableButtonEnhancement>
{(triggerProps: MenuButtonProps) => (
<SplitButton
menuButton={{ ...triggerProps, "aria-label": "More commands" }}
primaryActionButton={{ onClick: onPrimaryActionClick }}
icon={primaryAction.icon}
>
{primaryAction.label}
</SplitButton>
<>
<SplitButton
menuButton={{ ...triggerProps, "aria-label": "More commands" }}
primaryActionButton={{ onClick: onPrimaryActionClick }}
className={styles.globalCommandsSplitButton}
icon={primaryAction.icon}
>
{primaryAction.label}
</SplitButton>
<MenuButton {...triggerProps} icon={primaryAction.icon} className={styles.globalCommandsMenuButton}>
New...
</MenuButton>
</>
)}
</MenuTrigger>
<MenuPopover>
@@ -199,7 +219,7 @@ interface SidebarProps {
explorer: Explorer;
}
const CollapseThreshold = 50;
const CollapseThreshold = 140;
export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
const styles = useSidebarStyles();
@@ -260,7 +280,7 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
{/* Collections Tree - Start */}
{hasSidebar && (
// When collapsed, we force the pane to 24 pixels wide and make it non-resizable.
<Allotment.Pane minSize={24} preferredSize={300}>
<Allotment.Pane minSize={24} preferredSize={250}>
<CosmosFluentProvider className={mergeClasses(styles.sidebar)}>
<div className={styles.sidebarContainer}>
{loading && (
@@ -274,6 +294,7 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
<div className={styles.floatingControls}>
<button
type="button"
data-test="Sidebar/RefreshButton"
className={styles.floatingControlButton}
disabled={loading}
title="Refresh"
@@ -313,7 +334,7 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
</CosmosFluentProvider>
</Allotment.Pane>
)}
<Allotment.Pane minSize={800}>
<Allotment.Pane minSize={200}>
<Tabs explorer={explorer} />
</Allotment.Pane>
</Allotment>

View File

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

View File

@@ -13,6 +13,7 @@ import {
SAVE_BUTTON_ID,
UPDATE_BUTTON_ID,
UPLOAD_BUTTON_ID,
addStringsNoDuplicate,
buildQuery,
getDiscardExistingDocumentChangesButtonState,
getDiscardNewDocumentChangesButtonState,
@@ -91,7 +92,13 @@ async function waitForComponentToPaint<P = unknown>(wrapper: ReactWrapper<P> | S
describe("Documents tab (noSql API)", () => {
describe("buildQuery", () => {
it("should generate the right select query for SQL API", () => {
expect(buildQuery(false, "")).toContain("select");
expect(
buildQuery(false, "", ["pk"], {
paths: ["pk"],
kind: "Hash",
version: 2,
}),
).toContain("select");
});
});
@@ -339,7 +346,10 @@ describe("Documents tab (noSql API)", () => {
const createMockProps = (): IDocumentsTabComponentProps => ({
isPreferredApiMongoDB: false,
documentIds: [],
collection: undefined,
collection: {
id: ko.observable<string>("collectionId"),
databaseId: "databaseId",
} as ViewModels.CollectionBase,
partitionKey: { kind: "Hash", paths: ["/foo"], version: 2 },
onLoadStartKey: 0,
tabTitle: "",
@@ -380,7 +390,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();
});
});
@@ -474,3 +484,13 @@ describe("Documents tab (noSql API)", () => {
});
});
});
describe("Documents tab", () => {
it("should add strings to array without duplicate", () => {
const array1 = ["a", "b", "c"];
const array2 = ["b", "c", "d"];
const array3 = addStringsNoDuplicate(array1, array2);
expect(array3).toEqual(["a", "b", "c", "d"]);
});
});

View File

@@ -1,10 +1,9 @@
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 { 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,
@@ -20,6 +19,15 @@ import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
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";
@@ -42,13 +50,16 @@ import * as Logger from "../../../Common/Logger";
import * as MongoProxyClient from "../../../Common/MongoProxyClient";
import * as DataModels from "../../../Contracts/DataModels";
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 loadMoreHeight = LayoutConstants.rowHeight;
export const useDocumentsTabStyles = makeStyles({
@@ -92,17 +103,6 @@ export const useDocumentsTabStyles = makeStyles({
...shorthands.outline("1px", "dotted"),
},
},
floatingControlsContainer: {
position: "relative",
},
floatingControls: {
position: "absolute",
top: "6px",
right: 0,
float: "right",
backgroundColor: "white",
zIndex: 1,
},
});
export class DocumentsTabV2 extends TabsBase {
@@ -272,7 +272,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,
@@ -460,17 +460,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
@@ -487,6 +521,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 - 29;
// Export to expose to unit tests
export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabComponentProps> = ({
isPreferredApiMongoDB,
@@ -505,7 +553,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
const [isFilterFocused, setIsFilterFocused] = useState<boolean>(false);
const [appliedFilter, setAppliedFilter] = useState<string>("");
const [filterContent, setFilterContent] = useState<string>("");
const [documentIds, setDocumentIds] = useState<DocumentId[]>([]);
const [documentIds, setDocumentIds] = useState<ExtendedDocumentId[]>([]);
const [isExecuting, setIsExecuting] = useState<boolean>(false);
const filterInput = useRef<HTMLInputElement>(null);
const styles = useDocumentsTabStyles();
@@ -534,6 +582,13 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
ViewModels.DocumentExplorerState.noDocumentSelected,
);
// State
const [tabStateData, setTabStateData] = useState<TabDivider>(() =>
readSubComponentState<TabDivider>(SubComponentName.MainTabDivider, _collection, {
leftPaneWidthPercent: 35,
}),
);
const isQueryCopilotSampleContainer =
_collection?.isSampleCollection &&
_collection?.databaseId === QueryCopilotSampleDatabaseId &&
@@ -542,6 +597,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
// For Mongo only
const [continuationToken, setContinuationToken] = useState<string>(undefined);
// User's filter history
const [lastFilterContents, setLastFilterContents] = useState<FilterHistory>(() =>
readSubComponentState<FilterHistory>(SubComponentName.FilterHistory, _collection, [] as FilterHistory),
);
const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB);
useEffect(() => {
@@ -567,8 +627,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
}
}, [documentIds, clickedRowIndex, editorState]);
let lastFilterContents = ['WHERE c.id = "foo"', "ORDER BY c._ts DESC", 'WHERE c.id = "foo" ORDER BY c._ts DESC'];
const applyFilterButton = {
enabled: true,
visible: true,
@@ -590,10 +648,37 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
[partitionKeyPropertyHeaders],
);
const getInitialColumnSelection = () => {
const defaultColumnsIds = ["id"];
if (showPartitionKey(_collection, isPreferredApiMongoDB)) {
defaultColumnsIds.push(...partitionKeyPropertyHeaders);
}
return defaultColumnsIds;
};
const [selectedColumnIds, setSelectedColumnIds] = useState<string[]>(() => {
const persistedColumnsSelection = readSubComponentState<ColumnsSelection>(
SubComponentName.ColumnsSelection,
_collection,
undefined,
);
if (!persistedColumnsSelection) {
return getInitialColumnSelection();
}
return persistedColumnsSelection.selectedColumnIds;
});
// new DocumentId() requires a DocumentTab which we mock with only the required properties
const newDocumentId = useCallback(
(rawDocument: DataModels.DocumentId, partitionKeyProperties: string[], partitionKeyValue: string[]) =>
new DocumentId(
(
rawDocument: DataModels.DocumentId,
partitionKeyProperties: string[],
partitionKeyValue: string[],
): ExtendedDocumentId => {
const extendedDocumentId = new DocumentId(
{
partitionKey,
partitionKeyProperties,
@@ -603,7 +688,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
},
rawDocument,
partitionKeyValue,
),
) as ExtendedDocumentId;
extendedDocumentId.tableFields = { ...rawDocument };
return extendedDocumentId;
},
[partitionKey],
);
@@ -765,6 +853,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
setDocumentIds(ids);
setEditorState(ViewModels.DocumentExplorerState.existingDocumentNoEdits);
// Update column choices
setColumnDefinitionsFromDocument(savedDocument);
TelemetryProcessor.traceSuccess(
Action.CreateDocument,
{
@@ -847,6 +939,10 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
},
startKey,
);
// Update column choices
selectedDocumentId.tableFields = { ...documentContent };
setColumnDefinitionsFromDocument(documentContent);
},
(error) => {
onExecutionErrorChange(true);
@@ -883,7 +979,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
/**
* Implementation using bulk delete NoSQL API
*/
let _deleteDocuments = useCallback(
const _deleteDocuments = useCallback(
async (toDeleteDocumentIds: DocumentId[]): Promise<DocumentId[]> => {
onExecutionErrorChange(false);
const startKey: number = TelemetryProcessor.traceStart(Action.DeleteDocuments, {
@@ -894,11 +990,29 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
// 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.
return (
partitionKey.systemKey
? deleteNoSqlDocument(_collection, toDeleteDocumentIds[0]).then(() => [toDeleteDocumentIds[0]])
: deleteNoSqlDocuments(_collection, toDeleteDocumentIds)
)
const _deleteNoSqlDocuments = async (
collection: CollectionBase,
toDeleteDocumentIds: DocumentId[],
): Promise<DocumentId[]> => {
return partitionKey.systemKey
? deleteNoSqlDocument(collection, toDeleteDocumentIds[0]).then(() => [toDeleteDocumentIds[0]])
: deleteNoSqlDocuments(collection, toDeleteDocumentIds);
};
const deletePromise = !isPreferredApiMongoDB
? _deleteNoSqlDocuments(_collection, toDeleteDocumentIds)
: MongoProxyClient.deleteDocuments(
_collection.databaseId,
_collection as ViewModels.Collection,
toDeleteDocumentIds,
).then(({ deletedCount, isAcknowledged }) => {
if (deletedCount === toDeleteDocumentIds.length && isAcknowledged) {
return toDeleteDocumentIds;
}
throw new Error(`Delete failed with deletedCount: ${deletedCount} and isAcknowledged: ${isAcknowledged}`);
});
return deletePromise
.then(
(deletedIds) => {
TelemetryProcessor.traceSuccess(
@@ -929,7 +1043,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
)
.finally(() => setIsExecuting(false));
},
[_collection, onExecutionErrorChange, tabTitle],
[_collection, isPreferredApiMongoDB, onExecutionErrorChange, tabTitle],
);
const deleteDocuments = useCallback(
@@ -954,7 +1068,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
(error: Error) =>
useDialog
.getState()
.showOkModalDialog("Delete documents", `Document(s) deleted failed (${JSON.stringify(error)})`),
.showOkModalDialog("Delete documents", `Deleting document(s) failed (${error.message})`),
)
.finally(() => setIsExecuting(false));
},
@@ -1030,7 +1144,13 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
const _queryAbortController = new AbortController();
setQueryAbortController(_queryAbortController);
const filter: string = filterContent.trim();
const query: string = buildQuery(isPreferredApiMongoDB, filter, partitionKeyProperties, partitionKey);
const query: string = buildQuery(
isPreferredApiMongoDB,
filter,
partitionKeyProperties,
partitionKey,
selectedColumnIds,
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const options: any = {};
// TODO: Property 'enableCrossPartitionQuery' does not exist on type 'FeedOptions'.
@@ -1053,6 +1173,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
resourceTokenPartitionKey,
isQueryCopilotSampleContainer,
_collection,
selectedColumnIds,
]);
const onHideFilterClick = (): void => {
@@ -1198,16 +1319,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
documentsIterator, // loadNextPage: disabled as it will trigger a circular dependency and infinite loop
]);
const onRefreshKeyInput: KeyboardEventHandler<HTMLButtonElement> = (event) => {
if (event.key === " " || event.key === "Enter") {
const focusElement = event.target as HTMLElement;
refreshDocumentsGrid(false);
focusElement && focusElement.focus();
event.stopPropagation();
event.preventDefault();
}
};
const onLoadMoreKeyInput: KeyboardEventHandler<HTMLAnchorElement> = (event) => {
if (event.key === " " || event.key === "Enter") {
const focusElement = event.target as HTMLElement;
@@ -1220,7 +1331,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
const onFilterKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.key === "Enter") {
refreshDocumentsGrid(true);
onApplyFilterClick();
// Suppress the default behavior of the key
e.preventDefault();
@@ -1239,9 +1350,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
// Table config here
const tableItems: DocumentsTableComponentItem[] = documentIds.map((documentId) => {
const item: Record<string, string> & { id: string } = {
id: documentId.id(),
};
const item: DocumentsTableComponentItem = documentId.tableFields || { id: documentId.id() };
if (partitionKeyPropertyHeaders && documentId.stringPartitionKeyValues) {
for (let i = 0; i < partitionKeyPropertyHeaders.length; i++) {
@@ -1252,6 +1361,44 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
return item;
});
const extractColumnDefinitionsFromDocument = (document: unknown): ColumnDefinition[] => {
let columnDefinitions: ColumnDefinition[] = Object.keys(document)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.filter((key) => typeof (document as any)[key] === "string" || typeof (document as any)[key] === "number") // Only allow safe types for displayable React children
.map((key) =>
key === "id"
? { id: key, label: isPreferredApiMongoDB ? "_id" : "id", isPartitionKey: false }
: { id: key, label: key, isPartitionKey: false },
);
if (showPartitionKey(_collection, isPreferredApiMongoDB)) {
columnDefinitions.push(
...partitionKeyPropertyHeaders.map((key) => ({ id: key, label: key, isPartitionKey: true })),
);
// Remove properties that are the partition keys, since they are already included
columnDefinitions = columnDefinitions.filter(
(columnDefinition) => !partitionKeyProperties.includes(columnDefinition.id),
);
}
return columnDefinitions;
};
/**
* Extract column definitions from document and add to the definitions
* @param document
*/
const setColumnDefinitionsFromDocument = (document: unknown): void => {
const currentIds = new Set(columnDefinitions.map((columnDefinition) => columnDefinition.id));
extractColumnDefinitionsFromDocument(document).forEach((columnDefinition) => {
if (!currentIds.has(columnDefinition.id)) {
columnDefinitions.push(columnDefinition);
}
});
setColumnDefinitions([...columnDefinitions]);
};
/**
* replicate logic of selectedDocument.click();
* Document has been clicked on in table
@@ -1267,6 +1414,9 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
(_isQueryCopilotSampleContainer ? readSampleDocument(documentId) : readDocument(_collection, documentId)).then(
(content) => {
initDocumentEditor(documentId, content);
// Update columns
setColumnDefinitionsFromDocument(content);
},
);
@@ -1357,10 +1507,22 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
return () => resizeObserver.disconnect(); // clean up
}, []);
const columnHeaders = {
idHeader: isPreferredApiMongoDB ? "_id" : "id",
partitionKeyHeaders: (showPartitionKey(_collection, isPreferredApiMongoDB) && partitionKeyPropertyHeaders) || [],
};
// Column definition is a map<id, ColumnDefinition> to garantee uniqueness
const [columnDefinitions, setColumnDefinitions] = useState<ColumnDefinition[]>(() => {
const persistedColumnsSelection = readSubComponentState<ColumnsSelection>(
SubComponentName.ColumnsSelection,
_collection,
undefined,
);
if (!persistedColumnsSelection) {
return extractColumnDefinitionsFromDocument({
id: "id",
});
}
return persistedColumnsSelection.columnDefinitions;
});
const onSelectedRowsChange = (selectedRows: Set<TableRowId>) => {
confirmDiscardingChange(() => {
@@ -1423,7 +1585,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
return partitionKey;
};
lastFilterContents = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"];
partitionKeyProperties = partitionKeyProperties?.map((partitionKeyProperty, i) => {
if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) {
partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, "");
@@ -1438,62 +1599,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
return partitionKeyProperty;
});
/**
* Mongo implementation
* TODO: update proxy to use mongo driver deleteMany
*/
_deleteDocuments = (toDeleteDocumentIds: DocumentId[]): Promise<DocumentId[]> => {
const promises = toDeleteDocumentIds.map((documentId) => _deleteDocument(documentId));
return Promise.all(promises);
};
const __deleteDocument = async (documentId: DocumentId): Promise<DocumentId> => {
await MongoProxyClient.deleteDocument(_collection.databaseId, _collection as ViewModels.Collection, documentId);
return documentId;
};
const _deleteDocument = useCallback(
(documentId: DocumentId): Promise<DocumentId> => {
onExecutionErrorChange(false);
const startKey: number = TelemetryProcessor.traceStart(Action.DeleteDocument, {
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
});
setIsExecuting(true);
return __deleteDocument(documentId)
.then(
(deletedDocumentId) => {
TelemetryProcessor.traceSuccess(
Action.DeleteDocument,
{
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
},
startKey,
);
return deletedDocumentId;
},
(error) => {
onExecutionErrorChange(true);
console.error(error);
TelemetryProcessor.traceFailure(
Action.DeleteDocument,
{
dataExplorerArea: Constants.Areas.Tab,
tabTitle,
error: getErrorMessage(error),
errorStack: getErrorStack(error),
},
startKey,
);
return undefined;
},
)
.finally(() => setIsExecuting(false));
},
[__deleteDocument, onExecutionErrorChange, tabTitle],
);
onSaveNewDocumentClick = useCallback((): Promise<unknown> => {
const documentContent = JSON.parse(selectedDocumentContent);
const startKey: number = TelemetryProcessor.traceStart(Action.CreateDocument, {
@@ -1649,7 +1754,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
setIsExecuting(true);
onExecutionErrorChange(false);
const filter: string = filterContent.trim();
const query: string = buildQuery(isPreferredApiMongoDB, filter);
const query: string = buildQuery(isPreferredApiMongoDB, filter, selectedColumnIds);
return MongoProxyClient.queryDocuments(
_collection.databaseId,
@@ -1700,6 +1805,24 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
}
// ***************** Mongo ***************************
const onApplyFilterClick = (): void => {
refreshDocumentsGrid(true);
// Remove duplicates, but keep order
if (lastFilterContents.includes(filterContent)) {
lastFilterContents.splice(lastFilterContents.indexOf(filterContent), 1);
}
// Save filter content to local storage
lastFilterContents.unshift(filterContent);
// Keep the list size under MAX_FILTER_HISTORY_COUNT. Drop last element if needed.
const limitedLastFilterContents = lastFilterContents.slice(0, MAX_FILTER_HISTORY_COUNT);
setLastFilterContents(limitedLastFilterContents);
saveSubComponentState<FilterHistory>(SubComponentName.FilterHistory, _collection, lastFilterContents);
};
const refreshDocumentsGrid = useCallback(
(applyFilterButtonPressed: boolean): void => {
// clear documents grid
@@ -1730,6 +1853,41 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
[createIterator, filterContent],
);
const onColumnSelectionChange = (newSelectedColumnIds: string[]): void => {
// Do not allow to unselecting all columns
if (newSelectedColumnIds.length === 0) {
return;
}
setSelectedColumnIds(newSelectedColumnIds);
saveSubComponentState<ColumnsSelection>(SubComponentName.ColumnsSelection, _collection, {
selectedColumnIds: newSelectedColumnIds,
columnDefinitions,
});
};
const prevSelectedColumnIds = usePrevious({ selectedColumnIds, setSelectedColumnIds });
useEffect(() => {
// If we are adding a field, let's refresh to include the field in the query
let addedField = false;
for (const field of selectedColumnIds) {
if (
!defaultQueryFields.includes(field) &&
prevSelectedColumnIds &&
!prevSelectedColumnIds.selectedColumnIds.includes(field)
) {
addedField = true;
break;
}
}
if (addedField) {
refreshDocumentsGrid(false);
}
}, [prevSelectedColumnIds, refreshDocumentsGrid, selectedColumnIds]);
return (
<CosmosFluentProvider className={styles.container}>
<div className="tab-pane active" role="tabpanel" style={{ display: "flex" }}>
@@ -1758,12 +1916,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
<div className={styles.filterRow}>
{!isPreferredApiMongoDB && <span> SELECT * FROM c </span>}
<Input
id="filterInput"
ref={filterInput}
type="text"
size="small"
list="filtersList"
className={styles.filterInput}
list={`filtersList-${getUniqueId(_collection)}`}
className={`filterInput ${styles.filterInput}`}
title="Type a query predicate or choose one from the list."
placeholder={
isPreferredApiMongoDB
@@ -1777,8 +1934,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
onBlur={() => setIsFilterFocused(false)}
/>
<datalist id="filtersList">
{lastFilterContents.map((filter) => (
<datalist id={`filtersList-${getUniqueId(_collection)}`}>
{addStringsNoDuplicate(
lastFilterContents,
isPreferredApiMongoDB ? defaultMongoFilters : getDefaultSqlFilters(partitionKeyProperties),
).map((filter) => (
<option key={filter} value={filter} />
))}
</datalist>
@@ -1786,7 +1946,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
<Button
appearance="primary"
size="small"
onClick={() => refreshDocumentsGrid(true)}
onClick={onApplyFilterClick}
disabled={!applyFilterButton.enabled}
aria-label="Apply filter"
tabIndex={0}
@@ -1817,41 +1977,45 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
)}
</>
)}
{/* <Split> doesn't like to be a flex child */}
<div style={{ overflow: "hidden", height: "100%" }}>
<Allotment>
<Allotment.Pane preferredSize="35%" minSize={175}>
<Allotment
onDragEnd={(sizes: number[]) => {
tabStateData.leftPaneWidthPercent = (100 * sizes[0]) / (sizes[0] + sizes[1]);
saveSubComponentState<TabDivider>(SubComponentName.MainTabDivider, _collection, tabStateData);
setTabStateData(tabStateData);
}}
>
<Allotment.Pane preferredSize={`${tabStateData.leftPaneWidthPercent}%`} minSize={55}>
<div style={{ height: "100%", width: "100%", overflow: "hidden" }} ref={tableContainerRef}>
<div className={styles.floatingControlsContainer}>
<div className={styles.floatingControls}>
<Button
appearance="transparent"
aria-label="Refresh"
size="small"
icon={<ArrowClockwise16Filled />}
style={{
color: StyleConstants.AccentMedium,
}}
onClick={() => refreshDocumentsGrid(false)}
onKeyDown={onRefreshKeyInput}
<div className={styles.tableContainer}>
<div
style={
{
height: "100%",
width: `calc(100% + ${calculateOffset(selectedColumnIds.length)}px)`,
} /* Fix to make table not resize beyond parent's width */
}
>
<DocumentsTableComponent
onRefreshTable={() => refreshDocumentsGrid(false)}
items={tableItems}
onItemClicked={(index) => onDocumentClicked(index, documentIds)}
onSelectedRowsChange={onSelectedRowsChange}
selectedRows={selectedRows}
size={tableContainerSizePx}
selectedColumnIds={selectedColumnIds}
columnDefinitions={columnDefinitions}
isRowSelectionDisabled={
configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly
}
onColumnSelectionChange={onColumnSelectionChange}
defaultColumnSelection={getInitialColumnSelection()}
collection={_collection}
isColumnSelectionDisabled={isPreferredApiMongoDB}
/>
</div>
</div>
<div className={styles.tableContainer}>
<DocumentsTableComponent
items={tableItems}
onItemClicked={(index) => onDocumentClicked(index, documentIds)}
onSelectedRowsChange={onSelectedRowsChange}
selectedRows={selectedRows}
size={tableContainerSizePx}
columnHeaders={columnHeaders}
isSelectionDisabled={
(partitionKey.systemKey && !isPreferredApiMongoDB) ||
(configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly)
}
/>
</div>
{tableItems.length > 0 && (
<a
className={styles.loadMore}
@@ -1865,7 +2029,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
)}
</div>
</Allotment.Pane>
<Allotment.Pane preferredSize="65%" minSize={300}>
<Allotment.Pane minSize={30}>
<div style={{ height: "100%", width: "100%" }}>
{isTabActive && selectedDocumentContent && selectedRows.size <= 1 && (
<EditorReact

View File

@@ -1,4 +1,4 @@
import { deleteDocument } from "Common/MongoProxyClient";
import { deleteDocuments } from "Common/MongoProxyClient";
import { Platform, updateConfigContext } from "ConfigContext";
import { EditorReactProps } from "Explorer/Controls/Editor/EditorReact";
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
@@ -49,7 +49,7 @@ jest.mock("Common/MongoProxyClient", () => ({
id: "id1",
}),
),
deleteDocument: jest.fn(() => Promise.resolve()),
deleteDocuments: jest.fn(() => Promise.resolve()),
}));
jest.mock("Explorer/Controls/Editor/EditorReact", () => ({
@@ -179,8 +179,8 @@ describe("Documents tab (Mongo API)", () => {
});
it("clicking Delete Document asks for confirmation", () => {
const mockDeleteDocument = deleteDocument as jest.Mock;
mockDeleteDocument.mockClear();
const mockDeleteDocuments = deleteDocuments as jest.Mock;
mockDeleteDocuments.mockClear();
act(() => {
useCommandBar
@@ -189,7 +189,7 @@ describe("Documents tab (Mongo API)", () => {
.onCommandClick(undefined);
});
expect(mockDeleteDocument).toHaveBeenCalled();
expect(mockDeleteDocuments).toHaveBeenCalled();
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import MongoUtility from "../../../Common/MongoUtility";
import * as ViewModels from "../../../Contracts/ViewModels";
import Explorer from "../../Explorer";
import { NewQueryTab } from "../QueryTab/QueryTab";
import QueryTabComponent, { IQueryTabComponentProps, ITabAccessor } from "../QueryTab/QueryTabComponent";
import { IQueryTabComponentProps, ITabAccessor, QueryTabComponent } from "../QueryTab/QueryTabComponent";
export interface IMongoQueryTabProps {
container: Explorer;

View File

@@ -0,0 +1,124 @@
import {
Button,
DataGrid,
DataGridBody,
DataGridCell,
DataGridHeader,
DataGridHeaderCell,
DataGridRow,
TableCellLayout,
TableColumnDefinition,
TableColumnSizingOptions,
createTableColumn,
tokens,
} from "@fluentui/react-components";
import { ErrorCircleFilled, MoreHorizontalRegular, WarningFilled } from "@fluentui/react-icons";
import QueryError, { QueryErrorSeverity, compareSeverity } from "Common/QueryError";
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
import { useNotificationConsole } from "hooks/useNotificationConsole";
import React from "react";
const severityIcons = {
[QueryErrorSeverity.Error]: <ErrorCircleFilled color={tokens.colorPaletteRedBackground3} />,
[QueryErrorSeverity.Warning]: <WarningFilled color={tokens.colorPaletteYellowForeground1} />,
};
export const ErrorList: React.FC<{ errors: QueryError[] }> = ({ errors }) => {
const styles = useQueryTabStyles();
const onErrorDetailsClick = (): boolean => {
useNotificationConsole.getState().expandConsole();
return false;
};
const columns: TableColumnDefinition<QueryError>[] = [
createTableColumn<QueryError>({
columnId: "code",
compare: (item1, item2) => item1.code.localeCompare(item2.code),
renderHeaderCell: () => null,
renderCell: (item) => item.code,
}),
createTableColumn<QueryError>({
columnId: "severity",
compare: (item1, item2) => compareSeverity(item1.severity, item2.severity),
renderHeaderCell: () => null,
renderCell: (item) => <TableCellLayout media={severityIcons[item.severity]}>{item.severity}</TableCellLayout>,
}),
createTableColumn<QueryError>({
columnId: "location",
compare: (item1, item2) => item1.location?.start?.offset - item2.location?.start?.offset,
renderHeaderCell: () => "Location",
renderCell: (item) =>
item.location
? item.location.start.lineNumber
? `Line ${item.location.start.lineNumber}`
: "<unknown>"
: "<no location>",
}),
createTableColumn<QueryError>({
columnId: "message",
compare: (item1, item2) => item1.message.localeCompare(item2.message),
renderHeaderCell: () => "Message",
renderCell: (item) => (
<div className={styles.errorListMessageCell}>
<div className={styles.errorListMessage}>{item.message}</div>
<div>
<Button
aria-label="Details"
appearance="subtle"
icon={<MoreHorizontalRegular />}
onClick={onErrorDetailsClick}
/>
</div>
</div>
),
}),
];
const columnSizingOptions: TableColumnSizingOptions = {
code: {
minWidth: 75,
idealWidth: 75,
defaultWidth: 75,
},
severity: {
minWidth: 100,
idealWidth: 100,
defaultWidth: 100,
},
location: {
minWidth: 100,
idealWidth: 100,
defaultWidth: 100,
},
message: {
minWidth: 500,
},
};
return (
<DataGrid
data-test="QueryTab/ResultsPane/ErrorList"
items={errors}
columns={columns}
sortable
resizableColumns
columnSizingOptions={columnSizingOptions}
focusMode="composite"
>
<DataGridHeader>
<DataGridRow>
{({ renderHeaderCell }) => <DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>}
</DataGridRow>
</DataGridHeader>
<DataGridBody<QueryError>>
{({ item, rowId }) => (
<DataGridRow<QueryError> key={rowId} data-test={`Row:${rowId}`}>
{({ columnId, renderCell }) => (
<DataGridCell data-test={`Row:${rowId}/Column:${columnId}`}>{renderCell(item)}</DataGridCell>
)}
</DataGridRow>
)}
</DataGridBody>
</DataGrid>
);
};

View File

@@ -1,544 +1,93 @@
import {
DetailsList,
DetailsListLayoutMode,
IColumn,
Icon,
IconButton,
Link,
Pivot,
PivotItem,
SelectionMode,
Stack,
Text,
TooltipHost,
} from "@fluentui/react";
import { HttpHeaders, NormalizedEventKey } from "Common/Constants";
import MongoUtility from "Common/MongoUtility";
import { QueryMetrics } from "Contracts/DataModels";
import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
import { IDocument } from "Explorer/Tabs/QueryTab/QueryTabComponent";
import { userContext } from "UserContext";
import copy from "clipboard-copy";
import { useNotificationConsole } from "hooks/useNotificationConsole";
import { Link } from "@fluentui/react-components";
import QueryError from "Common/QueryError";
import { IndeterminateProgressBar } from "Explorer/Controls/IndeterminateProgressBar";
import { MessageBanner } from "Explorer/Controls/MessageBanner";
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
import React from "react";
import CopilotCopy from "../../../../images/CopilotCopy.svg";
import DownloadQueryMetrics from "../../../../images/DownloadQuery.svg";
import QueryEditorNext from "../../../../images/Query-Editor-Next.svg";
import RunQuery from "../../../../images/RunQuery.png";
import InfoColor from "../../../../images/info_color.svg";
import { QueryResults } from "../../../Contracts/ViewModels";
import { ErrorList } from "./ErrorList";
import { ResultsView } from "./ResultsView";
interface QueryResultProps {
export interface ResultsViewProps {
isMongoDB: boolean;
queryEditorContent: string;
error: string;
isExecuting: boolean;
queryResults: QueryResults;
executeQueryDocumentsPage: (firstItemIndex: number) => Promise<void>;
}
interface QueryResultProps extends ResultsViewProps {
queryEditorContent: string;
errors: QueryError[];
isExecuting: boolean;
}
const ExecuteQueryCallToAction: React.FC = () => {
const styles = useQueryTabStyles();
return (
<div data-test="QueryTab/ResultsPane/ExecuteCTA" className={styles.executeCallToAction}>
<div>
<p>
<img src={RunQuery} aria-hidden="true" />
</p>
<p>Execute a query to see the results</p>
</div>
</div>
);
};
export const QueryResultSection: React.FC<QueryResultProps> = ({
isMongoDB,
queryEditorContent,
error,
errors,
queryResults,
isExecuting,
executeQueryDocumentsPage,
isExecuting,
}: QueryResultProps): JSX.Element => {
const queryMetrics = React.useRef(queryResults?.headers?.[HttpHeaders.queryMetrics]);
React.useEffect(() => {
const latestQueryMetrics = queryResults?.headers?.[HttpHeaders.queryMetrics];
if (latestQueryMetrics && Object.keys(latestQueryMetrics).length > 0) {
queryMetrics.current = latestQueryMetrics;
}
}, [queryResults]);
const onRender = (item: IDocument): JSX.Element => (
<>
<Text style={{ paddingLeft: 10, margin: 0 }}>{`${item.metric}`}</Text>
</>
);
const columns: IColumn[] = [
{
key: "column1",
name: "Description",
iconName: "Info",
isIconOnly: true,
minWidth: 10,
maxWidth: 12,
iconClassName: "iconheadercell",
data: String,
fieldName: "",
onRender: (item: IDocument) => {
if (item.toolTip !== "") {
return (
<>
<TooltipHost content={`${item.toolTip}`}>
<Link style={{ color: "#323130" }}>
<Icon iconName="Info" ariaLabel={`${item.toolTip}`} className="panelInfoIcon" tabIndex={0} />
</Link>
</TooltipHost>
</>
);
} else {
return undefined;
}
},
},
{
key: "column2",
name: "METRIC",
minWidth: 200,
data: String,
fieldName: "metric",
onRender,
},
{
key: "column3",
name: "VALUE",
minWidth: 200,
data: String,
fieldName: "value",
},
];
const styles = useQueryTabStyles();
const maybeSubQuery = queryEditorContent && /.*\(.*SELECT.*\)/i.test(queryEditorContent);
const queryResultsString = queryResults
? isMongoDB
? MongoUtility.tojson(queryResults.documents, undefined, false)
: JSON.stringify(queryResults.documents, undefined, 4)
: "";
const onErrorDetailsClick = (): boolean => {
useNotificationConsole.getState().expandConsole();
return false;
};
const onErrorDetailsKeyPress = (event: React.KeyboardEvent<HTMLAnchorElement>): boolean => {
if (event.key === NormalizedEventKey.Space || event.key === NormalizedEventKey.Enter) {
onErrorDetailsClick();
return false;
}
return true;
};
const onDownloadQueryMetricsCsvClick = (): boolean => {
downloadQueryMetricsCsvData();
return false;
};
const onDownloadQueryMetricsCsvKeyPress = (event: React.KeyboardEvent<HTMLAnchorElement>): boolean => {
if (event.key === NormalizedEventKey.Space || NormalizedEventKey.Enter) {
downloadQueryMetricsCsvData();
return false;
}
return true;
};
const downloadQueryMetricsCsvData = (): void => {
const csvData: string = generateQueryMetricsCsvData();
if (!csvData) {
return;
}
if (navigator.msSaveBlob) {
// for IE and Edge
navigator.msSaveBlob(
new Blob([csvData], { type: "data:text/csv;charset=utf-8" }),
"PerPartitionQueryMetrics.csv",
);
} else {
const downloadLink: HTMLAnchorElement = document.createElement("a");
downloadLink.href = "data:text/csv;charset=utf-8," + encodeURI(csvData);
downloadLink.target = "_self";
downloadLink.download = "QueryMetricsPerPartition.csv";
// for some reason, FF displays the download prompt only when
// the link is added to the dom so we add and remove it
document.body.appendChild(downloadLink);
downloadLink.click();
downloadLink.remove();
}
};
const getAggregatedQueryMetrics = (): QueryMetrics => {
const aggregatedQueryMetrics = {
documentLoadTime: 0,
documentWriteTime: 0,
indexHitDocumentCount: 0,
outputDocumentCount: 0,
outputDocumentSize: 0,
indexLookupTime: 0,
retrievedDocumentCount: 0,
retrievedDocumentSize: 0,
vmExecutionTime: 0,
runtimeExecutionTimes: {
queryEngineExecutionTime: 0,
systemFunctionExecutionTime: 0,
userDefinedFunctionExecutionTime: 0,
},
totalQueryExecutionTime: 0,
} as QueryMetrics;
if (queryMetrics.current) {
Object.keys(queryMetrics.current).forEach((partitionKeyRangeId) => {
const queryMetricsPerPartition = queryMetrics.current[partitionKeyRangeId];
if (!queryMetricsPerPartition) {
return;
}
aggregatedQueryMetrics.documentLoadTime += queryMetricsPerPartition.documentLoadTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.documentWriteTime +=
queryMetricsPerPartition.documentWriteTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.indexHitDocumentCount += queryMetricsPerPartition.indexHitDocumentCount || 0;
aggregatedQueryMetrics.outputDocumentCount += queryMetricsPerPartition.outputDocumentCount || 0;
aggregatedQueryMetrics.outputDocumentSize += queryMetricsPerPartition.outputDocumentSize || 0;
aggregatedQueryMetrics.indexLookupTime += queryMetricsPerPartition.indexLookupTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.retrievedDocumentCount += queryMetricsPerPartition.retrievedDocumentCount || 0;
aggregatedQueryMetrics.retrievedDocumentSize += queryMetricsPerPartition.retrievedDocumentSize || 0;
aggregatedQueryMetrics.vmExecutionTime += queryMetricsPerPartition.vmExecutionTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.totalQueryExecutionTime +=
queryMetricsPerPartition.totalQueryExecutionTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.runtimeExecutionTimes.queryEngineExecutionTime +=
queryMetricsPerPartition.runtimeExecutionTimes?.queryEngineExecutionTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.runtimeExecutionTimes.systemFunctionExecutionTime +=
queryMetricsPerPartition.runtimeExecutionTimes?.systemFunctionExecutionTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.runtimeExecutionTimes.userDefinedFunctionExecutionTime +=
queryMetricsPerPartition.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.totalMilliseconds() || 0;
});
}
return aggregatedQueryMetrics;
};
const generateQueryMetricsCsvData = (): string => {
if (queryMetrics.current) {
let csvData =
[
"Partition key range id",
"Retrieved document count",
"Retrieved document size (in bytes)",
"Output document count",
"Output document size (in bytes)",
"Index hit document count",
"Index lookup time (ms)",
"Document load time (ms)",
"Query engine execution time (ms)",
"System function execution time (ms)",
"User defined function execution time (ms)",
"Document write time (ms)",
].join(",") + "\n";
Object.keys(queryMetrics.current).forEach((partitionKeyRangeId) => {
const queryMetricsPerPartition = queryMetrics.current[partitionKeyRangeId];
csvData +=
[
partitionKeyRangeId,
queryMetricsPerPartition.retrievedDocumentCount,
queryMetricsPerPartition.retrievedDocumentSize,
queryMetricsPerPartition.outputDocumentCount,
queryMetricsPerPartition.outputDocumentSize,
queryMetricsPerPartition.indexHitDocumentCount,
queryMetricsPerPartition.indexLookupTime?.totalMilliseconds(),
queryMetricsPerPartition.documentLoadTime?.totalMilliseconds(),
queryMetricsPerPartition.runtimeExecutionTimes?.queryEngineExecutionTime?.totalMilliseconds(),
queryMetricsPerPartition.runtimeExecutionTimes?.systemFunctionExecutionTime?.totalMilliseconds(),
queryMetricsPerPartition.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.totalMilliseconds(),
queryMetricsPerPartition.documentWriteTime?.totalMilliseconds(),
].join(",") + "\n";
});
return csvData;
}
return undefined;
};
const onFetchNextPageClick = async (): Promise<void> => {
const { firstItemIndex, itemCount } = queryResults;
await executeQueryDocumentsPage(firstItemIndex + itemCount - 1);
};
const generateQueryStatsItems = (): IDocument[] => {
const items: IDocument[] = [
{
metric: "Request Charge",
value: `${queryResults.requestCharge} RUs`,
toolTip: "Request Charge",
},
{
metric: "Showing Results",
value: queryResults.itemCount > 0 ? `${queryResults.firstItemIndex} - ${queryResults.lastItemIndex}` : `0 - 0`,
toolTip: "Showing Results",
},
];
if (userContext.apiType === "SQL") {
const aggregatedQueryMetrics = getAggregatedQueryMetrics();
items.push(
{
metric: "Retrieved document count",
value: aggregatedQueryMetrics.retrievedDocumentCount?.toString() || "",
toolTip: "Total number of retrieved documents",
},
{
metric: "Retrieved document size",
value: `${aggregatedQueryMetrics.retrievedDocumentSize?.toString() || 0} bytes`,
toolTip: "Total size of retrieved documents in bytes",
},
{
metric: "Output document count",
value: aggregatedQueryMetrics.outputDocumentCount?.toString() || "",
toolTip: "Number of output documents",
},
{
metric: "Output document size",
value: `${aggregatedQueryMetrics.outputDocumentSize?.toString() || 0} bytes`,
toolTip: "Total size of output documents in bytes",
},
{
metric: "Index hit document count",
value: aggregatedQueryMetrics.indexHitDocumentCount?.toString() || "",
toolTip: "Total number of documents matched by the filter",
},
{
metric: "Index lookup time",
value: `${aggregatedQueryMetrics.indexLookupTime?.toString() || 0} ms`,
toolTip: "Time spent in physical index layer",
},
{
metric: "Document load time",
value: `${aggregatedQueryMetrics.documentLoadTime?.toString() || 0} ms`,
toolTip: "Time spent in loading documents",
},
{
metric: "Query engine execution time",
value: `${aggregatedQueryMetrics.runtimeExecutionTimes?.queryEngineExecutionTime?.toString() || 0} ms`,
toolTip:
"Time spent by the query engine to execute the query expression (excludes other execution times like load documents or write results)",
},
{
metric: "System function execution time",
value: `${aggregatedQueryMetrics.runtimeExecutionTimes?.systemFunctionExecutionTime?.toString() || 0} ms`,
toolTip: "Total time spent executing system (built-in) functions",
},
{
metric: "User defined function execution time",
value: `${
aggregatedQueryMetrics.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.toString() || 0
} ms`,
toolTip: "Total time spent executing user-defined functions",
},
{
metric: "Document write time",
value: `${aggregatedQueryMetrics.documentWriteTime.toString() || 0} ms`,
toolTip: "Time spent to write query result set to response buffer",
},
);
}
if (queryResults.roundTrips) {
items.push({
metric: "Round Trips",
value: queryResults.roundTrips?.toString(),
toolTip: "Number of round trips",
});
}
if (queryResults.activityId) {
items.push({
metric: "Activity id",
value: queryResults.activityId,
toolTip: "",
});
}
return items;
};
const onClickCopyResults = (): void => {
copy(queryResultsString);
};
return (
<Stack style={{ height: "100%" }}>
{isMongoDB && queryEditorContent.length === 0 && (
<div className="mongoQueryHelper">
Start by writing a Mongo query, for example: <strong>{"{'id':'foo'}"}</strong> or{" "}
<strong>
{"{ "}
{" }"}
</strong>{" "}
to get all the documents.
</div>
)}
{maybeSubQuery && (
<div className="warningErrorContainer" aria-live="assertive">
<div className="warningErrorContent">
<span>
<img className="paneErrorIcon" src={InfoColor} alt="Error" />
</span>
<span className="warningErrorDetailsLinkContainer">
We detected you may be using a subquery. To learn more about subqueries effectively,{" "}
<a
href="https://learn.microsoft.com/azure/cosmos-db/nosql/query/subquery"
target="_blank"
rel="noreferrer"
>
visit the documentation
</a>
</span>
</div>
</div>
)}
{/* <!-- Query Errors Tab - Start--> */}
{error && (
<div className="active queryErrorsHeaderContainer">
<span className="queryErrors" data-toggle="tab">
Errors
</span>
</div>
)}
{/* <!-- Query Errors Tab - End --> */}
<div data-test="QueryTab/ResultsPane" className={styles.queryResultsPanel}>
{isExecuting && <IndeterminateProgressBar />}
<MessageBanner
messageId="QueryEditor.EmptyMongoQuery"
visible={isMongoDB && queryEditorContent.length === 0}
className={styles.queryResultsMessage}
>
Start by writing a Mongo query, for example: <strong>{"{'id':'foo'}"}</strong> or{" "}
<strong>
{"{ "}
{" }"}
</strong>{" "}
to get all the documents.
</MessageBanner>
{/* {maybeSubQuery && ( */}
<MessageBanner
messageId="QueryEditor.SubQueryWarning"
visible={maybeSubQuery}
className={styles.queryResultsMessage}
>
We detected you may be using a subquery. To learn more about subqueries effectively,{" "}
<Link
href="https://learn.microsoft.com/azure/cosmos-db/nosql/query/subquery"
target="_blank"
rel="noreferrer noopener"
>
visit the documentation
</Link>
</MessageBanner>
{/* <!-- Query Results & Errors Content Container - Start--> */}
<div className="queryResultErrorContentContainer">
{!queryResults && !error && !isExecuting && (
<div className="queryEditorWatermark">
<p>
<img src={RunQuery} alt="Execute Query Watermark" />
</p>
<p className="queryEditorWatermarkText">Execute a query to see the results</p>
</div>
)}
{(queryResults || !!error) && (
<div className="queryResultsErrorsContent">
{!error && (
<Pivot aria-label="Successful execution" style={{ height: "100%" }}>
<PivotItem
headerText="Results"
headerButtonProps={{
"data-order": 1,
"data-title": "Results",
}}
style={{ height: "100%" }}
>
<div className="result-metadata">
<span>
<span>
{queryResults.itemCount > 0
? `${queryResults.firstItemIndex} - ${queryResults.lastItemIndex}`
: `0 - 0`}
</span>
</span>
{queryResults.hasMoreResults && (
<>
<span className="queryResultDivider">|</span>
<span className="queryResultNextEnable">
<a onClick={() => onFetchNextPageClick()}>
<span>Load more</span>
<img className="queryResultnextImg" src={QueryEditorNext} alt="Fetch next page" />
</a>
</span>
</>
)}
<IconButton
style={{
height: "100%",
verticalAlign: "middle",
float: "right",
}}
iconProps={{ imageProps: { src: CopilotCopy } }}
title="Copy to Clipboard"
ariaLabel="Copy"
onClick={onClickCopyResults}
/>
</div>
{queryResults && queryResultsString?.length > 0 && !error && (
<div
style={{
paddingBottom: "100px",
height: "100%",
}}
>
<EditorReact
language={"json"}
content={queryResultsString}
isReadOnly={true}
ariaLabel={"Query results"}
/>
</div>
)}
</PivotItem>
<PivotItem
headerText="Query Stats"
headerButtonProps={{
"data-order": 2,
"data-title": "Query Stats",
}}
style={{ height: "100%", overflowY: "scroll" }}
>
{queryResults && !error && (
<div className="queryMetricsSummaryContainer">
<div className="queryMetricsSummary">
<h3>Query Statistics</h3>
<DetailsList
items={generateQueryStatsItems()}
columns={columns}
selectionMode={SelectionMode.none}
layoutMode={DetailsListLayoutMode.justified}
compact={true}
/>
</div>
{userContext.apiType === "SQL" && (
<div className="downloadMetricsLinkContainer">
<a
id="downloadMetricsLink"
role="button"
tabIndex={0}
onClick={() => onDownloadQueryMetricsCsvClick()}
onKeyPress={(event: React.KeyboardEvent<HTMLAnchorElement>) =>
onDownloadQueryMetricsCsvKeyPress(event)
}
>
<img
className="downloadCsvImg"
src={DownloadQueryMetrics}
alt="download query metrics csv"
/>
<span>Per-partition query metrics (CSV)</span>
</a>
</div>
)}
</div>
)}
</PivotItem>
</Pivot>
)}
{/* <!-- Query Errors Content - Start--> */}
{!!error && (
<div className="tab-pane active">
<div className="errorContent">
<span className="errorMessage">{error}</span>
<span className="errorDetailsLink">
<a
onClick={() => onErrorDetailsClick()}
onKeyPress={(event: React.KeyboardEvent<HTMLAnchorElement>) => onErrorDetailsKeyPress(event)}
id="error-display"
tabIndex={0}
aria-label="Error details link"
>
More details
</a>
</span>
</div>
</div>
)}
{/* <!-- Query Errors Content - End--> */}
</div>
)}
</div>
</Stack>
{errors.length > 0 ? (
<ErrorList errors={errors} />
) : queryResults ? (
<ResultsView
queryResults={queryResults}
executeQueryDocumentsPage={executeQueryDocumentsPage}
isMongoDB={isMongoDB}
/>
) : (
<ExecuteQueryCallToAction />
)}
</div>
);
};

View File

@@ -7,10 +7,11 @@ import * as DataModels from "../../../Contracts/DataModels";
import type { QueryTabOptions } from "../../../Contracts/ViewModels";
import { useTabs } from "../../../hooks/useTabs";
import Explorer from "../../Explorer";
import QueryTabComponent, {
import {
IQueryTabComponentProps,
ITabAccessor,
QueryTabFunctionComponent,
QueryTabComponent,
QueryTabCopilotComponent,
} from "../../Tabs/QueryTab/QueryTabComponent";
import TabsBase from "../TabsBase";
@@ -49,7 +50,7 @@ export class NewQueryTab extends TabsBase {
public render(): JSX.Element {
return userContext.apiType === "SQL" ? (
<CopilotProvider>
<QueryTabFunctionComponent {...this.iQueryTabComponentProps} />
<QueryTabCopilotComponent {...this.iQueryTabComponentProps} />
</CopilotProvider>
) : (
<QueryTabComponent {...this.iQueryTabComponentProps} />

View File

@@ -2,9 +2,10 @@ import { fireEvent, render } from "@testing-library/react";
import { CollectionTabKind } from "Contracts/ViewModels";
import { CopilotProvider } from "Explorer/QueryCopilot/QueryCopilotContext";
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
import QueryTabComponent, {
import {
IQueryTabComponentProps,
QueryTabFunctionComponent,
QueryTabComponent,
QueryTabCopilotComponent,
} from "Explorer/Tabs/QueryTab/QueryTabComponent";
import TabsBase from "Explorer/Tabs/TabsBase";
import { updateUserContext, userContext } from "UserContext";
@@ -42,7 +43,7 @@ describe("QueryTabComponent", () => {
const { container } = render(<QueryTabComponent {...propsMock} />);
const launchCopilotButton = container.querySelector(".queryEditorWatermarkText");
const launchCopilotButton = container.querySelector('[data-test="QueryTab/ResultsPane/ExecuteCTA"]');
fireEvent.keyDown(launchCopilotButton, { key: "c", altKey: true });
expect(mockStore.setShowCopilotSidebar).toHaveBeenCalledWith(true);
@@ -70,7 +71,7 @@ describe("QueryTabComponent", () => {
const container = mount(
<CopilotProvider>
<QueryTabFunctionComponent {...propsMock} />
<QueryTabCopilotComponent {...propsMock} />
</CopilotProvider>,
);
expect(container.find(QueryCopilotPromptbar).exists()).toBe(true);

View File

@@ -1,15 +1,19 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable no-console */
import { FeedOptions, QueryOperationOptions } from "@azure/cosmos";
import QueryError, { createMonacoErrorLocationResolver, createMonacoMarkersForQueryErrors } from "Common/QueryError";
import { SplitterDirection } from "Common/Splitter";
import { Platform, configContext } from "ConfigContext";
import { useDialog } from "Explorer/Controls/Dialog";
import { monaco } from "Explorer/LazyMonaco";
import { QueryCopilotFeedbackModal } from "Explorer/QueryCopilot/Modal/QueryCopilotFeedbackModal";
import { useCopilotStore } from "Explorer/QueryCopilot/QueryCopilotContext";
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
import { OnExecuteQueryClick, QueryDocumentsPerPage } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { QueryCopilotSidebar } from "Explorer/QueryCopilot/V2/Sidebar/QueryCopilotSidebar";
import { QueryResultSection } from "Explorer/Tabs/QueryTab/QueryResultSection";
import { QueryTabStyles, useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
import { CosmosFluentProvider } from "Explorer/Theme/ThemeUtil";
import { useSelectedNode } from "Explorer/useSelectedNode";
import { KeyboardAction } from "KeyboardShortcuts";
import { QueryConstants } from "Shared/Constants";
@@ -21,10 +25,10 @@ import {
ruThresholdEnabled,
} from "Shared/StorageUtility";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import { Allotment } from "allotment";
import { QueryCopilotState, useQueryCopilot } from "hooks/useQueryCopilot";
import { TabsState, useTabs } from "hooks/useTabs";
import React, { Fragment } from "react";
import SplitterLayout from "react-splitter-layout";
import React, { Fragment, createRef } from "react";
import "react-splitter-layout/lib/index.css";
import { format } from "react-string-format";
import QueryCommandIcon from "../../../../images/CopilotCommand.svg";
@@ -35,7 +39,6 @@ import ExecuteQueryIcon from "../../../../images/ExecuteQuery.svg";
import CheckIcon from "../../../../images/check-1.svg";
import SaveQueryIcon from "../../../../images/save-cosmos.svg";
import { NormalizedEventKey } from "../../../Common/Constants";
import { getErrorMessage } from "../../../Common/ErrorHandlingUtils";
import * as HeadersUtility from "../../../Common/HeadersUtility";
import { MinimalQueryIterator } from "../../../Common/IteratorUtilities";
import { queryIterator } from "../../../Common/MongoProxyClient";
@@ -102,8 +105,9 @@ interface IQueryTabStates {
toggleState: ToggleState;
sqlQueryEditorContent: string;
selectedContent: string;
selection?: monaco.Selection;
executedSelection?: monaco.Selection; // We need to capture the selection that was used when executing, in case the user changes their section while the query is executing.
queryResults: ViewModels.QueryResults;
error: string;
isExecutionError: boolean;
isExecuting: boolean;
showCopilotSidebar: boolean;
@@ -112,9 +116,12 @@ interface IQueryTabStates {
copilotActive: boolean;
currentTabActive: boolean;
queryResultsView: SplitterDirection;
errors?: QueryError[];
modelMarkers?: monaco.editor.IMarkerData[];
}
export const QueryTabFunctionComponent = (props: IQueryTabComponentProps): any => {
export const QueryTabCopilotComponent = (props: IQueryTabComponentProps): any => {
const styles = useQueryTabStyles();
const copilotStore = useCopilotStore();
const isSampleCopilotActive = useSelectedNode.getState().isQueryCopilotCollectionSelected();
const queryTabProps = {
@@ -125,10 +132,20 @@ export const QueryTabFunctionComponent = (props: IQueryTabComponentProps): any =
isSampleCopilotActive: isSampleCopilotActive,
copilotStore: copilotStore,
};
return <QueryTabComponent {...queryTabProps}></QueryTabComponent>;
return <QueryTabComponentImpl styles={styles} {...queryTabProps}></QueryTabComponentImpl>;
};
export default class QueryTabComponent extends React.Component<IQueryTabComponentProps, IQueryTabStates> {
export const QueryTabComponent = (props: IQueryTabComponentProps): any => {
const styles = useQueryTabStyles();
return <QueryTabComponentImpl styles={styles} {...props}></QueryTabComponentImpl>;
};
type QueryTabComponentImplProps = IQueryTabComponentProps & {
styles: QueryTabStyles;
};
// Inner (legacy) class component. We only use this component via one of the two functional components above (since we need to use the `useQueryTabStyles` hook).
class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps, IQueryTabStates> {
public queryEditorId: string;
public executeQueryButton: Button;
public saveQueryButton: Button;
@@ -139,16 +156,19 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
public isCopilotTabActive: boolean;
private _iterator: MinimalQueryIterator;
private queryAbortController: AbortController;
queryEditor: React.RefObject<EditorReact>;
constructor(props: IQueryTabComponentProps) {
constructor(props: QueryTabComponentImplProps) {
super(props);
this.queryEditor = createRef<EditorReact>();
this.state = {
toggleState: ToggleState.Result,
sqlQueryEditorContent: props.isPreferredApiMongoDB ? "{}" : props.queryText || "SELECT * FROM c",
selectedContent: "",
queryResults: undefined,
error: "",
errors: [],
isExecutionError: this.props.isExecutionError,
isExecuting: false,
showCopilotSidebar: useQueryCopilot.getState().showCopilotSidebar,
@@ -221,9 +241,10 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
public onExecuteQueryClick = async (): Promise<void> => {
this._iterator = undefined;
setTimeout(async () => {
await this._executeQueryDocumentsPage(0);
}, 100);
}, 100); // TODO: Revert this
if (this.state.copilotActive) {
const query = this.state.sqlQueryEditorContent.split("\r\n")?.pop();
const isqueryEdited = this.props.copilotStore.generatedQuery && this.props.copilotStore.generatedQuery !== query;
@@ -302,23 +323,22 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
}
private async _executeQueryDocumentsPage(firstItemIndex: number): Promise<void> {
// Capture the query content and the selection being executed (if any).
const query = this.state.selectedContent || this.state.sqlQueryEditorContent;
const selection = this.state.selection;
this.setState({
// Track the executed selection so that we can evaluate error positions relative to it, even if the user changes their current selection.
executedSelection: selection,
});
this.queryAbortController = new AbortController();
if (this._iterator === undefined) {
this._iterator = this.props.isPreferredApiMongoDB
? queryIterator(
this.props.collection.databaseId,
this.props.viewModelcollection,
this.state.selectedContent || this.state.sqlQueryEditorContent,
)
: queryDocuments(
this.props.collection.databaseId,
this.props.collection.id(),
this.state.selectedContent || this.state.sqlQueryEditorContent,
{
enableCrossPartitionQuery: HeadersUtility.shouldEnableCrossPartitionKey(),
abortSignal: this.queryAbortController.signal,
} as unknown as FeedOptions,
);
? queryIterator(this.props.collection.databaseId, this.props.viewModelcollection, query)
: queryDocuments(this.props.collection.databaseId, this.props.collection.id(), query, {
enableCrossPartitionQuery: HeadersUtility.shouldEnableCrossPartitionKey(),
abortSignal: this.queryAbortController.signal,
} as unknown as FeedOptions);
}
await this._queryDocumentsPage(firstItemIndex);
@@ -383,18 +403,22 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
firstItemIndex,
queryDocuments,
);
this.setState({ queryResults, error: "" });
this.setState({ queryResults, errors: [] });
} catch (error) {
this.props.tabsBaseInstance.isExecutionError(true);
this.setState({
isExecutionError: true,
});
const errorMessage = getErrorMessage(error);
this.setState({
error: errorMessage,
});
document.getElementById("error-display").focus();
// Try to parse this as a query error
const queryErrors = QueryError.tryParse(
error,
createMonacoErrorLocationResolver(this.queryEditor.current.editor, this.state.executedSelection),
);
this.setState({
errors: queryErrors,
modelMarkers: createMonacoMarkersForQueryErrors(queryErrors),
});
} finally {
this.props.tabsBaseInstance.isExecuting(false);
this.setState({
@@ -584,6 +608,9 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
this.setState({
sqlQueryEditorContent: newContent,
queryCopilotGeneratedQuery: "",
// Clear the markers when the user edits the document.
modelMarkers: [],
});
if (this.isPreferredApiMongoDB) {
if (newContent.length > 0) {
@@ -604,14 +631,16 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
useCommandBar.getState().setContextButtons(this.getTabsButtons());
}
public onSelectedContent(selectedContent: string): void {
public onSelectedContent(selectedContent: string, selection: monaco.Selection): void {
if (selectedContent.trim().length > 0) {
this.setState({
selectedContent,
selection,
});
} else {
this.setState({
selectedContent: "",
selection: undefined,
});
}
@@ -668,9 +697,10 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
}
private getEditorAndQueryResult(): JSX.Element {
const vertical = this.state.queryResultsView === SplitterDirection.Horizontal;
return (
<Fragment>
<div className="tab-pane" id={this.props.tabId} role="tabpanel">
<CosmosFluentProvider id={this.props.tabId} className={this.props.styles.queryTab} role="tabpanel">
{this.props.copilotEnabled && this.state.currentTabActive && this.state.copilotActive && (
<QueryCopilotPromptbar
explorer={this.props.collection.container}
@@ -679,40 +709,33 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
containerId={this.props.collection.id()}
></QueryCopilotPromptbar>
)}
<div className="tabPaneContentContainer">
<SplitterLayout
primaryIndex={0}
primaryMinSize={20}
secondaryMinSize={20}
// Percentage is a bit better when the splitter flips from vertical to horizontal.
percentage={true}
// NOTE: It is intentional that this looks reversed!
// The 'vertical' property refers to the stacking of the panes so is the opposite of the orientation of the splitter itself
// (vertically stacked => horizontal splitter)
// Our setting refers to the orientation of the splitter, so we need to reverse it here.
vertical={this.state.queryResultsView === SplitterDirection.Horizontal}
>
<Fragment>
<div className="queryEditor" style={{ height: "100%" }}>
<EditorReact
language={"sql"}
content={this.getEditorContent()}
isReadOnly={false}
wordWrap={"on"}
ariaLabel={"Editing Query"}
lineNumbers={"on"}
onContentChanged={(newContent: string) => this.onChangeContent(newContent)}
onContentSelected={(selectedContent: string) => this.onSelectedContent(selectedContent)}
/>
</div>
</Fragment>
{/* Set 'key' to the value of vertical to force re-rendering when vertical changes, to work around https://github.com/johnwalley/allotment/issues/457 */}
<Allotment key={vertical.toString()} vertical={vertical}>
<Allotment.Pane data-test="QueryTab/EditorPane">
<EditorReact
ref={this.queryEditor}
className={this.props.styles.queryEditor}
language={"sql"}
content={this.getEditorContent()}
modelMarkers={this.state.modelMarkers}
isReadOnly={false}
wordWrap={"on"}
ariaLabel={"Editing Query"}
lineNumbers={"on"}
onContentChanged={(newContent: string) => this.onChangeContent(newContent)}
onContentSelected={(selectedContent: string, selection: monaco.Selection) =>
this.onSelectedContent(selectedContent, selection)
}
/>
</Allotment.Pane>
<Allotment.Pane>
{this.props.isSampleCopilotActive ? (
<QueryResultSection
isMongoDB={this.props.isPreferredApiMongoDB}
queryEditorContent={this.state.sqlQueryEditorContent}
error={this.props.copilotStore?.errorMessage}
queryResults={this.props.copilotStore?.queryResults}
errors={this.props.copilotStore?.errors}
isExecuting={this.props.copilotStore?.isExecuting}
queryResults={this.props.copilotStore?.queryResults}
executeQueryDocumentsPage={(firstItemIndex: number) =>
QueryDocumentsPerPage(
firstItemIndex,
@@ -725,17 +748,17 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
<QueryResultSection
isMongoDB={this.props.isPreferredApiMongoDB}
queryEditorContent={this.state.sqlQueryEditorContent}
error={this.state.error}
queryResults={this.state.queryResults}
errors={this.state.errors}
isExecuting={this.state.isExecuting}
queryResults={this.state.queryResults}
executeQueryDocumentsPage={(firstItemIndex: number) =>
this._executeQueryDocumentsPage(firstItemIndex)
}
/>
)}
</SplitterLayout>
</div>
</div>
</Allotment.Pane>
</Allotment>
</CosmosFluentProvider>
{this.props.copilotEnabled && this.props.copilotStore?.showFeedbackModal && (
<QueryCopilotFeedbackModal
explorer={this.props.collection.container}
@@ -751,7 +774,7 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
render(): JSX.Element {
const shouldScaleElements = this.state.showCopilotSidebar && this.isCopilotTabActive;
return (
<div style={{ display: "flex", flexDirection: "row", height: "100%" }}>
<div data-test="QueryTab" style={{ display: "flex", flexDirection: "row", height: "100%" }}>
<div style={{ width: shouldScaleElements ? "70%" : "100%", height: "100%" }}>
{this.getEditorAndQueryResult()}
</div>

View File

@@ -0,0 +1,396 @@
import {
Button,
DataGrid,
DataGridBody,
DataGridCell,
DataGridHeader,
DataGridHeaderCell,
DataGridRow,
SelectTabData,
SelectTabEvent,
Tab,
TabList,
TableColumnDefinition,
createTableColumn,
} from "@fluentui/react-components";
import { ArrowDownloadRegular, CopyRegular } from "@fluentui/react-icons";
import { HttpHeaders } from "Common/Constants";
import MongoUtility from "Common/MongoUtility";
import { QueryMetrics } from "Contracts/DataModels";
import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
import { IDocument } from "Explorer/Tabs/QueryTab/QueryTabComponent";
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
import { userContext } from "UserContext";
import copy from "clipboard-copy";
import React, { useCallback, useState } from "react";
import { ResultsViewProps } from "./QueryResultSection";
enum ResultsTabs {
Results = "results",
QueryStats = "queryStats",
}
const ResultsTab: React.FC<ResultsViewProps> = ({ queryResults, isMongoDB, executeQueryDocumentsPage }) => {
const styles = useQueryTabStyles();
const queryResultsString = queryResults
? isMongoDB
? MongoUtility.tojson(queryResults.documents, undefined, false)
: JSON.stringify(queryResults.documents, undefined, 4)
: "";
const onClickCopyResults = (): void => {
copy(queryResultsString);
};
const onFetchNextPageClick = async (): Promise<void> => {
const { firstItemIndex, itemCount } = queryResults;
await executeQueryDocumentsPage(firstItemIndex + itemCount - 1);
};
return (
<>
<div className={styles.queryResultsBar}>
<div>
{queryResults.itemCount > 0 ? `${queryResults.firstItemIndex} - ${queryResults.lastItemIndex}` : `0 - 0`}
</div>
{queryResults.hasMoreResults && (
<a href="#" onClick={() => onFetchNextPageClick()}>
Load more
</a>
)}
<div className={styles.flexGrowSpacer} />
<Button
size="small"
appearance="transparent"
icon={<CopyRegular />}
title="Copy to Clipboard"
aria-label="Copy"
onClick={onClickCopyResults}
/>
</div>
<div className={styles.queryResultsViewer}>
<EditorReact language={"json"} content={queryResultsString} isReadOnly={true} ariaLabel={"Query results"} />
</div>
</>
);
};
const QueryStatsTab: React.FC<Pick<ResultsViewProps, "queryResults">> = ({ queryResults }) => {
const styles = useQueryTabStyles();
const queryMetrics = React.useRef(queryResults?.headers?.[HttpHeaders.queryMetrics]);
React.useEffect(() => {
const latestQueryMetrics = queryResults?.headers?.[HttpHeaders.queryMetrics];
if (latestQueryMetrics && Object.keys(latestQueryMetrics).length > 0) {
queryMetrics.current = latestQueryMetrics;
}
}, [queryResults]);
const getAggregatedQueryMetrics = (): QueryMetrics => {
const aggregatedQueryMetrics = {
documentLoadTime: 0,
documentWriteTime: 0,
indexHitDocumentCount: 0,
outputDocumentCount: 0,
outputDocumentSize: 0,
indexLookupTime: 0,
retrievedDocumentCount: 0,
retrievedDocumentSize: 0,
vmExecutionTime: 0,
runtimeExecutionTimes: {
queryEngineExecutionTime: 0,
systemFunctionExecutionTime: 0,
userDefinedFunctionExecutionTime: 0,
},
totalQueryExecutionTime: 0,
} as QueryMetrics;
if (queryMetrics.current) {
Object.keys(queryMetrics.current).forEach((partitionKeyRangeId) => {
const queryMetricsPerPartition = queryMetrics.current[partitionKeyRangeId];
if (!queryMetricsPerPartition) {
return;
}
aggregatedQueryMetrics.documentLoadTime += queryMetricsPerPartition.documentLoadTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.documentWriteTime +=
queryMetricsPerPartition.documentWriteTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.indexHitDocumentCount += queryMetricsPerPartition.indexHitDocumentCount || 0;
aggregatedQueryMetrics.outputDocumentCount += queryMetricsPerPartition.outputDocumentCount || 0;
aggregatedQueryMetrics.outputDocumentSize += queryMetricsPerPartition.outputDocumentSize || 0;
aggregatedQueryMetrics.indexLookupTime += queryMetricsPerPartition.indexLookupTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.retrievedDocumentCount += queryMetricsPerPartition.retrievedDocumentCount || 0;
aggregatedQueryMetrics.retrievedDocumentSize += queryMetricsPerPartition.retrievedDocumentSize || 0;
aggregatedQueryMetrics.vmExecutionTime += queryMetricsPerPartition.vmExecutionTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.totalQueryExecutionTime +=
queryMetricsPerPartition.totalQueryExecutionTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.runtimeExecutionTimes.queryEngineExecutionTime +=
queryMetricsPerPartition.runtimeExecutionTimes?.queryEngineExecutionTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.runtimeExecutionTimes.systemFunctionExecutionTime +=
queryMetricsPerPartition.runtimeExecutionTimes?.systemFunctionExecutionTime?.totalMilliseconds() || 0;
aggregatedQueryMetrics.runtimeExecutionTimes.userDefinedFunctionExecutionTime +=
queryMetricsPerPartition.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.totalMilliseconds() || 0;
});
}
return aggregatedQueryMetrics;
};
const columns: TableColumnDefinition<IDocument>[] = [
createTableColumn<IDocument>({
columnId: "metric",
renderHeaderCell: () => "Metric",
renderCell: (item) => item.metric,
}),
createTableColumn<IDocument>({
columnId: "value",
renderHeaderCell: () => "Value",
renderCell: (item) => item.value,
}),
];
const generateQueryStatsItems = (): IDocument[] => {
const items: IDocument[] = [
{
metric: "Request Charge",
value: `${queryResults.requestCharge} RUs`,
toolTip: "Request Charge",
},
{
metric: "Showing Results",
value: queryResults.itemCount > 0 ? `${queryResults.firstItemIndex} - ${queryResults.lastItemIndex}` : `0 - 0`,
toolTip: "Showing Results",
},
];
if (userContext.apiType === "SQL") {
const aggregatedQueryMetrics = getAggregatedQueryMetrics();
items.push(
{
metric: "Retrieved document count",
value: aggregatedQueryMetrics.retrievedDocumentCount?.toString() || "",
toolTip: "Total number of retrieved documents",
},
{
metric: "Retrieved document size",
value: `${aggregatedQueryMetrics.retrievedDocumentSize?.toString() || 0} bytes`,
toolTip: "Total size of retrieved documents in bytes",
},
{
metric: "Output document count",
value: aggregatedQueryMetrics.outputDocumentCount?.toString() || "",
toolTip: "Number of output documents",
},
{
metric: "Output document size",
value: `${aggregatedQueryMetrics.outputDocumentSize?.toString() || 0} bytes`,
toolTip: "Total size of output documents in bytes",
},
{
metric: "Index hit document count",
value: aggregatedQueryMetrics.indexHitDocumentCount?.toString() || "",
toolTip: "Total number of documents matched by the filter",
},
{
metric: "Index lookup time",
value: `${aggregatedQueryMetrics.indexLookupTime?.toString() || 0} ms`,
toolTip: "Time spent in physical index layer",
},
{
metric: "Document load time",
value: `${aggregatedQueryMetrics.documentLoadTime?.toString() || 0} ms`,
toolTip: "Time spent in loading documents",
},
{
metric: "Query engine execution time",
value: `${aggregatedQueryMetrics.runtimeExecutionTimes?.queryEngineExecutionTime?.toString() || 0} ms`,
toolTip:
"Time spent by the query engine to execute the query expression (excludes other execution times like load documents or write results)",
},
{
metric: "System function execution time",
value: `${aggregatedQueryMetrics.runtimeExecutionTimes?.systemFunctionExecutionTime?.toString() || 0} ms`,
toolTip: "Total time spent executing system (built-in) functions",
},
{
metric: "User defined function execution time",
value: `${
aggregatedQueryMetrics.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.toString() || 0
} ms`,
toolTip: "Total time spent executing user-defined functions",
},
{
metric: "Document write time",
value: `${aggregatedQueryMetrics.documentWriteTime.toString() || 0} ms`,
toolTip: "Time spent to write query result set to response buffer",
},
);
}
if (queryResults.roundTrips) {
items.push({
metric: "Round Trips",
value: queryResults.roundTrips?.toString(),
toolTip: "Number of round trips",
});
}
if (queryResults.activityId) {
items.push({
metric: "Activity id",
value: queryResults.activityId,
toolTip: "",
});
}
return items;
};
const generateQueryMetricsCsvData = (): string => {
if (queryMetrics.current) {
let csvData =
[
"Partition key range id",
"Retrieved document count",
"Retrieved document size (in bytes)",
"Output document count",
"Output document size (in bytes)",
"Index hit document count",
"Index lookup time (ms)",
"Document load time (ms)",
"Query engine execution time (ms)",
"System function execution time (ms)",
"User defined function execution time (ms)",
"Document write time (ms)",
].join(",") + "\n";
Object.keys(queryMetrics.current).forEach((partitionKeyRangeId) => {
const queryMetricsPerPartition = queryMetrics.current[partitionKeyRangeId];
csvData +=
[
partitionKeyRangeId,
queryMetricsPerPartition.retrievedDocumentCount,
queryMetricsPerPartition.retrievedDocumentSize,
queryMetricsPerPartition.outputDocumentCount,
queryMetricsPerPartition.outputDocumentSize,
queryMetricsPerPartition.indexHitDocumentCount,
queryMetricsPerPartition.indexLookupTime?.totalMilliseconds(),
queryMetricsPerPartition.documentLoadTime?.totalMilliseconds(),
queryMetricsPerPartition.runtimeExecutionTimes?.queryEngineExecutionTime?.totalMilliseconds(),
queryMetricsPerPartition.runtimeExecutionTimes?.systemFunctionExecutionTime?.totalMilliseconds(),
queryMetricsPerPartition.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.totalMilliseconds(),
queryMetricsPerPartition.documentWriteTime?.totalMilliseconds(),
].join(",") + "\n";
});
return csvData;
}
return undefined;
};
const downloadQueryMetricsCsvData = (): void => {
const csvData: string = generateQueryMetricsCsvData();
if (!csvData) {
return;
}
if (navigator.msSaveBlob) {
// for IE and Edge
navigator.msSaveBlob(
new Blob([csvData], { type: "data:text/csv;charset=utf-8" }),
"PerPartitionQueryMetrics.csv",
);
} else {
const downloadLink: HTMLAnchorElement = document.createElement("a");
downloadLink.href = "data:text/csv;charset=utf-8," + encodeURI(csvData);
downloadLink.target = "_self";
downloadLink.download = "QueryMetricsPerPartition.csv";
// for some reason, FF displays the download prompt only when
// the link is added to the dom so we add and remove it
document.body.appendChild(downloadLink);
downloadLink.click();
downloadLink.remove();
}
};
const onDownloadQueryMetricsCsvClick = (): boolean => {
downloadQueryMetricsCsvData();
return false;
};
return (
<div className={styles.metricsGridContainer}>
<DataGrid
data-test="QueryTab/ResultsPane/ResultsView/QueryStatsList"
className={styles.queryStatsGrid}
items={generateQueryStatsItems()}
columns={columns}
sortable
getRowId={(item) => item.metric}
focusMode="composite"
>
<DataGridHeader>
<DataGridRow>
{({ renderHeaderCell }) => <DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>}
</DataGridRow>
</DataGridHeader>
<DataGridBody<IDocument>>
{({ item, rowId }) => (
<DataGridRow<IDocument> key={rowId} data-test={`Row:${rowId}`}>
{({ columnId, renderCell }) => (
<DataGridCell data-test={`Row:${rowId}/Column:${columnId}`}>{renderCell(item)}</DataGridCell>
)}
</DataGridRow>
)}
</DataGridBody>
</DataGrid>
<div className={styles.metricsGridButtons}>
{userContext.apiType === "SQL" && (
<Button appearance="subtle" onClick={() => onDownloadQueryMetricsCsvClick()} icon={<ArrowDownloadRegular />}>
Per-partition query metrics (CSV)
</Button>
)}
</div>
</div>
);
};
export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResults, executeQueryDocumentsPage }) => {
const styles = useQueryTabStyles();
const [activeTab, setActiveTab] = useState<ResultsTabs>(ResultsTabs.Results);
const onTabSelect = useCallback((event: SelectTabEvent, data: SelectTabData) => {
setActiveTab(data.value as ResultsTabs);
}, []);
return (
<div data-test="QueryTab/ResultsPane/ResultsView" className={styles.queryResultsTabPanel}>
<TabList selectedValue={activeTab} onTabSelect={onTabSelect}>
<Tab
data-test="QueryTab/ResultsPane/ResultsView/ResultsTab"
id={ResultsTabs.Results}
value={ResultsTabs.Results}
>
Results
</Tab>
<Tab
data-test="QueryTab/ResultsPane/ResultsView/QueryStatsTab"
id={ResultsTabs.QueryStats}
value={ResultsTabs.QueryStats}
>
Query Stats
</Tab>
</TabList>
<div className={styles.queryResultsTabContentContainer}>
{activeTab === ResultsTabs.Results && (
<ResultsTab
queryResults={queryResults}
isMongoDB={isMongoDB}
executeQueryDocumentsPage={executeQueryDocumentsPage}
/>
)}
{activeTab === ResultsTabs.QueryStats && <QueryStatsTab queryResults={queryResults} />}
</div>
</div>
);
};

View File

@@ -0,0 +1,84 @@
import { makeStyles, shorthands } from "@fluentui/react-components";
import { cosmosShorthands } from "Explorer/Theme/ThemeUtil";
export type QueryTabStyles = ReturnType<typeof useQueryTabStyles>;
export const useQueryTabStyles = makeStyles({
queryTab: {
height: "100%",
display: "flex",
flexDirection: "column",
},
queryEditor: {
...shorthands.border("none"),
paddingTop: "4px",
height: "100%",
width: "100%",
},
executeCallToAction: {
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
textAlign: "center",
},
queryResultsPanel: {
height: "100%",
display: "flex",
flexDirection: "column",
},
queryResultsMessage: {
...shorthands.margin("5px"),
},
queryResultsBody: {
flexGrow: 1,
justifySelf: "stretch",
},
queryResultsTabPanel: {
height: "100%",
display: "flex",
rowGap: "12px",
flexDirection: "column",
},
queryResultsTabContentContainer: {
display: "flex",
flexDirection: "column",
flexGrow: 1,
paddingLeft: "12px",
paddingRight: "12px",
overflow: "auto",
},
queryResultsViewer: {
flexGrow: 1,
},
queryResultsBar: {
display: "flex",
flexDirection: "row",
columnGap: "4px",
paddingBottom: "4px",
},
flexGrowSpacer: {
flexGrow: 1,
},
queryStatsGrid: {
flexGrow: 1,
overflow: "auto",
},
metricsGridContainer: {
display: "flex",
flexDirection: "column",
paddingBottom: "6px",
maxHeight: "100%",
},
metricsGridButtons: {
...cosmosShorthands.borderTop(),
},
errorListMessageCell: {
display: "flex",
flexDirection: "row",
width: "100%",
alignItems: "center",
},
errorListMessage: {
flexGrow: 1,
},
});

View File

@@ -57,7 +57,8 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
const defaultMessageBarStyles: IMessageBarStyles = {
root: {
height: `${LayoutConstants.rowHeight}px`,
overflow: "auto",
overflow: "hidden",
flexDirection: "row",
},
};
@@ -99,8 +100,7 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
},
}}
>
{`To prevent queries from using excessive RUs, Data Explorer has a 5,000 RU default limit. To modify or remove
the limit, go to the Settings cog on the right and find "RU Threshold".`}
{`Data Explorer has a 5,000 RU default limit. To adjust the limit, go to the Settings page and find "RU Threshold".`}
<Link
className="underlinedLink"
href="https://review.learn.microsoft.com/en-us/azure/cosmos-db/data-explorer?branch=main#configure-request-unit-threshold"
@@ -298,11 +298,15 @@ function TabPane({ tab, active }: { tab: Tab; active: boolean }) {
if (tab) {
if ("render" in tab) {
return <div {...attrs}>{tab.render()}</div>;
return (
<div data-test={`Tab:${tab.tabId}`} {...attrs}>
{tab.render()}
</div>
);
}
}
return <div {...attrs} ref={ref} data-bind="html:html" />;
return <div data-test={`Tab:${tab.tabId}`} {...attrs} ref={ref} data-bind="html:html" />;
}
const onKeyPressReactTab = (e: KeyboardEvent, tabKind: ReactTabKind): void => {

View File

@@ -1,6 +1,8 @@
import {
BrandVariants,
ComponentProps,
FluentProvider,
FluentProviderSlots,
Theme,
createLightTheme,
makeStyles,
@@ -10,16 +12,19 @@ import {
webLightTheme,
} from "@fluentui/react-components";
import { Platform, configContext } from "ConfigContext";
import React, { PropsWithChildren } from "react";
import React from "react";
import { appThemeFabricTealBrandRamp } from "../../Platform/Fabric/FabricTheme";
export const LayoutConstants = {
rowHeight: 36,
rowHeight: 32,
};
export type CosmosFluentProviderProps = PropsWithChildren<{
className?: string;
}>;
// Our CosmosFluentProvider has the same props as a FluentProvider.
export type CosmosFluentProviderProps = Omit<ComponentProps<FluentProviderSlots, "root">, "dir">;
// PropsWithChildren<{
// className?: string;
// }>;
const useDefaultRootStyles = makeStyles({
fluentProvider: {
@@ -32,15 +37,37 @@ const useDefaultRootStyles = makeStyles({
},
});
export const CosmosFluentProvider: React.FC<CosmosFluentProviderProps> = ({ children, className }) => {
const FluentProviderContext = React.createContext({
isInFluentProvider: false,
});
export const CosmosFluentProvider: React.FC<CosmosFluentProviderProps> = ({ children, className, ...props }) => {
// We use a React context to ensure that nested CosmosFluentProviders don't create nested FluentProviders.
// This helps during the transition from Fluent UI 8 to Fluent UI 9.
// As we convert components to Fluent UI 9, if we end up with nested FluentProviders, the inner FluentProvider will be a no-op.
const { isInFluentProvider } = React.useContext(FluentProviderContext);
const styles = useDefaultRootStyles();
if (isInFluentProvider) {
// We're already in a fluent context, don't create another.
console.warn("Nested CosmosFluentProvider detected. This is likely a bug.");
return (
<div className={className} {...props}>
{children}
</div>
);
}
return (
<FluentProvider
theme={getPlatformTheme(configContext.platform)}
className={mergeClasses(styles.fluentProvider, className)}
>
{children}
</FluentProvider>
<FluentProviderContext.Provider value={{ isInFluentProvider: true }}>
<FluentProvider
theme={getPlatformTheme(configContext.platform)}
className={mergeClasses(styles.fluentProvider, className)}
{...props}
>
{children}
</FluentProvider>
</FluentProviderContext.Provider>
);
};
@@ -64,15 +91,30 @@ const appThemePortalBrandRamp: BrandVariants = {
160: "#CDD8EF",
};
const cosmosThemeElements = {
layoutRowHeight: `${LayoutConstants.rowHeight}px`,
export enum LayoutSize {
Compact,
// TODO: Cozy and Roomy layouts.
}
interface CosmosThemeElements {
layoutRowHeight: string;
}
export type CosmosTheme = Theme & CosmosThemeElements;
const sizeMappings: Record<LayoutSize, Partial<Theme> & CosmosThemeElements> = {
[LayoutSize.Compact]: {
layoutRowHeight: "32px",
fontSizeBase300: "13px",
},
};
const cosmosTheme = {
sidebarMinimumWidth: "200px",
sidebarInitialWidth: "300px",
};
export type CosmosTheme = Theme & typeof cosmosThemeElements;
export const tokens = themeToTokensObject({ ...webLightTheme, ...cosmosThemeElements });
export const tokens = themeToTokensObject({ ...webLightTheme, ...cosmosTheme, ...sizeMappings[LayoutSize.Compact] });
export const cosmosShorthands = {
border: () => shorthands.border("1px", "solid", tokens.colorNeutralStroke2),
@@ -90,6 +132,7 @@ export function getPlatformTheme(platform: Platform): CosmosTheme {
return {
...baseTheme,
...cosmosThemeElements,
...cosmosTheme,
...sizeMappings[LayoutSize.Compact], // TODO: Allow for different layout sizes.
};
}

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import { initializeIcons, loadTheme } from "@fluentui/react";
import { QuickstartCarousel } from "Explorer/Quickstart/QuickstartCarousel";
import { MongoQuickstartTutorial } from "Explorer/Quickstart/Tutorials/MongoQuickstartTutorial";
import { SQLQuickstartTutorial } from "Explorer/Quickstart/Tutorials/SQLQuickstartTutorial";
import "allotment/dist/style.css";
import "bootstrap/dist/css/bootstrap.css";
import { useCarousel } from "hooks/useCarousel";
import React from "react";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -52,7 +52,11 @@ export const defaultAllowedArmEndpoints: ReadonlyArray<string> = [
"https://management.chinacloudapi.cn",
];
export const allowedAadEndpoints: ReadonlyArray<string> = ["https://login.microsoftonline.com/"];
export const allowedAadEndpoints: ReadonlyArray<string> = [
"https://login.microsoftonline.com/",
"https://login.microsoftonline.us/",
"https://login.partner.microsoftonline.cn/",
];
export const defaultAllowedBackendEndpoints: ReadonlyArray<string> = [
"https://main.documentdb.ext.azure.com",
@@ -74,6 +78,13 @@ export const PortalBackendIPs: { [key: string]: string[] } = {
//usnat: ["7.28.202.68"],
};
export const PortalBackendOutboundIPs: { [key: string]: string[] } = {
[PortalBackendEndpoints.Mpac]: ["13.91.105.215", "4.210.172.107"],
[PortalBackendEndpoints.Prod]: ["13.88.56.148", "40.91.218.243"],
[PortalBackendEndpoints.Fairfax]: ["52.247.163.6", "52.244.134.181"],
[PortalBackendEndpoints.Mooncake]: ["163.228.137.6", "143.64.170.142"],
};
export const MongoProxyOutboundIPs: { [key: string]: string[] } = {
[MongoProxyEndpoints.Mpac]: ["20.245.81.54", "40.118.23.126"],
[MongoProxyEndpoints.Prod]: ["40.80.152.199", "13.95.130.121"],
@@ -164,7 +175,23 @@ export function useNewPortalBackendEndpoint(backendApi: string): boolean {
PortalBackendEndpoints.Mpac,
PortalBackendEndpoints.Prod,
],
[BackendApi.AccountRestrictions]: [PortalBackendEndpoints.Development, PortalBackendEndpoints.Mpac],
[BackendApi.AccountRestrictions]: [
PortalBackendEndpoints.Development,
PortalBackendEndpoints.Mpac,
PortalBackendEndpoints.Prod,
],
[BackendApi.RuntimeProxy]: [
PortalBackendEndpoints.Development,
PortalBackendEndpoints.Mpac,
PortalBackendEndpoints.Prod,
],
[BackendApi.DisallowedLocations]: [
PortalBackendEndpoints.Development,
PortalBackendEndpoints.Mpac,
PortalBackendEndpoints.Prod,
PortalBackendEndpoints.Fairfax,
PortalBackendEndpoints.Mooncake,
],
};
if (!newBackendApiEnvironmentMap[backendApi] || !configContext.PORTAL_BACKEND_ENDPOINT) {

View File

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

View File

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

View File

@@ -148,7 +148,7 @@ describe("Query Utils", () => {
expect(expectedPartitionKeyValues).toContain(documentContent["Status"]);
});
it("should extract three partition key values", () => {
it("should extract three partition key values even if one is empty", () => {
const multiPartitionKeyDefinition: PartitionKeyDefinition = {
kind: PartitionKeyKind.MultiHash,
paths: ["/Country", "/Region", "/Category"],
@@ -163,5 +163,17 @@ describe("Query Utils", () => {
expect(expectedPartitionKeyValues).toContain(documentContent["Region"]);
expect(expectedPartitionKeyValues).toContain(documentContent["Category"]);
});
it("should extract no partition key values in the case nested partition key", () => {
const singlePartitionKeyDefinition: PartitionKeyDefinition = {
kind: PartitionKeyKind.Hash,
paths: ["/Location.type"],
};
const partitionKeyValues: PartitionKey[] = extractPartitionKeyValues(
documentContent,
singlePartitionKeyDefinition,
);
expect(partitionKeyValues.length).toBe(0);
});
});
});

View File

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

View File

@@ -4,6 +4,7 @@ import { DATA_EXPLORER_RPC_VERSION } from "Contracts/DataExplorerMessagesContrac
import { FabricMessageTypes } from "Contracts/FabricMessageTypes";
import { FABRIC_RPC_VERSION, FabricMessageV2 } from "Contracts/FabricMessagesContract";
import Explorer from "Explorer/Explorer";
import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
import { useSelectedNode } from "Explorer/useSelectedNode";
import { scheduleRefreshDatabaseResourceToken } from "Platform/Fabric/FabricUtil";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
@@ -15,6 +16,7 @@ import { useEffect, useState } from "react";
import { AuthType } from "../AuthType";
import { AccountKind, Flights } from "../Common/Constants";
import { normalizeArmEndpoint } from "../Common/EnvironmentUtility";
import * as Logger from "../Common/Logger";
import { handleCachedDataMessage, sendMessage, sendReadyMessage } from "../Common/MessageHandler";
import { Platform, configContext, updateConfigContext } from "../ConfigContext";
import { ActionType, DataExplorerAction, TabKind } from "../Contracts/ActionContracts";
@@ -42,8 +44,6 @@ import { acquireTokenWithMsal, getAuthorizationHeader, getMsalInstance } from ".
import { isInvalidParentFrameOrigin, shouldProcessMessage } from "../Utils/MessageValidation";
import { getReadOnlyKeys, listKeys } from "../Utils/arm/generatedClients/cosmos/databaseAccounts";
import { applyExplorerBindings } from "../applyExplorerBindings";
import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
import * as Logger from "../Common/Logger";
// This hook will create a new instance of Explorer.ts and bind it to the DOM
// This hook has a LOT of magic, but ideally we can delete it once we have removed KO and switched entirely to React
@@ -83,6 +83,7 @@ export function useKnockoutExplorer(platform: Platform): Explorer {
useEffect(() => {
if (explorer) {
applyExplorerBindings(explorer);
explorer.openNPSSurveyDialog();
}
}, [explorer]);
@@ -588,10 +589,6 @@ async function configurePortal(): Promise<Explorer> {
explorer = new Explorer();
resolve(explorer);
if (userContext.apiType === "Postgres" || userContext.apiType === "SQL" || userContext.apiType === "Mongo") {
setTimeout(() => explorer.openNPSSurveyDialog(), 3000);
}
if (openAction) {
handleOpenAction(openAction, useDatabases.getState().databases, explorer);
}
@@ -622,6 +619,31 @@ function shouldForwardMessage(message: PortalMessage, messageOrigin: string) {
return messageOrigin === window.document.location.origin && message.type === MessageTypes.TelemetryInfo;
}
function updateAADEndpoints(portalEnv: PortalEnv) {
switch (portalEnv) {
case "prod1":
case "prod":
updateConfigContext({
AAD_ENDPOINT: Constants.AadEndpoints.Prod,
});
break;
case "fairfax":
updateConfigContext({
AAD_ENDPOINT: Constants.AadEndpoints.Fairfax,
});
break;
case "mooncake":
updateConfigContext({
AAD_ENDPOINT: Constants.AadEndpoints.Mooncake,
});
break;
default:
console.warn(`Unknown portal environment: ${portalEnv}`);
break;
}
}
function updateContextsFromPortalMessage(inputs: DataExplorerInputsFrame) {
if (
configContext.BACKEND_ENDPOINT &&
@@ -642,6 +664,8 @@ function updateContextsFromPortalMessage(inputs: DataExplorerInputsFrame) {
PORTAL_BACKEND_ENDPOINT: inputs.portalBackendEndpoint,
});
updateAADEndpoints(inputs.serverId as PortalEnv);
updateUserContext({
authorizationToken,
databaseAccount,

View File

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

View File

@@ -1,4 +1,5 @@
import { MinimalQueryIterator } from "Common/IteratorUtilities";
import QueryError from "Common/QueryError";
import { QueryResults } from "Contracts/ViewModels";
import { CopilotMessage, CopilotSchemaAllocationInfo } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
import { guid } from "Explorer/Tables/Utilities";
@@ -27,7 +28,7 @@ export interface QueryCopilotState {
showSamplePrompts: boolean;
queryIterator: MinimalQueryIterator | undefined;
queryResults: QueryResults | undefined;
errorMessage: string;
errors: QueryError[];
isSamplePromptsOpen: boolean;
showPromptTeachingBubble: boolean;
showDeletePopup: boolean;
@@ -70,7 +71,7 @@ export interface QueryCopilotState {
setShowSamplePrompts: (showSamplePrompts: boolean) => void;
setQueryIterator: (queryIterator: MinimalQueryIterator | undefined) => void;
setQueryResults: (queryResults: QueryResults | undefined) => void;
setErrorMessage: (errorMessage: string) => void;
setErrors: (errors: QueryError[]) => void;
setIsSamplePromptsOpen: (isSamplePromptsOpen: boolean) => void;
setShowPromptTeachingBubble: (showPromptTeachingBubble: boolean) => void;
setShowDeletePopup: (showDeletePopup: boolean) => void;
@@ -117,7 +118,7 @@ export const useQueryCopilot: QueryCopilotStore = create((set) => ({
showSamplePrompts: false,
queryIterator: undefined,
queryResults: undefined,
errorMessage: "",
errors: [],
isSamplePromptsOpen: false,
showDeletePopup: false,
showFeedbackBar: false,
@@ -170,7 +171,7 @@ export const useQueryCopilot: QueryCopilotStore = create((set) => ({
setShowSamplePrompts: (showSamplePrompts: boolean) => set({ showSamplePrompts }),
setQueryIterator: (queryIterator: MinimalQueryIterator | undefined) => set({ queryIterator }),
setQueryResults: (queryResults: QueryResults | undefined) => set({ queryResults }),
setErrorMessage: (errorMessage: string) => set({ errorMessage }),
setErrors: (errors: QueryError[]) => set({ errors }),
setIsSamplePromptsOpen: (isSamplePromptsOpen: boolean) => set({ isSamplePromptsOpen }),
setShowDeletePopup: (showDeletePopup: boolean) => set({ showDeletePopup }),
setShowFeedbackBar: (showFeedbackBar: boolean) => set({ showFeedbackBar }),
@@ -225,7 +226,7 @@ export const useQueryCopilot: QueryCopilotStore = create((set) => ({
showSamplePrompts: false,
queryIterator: undefined,
queryResults: undefined,
errorMessage: "",
errors: [],
isSamplePromptsOpen: false,
showDeletePopup: false,
showFeedbackBar: false,

View File

@@ -98,7 +98,7 @@ If you used all the standard deployment scripts and naming scheme, you can set t
If Azure Powershell's current subscription is not the one you want to use for testing, you can set the subscription using the following command:
```powershell
.\test\scripts\set-test-subscription.ps1 -Subscription "My Subscription"
.\test\scripts\set-test-accounts.ps1 -Subscription "My Subscription"
```
That script will confirm the resource group exists and then set the necessary environment variables:
@@ -151,3 +151,42 @@ npx playwright test --ui
The UI allows you to select a specific test to run and to see the results of the test in the browser.
See the [Playwright docs](https://playwright.dev/docs/running-tests) for more information on running tests.
## Clean-up
Tests should clean-up after themselves if they succeed (and sometimes even when they fail).
However, this is not guaranteed, and you may find that you have resources left over from failed tests.
Any resource (database, container, etc.) prefixed with `t_` is a test resource and can be safely deleted if you aren't currently running tests.
The `test/scripts/clean-test-accounts.ps1` script will attempt to clean all the test resources.
```powershell
.\test\scripts\clean-test-accounts.ps1 -Subscription "My Subscription"
```
That script will confirm the resource group exists and then prompt you to confirm the deletion of the resources:
```
Found a resource with the default resource prefix (ashleyst-e2e-). Configuring that prefix for E2E testing.
Cleaning E2E Testing Resources
Subscription: cosmosdb-portalteam-generaltest-msft (b9c77f10-b438-4c32-9819-eef8a654e478)
Resource Group: ashleyst-e2e-testing
Resource Prefix: ashleyst-e2e-
All databases with the prefix 't_' will be deleted.
Are you sure you want to delete these resources? (y/n): y
Cleaning Mongo Account: ashleyst-e2e-mongo
Cleaning Gremlin Account: ashleyst-e2e-gremlin
Cleaning Table Account: ashleyst-e2e-tables
Cleaning Cassandra Account: ashleyst-e2e-cassandra
Cleaning Keyspace: t_db90_1722888413729
Cleaning Keyspace: t_db76_1722882571248
Cleaning Keyspace: t_db3a_1722882413947
Cleaning Keyspace: t_db4d_1722882342943
Cleaning Keyspace: t_db64_1722888944788
Cleaning Keyspace: t_db90_1722882507916
Cleaning Keyspace: t_dbf5_1722888997915
Cleaning Keyspace: t_db7e_1722882689913
Cleaning SQL Account: ashleyst-e2e-sql
Cleaning Database: t_db32_1722890547089
Cleaning Mongo Account: ashleyst-e2e-mongo32
```

View File

@@ -1,39 +1,50 @@
import { expect, test } from "@playwright/test";
import { DataExplorer, TestAccount, generateDatabaseNameWithTimestamp, generateUniqueName } from "../fx";
import { DataExplorer, TestAccount, generateUniqueName } from "../fx";
test("Cassandra keyspace and table CRUD", async ({ page }) => {
const keyspaceId = generateDatabaseNameWithTimestamp();
const tableId = generateUniqueName("table");
const keyspaceId = generateUniqueName("db");
const tableId = "testtable"; // A unique table name isn't needed because the keyspace is unique
const explorer = await DataExplorer.open(page, TestAccount.Cassandra);
await explorer.globalCommandButton("New Table").click();
await explorer.whilePanelOpen("Add Table", async (panel, okButton) => {
await panel.getByPlaceholder("Type a new keyspace id").fill(keyspaceId);
await panel.getByPlaceholder("Enter table Id").fill(tableId);
await panel.getByLabel("Table max RU/s").fill("1000");
await okButton.click();
});
await explorer.whilePanelOpen(
"Add Table",
async (panel, okButton) => {
await panel.getByPlaceholder("Type a new keyspace id").fill(keyspaceId);
await panel.getByPlaceholder("Enter table Id").fill(tableId);
await panel.getByLabel("Table max RU/s").fill("1000");
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
const keyspaceNode = explorer.treeNode(keyspaceId);
await keyspaceNode.expand();
const tableNode = explorer.treeNode(`${keyspaceId}/${tableId}`);
const keyspaceNode = await explorer.waitForNode(keyspaceId);
const tableNode = await explorer.waitForContainerNode(keyspaceId, tableId);
await tableNode.openContextMenu();
await tableNode.contextMenuItem("Delete Table").click();
await explorer.whilePanelOpen("Delete Table", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the table id" }).fill(tableId);
await okButton.click();
});
await explorer.whilePanelOpen(
"Delete Table",
async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the table id" }).fill(tableId);
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
await expect(tableNode.element).not.toBeAttached();
await keyspaceNode.openContextMenu();
await keyspaceNode.contextMenuItem("Delete Keyspace").click();
await explorer.whilePanelOpen("Delete Keyspace", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the Keyspace id" }).fill(keyspaceId);
await okButton.click();
});
await explorer.whilePanelOpen(
"Delete Keyspace",
async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the Keyspace id" }).fill(keyspaceId);
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
await expect(keyspaceNode.element).not.toBeAttached();
});

View File

@@ -2,13 +2,22 @@ import { AzureCliCredentials } from "@azure/ms-rest-nodeauth";
import { expect, Frame, Locator, Page } from "@playwright/test";
import crypto from "crypto";
export function generateUniqueName(baseName = "", length = 4): string {
return `${baseName}${crypto.randomBytes(length).toString("hex")}`;
const RETRY_COUNT = 3;
export interface TestNameOptions {
length?: number;
timestampped?: boolean;
prefixed?: boolean;
}
export function generateDatabaseNameWithTimestamp(baseName = "db", length = 1): string {
// We use '_' as the separator because it's supported across all the API types.
return `${baseName}${crypto.randomBytes(length).toString("hex")}_${Date.now()}`;
export function generateUniqueName(baseName, options?: TestNameOptions): string {
const length = options?.length ?? 1;
const timestamp = options?.timestampped === undefined ? true : options.timestampped;
const prefixed = options?.prefixed === undefined ? true : options.prefixed;
const prefix = prefixed ? "t_" : "";
const suffix = timestamp ? `_${Date.now()}` : "";
return `${prefix}${baseName}${crypto.randomBytes(length).toString("hex")}${suffix}`;
}
export async function getAzureCLICredentials(): Promise<AzureCliCredentials> {
@@ -97,25 +106,132 @@ class TreeNode {
}
async expand(): Promise<void> {
// Sometimes, the expand button doesn't load at all, because the node didn't have children when it was initially loaded.
// Still, clicking the node will trigger loading and expansion. So if the node isn't expanded, we click it.
// The "aria-expanded" attribute is applied to the TreeItem. But we have the TreeItemLayout selected because the TreeItem contains the child tree as well.
// So, we need to find the TreeItem that contains this TreeItemLayout.
const treeNodeContainer = this.frame.getByTestId(`TreeNodeContainer:${this.id}`);
const tree = this.frame.getByTestId(`Tree:${this.id}`);
if ((await treeNodeContainer.getAttribute("aria-expanded")) !== "true") {
// Click the node, to trigger loading and expansion
await this.element.click();
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
const expandNode = async () => {
if ((await treeNodeContainer.getAttribute("aria-expanded")) !== "true") {
// Click the node, to trigger loading and expansion
await this.element.click();
}
// Try three times to wait for the node to expand.
for (let i = 0; i < RETRY_COUNT; i++) {
try {
await tree.waitFor({ state: "visible" });
// The tree has expanded, let's get out of here
return true;
} catch {
// Just try again
if ((await treeNodeContainer.getAttribute("aria-expanded")) !== "true") {
// We might have collapsed the node, try expanding it again, then retry.
await this.element.click();
}
}
}
return false;
};
if (await expandNode()) {
return;
}
await expect(treeNodeContainer).toHaveAttribute("aria-expanded", "true");
// The tree never expanded. OR, it may have expanded in between when we found the "ExpandIcon" and when we clicked it (it's happened before)
// So, let's try one more time to expand it.
if (!(await expandNode())) {
// The tree never expanded. This is a problem.
throw new Error(`Node ${this.id} did not expand after clicking it.`);
}
// We did it. It took a lot of weird messing around, but we expanded a tree node... I hope.
}
}
export class Editor {
constructor(
public frame: Frame,
public locator: Locator,
) {}
text(): Promise<string | null> {
return this.locator.evaluate((e) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const win = e.ownerDocument.defaultView as any;
if (win._monaco_getEditorContentForElement) {
return win._monaco_getEditorContentForElement(e);
}
return null;
});
}
async setText(text: string): Promise<void> {
// We trust that Monaco can handle the keyboard, and it's _extremely_ flaky to try and enter text using browser commands.
// So we use a hook we installed in 'window' to set the content of the editor.
// NOTE: This function is serialized and sent to the browser for execution
// So you can't use any variables from the outer scope, but we can send a string (via the second argument to evaluate)
await this.locator.evaluate((e, content) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const win = e.ownerDocument.defaultView as any;
if (win._monaco_setEditorContentForElement) {
win._monaco_setEditorContentForElement(e, content);
}
}, text);
expect(await this.text()).toEqual(text);
}
}
export class QueryTab {
resultsPane: Locator;
resultsView: Locator;
executeCTA: Locator;
errorList: Locator;
queryStatsList: Locator;
resultsEditor: Editor;
resultsTab: Locator;
queryStatsTab: Locator;
constructor(
public frame: Frame,
public tabId: string,
public tab: Locator,
public locator: Locator,
) {
this.resultsPane = locator.getByTestId("QueryTab/ResultsPane");
this.resultsView = locator.getByTestId("QueryTab/ResultsPane/ResultsView");
this.executeCTA = locator.getByTestId("QueryTab/ResultsPane/ExecuteCTA");
this.errorList = locator.getByTestId("QueryTab/ResultsPane/ErrorList");
this.resultsEditor = new Editor(this.frame, this.resultsView.getByTestId("EditorReact/Host/Loaded"));
this.queryStatsList = locator.getByTestId("QueryTab/ResultsPane/ResultsView/QueryStatsList");
this.resultsTab = this.resultsView.getByTestId("QueryTab/ResultsPane/ResultsView/ResultsTab");
this.queryStatsTab = this.resultsView.getByTestId("QueryTab/ResultsPane/ResultsView/QueryStatsTab");
}
editor(): Editor {
const locator = this.locator.getByTestId("EditorReact/Host/Loaded");
return new Editor(this.frame, locator);
}
}
type PanelOpenOptions = {
closeTimeout?: number;
};
/** Helper class that provides locator methods for DataExplorer components, on top of a Frame */
export class DataExplorer {
constructor(public frame: Frame) {}
tab(tabId: string): Locator {
return this.frame.getByTestId(`Tab:${tabId}`);
}
queryTab(tabId: string): QueryTab {
const tab = this.tab(tabId);
const queryTab = tab.getByTestId("QueryTab");
return new QueryTab(this.frame, tabId, tab, queryTab);
}
/** Select the primary global command button.
*
* There's only a single "primary" button, but we still require you to pass the label to confirm you're selecting the right button.
@@ -134,18 +250,68 @@ export class DataExplorer {
return this.frame.getByTestId(`Panel:${title}`);
}
async waitForNode(treeNodeId: string): Promise<TreeNode> {
const node = this.treeNode(treeNodeId);
// Is the node already visible?
if (await node.element.isVisible()) {
return node;
}
// No, try refreshing the tree
const refreshButton = this.frame.getByTestId("Sidebar/RefreshButton");
await refreshButton.click();
// Try a few times to find the node
for (let i = 0; i < RETRY_COUNT; i++) {
try {
await node.element.waitFor();
return node;
} catch {
// Just try again
}
}
// We tried 3 times, but the node never appeared
throw new Error(`Node ${treeNodeId} not found and did not appear after refreshing.`);
}
async waitForContainerNode(databaseId: string, containerId: string): Promise<TreeNode> {
const databaseNode = await this.waitForNode(databaseId);
// The container node may be auto-expanded. Wait 5s for that to happen
try {
const containerNode = this.treeNode(`${databaseId}/${containerId}`);
await containerNode.element.waitFor({ state: "visible", timeout: 5 * 1000 });
return containerNode;
} catch {
// It didn't auto-expand, that's fine, we'll expand it ourselves
}
// Ok, expand the database node.
await databaseNode.expand();
return await this.waitForNode(`${databaseId}/${containerId}`);
}
/** Select the tree node with the specified id */
treeNode(id: string): TreeNode {
return new TreeNode(this.frame.getByTestId(`TreeNode:${id}`), this.frame, id);
}
/** Waits for the panel with the specified title to be open, then runs the provided callback. After the callback completes, waits for the panel to close. */
async whilePanelOpen(title: string, action: (panel: Locator, okButton: Locator) => Promise<void>): Promise<void> {
async whilePanelOpen(
title: string,
action: (panel: Locator, okButton: Locator) => Promise<void>,
options?: PanelOpenOptions,
): Promise<void> {
options ||= {};
const panel = this.panel(title);
await panel.waitFor();
const okButton = panel.getByTestId("Panel/OkButton");
await action(panel, okButton);
await panel.waitFor({ state: "detached" });
await panel.waitFor({ state: "detached", timeout: options.closeTimeout });
}
/** Waits for the Data Explorer app to load */

View File

@@ -1,41 +1,52 @@
import { expect, test } from "@playwright/test";
import { DataExplorer, TestAccount, generateDatabaseNameWithTimestamp, generateUniqueName } from "../fx";
import { DataExplorer, TestAccount, generateUniqueName } from "../fx";
test("Gremlin graph CRUD", async ({ page }) => {
const databaseId = generateDatabaseNameWithTimestamp();
const graphId = generateUniqueName("graph");
const databaseId = generateUniqueName("db");
const graphId = "testgraph"; // A unique graph name isn't needed because the database is unique
const explorer = await DataExplorer.open(page, TestAccount.Gremlin);
// Create new database and graph
await explorer.globalCommandButton("New Graph").click();
await explorer.whilePanelOpen("New Graph", async (panel, okButton) => {
await panel.getByPlaceholder("Type a new database id").fill(databaseId);
await panel.getByRole("textbox", { name: "Graph id, Example Graph1" }).fill(graphId);
await panel.getByRole("textbox", { name: "Partition key" }).fill("/pk");
await panel.getByLabel("Database max RU/s").fill("1000");
await okButton.click();
});
await explorer.whilePanelOpen(
"New Graph",
async (panel, okButton) => {
await panel.getByPlaceholder("Type a new database id").fill(databaseId);
await panel.getByRole("textbox", { name: "Graph id, Example Graph1" }).fill(graphId);
await panel.getByRole("textbox", { name: "Partition key" }).fill("/pk");
await panel.getByLabel("Database max RU/s").fill("1000");
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
const databaseNode = explorer.treeNode(databaseId);
await databaseNode.expand();
const graphNode = explorer.treeNode(`${databaseId}/${graphId}`);
const databaseNode = await explorer.waitForNode(databaseId);
const graphNode = await explorer.waitForContainerNode(databaseId, graphId);
await graphNode.openContextMenu();
await graphNode.contextMenuItem("Delete Graph").click();
await explorer.whilePanelOpen("Delete Graph", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the graph id" }).fill(graphId);
await okButton.click();
});
await explorer.whilePanelOpen(
"Delete Graph",
async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the graph id" }).fill(graphId);
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
await expect(graphNode.element).not.toBeAttached();
await databaseNode.openContextMenu();
await databaseNode.contextMenuItem("Delete Database").click();
await explorer.whilePanelOpen("Delete Database", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the Database id" }).fill(databaseId);
await okButton.click();
});
await explorer.whilePanelOpen(
"Delete Database",
async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the Database id" }).fill(databaseId);
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
await expect(databaseNode.element).not.toBeAttached();
});

View File

@@ -1,6 +1,6 @@
import { expect, test } from "@playwright/test";
import { DataExplorer, TestAccount, generateDatabaseNameWithTimestamp, generateUniqueName } from "../fx";
import { DataExplorer, TestAccount, generateUniqueName } from "../fx";
(
[
@@ -9,38 +9,49 @@ import { DataExplorer, TestAccount, generateDatabaseNameWithTimestamp, generateU
] as [string, TestAccount][]
).forEach(([apiVersionDescription, accountType]) => {
test(`Mongo CRUD using ${apiVersionDescription}`, async ({ page }) => {
const databaseId = generateDatabaseNameWithTimestamp();
const collectionId = generateUniqueName("collection");
const databaseId = generateUniqueName("db");
const collectionId = "testcollection"; // A unique collection name isn't needed because the database is unique
const explorer = await DataExplorer.open(page, accountType);
await explorer.globalCommandButton("New Collection").click();
await explorer.whilePanelOpen("New Collection", async (panel, okButton) => {
await panel.getByPlaceholder("Type a new database id").fill(databaseId);
await panel.getByRole("textbox", { name: "Collection id, Example Collection1" }).fill(collectionId);
await panel.getByRole("textbox", { name: "Shard key" }).fill("pk");
await panel.getByLabel("Database max RU/s").fill("1000");
await okButton.click();
});
await explorer.whilePanelOpen(
"New Collection",
async (panel, okButton) => {
await panel.getByPlaceholder("Type a new database id").fill(databaseId);
await panel.getByRole("textbox", { name: "Collection id, Example Collection1" }).fill(collectionId);
await panel.getByRole("textbox", { name: "Shard key" }).fill("pk");
await panel.getByLabel("Database max RU/s").fill("1000");
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
const databaseNode = explorer.treeNode(databaseId);
await databaseNode.expand();
const collectionNode = explorer.treeNode(`${databaseId}/${collectionId}`);
const databaseNode = await explorer.waitForNode(databaseId);
const collectionNode = await explorer.waitForContainerNode(databaseId, collectionId);
await collectionNode.openContextMenu();
await collectionNode.contextMenuItem("Delete Collection").click();
await explorer.whilePanelOpen("Delete Collection", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the collection id" }).fill(collectionId);
await okButton.click();
});
await explorer.whilePanelOpen(
"Delete Collection",
async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the collection id" }).fill(collectionId);
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
await expect(collectionNode.element).not.toBeAttached();
await databaseNode.openContextMenu();
await databaseNode.contextMenuItem("Delete Database").click();
await explorer.whilePanelOpen("Delete Database", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the Database id" }).fill(databaseId);
await okButton.click();
});
await explorer.whilePanelOpen(
"Delete Database",
async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the Database id" }).fill(databaseId);
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
await expect(databaseNode.element).not.toBeAttached();
});

View File

@@ -0,0 +1,107 @@
param(
[Parameter(Mandatory=$false)][string]$ResourceGroup,
[Parameter(Mandatory=$false)][string]$Subscription,
[Parameter(Mandatory=$false)][string]$ResourcePrefix,
[Parameter(Mandatory=$false)][string]$DatabasePrefix = "t_"
)
Import-Module "Az.Accounts" -Scope Local
Import-Module "Az.Resources" -Scope Local
if (-not $Subscription) {
# Show the user the currently-selected subscription and ask if that's what they want to use
$currentSubscription = Get-AzContext | Select-Object -ExpandProperty Subscription
Write-Host "The currently-selected subscription is $($currentSubscription.Name) ($($currentSubscription.Id))."
$useCurrentSubscription = Read-Host "Do you want to use this subscription? (y/n)"
if ($useCurrentSubscription -eq "n") {
throw "Either specify a subscription using '-Subscription' or select a subscription using 'Select-AzSubscription' before running this script."
}
$Subscription = $currentSubscription.Id
}
$AzSubscription = (Get-AzSubscription -SubscriptionId $Subscription -ErrorAction SilentlyContinue | Select-Object -First 1) ?? (Get-AzSubscription -SubscriptionName $Subscription -ErrorAction SilentlyContinue | Select-Object -First 1)
if (-not $AzSubscription) {
throw "The subscription '$Subscription' could not be found."
}
Set-AzContext $AzSubscription.Id | Out-Null
if (-not $ResourceGroup) {
# Check for the default resource group name
$DefaultResourceGroupName = $env:USERNAME + "-e2e-testing"
if (Get-AzResourceGroup -Name $DefaultResourceGroupName -ErrorAction SilentlyContinue) {
$ResourceGroup = $DefaultResourceGroupName
} else {
$ResourceGroup = Read-Host "Specify the name of the resource group to find the resources in."
}
}
$AzResourceGroup = Get-AzResourceGroup -Name $ResourceGroup -ErrorAction SilentlyContinue
if (-not $AzResourceGroup) {
throw "The resource group '$ResourceGroup' could not be found. You have to create the resource group manually before running this script."
}
if (-not $ResourcePrefix) {
$defaultResourcePrefix = $env:USERNAME + "-e2e-"
# Check for one of the default resources
$defaultResource = Get-AzResource -ResourceGroupName $AzResourceGroup.ResourceGroupName -ResourceName "$($defaultResourcePrefix)cassandra" -ResourceType "Microsoft.DocumentDB/databaseAccounts" -ErrorAction SilentlyContinue
if ($defaultResource) {
Write-Host "Found a resource with the default resource prefix ($defaultResourcePrefix). Configuring that prefix for E2E testing."
$ResourcePrefix = $defaultResourcePrefix
} else {
$ResourcePrefix = Read-Host "Specify the resource prefix used in the resource names."
}
}
Write-Host "Cleaning E2E Testing Resources"
Write-Host " Subscription: $($AzSubscription.Name) ($($AzSubscription.Id))"
Write-Host " Resource Group: $($AzResourceGroup.ResourceGroupName)"
Write-Host " Resource Prefix: $ResourcePrefix"
Write-Host
Write-Host "All databases with the prefix '$DatabasePrefix' will be deleted."
# Confirm the deletion
$confirm = Read-Host "Are you sure you want to delete these resources? (y/n)"
if ($confirm -ne "y") {
Write-Host "Aborting."
exit
}
Get-AzResource -ResourceGroupName $AzResourceGroup.ResourceGroupName -ResourceType "Microsoft.DocumentDB/databaseAccounts" -ErrorAction SilentlyContinue | ForEach-Object {
$account = Get-AzCosmosDBAccount -ResourceGroupName $AzResourceGroup.ResourceGroupName -Name $_.Name -ErrorAction SilentlyContinue
if (-not $account) {
return
}
if ($account.Kind -eq "MongoDB") {
Write-Host " Cleaning Mongo Account: $($_.Name)"
Get-AzCosmosDBMongoDBDatabase -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName | Where-Object { $_.Name -like "$DatabasePrefix*" } | ForEach-Object {
Write-Host " Cleaning Database: $($_.Name)"
Remove-AzCosmosDBMongoDBDatabase -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName -Name $_.Name
}
} elseif ($account.Capabilities | Where-Object { $_.Name -eq "EnableCassandra" }) {
Write-Host " Cleaning Cassandra Account: $($_.Name)"
Get-AzCosmosDBCassandraKeyspace -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName | Where-Object { $_.Name -like "$DatabasePrefix*" } | ForEach-Object {
Write-Host " Cleaning Keyspace: $($_.Name)"
Remove-AzCosmosDBCassandraKeyspace -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName -Name $_.Name
}
} elseif ($account.Capabilities | Where-Object { $_.Name -eq "EnableGremlin" }) {
Write-Host " Cleaning Gremlin Account: $($_.Name)"
Get-AzCosmosDBGremlinDatabase -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName | Where-Object { $_.Name -like "$DatabasePrefix*" } | ForEach-Object {
Write-Host " Cleaning Database: $($_.Name)"
Remove-AzCosmosDBGremlinDatabase -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName -Name $_.Name
}
} elseif ($account.Capabilities | Where-Object { $_.Name -eq "EnableTable" }) {
Write-Host " Cleaning Table Account: $($_.Name)"
Get-AzCosmosDBTable -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName | Where-Object { $_.Name -like "$DatabasePrefix*" } | ForEach-Object {
Write-Host " Cleaning Table: $($_.Name)"
Remove-AzCosmosDBTable -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName -Name $_.Name
}
} else {
Write-Host " Cleaning SQL Account: $($_.Name)"
Get-AzCosmosDBSqlDatabase -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName | Where-Object { $_.Name -like "$DatabasePrefix*" } | ForEach-Object {
Write-Host " Cleaning Database: $($_.Name)"
Remove-AzCosmosDBSqlDatabase -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName -Name $_.Name
}
}
}

View File

@@ -1,40 +1,51 @@
import { expect, test } from "@playwright/test";
import { DataExplorer, TestAccount, generateDatabaseNameWithTimestamp, generateUniqueName } from "../fx";
import { DataExplorer, TestAccount, generateUniqueName } from "../fx";
test("SQL database and container CRUD", async ({ page }) => {
const databaseId = generateDatabaseNameWithTimestamp();
const containerId = generateUniqueName("container");
const databaseId = generateUniqueName("db");
const containerId = "testcontainer"; // A unique container name isn't needed because the database is unique
const explorer = await DataExplorer.open(page, TestAccount.SQL);
await explorer.globalCommandButton("New Container").click();
await explorer.whilePanelOpen("New Container", async (panel, okButton) => {
await panel.getByPlaceholder("Type a new database id").fill(databaseId);
await panel.getByRole("textbox", { name: "Container id, Example Container1" }).fill(containerId);
await panel.getByRole("textbox", { name: "Partition key" }).fill("/pk");
await panel.getByLabel("Database max RU/s").fill("1000");
await okButton.click();
});
await explorer.whilePanelOpen(
"New Container",
async (panel, okButton) => {
await panel.getByPlaceholder("Type a new database id").fill(databaseId);
await panel.getByRole("textbox", { name: "Container id, Example Container1" }).fill(containerId);
await panel.getByRole("textbox", { name: "Partition key" }).fill("/pk");
await panel.getByLabel("Database max RU/s").fill("1000");
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
const databaseNode = explorer.treeNode(databaseId);
await databaseNode.expand();
const containerNode = explorer.treeNode(`${databaseId}/${containerId}`);
const databaseNode = await explorer.waitForNode(databaseId);
const containerNode = await explorer.waitForContainerNode(databaseId, containerId);
await containerNode.openContextMenu();
await containerNode.contextMenuItem("Delete Container").click();
await explorer.whilePanelOpen("Delete Container", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the container id" }).fill(containerId);
await okButton.click();
});
await explorer.whilePanelOpen(
"Delete Container",
async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the container id" }).fill(containerId);
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
await expect(containerNode.element).not.toBeAttached();
await databaseNode.openContextMenu();
await databaseNode.contextMenuItem("Delete Database").click();
await explorer.whilePanelOpen("Delete Database", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the database id" }).fill(databaseId);
await okButton.click();
});
await explorer.whilePanelOpen(
"Delete Database",
async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the database id" }).fill(databaseId);
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
await expect(databaseNode.element).not.toBeAttached();
});

93
test/sql/query.spec.ts Normal file
View File

@@ -0,0 +1,93 @@
import { expect, test } from "@playwright/test";
import { DataExplorer, Editor, QueryTab, TestAccount } from "../fx";
import { TestContainerContext, TestItem, createTestSQLContainer } from "../testData";
let context: TestContainerContext = null!;
let explorer: DataExplorer = null!;
let queryTab: QueryTab = null!;
let queryEditor: Editor = null!;
test.beforeAll("Create Test Database", async () => {
context = await createTestSQLContainer(true);
});
test.beforeEach("Open new query tab", async ({ page }) => {
// Open a query tab
explorer = await DataExplorer.open(page, TestAccount.SQL);
// Container nodes should be visible. The explorer auto-expands database nodes when they are first loaded.
const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id);
await containerNode.openContextMenu();
await containerNode.contextMenuItem("New SQL Query").click();
// Wait for the editor to load
queryTab = explorer.queryTab("tab0");
queryEditor = queryTab.editor();
await queryEditor.locator.waitFor({ timeout: 30 * 1000 });
await queryTab.executeCTA.waitFor();
await explorer.frame.getByTestId("NotificationConsole/ExpandCollapseButton").click();
await explorer.frame.getByTestId("NotificationConsole/Contents").waitFor();
});
test.afterAll("Delete Test Database", async () => {
await context?.dispose();
});
test("Query results", async () => {
// Run the query and verify the results
await queryEditor.locator.click();
const executeQueryButton = explorer.commandBarButton("Execute Query");
await executeQueryButton.click();
await expect(queryTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 });
// Read the results
const resultText = await queryTab.resultsEditor.text();
expect(resultText).not.toBeNull();
const resultData: TestItem[] = JSON.parse(resultText!);
// Pick 3 random documents and assert them
const randomDocs = [0, 1, 2].map(() => resultData[Math.floor(Math.random() * resultData.length)]);
randomDocs.forEach((doc) => {
const matchingDoc = context?.testData.get(doc.id);
expect(matchingDoc).not.toBeNull();
expect(doc.randomData).toEqual(matchingDoc?.randomData);
expect(doc.partitionKey).toEqual(matchingDoc?.partitionKey);
});
});
test("Query stats", async () => {
// Run the query and verify the results
await queryEditor.locator.click();
const executeQueryButton = explorer.commandBarButton("Execute Query");
await executeQueryButton.click();
await expect(queryTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 });
// Open the query stats tab and validate some data there
queryTab.queryStatsTab.click();
await expect(queryTab.queryStatsList).toBeAttached();
const showingResultsCell = queryTab.queryStatsList.getByTestId("Row:Showing Results/Column:value");
await expect(showingResultsCell).toContainText(/\d+ - \d+/);
});
test("Query errors", async () => {
test.skip(true, "Disabled due to an issue with error reporting in the backend.");
await queryEditor.locator.click();
await queryEditor.setText("SELECT\n glarb(c.id),\n blarg(c.id)\nFROM c");
// Run the query and verify the results
const executeQueryButton = explorer.commandBarButton("Execute Query");
await executeQueryButton.click();
await expect(queryTab.errorList).toBeAttached({ timeout: 60 * 1000 });
// Validating the squiggles requires a lot of digging through the Monaco model, OR a screenshot comparison.
// The screenshot ended up being fairly flaky, and a pain to maintain, so I decided not to include validation for the squiggles.
// Validate the errors are in the list
await expect(queryTab.errorList.getByTestId("Row:0/Column:code")).toHaveText("SC2005");
await expect(queryTab.errorList.getByTestId("Row:0/Column:location")).toHaveText("Line 2");
await expect(queryTab.errorList.getByTestId("Row:1/Column:code")).toHaveText("SC2005");
await expect(queryTab.errorList.getByTestId("Row:1/Column:location")).toHaveText("Line 3");
});

View File

@@ -19,7 +19,7 @@ test("SQL account using Resource token", async ({ page }) => {
const account = await armClient.databaseAccounts.get(resourceGroupName, accountName);
const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName);
const dbId = generateUniqueName("db");
const collectionId = generateUniqueName("col");
const collectionId = "testcollection";
const client = new CosmosClient({
endpoint: account.documentEndpoint!,
key: keys.primaryMasterKey,

View File

@@ -3,29 +3,33 @@ import { expect, test } from "@playwright/test";
import { DataExplorer, TestAccount, generateUniqueName } from "../fx";
test("Tables CRUD", async ({ page }) => {
const tableId = generateUniqueName("table");
const tableId = generateUniqueName("table"); // A unique table name IS needed because the database is shared when using Table Storage.
const explorer = await DataExplorer.open(page, TestAccount.Tables);
await explorer.globalCommandButton("New Table").click();
await explorer.whilePanelOpen("New Table", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Table id, Example Table1" }).fill(tableId);
await panel.getByLabel("Table Max RU/s").fill("1000");
await okButton.click();
});
await explorer.whilePanelOpen(
"New Table",
async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Table id, Example Table1" }).fill(tableId);
await panel.getByLabel("Table Max RU/s").fill("1000");
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
const databaseNode = explorer.treeNode("TablesDB");
await databaseNode.expand();
const tableNode = explorer.treeNode(`TablesDB/${tableId}`);
await expect(tableNode.element).toBeAttached();
const tableNode = await explorer.waitForContainerNode("TablesDB", tableId);
await tableNode.openContextMenu();
await tableNode.contextMenuItem("Delete Table").click();
await explorer.whilePanelOpen("Delete Table", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the table id" }).fill(tableId);
await okButton.click();
});
await explorer.whilePanelOpen(
"Delete Table",
async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the table id" }).fill(tableId);
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
await expect(tableNode.element).not.toBeAttached();
});

95
test/testData.ts Normal file
View File

@@ -0,0 +1,95 @@
import { CosmosDBManagementClient } from "@azure/arm-cosmosdb";
import { BulkOperationType, Container, CosmosClient, Database, JSONObject } from "@azure/cosmos";
import crypto from "crypto";
import {
TestAccount,
generateUniqueName,
getAccountName,
getAzureCLICredentials,
resourceGroupName,
subscriptionId,
} from "./fx";
export interface TestItem {
id: string;
partitionKey: string;
randomData: string;
}
const partitionCount = 4;
// If we increase this number, we need to split bulk creates into multiple batches.
// Bulk operations are limited to 100 items per partition.
const itemsPerPartition = 100;
function createTestItems(): TestItem[] {
const items: TestItem[] = [];
for (let i = 0; i < partitionCount; i++) {
for (let j = 0; j < itemsPerPartition; j++) {
const id = crypto.randomBytes(32).toString("base64");
items.push({
id,
partitionKey: `partition_${i}`,
randomData: crypto.randomBytes(32).toString("base64"),
});
}
}
return items;
}
export const TestData: TestItem[] = createTestItems();
export class TestContainerContext {
constructor(
public armClient: CosmosDBManagementClient,
public client: CosmosClient,
public database: Database,
public container: Container,
public testData: Map<string, TestItem>,
) {}
async dispose() {
await this.database.delete();
}
}
export async function createTestSQLContainer(includeTestData?: boolean) {
const databaseId = generateUniqueName("db");
const containerId = "testcontainer"; // A unique container name isn't needed because the database is unique
const credentials = await getAzureCLICredentials();
const armClient = new CosmosDBManagementClient(credentials, subscriptionId);
const accountName = getAccountName(TestAccount.SQL);
const account = await armClient.databaseAccounts.get(resourceGroupName, accountName);
const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName);
const client = new CosmosClient({
endpoint: account.documentEndpoint!,
key: keys.primaryMasterKey,
});
const { database } = await client.databases.createIfNotExists({ id: databaseId });
try {
const { container } = await database.containers.createIfNotExists({
id: containerId,
partitionKey: "/partitionKey",
});
if (includeTestData) {
const batchCount = TestData.length / 100;
for (let i = 0; i < batchCount; i++) {
const batchItems = TestData.slice(i * 100, i * 100 + 100);
await container.items.bulk(
batchItems.map((item) => ({
operationType: BulkOperationType.Create,
resourceBody: item as unknown as JSONObject,
})),
);
}
}
const testDataMap = new Map<string, TestItem>();
TestData.forEach((item) => testDataMap.set(item.id, item));
return new TestContainerContext(armClient, client, database, container, testDataMap);
} catch (e) {
await database.delete();
throw e;
}
}