mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-01-06 11:11:23 +00:00
Compare commits
22 Commits
users/aisa
...
ashleyst/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4facfba0c1 | ||
|
|
7e95f5d8c8 | ||
|
|
1be221e106 | ||
|
|
8e7a3db67e | ||
|
|
07c0ead523 | ||
|
|
4296b5ae02 | ||
|
|
e8a5658799 | ||
|
|
b4973e8367 | ||
|
|
4b207f3fa6 | ||
|
|
c5b7f599b3 | ||
|
|
6aeac542b1 | ||
|
|
0d22d4ab4d | ||
|
|
0658448b54 | ||
|
|
833d677d20 | ||
|
|
038142c180 | ||
|
|
94d3fcb30f | ||
|
|
d3722f2c99 | ||
|
|
5a5e155205 | ||
|
|
2226169a71 | ||
|
|
6f35fb5526 | ||
|
|
805a4ae168 | ||
|
|
cc89691da3 |
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
938
package-lock.json
generated
938
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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: [
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
@@ -676,19 +719,12 @@ export function useMongoProxyEndpoint(api: string): boolean {
|
||||
const activeMongoProxyEndpoints: string[] = [
|
||||
MongoProxyEndpoints.Local,
|
||||
MongoProxyEndpoints.Mpac,
|
||||
// MongoProxyEndpoints.Prod,
|
||||
// MongoProxyEndpoints.Fairfax,
|
||||
MongoProxyEndpoints.Prod,
|
||||
MongoProxyEndpoints.Fairfax,
|
||||
MongoProxyEndpoints.Mooncake,
|
||||
];
|
||||
let canAccessMongoProxy: boolean = userContext.databaseAccount.properties.publicNetworkAccess === "Enabled";
|
||||
if (
|
||||
configContext.MONGO_PROXY_ENDPOINT !== MongoProxyEndpoints.Local &&
|
||||
userContext.databaseAccount.properties.ipRules?.length > 0
|
||||
) {
|
||||
canAccessMongoProxy = canAccessMongoProxy && configContext.MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED;
|
||||
}
|
||||
|
||||
return (
|
||||
canAccessMongoProxy &&
|
||||
configContext.NEW_MONGO_APIS?.includes(api) &&
|
||||
activeMongoProxyEndpoints.includes(configContext.MONGO_PROXY_ENDPOINT)
|
||||
);
|
||||
|
||||
212
src/Common/QueryError.ts
Normal file
212
src/Common/QueryError.ts
Normal 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),
|
||||
};
|
||||
@@ -3,11 +3,12 @@ import * as React from "react";
|
||||
|
||||
export interface TooltipProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const InfoTooltip: React.FunctionComponent<TooltipProps> = ({ children }: TooltipProps) => {
|
||||
export const InfoTooltip: React.FunctionComponent<TooltipProps> = ({ children, className }: TooltipProps) => {
|
||||
return (
|
||||
<span>
|
||||
<span className={className}>
|
||||
<TooltipHost content={children}>
|
||||
<Icon iconName="Info" ariaLabel={children} className="panelInfoIcon" tabIndex={0} />
|
||||
</TooltipHost>
|
||||
|
||||
@@ -53,10 +53,8 @@ export interface ConfigContext {
|
||||
NEW_BACKEND_APIS?: BackendApi[];
|
||||
MONGO_BACKEND_ENDPOINT?: string;
|
||||
MONGO_PROXY_ENDPOINT?: string;
|
||||
MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED?: boolean;
|
||||
NEW_MONGO_APIS?: string[];
|
||||
CASSANDRA_PROXY_ENDPOINT?: string;
|
||||
CASSANDRA_PROXY_OUTBOUND_IPS_ALLOWLISTED: boolean;
|
||||
NEW_CASSANDRA_APIS?: string[];
|
||||
PROXY_PATH?: string;
|
||||
JUNO_ENDPOINT: string;
|
||||
@@ -87,7 +85,7 @@ let configContext: Readonly<ConfigContext> = {
|
||||
`^https:\\/\\/.*\\.analysis-df\\.net$`,
|
||||
`^https:\\/\\/.*\\.analysis-df\\.windows\\.net$`,
|
||||
`^https:\\/\\/.*\\.azure-test\\.net$`,
|
||||
`^https:\\/\\/cosmos-explorer-preview\\.azurewebsites\\.net`,
|
||||
`^https:\\/\\/cosmos-explorer-preview\\.azurewebsites\\.net$`,
|
||||
], // Webpack injects this at build time
|
||||
gitSha: process.env.GIT_SHA,
|
||||
hostedExplorerURL: "https://cosmos.azure.com/",
|
||||
@@ -117,11 +115,10 @@ let configContext: Readonly<ConfigContext> = {
|
||||
"deleteDocument",
|
||||
"createCollectionWithProxy",
|
||||
"legacyMongoShell",
|
||||
"bulkdelete",
|
||||
],
|
||||
MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED: false,
|
||||
CASSANDRA_PROXY_ENDPOINT: CassandraProxyEndpoints.Prod,
|
||||
NEW_CASSANDRA_APIS: ["postQuery", "createOrDelete", "getKeys", "getSchema"],
|
||||
CASSANDRA_PROXY_OUTBOUND_IPS_ALLOWLISTED: false,
|
||||
isTerminalEnabled: false,
|
||||
isPhoenixEnabled: false,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
37
src/Explorer/Controls/IndeterminateProgressBar.tsx
Normal file
37
src/Explorer/Controls/IndeterminateProgressBar.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
68
src/Explorer/Controls/MessageBanner.tsx
Normal file
68
src/Explorer/Controls/MessageBanner.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -167,15 +167,11 @@ export function createContextCommandBarButtons(
|
||||
}
|
||||
|
||||
export function createControlCommandBarButtons(container: Explorer): CommandButtonComponentProps[] {
|
||||
const buttons: CommandButtonComponentProps[] =
|
||||
configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly
|
||||
? []
|
||||
: [
|
||||
const buttons: CommandButtonComponentProps[] = [
|
||||
{
|
||||
iconSrc: SettingsIcon,
|
||||
iconAlt: "Settings",
|
||||
onCommandClick: () =>
|
||||
useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={container} />),
|
||||
onCommandClick: () => useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={container} />),
|
||||
commandButtonLabel: undefined,
|
||||
ariaLabel: "Settings",
|
||||
tooltipText: "Settings",
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import {
|
||||
Checkbox,
|
||||
ChoiceGroup,
|
||||
DefaultButton,
|
||||
IChoiceGroupOption,
|
||||
ISpinButtonStyles,
|
||||
IToggleStyles,
|
||||
Icon,
|
||||
MessageBar,
|
||||
MessageBarType,
|
||||
Position,
|
||||
SpinButton,
|
||||
Toggle,
|
||||
TooltipHost,
|
||||
} from "@fluentui/react";
|
||||
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, makeStyles } from "@fluentui/react-components";
|
||||
import { AuthType } from "AuthType";
|
||||
import * as Constants from "Common/Constants";
|
||||
import { SplitterDirection } from "Common/Splitter";
|
||||
import { InfoTooltip } from "Common/Tooltip/InfoTooltip";
|
||||
import { Platform, configContext } from "ConfigContext";
|
||||
import { useDialog } from "Explorer/Controls/Dialog";
|
||||
import { useDatabases } from "Explorer/useDatabases";
|
||||
import { deleteAllStates } from "Shared/AppStatePersistenceUtility";
|
||||
import {
|
||||
DefaultRUThreshold,
|
||||
LocalStorageUtility,
|
||||
@@ -29,14 +32,13 @@ import * as StringUtility from "Shared/StringUtility";
|
||||
import { updateUserContext, userContext } from "UserContext";
|
||||
import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils";
|
||||
import * as PriorityBasedExecutionUtils from "Utils/PriorityBasedExecutionUtils";
|
||||
import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
|
||||
import { useQueryCopilot } from "hooks/useQueryCopilot";
|
||||
import { useSidePanel } from "hooks/useSidePanel";
|
||||
import React, { FunctionComponent, useState } from "react";
|
||||
import create, { UseStore } from "zustand";
|
||||
import Explorer from "../../Explorer";
|
||||
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
|
||||
import { AuthType } from "AuthType";
|
||||
import create, { UseStore } from "zustand";
|
||||
import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
|
||||
|
||||
export interface DataPlaneRbacState {
|
||||
dataPlaneRbacEnabled: boolean;
|
||||
@@ -50,6 +52,39 @@ export interface DataPlaneRbacState {
|
||||
|
||||
type DataPlaneRbacStore = UseStore<Partial<DataPlaneRbacState>>;
|
||||
|
||||
const useStyles = makeStyles({
|
||||
bulletList: {
|
||||
listStyleType: "disc",
|
||||
paddingLeft: "20px",
|
||||
},
|
||||
container: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
},
|
||||
firstItem: {
|
||||
flex: "1",
|
||||
},
|
||||
header: {
|
||||
marginRight: "5px",
|
||||
},
|
||||
headerIcon: {
|
||||
paddingTop: "4px",
|
||||
cursor: "pointer",
|
||||
},
|
||||
settingsSectionContainer: {
|
||||
paddingLeft: "15px",
|
||||
},
|
||||
settingsSectionDescription: {
|
||||
paddingBottom: "10px",
|
||||
fontSize: "12px",
|
||||
},
|
||||
subHeader: {
|
||||
marginRight: "5px",
|
||||
fontSize: "12px",
|
||||
},
|
||||
});
|
||||
|
||||
export const useDataPlaneRbac: DataPlaneRbacStore = create(() => ({
|
||||
dataPlaneRbacEnabled: false,
|
||||
}));
|
||||
@@ -133,6 +168,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,6 +191,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
|
||||
LocalStorageUtility.setEntryNumber(StorageKey.CustomItemPerPage, customItemPerPage);
|
||||
|
||||
if (configContext.platform !== Platform.Fabric) {
|
||||
LocalStorageUtility.setEntryString(StorageKey.DataPlaneRbacEnabled, enableDataPlaneRBACOption);
|
||||
if (
|
||||
enableDataPlaneRBACOption === Constants.RBACOptions.setTrueRBACOption ||
|
||||
@@ -192,6 +231,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LocalStorageUtility.setEntryBoolean(StorageKey.RUThresholdEnabled, ruThresholdEnabled);
|
||||
LocalStorageUtility.setEntryBoolean(StorageKey.QueryTimeoutEnabled, queryTimeoutEnabled);
|
||||
@@ -428,18 +468,19 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
|
||||
return (
|
||||
<RightPaneForm {...genericPaneProps}>
|
||||
<div className="paneMainContent">
|
||||
<div className={`paneMainContent ${styles.container}`}>
|
||||
<Accordion className={styles.firstItem}>
|
||||
{shouldShowQueryPageOptions && (
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart">
|
||||
<fieldset>
|
||||
<legend id="pageOptions" className="settingsSectionLabel legendLabel">
|
||||
Page Options
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
Choose Custom to specify a fixed amount of query results to show, or choose Unlimited to show as many
|
||||
query results per page.
|
||||
</InfoTooltip>
|
||||
<AccordionItem value="1">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Page Options</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Choose Custom to specify a fixed amount of query results to show, or choose Unlimited to show as
|
||||
many query results per page.
|
||||
</div>
|
||||
<ChoiceGroup
|
||||
ariaLabelledBy="pageOptions"
|
||||
selectedKey={pageOption}
|
||||
@@ -447,14 +488,15 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
styles={choiceButtonStyles}
|
||||
onChange={handleOnPageOptionChange}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className="tabs settingsSectionPart">
|
||||
<div className={`tabs ${styles.settingsSectionContainer}`}>
|
||||
{isCustomPageOptionSelected() && (
|
||||
<div className="tabcontent">
|
||||
<div className="settingsSectionLabel">
|
||||
Query results per page
|
||||
<InfoTooltip>Enter the number of query results that should be shown per page.</InfoTooltip>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Query results per page{" "}
|
||||
<InfoTooltip className={styles.headerIcon}>
|
||||
Enter the number of query results that should be shown per page.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
|
||||
<SpinButton
|
||||
@@ -474,34 +516,18 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
)}
|
||||
{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>
|
||||
{userContext.apiType === "SQL" &&
|
||||
userContext.authType === AuthType.AAD &&
|
||||
configContext.platform !== Platform.Fabric && (
|
||||
<AccordionItem value="2">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Enable Entra ID RBAC</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
{showDataPlaneRBACWarning && configContext.platform === Platform.Portal && (
|
||||
<MessageBar
|
||||
messageBarType={MessageBarType.warning}
|
||||
@@ -513,6 +539,18 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
operations
|
||||
</MessageBar>
|
||||
)}
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable Entra
|
||||
ID RBAC.
|
||||
<a
|
||||
href="https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac#use-data-explorer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{" "}
|
||||
Learn more{" "}
|
||||
</a>
|
||||
</div>
|
||||
<ChoiceGroup
|
||||
ariaLabelledBy="enableDataPlaneRBACOptions"
|
||||
options={dataPlaneRBACOptionsList}
|
||||
@@ -520,58 +558,23 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
selectedKey={enableDataPlaneRBACOption}
|
||||
onChange={handleOnDataPlaneRBACOptionChange}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
{userContext.apiType === "SQL" && (
|
||||
<>
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart">
|
||||
<div>
|
||||
<legend id="ruThresholdLabel" className="settingsSectionLabel legendLabel">
|
||||
RU Threshold
|
||||
</legend>
|
||||
<InfoTooltip>If a query exceeds a configured RU threshold, the query will be aborted.</InfoTooltip>
|
||||
</div>
|
||||
<div>
|
||||
<Toggle
|
||||
styles={toggleStyles}
|
||||
label="Enable RU threshold"
|
||||
onChange={handleOnRUThresholdToggleChange}
|
||||
defaultChecked={ruThresholdEnabled}
|
||||
/>
|
||||
</div>
|
||||
{ruThresholdEnabled && (
|
||||
<div>
|
||||
<SpinButton
|
||||
label="RU Threshold (RU)"
|
||||
labelPosition={Position.top}
|
||||
defaultValue={(ruThreshold || DefaultRUThreshold).toString()}
|
||||
min={1}
|
||||
step={1000}
|
||||
onChange={handleOnRUThresholdSpinButtonChange}
|
||||
incrementButtonAriaLabel="Increase value by 1000"
|
||||
decrementButtonAriaLabel="Decrease value by 1000"
|
||||
styles={spinButtonStyles}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart">
|
||||
<div>
|
||||
<legend id="queryTimeoutLabel" className="settingsSectionLabel legendLabel">
|
||||
Query Timeout
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
<AccordionItem value="3">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Query Timeout</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
When a query reaches a specified time limit, a popup with an option to cancel the query will show
|
||||
unless automatic cancellation has been enabled
|
||||
</InfoTooltip>
|
||||
unless automatic cancellation has been enabled.
|
||||
</div>
|
||||
<div>
|
||||
<Toggle
|
||||
styles={toggleStyles}
|
||||
label="Enable query timeout"
|
||||
@@ -580,7 +583,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
/>
|
||||
</div>
|
||||
{queryTimeoutEnabled && (
|
||||
<div>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<SpinButton
|
||||
label="Query timeout (ms)"
|
||||
labelPosition={Position.top}
|
||||
@@ -600,17 +603,52 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="4">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>RU Threshold</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
If a query exceeds a configured RU threshold, the query will be aborted.
|
||||
</div>
|
||||
<Toggle
|
||||
styles={toggleStyles}
|
||||
label="Enable RU threshold"
|
||||
onChange={handleOnRUThresholdToggleChange}
|
||||
defaultChecked={ruThresholdEnabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart">
|
||||
<div>
|
||||
<legend id="defaultQueryResultsView" className="settingsSectionLabel legendLabel">
|
||||
Default Query Results View
|
||||
</legend>
|
||||
<InfoTooltip>Select the default view to use when displaying query results.</InfoTooltip>
|
||||
{ruThresholdEnabled && (
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<SpinButton
|
||||
label="RU Threshold (RU)"
|
||||
labelPosition={Position.top}
|
||||
defaultValue={(ruThreshold || DefaultRUThreshold).toString()}
|
||||
min={1}
|
||||
step={1000}
|
||||
onChange={handleOnRUThresholdSpinButtonChange}
|
||||
incrementButtonAriaLabel="Increase value by 1000"
|
||||
decrementButtonAriaLabel="Decrease value by 1000"
|
||||
styles={spinButtonStyles}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="5">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Default Query Results View</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Select the default view to use when displaying query results.
|
||||
</div>
|
||||
<div>
|
||||
<ChoiceGroup
|
||||
ariaLabelledBy="defaultQueryResultsView"
|
||||
selectedKey={defaultQueryResultsView}
|
||||
@@ -619,21 +657,25 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
onChange={handleOnDefaultQueryResultsViewChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</>
|
||||
)}
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart">
|
||||
<div className="settingsSectionLabel">
|
||||
Retry Settings
|
||||
<InfoTooltip>Retry policy associated with throttled requests during CosmosDB queries.</InfoTooltip>
|
||||
|
||||
<AccordionItem value="6">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Retry Settings</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Retry policy associated with throttled requests during CosmosDB queries.
|
||||
</div>
|
||||
<div>
|
||||
<legend id="queryRetryAttemptsLabel" className="settingsSectionLabel legendLabel">
|
||||
Max retry attempts
|
||||
</legend>
|
||||
<InfoTooltip>Max number of retries to be performed for a request. Default value 9.</InfoTooltip>
|
||||
<span className={styles.subHeader}>Max retry attempts</span>
|
||||
<InfoTooltip className={styles.headerIcon}>
|
||||
Max number of retries to be performed for a request. Default value 9.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<SpinButton
|
||||
labelPosition={Position.top}
|
||||
@@ -649,12 +691,10 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
styles={spinButtonStyles}
|
||||
/>
|
||||
<div>
|
||||
<legend id="queryRetryAttemptsLabel" className="settingsSectionLabel legendLabel">
|
||||
Fixed retry interval (ms)
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
Fixed retry interval in milliseconds to wait between each retry ignoring the retryAfter returned as part
|
||||
of the response. Default value is 0 milliseconds.
|
||||
<span className={styles.subHeader}>Fixed retry interval (ms)</span>
|
||||
<InfoTooltip className={styles.headerIcon}>
|
||||
Fixed retry interval in milliseconds to wait between each retry ignoring the retryAfter returned as
|
||||
part of the response. Default value is 0 milliseconds.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<SpinButton
|
||||
@@ -671,10 +711,8 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
styles={spinButtonStyles}
|
||||
/>
|
||||
<div>
|
||||
<legend id="queryRetryAttemptsLabel" className="settingsSectionLabel legendLabel">
|
||||
Max wait time (s)
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
<span className={styles.subHeader}>Max wait time (s)</span>
|
||||
<InfoTooltip className={styles.headerIcon}>
|
||||
Max wait time in seconds to wait for a request while the retries are happening. Default value 30
|
||||
seconds.
|
||||
</InfoTooltip>
|
||||
@@ -693,14 +731,17 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
styles={spinButtonStyles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart settingsSectionInlineCheckbox">
|
||||
<div className="settingsSectionLabel">
|
||||
Enable container pagination
|
||||
<InfoTooltip>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="7">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Enable container pagination</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Load 50 containers at a time. Currently, containers are not pulled in alphanumeric order.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<Checkbox
|
||||
styles={{
|
||||
@@ -710,20 +751,23 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
ariaLabel="Enable container pagination"
|
||||
checked={containerPaginationEnabled}
|
||||
onChange={() => setContainerPaginationEnabled(!containerPaginationEnabled)}
|
||||
label="Enable container pagination"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{shouldShowCrossPartitionOption && (
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart settingsSectionInlineCheckbox">
|
||||
<div className="settingsSectionLabel">
|
||||
Enable cross-partition query
|
||||
<InfoTooltip>
|
||||
Send more than one request while executing a query. More than one request is necessary if the query is
|
||||
not scoped to single partition key value.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
{shouldShowCrossPartitionOption && (
|
||||
<AccordionItem value="8">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Enable cross-partition query</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Send more than one request while executing a query. More than one request is necessary if the query
|
||||
is not scoped to single partition key value.
|
||||
</div>
|
||||
<Checkbox
|
||||
styles={{
|
||||
label: { padding: 0 },
|
||||
@@ -732,22 +776,25 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
ariaLabel="Enable cross partition query"
|
||||
checked={crossPartitionQueryEnabled}
|
||||
onChange={() => setCrossPartitionQueryEnabled(!crossPartitionQueryEnabled)}
|
||||
label="Enable cross-partition query"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
{shouldShowParallelismOption && (
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart">
|
||||
<div className="settingsSectionLabel">
|
||||
Max degree of parallelism
|
||||
<InfoTooltip>
|
||||
<AccordionItem value="9">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Max degree of parallelism</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Gets or sets the number of concurrent operations run client side during parallel query execution. A
|
||||
positive property value limits the number of concurrent operations to the set value. If it is set to
|
||||
less than 0, the system automatically decides the number of concurrent operations to run.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
|
||||
<SpinButton
|
||||
min={-1}
|
||||
step={1}
|
||||
@@ -755,26 +802,33 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
role="textbox"
|
||||
id="max-degree"
|
||||
value={"" + maxDegreeOfParallelism}
|
||||
onIncrement={(newValue) => setMaxDegreeOfParallelism(parseInt(newValue) + 1 || maxDegreeOfParallelism)}
|
||||
onDecrement={(newValue) => setMaxDegreeOfParallelism(parseInt(newValue) - 1 || maxDegreeOfParallelism)}
|
||||
onIncrement={(newValue) =>
|
||||
setMaxDegreeOfParallelism(parseInt(newValue) + 1 || maxDegreeOfParallelism)
|
||||
}
|
||||
onDecrement={(newValue) =>
|
||||
setMaxDegreeOfParallelism(parseInt(newValue) - 1 || maxDegreeOfParallelism)
|
||||
}
|
||||
onValidate={(newValue) => setMaxDegreeOfParallelism(parseInt(newValue) || maxDegreeOfParallelism)}
|
||||
ariaLabel="Max degree of parallelism"
|
||||
label="Max degree of parallelism"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
{shouldShowPriorityLevelOption && (
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart">
|
||||
<fieldset>
|
||||
<legend id="priorityLevel" className="settingsSectionLabel legendLabel">
|
||||
Priority Level
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
<AccordionItem value="10">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Priority Level</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Sets the priority level for data-plane requests from Data Explorer when using Priority-Based
|
||||
Execution. If "None" is selected, Data Explorer will not specify priority level, and the
|
||||
server-side default priority level will be used.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<ChoiceGroup
|
||||
ariaLabelledBy="priorityLevel"
|
||||
selectedKey={priorityLevel}
|
||||
@@ -782,21 +836,22 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
styles={choiceButtonStyles}
|
||||
onChange={handleOnPriorityLevelOptionChange}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
)}
|
||||
{shouldShowGraphAutoVizOption && (
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart">
|
||||
<div className="settingsSectionLabel">
|
||||
Display Gremlin query results as:
|
||||
<InfoTooltip>
|
||||
Select Graph to automatically visualize the query results as a Graph or JSON to display the results as
|
||||
JSON.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
|
||||
{shouldShowGraphAutoVizOption && (
|
||||
<AccordionItem value="11">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Display Gremlin query results as: </div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
Select Graph to automatically visualize the query results as a Graph or JSON to display the results
|
||||
as JSON.
|
||||
</div>
|
||||
<ChoiceGroup
|
||||
selectedKey={graphAutoVizDisabled}
|
||||
options={graphAutoOptionList}
|
||||
@@ -804,20 +859,22 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
aria-label="Graph Auto-visualization"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
)}
|
||||
{shouldShowCopilotSampleDBOption && (
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart settingsSectionInlineCheckbox">
|
||||
<div className="settingsSectionLabel">
|
||||
Enable sample database
|
||||
<InfoTooltip>
|
||||
This is a sample database and collection with synthetic product data you can use to explore using
|
||||
NoSQL queries and Query Advisor. This will appear as another database in the Data Explorer UI, and is
|
||||
created by, and maintained by Microsoft at no cost to you.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
|
||||
{shouldShowCopilotSampleDBOption && (
|
||||
<AccordionItem value="12">
|
||||
<AccordionHeader>
|
||||
<div className={styles.header}>Enable sample database</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.settingsSectionContainer}>
|
||||
<div className={styles.settingsSectionDescription}>
|
||||
This is a sample database and collection with synthetic product data you can use to explore using
|
||||
NoSQL queries and Query Advisor. This will appear as another database in the Data Explorer UI, and
|
||||
is created by, and maintained by Microsoft at no cost to you.
|
||||
</div>
|
||||
<Checkbox
|
||||
styles={{
|
||||
label: { padding: 0 },
|
||||
@@ -826,10 +883,42 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
ariaLabel="Enable sample db for Query Advisor"
|
||||
checked={copilotSampleDBEnabled}
|
||||
onChange={handleSampleDatabaseChange}
|
||||
label="Enable sample database"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
)}
|
||||
</Accordion>
|
||||
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart">
|
||||
<DefaultButton
|
||||
onClick={() => {
|
||||
useDialog.getState().showOkCancelModalDialog(
|
||||
"Clear History",
|
||||
undefined,
|
||||
"Are you sure you want to proceed?",
|
||||
() => deleteAllStates(),
|
||||
"Cancel",
|
||||
undefined,
|
||||
<>
|
||||
<span>
|
||||
This action will clear the all customizations for this account in this browser, including:
|
||||
</span>
|
||||
<ul className={styles.bulletList}>
|
||||
<li>Reset your customized tab layout, including the splitter positions</li>
|
||||
<li>Erase your table column preferences, including any custom columns</li>
|
||||
<li>Clear your filter history</li>
|
||||
</ul>
|
||||
</>,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Clear History
|
||||
</DefaultButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart">
|
||||
<div className="settingsSectionLabel">Explorer Version</div>
|
||||
|
||||
@@ -8,24 +8,30 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
submitButtonText="Apply"
|
||||
>
|
||||
<div
|
||||
className="paneMainContent"
|
||||
className="paneMainContent ___133e6fg_0000000 f22iagw f1vx9l62 f1l02sjl"
|
||||
>
|
||||
<Accordion
|
||||
className="___1uf6361_0000000 fz7g6wx"
|
||||
>
|
||||
<AccordionItem
|
||||
value="1"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
className="settingsSection"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionPart"
|
||||
>
|
||||
<fieldset>
|
||||
<legend
|
||||
className="settingsSectionLabel legendLabel"
|
||||
id="pageOptions"
|
||||
className="___15c001r_0000000 fq02s40"
|
||||
>
|
||||
Page Options
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div
|
||||
className="___1dfa554_0000000 fo7qwa0"
|
||||
>
|
||||
<div
|
||||
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
|
||||
>
|
||||
Choose Custom to specify a fixed amount of query results to show, or choose Unlimited to show as many query results per page.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<StyledChoiceGroup
|
||||
ariaLabelledBy="pageOptions"
|
||||
onChange={[Function]}
|
||||
@@ -66,19 +72,21 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
}
|
||||
}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div
|
||||
className="tabs settingsSectionPart"
|
||||
className="tabs ___1dfa554_0000000 fo7qwa0"
|
||||
>
|
||||
<div
|
||||
className="tabcontent"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionLabel"
|
||||
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
|
||||
>
|
||||
Query results per page
|
||||
<InfoTooltip>
|
||||
|
||||
<InfoTooltip
|
||||
className="___vtc5hy0_0000000 f10ra9hq f1k6fduh"
|
||||
>
|
||||
Enter the number of query results that should be shown per page.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
@@ -96,25 +104,68 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="3"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
className="___15c001r_0000000 fq02s40"
|
||||
>
|
||||
Query Timeout
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div
|
||||
className="settingsSection"
|
||||
className="___1dfa554_0000000 fo7qwa0"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionPart"
|
||||
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
|
||||
>
|
||||
<div>
|
||||
<legend
|
||||
className="settingsSectionLabel legendLabel"
|
||||
id="ruThresholdLabel"
|
||||
When a query reaches a specified time limit, a popup with an option to cancel the query will show unless automatic cancellation has been enabled.
|
||||
</div>
|
||||
<StyledToggleBase
|
||||
defaultChecked={false}
|
||||
label="Enable query timeout"
|
||||
onChange={[Function]}
|
||||
styles={
|
||||
{
|
||||
"container": {},
|
||||
"label": {
|
||||
"display": "block",
|
||||
"fontSize": 12,
|
||||
"fontWeight": 400,
|
||||
},
|
||||
"pill": {},
|
||||
"root": {},
|
||||
"text": {},
|
||||
"thumb": {},
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="4"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
className="___15c001r_0000000 fq02s40"
|
||||
>
|
||||
RU Threshold
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
If a query exceeds a configured RU threshold, the query will be aborted.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div
|
||||
className="___1dfa554_0000000 fo7qwa0"
|
||||
>
|
||||
<div
|
||||
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
|
||||
>
|
||||
If a query exceeds a configured RU threshold, the query will be aborted.
|
||||
</div>
|
||||
<StyledToggleBase
|
||||
defaultChecked={true}
|
||||
label="Enable RU threshold"
|
||||
@@ -135,7 +186,9 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className="___1dfa554_0000000 fo7qwa0"
|
||||
>
|
||||
<StyledSpinButton
|
||||
decrementButtonAriaLabel="Decrease value by 1000"
|
||||
defaultValue="5000"
|
||||
@@ -163,66 +216,27 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="5"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
className="settingsSection"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionPart"
|
||||
>
|
||||
<div>
|
||||
<legend
|
||||
className="settingsSectionLabel legendLabel"
|
||||
id="queryTimeoutLabel"
|
||||
>
|
||||
Query Timeout
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
When a query reaches a specified time limit, a popup with an option to cancel the query will show unless automatic cancellation has been enabled
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<div>
|
||||
<StyledToggleBase
|
||||
defaultChecked={false}
|
||||
label="Enable query timeout"
|
||||
onChange={[Function]}
|
||||
styles={
|
||||
{
|
||||
"container": {},
|
||||
"label": {
|
||||
"display": "block",
|
||||
"fontSize": 12,
|
||||
"fontWeight": 400,
|
||||
},
|
||||
"pill": {},
|
||||
"root": {},
|
||||
"text": {},
|
||||
"thumb": {},
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="settingsSection"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionPart"
|
||||
>
|
||||
<div>
|
||||
<legend
|
||||
className="settingsSectionLabel legendLabel"
|
||||
id="defaultQueryResultsView"
|
||||
className="___15c001r_0000000 fq02s40"
|
||||
>
|
||||
Default Query Results View
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
Select the default view to use when displaying query results.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div
|
||||
className="___1dfa554_0000000 fo7qwa0"
|
||||
>
|
||||
<div
|
||||
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
|
||||
>
|
||||
Select the default view to use when displaying query results.
|
||||
</div>
|
||||
<StyledChoiceGroup
|
||||
ariaLabelledBy="defaultQueryResultsView"
|
||||
onChange={[Function]}
|
||||
@@ -238,7 +252,7 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
},
|
||||
]
|
||||
}
|
||||
selectedKey="vertical"
|
||||
selectedKey="horizontal"
|
||||
styles={
|
||||
{
|
||||
"flexContainer": [
|
||||
@@ -264,30 +278,36 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="settingsSection"
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="6"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
className="settingsSectionPart"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionLabel"
|
||||
className="___15c001r_0000000 fq02s40"
|
||||
>
|
||||
Retry Settings
|
||||
<InfoTooltip>
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div
|
||||
className="___1dfa554_0000000 fo7qwa0"
|
||||
>
|
||||
<div
|
||||
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
|
||||
>
|
||||
Retry policy associated with throttled requests during CosmosDB queries.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<div>
|
||||
<legend
|
||||
className="settingsSectionLabel legendLabel"
|
||||
id="queryRetryAttemptsLabel"
|
||||
<span
|
||||
className="___zls1px0_0000000 fq02s40 f1ugzwwg"
|
||||
>
|
||||
Max retry attempts
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
</span>
|
||||
<InfoTooltip
|
||||
className="___vtc5hy0_0000000 f10ra9hq f1k6fduh"
|
||||
>
|
||||
Max number of retries to be performed for a request. Default value 9.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
@@ -320,13 +340,14 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
value="9"
|
||||
/>
|
||||
<div>
|
||||
<legend
|
||||
className="settingsSectionLabel legendLabel"
|
||||
id="queryRetryAttemptsLabel"
|
||||
<span
|
||||
className="___zls1px0_0000000 fq02s40 f1ugzwwg"
|
||||
>
|
||||
Fixed retry interval (ms)
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
</span>
|
||||
<InfoTooltip
|
||||
className="___vtc5hy0_0000000 f10ra9hq f1k6fduh"
|
||||
>
|
||||
Fixed retry interval in milliseconds to wait between each retry ignoring the retryAfter returned as part of the response. Default value is 0 milliseconds.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
@@ -359,13 +380,14 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
value="0"
|
||||
/>
|
||||
<div>
|
||||
<legend
|
||||
className="settingsSectionLabel legendLabel"
|
||||
id="queryRetryAttemptsLabel"
|
||||
<span
|
||||
className="___zls1px0_0000000 fq02s40 f1ugzwwg"
|
||||
>
|
||||
Max wait time (s)
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
</span>
|
||||
<InfoTooltip
|
||||
className="___vtc5hy0_0000000 f10ra9hq f1k6fduh"
|
||||
>
|
||||
Max wait time in seconds to wait for a request while the retries are happening. Default value 30 seconds.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
@@ -398,25 +420,32 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
value="30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="settingsSection"
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="7"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
className="settingsSectionPart settingsSectionInlineCheckbox"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionLabel"
|
||||
className="___15c001r_0000000 fq02s40"
|
||||
>
|
||||
Enable container pagination
|
||||
<InfoTooltip>
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div
|
||||
className="___1dfa554_0000000 fo7qwa0"
|
||||
>
|
||||
<div
|
||||
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
|
||||
>
|
||||
Load 50 containers at a time. Currently, containers are not pulled in alphanumeric order.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<StyledCheckboxBase
|
||||
ariaLabel="Enable container pagination"
|
||||
checked={false}
|
||||
className="padding"
|
||||
label="Enable container pagination"
|
||||
onChange={[Function]}
|
||||
styles={
|
||||
{
|
||||
@@ -427,25 +456,32 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="settingsSection"
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="8"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
className="settingsSectionPart settingsSectionInlineCheckbox"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionLabel"
|
||||
className="___15c001r_0000000 fq02s40"
|
||||
>
|
||||
Enable cross-partition query
|
||||
<InfoTooltip>
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div
|
||||
className="___1dfa554_0000000 fo7qwa0"
|
||||
>
|
||||
<div
|
||||
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
|
||||
>
|
||||
Send more than one request while executing a query. More than one request is necessary if the query is not scoped to single partition key value.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<StyledCheckboxBase
|
||||
ariaLabel="Enable cross partition query"
|
||||
checked={false}
|
||||
className="padding"
|
||||
label="Enable cross-partition query"
|
||||
onChange={[Function]}
|
||||
styles={
|
||||
{
|
||||
@@ -456,25 +492,32 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="settingsSection"
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="9"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
className="settingsSectionPart"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionLabel"
|
||||
className="___15c001r_0000000 fq02s40"
|
||||
>
|
||||
Max degree of parallelism
|
||||
<InfoTooltip>
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div
|
||||
className="___1dfa554_0000000 fo7qwa0"
|
||||
>
|
||||
<div
|
||||
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
|
||||
>
|
||||
Gets or sets the number of concurrent operations run client side during parallel query execution. A positive property value limits the number of concurrent operations to the set value. If it is set to less than 0, the system automatically decides the number of concurrent operations to run.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<StyledSpinButton
|
||||
ariaLabel="Max degree of parallelism"
|
||||
className="textfontclr"
|
||||
id="max-degree"
|
||||
label="Max degree of parallelism"
|
||||
min={-1}
|
||||
onDecrement={[Function]}
|
||||
onIncrement={[Function]}
|
||||
@@ -484,6 +527,21 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
value="6"
|
||||
/>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<div
|
||||
className="settingsSection"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionPart"
|
||||
>
|
||||
<CustomizedDefaultButton
|
||||
onClick={[Function]}
|
||||
>
|
||||
Clear History
|
||||
</CustomizedDefaultButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="settingsSection"
|
||||
@@ -511,30 +569,39 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
|
||||
submitButtonText="Apply"
|
||||
>
|
||||
<div
|
||||
className="paneMainContent"
|
||||
className="paneMainContent ___133e6fg_0000000 f22iagw f1vx9l62 f1l02sjl"
|
||||
>
|
||||
<div
|
||||
className="settingsSection"
|
||||
<Accordion
|
||||
className="___1uf6361_0000000 fz7g6wx"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionPart"
|
||||
<AccordionItem
|
||||
value="6"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
className="settingsSectionLabel"
|
||||
className="___15c001r_0000000 fq02s40"
|
||||
>
|
||||
Retry Settings
|
||||
<InfoTooltip>
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div
|
||||
className="___1dfa554_0000000 fo7qwa0"
|
||||
>
|
||||
<div
|
||||
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
|
||||
>
|
||||
Retry policy associated with throttled requests during CosmosDB queries.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<div>
|
||||
<legend
|
||||
className="settingsSectionLabel legendLabel"
|
||||
id="queryRetryAttemptsLabel"
|
||||
<span
|
||||
className="___zls1px0_0000000 fq02s40 f1ugzwwg"
|
||||
>
|
||||
Max retry attempts
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
</span>
|
||||
<InfoTooltip
|
||||
className="___vtc5hy0_0000000 f10ra9hq f1k6fduh"
|
||||
>
|
||||
Max number of retries to be performed for a request. Default value 9.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
@@ -567,13 +634,14 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
|
||||
value="9"
|
||||
/>
|
||||
<div>
|
||||
<legend
|
||||
className="settingsSectionLabel legendLabel"
|
||||
id="queryRetryAttemptsLabel"
|
||||
<span
|
||||
className="___zls1px0_0000000 fq02s40 f1ugzwwg"
|
||||
>
|
||||
Fixed retry interval (ms)
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
</span>
|
||||
<InfoTooltip
|
||||
className="___vtc5hy0_0000000 f10ra9hq f1k6fduh"
|
||||
>
|
||||
Fixed retry interval in milliseconds to wait between each retry ignoring the retryAfter returned as part of the response. Default value is 0 milliseconds.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
@@ -606,13 +674,14 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
|
||||
value="0"
|
||||
/>
|
||||
<div>
|
||||
<legend
|
||||
className="settingsSectionLabel legendLabel"
|
||||
id="queryRetryAttemptsLabel"
|
||||
<span
|
||||
className="___zls1px0_0000000 fq02s40 f1ugzwwg"
|
||||
>
|
||||
Max wait time (s)
|
||||
</legend>
|
||||
<InfoTooltip>
|
||||
</span>
|
||||
<InfoTooltip
|
||||
className="___vtc5hy0_0000000 f10ra9hq f1k6fduh"
|
||||
>
|
||||
Max wait time in seconds to wait for a request while the retries are happening. Default value 30 seconds.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
@@ -645,25 +714,32 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
|
||||
value="30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="settingsSection"
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="7"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
className="settingsSectionPart settingsSectionInlineCheckbox"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionLabel"
|
||||
className="___15c001r_0000000 fq02s40"
|
||||
>
|
||||
Enable container pagination
|
||||
<InfoTooltip>
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div
|
||||
className="___1dfa554_0000000 fo7qwa0"
|
||||
>
|
||||
<div
|
||||
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
|
||||
>
|
||||
Load 50 containers at a time. Currently, containers are not pulled in alphanumeric order.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<StyledCheckboxBase
|
||||
ariaLabel="Enable container pagination"
|
||||
checked={false}
|
||||
className="padding"
|
||||
label="Enable container pagination"
|
||||
onChange={[Function]}
|
||||
styles={
|
||||
{
|
||||
@@ -674,20 +750,26 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="settingsSection"
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
value="11"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<div
|
||||
className="settingsSectionPart"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionLabel"
|
||||
className="___15c001r_0000000 fq02s40"
|
||||
>
|
||||
Display Gremlin query results as:
|
||||
<InfoTooltip>
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div
|
||||
className="___1dfa554_0000000 fo7qwa0"
|
||||
>
|
||||
<div
|
||||
className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
|
||||
>
|
||||
Select Graph to automatically visualize the query results as a Graph or JSON to display the results as JSON.
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
<StyledChoiceGroup
|
||||
aria-label="Graph Auto-visualization"
|
||||
@@ -707,6 +789,21 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
|
||||
selectedKey="false"
|
||||
/>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<div
|
||||
className="settingsSection"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionPart"
|
||||
>
|
||||
<CustomizedDefaultButton
|
||||
onClick={[Function]}
|
||||
>
|
||||
Clear History
|
||||
</CustomizedDefaultButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="settingsSection"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 we’ll 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 && (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuButtonProps,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
@@ -25,7 +26,7 @@ import { getCollectionName, getDatabaseName } from "Utils/APITypeUtils";
|
||||
import { Allotment, AllotmentHandle } from "allotment";
|
||||
import { useSidePanel } from "hooks/useSidePanel";
|
||||
import { debounce } from "lodash";
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
const useSidebarStyles = makeStyles({
|
||||
sidebar: {
|
||||
@@ -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 {
|
||||
@@ -99,6 +113,12 @@ interface GlobalCommand {
|
||||
|
||||
const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
|
||||
const styles = useSidebarStyles();
|
||||
|
||||
// Since we have two buttons in the DOM (one for small screens and one for larger screens), we wrap the entire thing in a div.
|
||||
// However, that messes with the Menu positioning, so we need to get a reference to the 'div' to pass to the Menu.
|
||||
// We can't use a ref though, because it would be set after the Menu is rendered, so we use a state value to force a re-render.
|
||||
const [globalCommandButton, setGlobalCommandButton] = useState<HTMLElement | null>(null);
|
||||
|
||||
const actions = useMemo<GlobalCommand[]>(() => {
|
||||
if (
|
||||
configContext.platform === Platform.Fabric ||
|
||||
@@ -168,16 +188,22 @@ const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
|
||||
{primaryAction.label}
|
||||
</Button>
|
||||
) : (
|
||||
<Menu positioning="below-end">
|
||||
<Menu positioning={{ target: globalCommandButton, position: "below", align: "end" }}>
|
||||
<MenuTrigger disableButtonEnhancement>
|
||||
{(triggerProps: MenuButtonProps) => (
|
||||
<div ref={setGlobalCommandButton}>
|
||||
<SplitButton
|
||||
menuButton={{ ...triggerProps, "aria-label": "More commands" }}
|
||||
primaryActionButton={{ onClick: onPrimaryActionClick }}
|
||||
className={styles.globalCommandsSplitButton}
|
||||
icon={primaryAction.icon}
|
||||
>
|
||||
{primaryAction.label}
|
||||
</SplitButton>
|
||||
<MenuButton {...triggerProps} icon={primaryAction.icon} className={styles.globalCommandsMenuButton}>
|
||||
New...
|
||||
</MenuButton>
|
||||
</div>
|
||||
)}
|
||||
</MenuTrigger>
|
||||
<MenuPopover>
|
||||
@@ -199,7 +225,7 @@ interface SidebarProps {
|
||||
explorer: Explorer;
|
||||
}
|
||||
|
||||
const CollapseThreshold = 50;
|
||||
const CollapseThreshold = 140;
|
||||
|
||||
export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
|
||||
const styles = useSidebarStyles();
|
||||
@@ -260,7 +286,7 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
|
||||
{/* Collections Tree - Start */}
|
||||
{hasSidebar && (
|
||||
// When collapsed, we force the pane to 24 pixels wide and make it non-resizable.
|
||||
<Allotment.Pane minSize={24} preferredSize={300}>
|
||||
<Allotment.Pane minSize={24} preferredSize={250}>
|
||||
<CosmosFluentProvider className={mergeClasses(styles.sidebar)}>
|
||||
<div className={styles.sidebarContainer}>
|
||||
{loading && (
|
||||
@@ -274,6 +300,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 +340,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>
|
||||
|
||||
@@ -753,17 +753,11 @@ export class CassandraAPIDataClient extends TableDataClient {
|
||||
CassandraProxyEndpoints.Development,
|
||||
CassandraProxyEndpoints.Mpac,
|
||||
CassandraProxyEndpoints.Prod,
|
||||
CassandraProxyEndpoints.Fairfax,
|
||||
CassandraProxyEndpoints.Mooncake,
|
||||
];
|
||||
let canAccessCassandraProxy: boolean = userContext.databaseAccount.properties.publicNetworkAccess === "Enabled";
|
||||
if (
|
||||
configContext.CASSANDRA_PROXY_ENDPOINT !== CassandraProxyEndpoints.Development &&
|
||||
userContext.databaseAccount.properties.ipRules?.length > 0
|
||||
) {
|
||||
canAccessCassandraProxy = canAccessCassandraProxy && configContext.CASSANDRA_PROXY_OUTBOUND_IPS_ALLOWLISTED;
|
||||
}
|
||||
|
||||
return (
|
||||
canAccessCassandraProxy &&
|
||||
configContext.NEW_CASSANDRA_APIS?.includes(api) &&
|
||||
activeCassandraProxyEndpoints.includes(configContext.CASSANDRA_PROXY_ENDPOINT)
|
||||
);
|
||||
|
||||
106
src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts
Normal file
106
src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts
Normal 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(),
|
||||
});
|
||||
};
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}>
|
||||
<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,
|
||||
<Allotment
|
||||
onDragEnd={(sizes: number[]) => {
|
||||
tabStateData.leftPaneWidthPercent = (100 * sizes[0]) / (sizes[0] + sizes[1]);
|
||||
saveSubComponentState<TabDivider>(SubComponentName.MainTabDivider, _collection, tabStateData);
|
||||
setTabStateData(tabStateData);
|
||||
}}
|
||||
onClick={() => refreshDocumentsGrid(false)}
|
||||
onKeyDown={onRefreshKeyInput}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
>
|
||||
<Allotment.Pane preferredSize={`${tabStateData.leftPaneWidthPercent}%`} minSize={55}>
|
||||
<div style={{ height: "100%", width: "100%", overflow: "hidden" }} ref={tableContainerRef}>
|
||||
<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}
|
||||
columnHeaders={columnHeaders}
|
||||
isSelectionDisabled={
|
||||
(partitionKey.systemKey && !isPreferredApiMongoDB) ||
|
||||
(configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly)
|
||||
selectedColumnIds={selectedColumnIds}
|
||||
columnDefinitions={columnDefinitions}
|
||||
isRowSelectionDisabled={
|
||||
configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly
|
||||
}
|
||||
onColumnSelectionChange={onColumnSelectionChange}
|
||||
defaultColumnSelection={getInitialColumnSelection()}
|
||||
collection={_collection}
|
||||
isColumnSelectionDisabled={isPreferredApiMongoDB}
|
||||
/>
|
||||
</div>
|
||||
</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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TableRowId } from "@fluentui/react-components";
|
||||
import { mount } from "enzyme";
|
||||
import React from "react";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import { DocumentsTableComponent, IDocumentsTableComponentProps } from "./DocumentsTableComponent";
|
||||
|
||||
const PARTITION_KEY_HEADER = "partitionKey";
|
||||
@@ -20,11 +21,19 @@ describe("DocumentsTableComponent", () => {
|
||||
height: 0,
|
||||
width: 0,
|
||||
},
|
||||
columnHeaders: {
|
||||
idHeader: ID_HEADER,
|
||||
partitionKeyHeaders: [PARTITION_KEY_HEADER],
|
||||
columnDefinitions: [
|
||||
{ id: ID_HEADER, label: "ID", isPartitionKey: false },
|
||||
{ id: PARTITION_KEY_HEADER, label: "Partition Key", isPartitionKey: true },
|
||||
],
|
||||
isRowSelectionDisabled: false,
|
||||
collection: {
|
||||
databaseId: "db",
|
||||
id: ((): string => "coll") as ko.Observable<string>,
|
||||
} as ViewModels.CollectionBase,
|
||||
onRefreshTable: (): void => {
|
||||
throw new Error("Function not implemented.");
|
||||
},
|
||||
isSelectionDisabled: false,
|
||||
selectedColumnIds: [],
|
||||
});
|
||||
|
||||
it("should render documents and partition keys in header", () => {
|
||||
@@ -35,7 +44,7 @@ describe("DocumentsTableComponent", () => {
|
||||
|
||||
it("should not render selection column when isSelectionDisabled is true", () => {
|
||||
const props: IDocumentsTableComponentProps = createMockProps();
|
||||
props.isSelectionDisabled = true;
|
||||
props.isRowSelectionDisabled = true;
|
||||
const wrapper = mount(<DocumentsTableComponent {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -1,52 +1,86 @@
|
||||
import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuDivider,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
MenuPopover,
|
||||
MenuTrigger,
|
||||
TableRowData as RowStateBase,
|
||||
SortDirection,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellLayout,
|
||||
TableColumnDefinition,
|
||||
TableColumnId,
|
||||
TableColumnSizingOptions,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
TableRowId,
|
||||
TableSelectionCell,
|
||||
createTableColumn,
|
||||
tokens,
|
||||
useArrowNavigationGroup,
|
||||
useTableColumnSizing_unstable,
|
||||
useTableFeatures,
|
||||
useTableSelection,
|
||||
useTableSort,
|
||||
} from "@fluentui/react-components";
|
||||
import {
|
||||
ArrowClockwise16Regular,
|
||||
ArrowResetRegular,
|
||||
DeleteRegular,
|
||||
EditRegular,
|
||||
MoreHorizontalRegular,
|
||||
TableResizeColumnRegular,
|
||||
TextSortAscendingRegular,
|
||||
TextSortDescendingRegular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { NormalizedEventKey } from "Common/Constants";
|
||||
import { TableColumnSelectionPane } from "Explorer/Panes/TableColumnSelectionPane/TableColumnSelectionPane";
|
||||
import {
|
||||
ColumnSizesMap,
|
||||
ColumnSort,
|
||||
deleteSubComponentState,
|
||||
readSubComponentState,
|
||||
saveSubComponentState,
|
||||
SubComponentName,
|
||||
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
|
||||
import { INITIAL_SELECTED_ROW_INDEX, useDocumentsTabStyles } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2";
|
||||
import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
|
||||
import { LayoutConstants } from "Explorer/Theme/ThemeUtil";
|
||||
import { userContext } from "UserContext";
|
||||
import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils";
|
||||
import { useSidePanel } from "hooks/useSidePanel";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { FixedSizeList as List, ListChildComponentProps } from "react-window";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
|
||||
export type DocumentsTableComponentItem = {
|
||||
id: string;
|
||||
} & Record<string, string>;
|
||||
} & Record<string, string | number>;
|
||||
|
||||
export type ColumnHeaders = {
|
||||
idHeader: string;
|
||||
partitionKeyHeaders: string[];
|
||||
export type ColumnDefinition = {
|
||||
id: string;
|
||||
label: string;
|
||||
isPartitionKey: boolean;
|
||||
};
|
||||
export interface IDocumentsTableComponentProps {
|
||||
onRefreshTable: () => void;
|
||||
items: DocumentsTableComponentItem[];
|
||||
onItemClicked: (index: number) => void;
|
||||
onSelectedRowsChange: (selectedItemsIndices: Set<TableRowId>) => void;
|
||||
selectedRows: Set<TableRowId>;
|
||||
size: { height: number; width: number };
|
||||
columnHeaders: ColumnHeaders;
|
||||
selectedColumnIds: string[];
|
||||
columnDefinitions: ColumnDefinition[];
|
||||
style?: React.CSSProperties;
|
||||
isSelectionDisabled?: boolean;
|
||||
isRowSelectionDisabled?: boolean;
|
||||
collection: ViewModels.CollectionBase;
|
||||
onColumnSelectionChange?: (newSelectedColumnIds: string[]) => void;
|
||||
defaultColumnSelection?: string[];
|
||||
isColumnSelectionDisabled?: boolean;
|
||||
}
|
||||
|
||||
interface TableRowData extends RowStateBase<DocumentsTableComponentItem> {
|
||||
@@ -59,72 +93,203 @@ interface ReactWindowRenderFnProps extends ListChildComponentProps {
|
||||
data: TableRowData[];
|
||||
}
|
||||
|
||||
const COLUMNS_MENU_NAME = "columnsMenu";
|
||||
|
||||
const defaultSize = {
|
||||
idealWidth: 200,
|
||||
minWidth: 50,
|
||||
};
|
||||
export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> = ({
|
||||
onRefreshTable,
|
||||
items,
|
||||
onSelectedRowsChange,
|
||||
selectedRows,
|
||||
style,
|
||||
size,
|
||||
columnHeaders,
|
||||
isSelectionDisabled,
|
||||
selectedColumnIds,
|
||||
columnDefinitions,
|
||||
isRowSelectionDisabled: isSelectionDisabled,
|
||||
collection,
|
||||
onColumnSelectionChange,
|
||||
defaultColumnSelection,
|
||||
isColumnSelectionDisabled,
|
||||
}: IDocumentsTableComponentProps) => {
|
||||
const styles = useDocumentsTabStyles();
|
||||
|
||||
const initialSizingOptions: TableColumnSizingOptions = {
|
||||
id: {
|
||||
idealWidth: 280,
|
||||
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,
|
||||
},
|
||||
};
|
||||
columnHeaders.partitionKeyHeaders.forEach((pkHeader) => {
|
||||
initialSizingOptions[pkHeader] = {
|
||||
idealWidth: 200,
|
||||
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 }) => {
|
||||
setColumnSizingOptions((state) => ({
|
||||
const onColumnResize = React.useCallback((_, { columnId, width }: { columnId: string; width: number }) => {
|
||||
setColumnSizingOptions((state) => {
|
||||
const newSizingOptions = {
|
||||
...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,10 +393,20 @@ 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 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,
|
||||
@@ -244,7 +420,8 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
|
||||
selected,
|
||||
appearance: selected ? ("brand" as const) : ("none" as const),
|
||||
};
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const toggleAllKeydown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
@@ -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>
|
||||
{columns.map((column) => (
|
||||
<TableHeaderCell
|
||||
className={styles.tableCell}
|
||||
key={column.columnId}
|
||||
{...columnSizing.getTableHeaderCellProps(column.columnId)}
|
||||
{...headerSortProps(column.columnId)}
|
||||
>
|
||||
{column.renderHeaderCell()}
|
||||
</TableHeaderCell>
|
||||
</MenuTrigger>
|
||||
<MenuPopover>
|
||||
<MenuList>
|
||||
<MenuItem onClick={columnSizing.enableKeyboardMode(column.columnId)}>
|
||||
Keyboard Column Resizing
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</MenuPopover>
|
||||
</Menu>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Utility class to help with selection.
|
||||
* This emulates File Explorer selection behavior.
|
||||
@@ -90,3 +92,12 @@ export const selectionHelper = (
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// To get previous values of a state in useEffect
|
||||
export const usePrevious = <T>(value: T): T | undefined => {
|
||||
const ref = useRef<T>();
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
});
|
||||
return ref.current;
|
||||
};
|
||||
|
||||
@@ -38,9 +38,11 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
||||
}
|
||||
}
|
||||
>
|
||||
<Allotment>
|
||||
<Allotment
|
||||
onDragEnd={[Function]}
|
||||
>
|
||||
<Allotment.Pane
|
||||
minSize={175}
|
||||
minSize={55}
|
||||
preferredSize="35%"
|
||||
>
|
||||
<div
|
||||
@@ -52,41 +54,50 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="___77lcry0_0000000 f10pi13n"
|
||||
>
|
||||
<div
|
||||
className="___1rwkz4r_0000000 f1euv43f f1l8gmrm f1e31b4d f150nix6 fy6ml6n f19g0ac"
|
||||
>
|
||||
<Button
|
||||
appearance="transparent"
|
||||
aria-label="Refresh"
|
||||
icon={<ArrowClockwise16Filled />}
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
size="small"
|
||||
style={
|
||||
{
|
||||
"color": undefined,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="___9o87uj0_0000000 ffefeo0"
|
||||
>
|
||||
<DocumentsTableComponent
|
||||
columnHeaders={
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"idHeader": "id",
|
||||
"partitionKeyHeaders": [],
|
||||
"height": "100%",
|
||||
"width": "calc(100% + -13px)",
|
||||
}
|
||||
}
|
||||
isSelectionDisabled={true}
|
||||
>
|
||||
<DocumentsTableComponent
|
||||
collection={
|
||||
{
|
||||
"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,
|
||||
@@ -95,10 +106,10 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Allotment.Pane>
|
||||
<Allotment.Pane
|
||||
minSize={300}
|
||||
preferredSize="65%"
|
||||
minSize={30}
|
||||
>
|
||||
<div
|
||||
style={
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
124
src/Explorer/Tabs/QueryTab/ErrorList.tsx
Normal file
124
src/Explorer/Tabs/QueryTab/ErrorList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
<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.
|
||||
</div>
|
||||
)}
|
||||
{maybeSubQuery && (
|
||||
<div className="warningErrorContainer" aria-live="assertive">
|
||||
<div className="warningErrorContent">
|
||||
<span>
|
||||
<img className="paneErrorIcon" src={InfoColor} alt="Error" />
|
||||
</span>
|
||||
<span className="warningErrorDetailsLinkContainer">
|
||||
</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,{" "}
|
||||
<a
|
||||
<Link
|
||||
href="https://learn.microsoft.com/azure/cosmos-db/nosql/query/subquery"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
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 --> */}
|
||||
</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}
|
||||
{errors.length > 0 ? (
|
||||
<ErrorList errors={errors} />
|
||||
) : queryResults ? (
|
||||
<ResultsView
|
||||
queryResults={queryResults}
|
||||
executeQueryDocumentsPage={executeQueryDocumentsPage}
|
||||
isMongoDB={isMongoDB}
|
||||
/>
|
||||
</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>
|
||||
) : (
|
||||
<ExecuteQueryCallToAction />
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
? 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,
|
||||
);
|
||||
} 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%" }}>
|
||||
{/* 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) => this.onSelectedContent(selectedContent)}
|
||||
onContentSelected={(selectedContent: string, selection: monaco.Selection) =>
|
||||
this.onSelectedContent(selectedContent, selection)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
</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>
|
||||
|
||||
396
src/Explorer/Tabs/QueryTab/ResultsView.tsx
Normal file
396
src/Explorer/Tabs/QueryTab/ResultsView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
84
src/Explorer/Tabs/QueryTab/Styles.ts
Normal file
84
src/Explorer/Tabs/QueryTab/Styles.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IMessageBarStyles, Link, MessageBar, MessageBarButton, MessageBarType } from "@fluentui/react";
|
||||
import { CassandraProxyEndpoints, MongoProxyEndpoints } from "Common/Constants";
|
||||
import { sendMessage } from "Common/MessageHandler";
|
||||
import { Platform, configContext, updateConfigContext } from "ConfigContext";
|
||||
import { Platform, configContext } from "ConfigContext";
|
||||
import { IpRule } from "Contracts/DataModels";
|
||||
import { MessageTypes } from "Contracts/ExplorerContracts";
|
||||
import { CollectionTabKind } from "Contracts/ViewModels";
|
||||
@@ -100,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"
|
||||
@@ -119,7 +118,7 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
|
||||
setShowMongoAndCassandraProxiesNetworkSettingsWarningState(false);
|
||||
}}
|
||||
>
|
||||
{`We are moving our middleware to new infrastructure. To avoid future issues with Data Explorer access, please
|
||||
{`We have migrated our middleware to new infrastructure. To avoid issues with Data Explorer access, please
|
||||
re-enable "Allow access from Azure Portal" on the Networking blade for your account.`}
|
||||
</MessageBar>
|
||||
)}
|
||||
@@ -299,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 => {
|
||||
@@ -394,12 +397,6 @@ const showMongoAndCassandraProxiesNetworkSettingsWarning = (): boolean => {
|
||||
ipAddressesFromIPRules.includes(mongoProxyOutboundIP),
|
||||
);
|
||||
|
||||
if (ipRulesIncludeMongoProxy) {
|
||||
updateConfigContext({
|
||||
MONGO_PROXY_OUTBOUND_IPS_ALLOWLISTED: true,
|
||||
});
|
||||
}
|
||||
|
||||
return !ipRulesIncludeMongoProxy;
|
||||
} else if (userContext.apiType === "Cassandra") {
|
||||
const isProdOrMpacCassandraProxyEndpoint: boolean = [
|
||||
@@ -418,12 +415,6 @@ const showMongoAndCassandraProxiesNetworkSettingsWarning = (): boolean => {
|
||||
(cassandraProxyOutboundIP: string) => ipAddressesFromIPRules.includes(cassandraProxyOutboundIP),
|
||||
);
|
||||
|
||||
if (ipRulesIncludeCassandraProxy) {
|
||||
updateConfigContext({
|
||||
CASSANDRA_PROXY_OUTBOUND_IPS_ALLOWLISTED: true,
|
||||
});
|
||||
}
|
||||
|
||||
return !ipRulesIncludeCassandraProxy;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<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.
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
|
||||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "standardCollection",
|
||||
@@ -69,6 +72,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
|
||||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "conflictsCollection",
|
||||
@@ -92,6 +98,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
|
||||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "standardDb",
|
||||
@@ -102,6 +111,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"iconSrc": <SettingsRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"id": "",
|
||||
"isSelected": [Function],
|
||||
"label": "Scale",
|
||||
@@ -133,6 +145,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
|
||||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "sampleItemsCollection",
|
||||
@@ -156,6 +171,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
|
||||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "sharedDatabase",
|
||||
@@ -246,6 +264,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
|
||||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "schemaCollection",
|
||||
@@ -274,6 +295,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca
|
||||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "giganticDatabase",
|
||||
@@ -345,6 +369,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
|
||||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "standardCollection",
|
||||
@@ -415,6 +442,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
|
||||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "conflictsCollection",
|
||||
@@ -438,6 +468,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
|
||||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "standardDb",
|
||||
@@ -448,6 +481,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"iconSrc": <SettingsRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"id": "",
|
||||
"isSelected": [Function],
|
||||
"label": "Scale",
|
||||
@@ -510,6 +546,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
|
||||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "sampleItemsCollection",
|
||||
@@ -533,6 +572,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
|
||||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "sharedDatabase",
|
||||
@@ -654,6 +696,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
|
||||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "schemaCollection",
|
||||
@@ -682,6 +727,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Mo
|
||||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "giganticDatabase",
|
||||
@@ -706,6 +754,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
||||
"onClick": [Function],
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "standardCollection",
|
||||
@@ -724,6 +775,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
||||
"onClick": [Function],
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "conflictsCollection",
|
||||
@@ -747,6 +801,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
||||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "standardDb",
|
||||
@@ -766,6 +823,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
||||
"onClick": [Function],
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "sampleItemsCollection",
|
||||
@@ -789,6 +849,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
||||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "sharedDatabase",
|
||||
@@ -808,6 +871,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
||||
"onClick": [Function],
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "schemaCollection",
|
||||
@@ -836,6 +902,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
||||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "giganticDatabase",
|
||||
@@ -976,6 +1045,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
||||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "standardCollection",
|
||||
@@ -1076,6 +1148,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
||||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "conflictsCollection",
|
||||
@@ -1099,6 +1174,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
||||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "standardDb",
|
||||
@@ -1109,6 +1187,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"iconSrc": <SettingsRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"id": "",
|
||||
"isSelected": [Function],
|
||||
"label": "Scale",
|
||||
@@ -1201,6 +1282,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
||||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "sampleItemsCollection",
|
||||
@@ -1224,6 +1308,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
||||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "sharedDatabase",
|
||||
@@ -1375,6 +1462,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
||||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "schemaCollection",
|
||||
@@ -1403,6 +1493,9 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the SQ
|
||||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "giganticDatabase",
|
||||
@@ -1543,6 +1636,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
|
||||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "standardCollection",
|
||||
@@ -1638,6 +1734,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
|
||||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "conflictsCollection",
|
||||
@@ -1661,6 +1760,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
|
||||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "standardDb",
|
||||
@@ -1671,6 +1773,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"iconSrc": <SettingsRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"id": "",
|
||||
"isSelected": [Function],
|
||||
"label": "Scale",
|
||||
@@ -1763,6 +1868,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
|
||||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "sampleItemsCollection",
|
||||
@@ -1786,6 +1894,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
|
||||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "sharedDatabase",
|
||||
@@ -1937,6 +2048,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
|
||||
"styleClass": "deleteCollectionMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "schemaCollection",
|
||||
@@ -1965,6 +2079,9 @@ exports[`createDatabaseTreeNodes using NoSQL API on Hosted Platform creates expe
|
||||
"styleClass": "deleteDatabaseMenuItem",
|
||||
},
|
||||
],
|
||||
"iconSrc": <DatabaseRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "giganticDatabase",
|
||||
@@ -1986,6 +2103,9 @@ exports[`createResourceTokenTreeNodes creates the expected tree nodes 1`] = `
|
||||
},
|
||||
],
|
||||
"className": "collectionNode",
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": true,
|
||||
"isSelected": [Function],
|
||||
"label": "testCollection",
|
||||
@@ -2021,6 +2141,9 @@ exports[`createSampleDataTreeNodes creates the expected tree nodes 1`] = `
|
||||
"onClick": [Function],
|
||||
},
|
||||
],
|
||||
"iconSrc": <DocumentMultipleRegular
|
||||
fontSize={16}
|
||||
/>,
|
||||
"isExpanded": false,
|
||||
"isSelected": [Function],
|
||||
"label": "testCollection",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DatabaseRegular, DocumentMultipleRegular, SettingsRegular } from "@fluentui/react-icons";
|
||||
import { TreeNode } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
|
||||
import TabsBase from "Explorer/Tabs/TabsBase";
|
||||
import StoredProcedure from "Explorer/Tree/StoredProcedure";
|
||||
@@ -7,6 +8,7 @@ import { useDatabases } from "Explorer/useDatabases";
|
||||
import { getItemName } from "Utils/APITypeUtils";
|
||||
import { isServerlessAccount } from "Utils/CapabilityUtils";
|
||||
import { useTabs } from "hooks/useTabs";
|
||||
import React from "react";
|
||||
import { isPublicInternetAccessAllowed } from "../../Common/DatabaseAccountUtility";
|
||||
import { Platform, configContext } from "../../ConfigContext";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
@@ -25,6 +27,10 @@ export const shouldShowScriptNodes = (): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
const TreeDatabaseIcon = <DatabaseRegular fontSize={16} />;
|
||||
const TreeSettingsIcon = <SettingsRegular fontSize={16} />;
|
||||
const TreeCollectionIcon = <DocumentMultipleRegular fontSize={16} />;
|
||||
|
||||
export const createSampleDataTreeNodes = (sampleDataResourceTokenCollection: ViewModels.CollectionBase): TreeNode[] => {
|
||||
const updatedSampleTree: TreeNode = {
|
||||
label: sampleDataResourceTokenCollection.databaseId,
|
||||
@@ -36,6 +42,7 @@ export const createSampleDataTreeNodes = (sampleDataResourceTokenCollection: Vie
|
||||
isExpanded: false,
|
||||
className: "collectionNode",
|
||||
contextMenu: ResourceTreeContextMenuButtonFactory.createSampleCollectionContextMenuButton(),
|
||||
iconSrc: TreeCollectionIcon,
|
||||
onClick: () => {
|
||||
useSelectedNode.getState().setSelectedNode(sampleDataResourceTokenCollection);
|
||||
useCommandBar.getState().setContextButtons([]);
|
||||
@@ -104,6 +111,7 @@ export const createResourceTokenTreeNodes = (collection: ViewModels.CollectionBa
|
||||
isExpanded: true,
|
||||
children,
|
||||
className: "collectionNode",
|
||||
iconSrc: TreeCollectionIcon,
|
||||
onClick: () => {
|
||||
// Rewritten version of expandCollapseCollection
|
||||
useSelectedNode.getState().setSelectedNode(collection);
|
||||
@@ -133,6 +141,7 @@ export const createDatabaseTreeNodes = (
|
||||
databaseNode.children.push({
|
||||
id: database.isSampleDB ? "sampleScaleSettings" : "",
|
||||
label: "Scale",
|
||||
iconSrc: TreeSettingsIcon,
|
||||
isSelected: () =>
|
||||
useSelectedNode
|
||||
.getState()
|
||||
@@ -169,6 +178,7 @@ export const createDatabaseTreeNodes = (
|
||||
children: [],
|
||||
isSelected: () => useSelectedNode.getState().isDataNodeSelected(database.id()),
|
||||
contextMenu: ResourceTreeContextMenuButtonFactory.createDatabaseContextMenu(container, database.id()),
|
||||
iconSrc: TreeDatabaseIcon,
|
||||
onExpanded: async () => {
|
||||
useSelectedNode.getState().setSelectedNode(database);
|
||||
if (!databaseNode.children || databaseNode.children?.length === 0) {
|
||||
@@ -219,6 +229,7 @@ export const buildCollectionNode = (
|
||||
children: children,
|
||||
className: "collectionNode",
|
||||
contextMenu: ResourceTreeContextMenuButtonFactory.createCollectionContextMenuButton(container, collection),
|
||||
iconSrc: TreeCollectionIcon,
|
||||
onClick: () => {
|
||||
useSelectedNode.getState().setSelectedNode(collection);
|
||||
collection.openTab();
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
170
src/Shared/AppStatePersistenceUtility.test.ts
Normal file
170
src/Shared/AppStatePersistenceUtility.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
109
src/Shared/AppStatePersistenceUtility.ts
Normal file
109
src/Shared/AppStatePersistenceUtility.ts
Normal 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);
|
||||
};
|
||||
@@ -20,3 +20,14 @@ export const setEntryNumber = (key: StorageKey, value: number): void =>
|
||||
|
||||
export const setEntryBoolean = (key: StorageKey, value: boolean): void =>
|
||||
localStorage.setItem(StorageKey[key], value.toString());
|
||||
|
||||
export const setEntryObject = (key: StorageKey, value: unknown): void => {
|
||||
localStorage.setItem(StorageKey[key], JSON.stringify(value));
|
||||
};
|
||||
export const getEntryObject = <T>(key: StorageKey): T | null => {
|
||||
const item = localStorage.getItem(StorageKey[key]);
|
||||
if (item) {
|
||||
return JSON.parse(item) as T;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -29,7 +29,9 @@ export enum StorageKey {
|
||||
GalleryCalloutDismissed,
|
||||
VisitedAccounts,
|
||||
PriorityLevel,
|
||||
DocumentsTabPrefs,
|
||||
DefaultQueryResultsView,
|
||||
AppState,
|
||||
}
|
||||
|
||||
export const hasRUThresholdBeenConfigured = (): boolean => {
|
||||
@@ -56,10 +58,10 @@ export const getRUThreshold = (): number => {
|
||||
|
||||
export const getDefaultQueryResultsView = (): SplitterDirection => {
|
||||
const defaultQueryResultsViewRaw = LocalStorageUtility.getEntryString(StorageKey.DefaultQueryResultsView);
|
||||
if (defaultQueryResultsViewRaw === SplitterDirection.Horizontal) {
|
||||
return SplitterDirection.Horizontal;
|
||||
}
|
||||
if (defaultQueryResultsViewRaw === SplitterDirection.Vertical) {
|
||||
return SplitterDirection.Vertical;
|
||||
}
|
||||
return SplitterDirection.Horizontal;
|
||||
};
|
||||
|
||||
export const DefaultRUThreshold = 5000;
|
||||
|
||||
@@ -139,6 +139,9 @@ export enum Action {
|
||||
QueryEdited,
|
||||
ExecuteQueryGeneratedFromQueryCopilot,
|
||||
DeleteDocuments,
|
||||
ReadPersistedTabState,
|
||||
SavePersistedTabState,
|
||||
DeletePersistedTabState,
|
||||
}
|
||||
|
||||
export const ActionModifiers = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,20 +47,28 @@ 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) => {
|
||||
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: PortalBackendIPs[endpoint].map((ip: string) => ({ ipAddressOrRange: ip }) as IpRule),
|
||||
ipRules: portalBackendOutboundIPsWithLegacyIPs.map((ip: string) => ({ ipAddressOrRange: ip }) as IpRule),
|
||||
publicNetworkAccess: "Enabled",
|
||||
},
|
||||
} as DatabaseAccount,
|
||||
});
|
||||
|
||||
updateConfigContext({
|
||||
BACKEND_ENDPOINT: endpoint,
|
||||
BACKEND_ENDPOINT: legacyBackendEndpoint,
|
||||
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Mpac,
|
||||
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Mpac,
|
||||
});
|
||||
|
||||
let asyncWarningMessageResult: string;
|
||||
@@ -74,10 +77,8 @@ describe("NetworkUtility tests", () => {
|
||||
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) => {
|
||||
it("should return accessMessage when incorrent ip rule is added to mongo/cassandra account per endpoint", async () => {
|
||||
updateUserContext({
|
||||
databaseAccount: {
|
||||
kind: "MongoDB",
|
||||
@@ -89,7 +90,9 @@ describe("NetworkUtility tests", () => {
|
||||
});
|
||||
|
||||
updateConfigContext({
|
||||
BACKEND_ENDPOINT: endpoint,
|
||||
BACKEND_ENDPOINT: legacyBackendEndpoint,
|
||||
PORTAL_BACKEND_ENDPOINT: PortalBackendEndpoints.Mpac,
|
||||
MONGO_PROXY_ENDPOINT: MongoProxyEndpoints.Mpac,
|
||||
});
|
||||
|
||||
let asyncWarningMessageResult: string;
|
||||
@@ -98,7 +101,6 @@ describe("NetworkUtility tests", () => {
|
||||
await getNetworkSettingsWarningMessage(asyncWarningMessageFunc);
|
||||
expect(asyncWarningMessageResult).toContain(accessMessagePart);
|
||||
});
|
||||
});
|
||||
|
||||
// Postgres and vcore mongo account checks basically pass through to CheckFirewallRules so those
|
||||
// tests are omitted here and included in CheckFirewallRules.test.ts
|
||||
|
||||
@@ -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,8 +51,44 @@ 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];
|
||||
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 (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) {
|
||||
@@ -59,5 +101,4 @@ export const getNetworkSettingsWarningMessage = async (
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,7 @@ export const extractPartitionKeyValues = (
|
||||
const partitionKeyValues: PartitionKey[] = [];
|
||||
partitionKeyDefinition.paths.forEach((partitionKeyPath: string) => {
|
||||
const partitionKeyPathWithoutSlash: string = partitionKeyPath.substring(1);
|
||||
if (documentContent[partitionKeyPathWithoutSlash]) {
|
||||
if (documentContent[partitionKeyPathWithoutSlash] !== undefined) {
|
||||
partitionKeyValues.push(documentContent[partitionKeyPathWithoutSlash]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -619,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 &&
|
||||
@@ -639,6 +664,8 @@ function updateContextsFromPortalMessage(inputs: DataExplorerInputsFrame) {
|
||||
PORTAL_BACKEND_ENDPOINT: inputs.portalBackendEndpoint,
|
||||
});
|
||||
|
||||
updateAADEndpoints(inputs.serverId as PortalEnv);
|
||||
|
||||
updateUserContext({
|
||||
authorizationToken,
|
||||
databaseAccount,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 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 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 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();
|
||||
});
|
||||
|
||||
192
test/fx.ts
192
test/fx.ts
@@ -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}`);
|
||||
|
||||
// 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();
|
||||
}
|
||||
await expect(treeNodeContainer).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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 */
|
||||
|
||||
@@ -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 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 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 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();
|
||||
});
|
||||
|
||||
@@ -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 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 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 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();
|
||||
});
|
||||
|
||||
107
test/scripts/clean-test-accounts.ps1
Normal file
107
test/scripts/clean-test-accounts.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 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 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 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
93
test/sql/query.spec.ts
Normal 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");
|
||||
});
|
||||
@@ -12,14 +12,18 @@ import {
|
||||
subscriptionId,
|
||||
} from "../fx";
|
||||
|
||||
test("SQL account using Resource token", async ({ page }) => {
|
||||
// This test is currently failing because of issues with resource token auth in the backend.
|
||||
// Marking it as 'fail' means that it will still be run, but Playwright's reporting will be inverted (failing tests will be marked as passing and vice versa).
|
||||
// This allows us to detect when it starts working again, as the test will fail again.
|
||||
// At that point, we can remove the 'fail' marker and continue running the test as normal.
|
||||
test.fail("SQL account using Resource token", async ({ page }) => {
|
||||
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 dbId = generateUniqueName("db");
|
||||
const collectionId = generateUniqueName("col");
|
||||
const collectionId = "testcollection";
|
||||
const client = new CosmosClient({
|
||||
endpoint: account.documentEndpoint!,
|
||||
key: keys.primaryMasterKey,
|
||||
@@ -34,9 +38,7 @@ test("SQL account using Resource token", async ({ page }) => {
|
||||
});
|
||||
await expect(containerPermission).toBeDefined();
|
||||
|
||||
const resourceTokenConnectionString = `AccountEndpoint=${account.documentEndpoint};DatabaseId=${
|
||||
database.id
|
||||
};CollectionId=${container.id};${containerPermission!._token}`;
|
||||
const resourceTokenConnectionString = `AccountEndpoint=${account.documentEndpoint};DatabaseId=${database.id};CollectionId=${container.id};${containerPermission!._token}`;
|
||||
|
||||
await page.goto("https://localhost:1234/hostedExplorer.html");
|
||||
const switchConnectionLink = page.getByTestId("Link:SwitchConnectionType");
|
||||
|
||||
@@ -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 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 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
95
test/testData.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user