Compare commits

..

1 Commits

Author SHA1 Message Date
Ashley Stanton-Nurse
4facfba0c1 Mark resource token tests as failing 2024-09-05 11:03:20 -07:00
87 changed files with 1151 additions and 1782 deletions

View File

@@ -1,16 +0,0 @@
FROM mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm
# Install pre-reqs for gyp, and 'canvas' npm module
RUN apt-get update && \
apt-get install -y \
make \
gcc \
g++ \
python3-minimal \
libcairo2-dev \
libpango1.0-dev \
&& \
rm -rf /var/lib/apt/lists/*
# Install node-gyp to build native modules
RUN npm install -g node-gyp

View File

@@ -1,32 +0,0 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
{
"name": "Azure Cosmos DB Explorer",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"build": {
"dockerfile": "Dockerfile"
},
"onCreateCommand": ".devcontainer/oncreate",
"features": {
"ghcr.io/devcontainers/features/azure-cli:1": {
"version": "latest"
},
"ghcr.io/devcontainers/features/github-cli:1": {
"installDirectlyFromGitHubRelease": true,
"version": "latest"
},
"ghcr.io/devcontainers/features/sshd:1": {
"version": "latest"
}
}
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "yarn install",
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

View File

@@ -1,4 +0,0 @@
#!/usr/bin/env bash
# Install packages once, to prime the node_modules directory.
npm ci

View File

@@ -2618,7 +2618,6 @@ a:link {
.tabPanesContainer { .tabPanesContainer {
display: flex; display: flex;
flex-grow: 1; flex-grow: 1;
flex-direction: column;
overflow: hidden; overflow: hidden;
} }

View File

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

View File

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

View File

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

View File

@@ -730,12 +730,6 @@ export function useMongoProxyEndpoint(api: string): boolean {
); );
} }
export class ThrottlingError extends Error {
constructor(message: string) {
super(message);
}
}
// TODO: This function throws most of the time except on Forbidden which is a bit strange // TODO: This function throws most of the time except on Forbidden which is a bit strange
// It causes problems for TypeScript understanding the types // It causes problems for TypeScript understanding the types
async function errorHandling(response: Response, action: string, params: unknown): Promise<void> { async function errorHandling(response: Response, action: string, params: unknown): Promise<void> {
@@ -745,14 +739,6 @@ async function errorHandling(response: Response, action: string, params: unknown
if (response.status === HttpStatusCodes.Forbidden) { if (response.status === HttpStatusCodes.Forbidden) {
sendMessage({ type: MessageTypes.ForbiddenError, reason: errorMessage }); sendMessage({ type: MessageTypes.ForbiddenError, reason: errorMessage });
return; return;
} else if (
response.status === HttpStatusCodes.BadRequest &&
errorMessage.includes("Error=16500") &&
errorMessage.includes("RetryAfterMs=")
) {
// If throttling is happening, Cosmos DB will return a 400 with a body of:
// A write operation resulted in an error. Error=16500, RetryAfterMs=4, Details='Batch write error.
throw new ThrottlingError(errorMessage);
} }
throw new Error(errorMessage); throw new Error(errorMessage);
} }

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { getErrorMessage } from "Common/ErrorHandlingUtils";
import { monaco } from "Explorer/LazyMonaco"; import { monaco } from "Explorer/LazyMonaco";
import { getRUThreshold, ruThresholdEnabled } from "Shared/StorageUtility";
export enum QueryErrorSeverity { export enum QueryErrorSeverity {
Error = "Error", Error = "Error",
@@ -97,44 +97,13 @@ export const createMonacoMarkersForQueryErrors = (errors: QueryError[]) => {
.filter((marker) => !!marker); .filter((marker) => !!marker);
}; };
export interface ErrorEnrichment {
title?: string;
message: string;
learnMoreUrl?: string;
}
const REPLACEMENT_MESSAGES: Record<string, (original: string) => string> = {
OPERATION_RU_LIMIT_EXCEEDED: (original) => {
if (ruThresholdEnabled()) {
const threshold = getRUThreshold();
return `Query exceeded the Request Unit (RU) limit of ${threshold} RUs. You can change this limit in Data Explorer settings.`;
}
return original;
},
};
const HELP_LINKS: Record<string, string> = {
OPERATION_RU_LIMIT_EXCEEDED:
"https://learn.microsoft.com/en-us/azure/cosmos-db/data-explorer#configure-request-unit-threshold",
};
export default class QueryError { export default class QueryError {
message: string;
helpLink?: string;
constructor( constructor(
message: string, public message: string,
public severity: QueryErrorSeverity, public severity: QueryErrorSeverity,
public code?: string, public code?: string,
public location?: QueryErrorLocation, public location?: QueryErrorLocation,
helpLink?: string, ) {}
) {
// Automatically replace the message with a more Data Explorer-specific message if we have for this error code.
this.message = REPLACEMENT_MESSAGES[code] ? REPLACEMENT_MESSAGES[code](message) : message;
// Automatically set the help link if we have one for this error code.
this.helpLink = helpLink ?? HELP_LINKS[code];
}
getMonacoSeverity(): monaco.MarkerSeverity { getMonacoSeverity(): monaco.MarkerSeverity {
// It's very difficult to use the monaco.MarkerSeverity enum from here, so we'll just use the numbers directly. // It's very difficult to use the monaco.MarkerSeverity enum from here, so we'll just use the numbers directly.
@@ -166,7 +135,7 @@ export default class QueryError {
return errors; return errors;
} }
const errorMessage = error as string; const errorMessage = getErrorMessage(error as string | Error);
// Map some well known messages to richer errors // Map some well known messages to richer errors
const knownError = knownErrors[errorMessage]; const knownError = knownErrors[errorMessage];
@@ -191,9 +160,7 @@ export default class QueryError {
} }
const severity = const severity =
"severity" in error && typeof error.severity === "string" "severity" in error && typeof error.severity === "string" ? (error.severity as QueryErrorSeverity) : undefined;
? (error.severity as QueryErrorSeverity)
: QueryErrorSeverity.Error;
const location = const location =
"location" in error && typeof error.location === "object" "location" in error && typeof error.location === "object"
? locationResolver(error.location as { start: number; end: number }) ? locationResolver(error.location as { start: number; end: number })
@@ -206,15 +173,16 @@ export default class QueryError {
error: unknown, error: unknown,
locationResolver: (location: { start: number; end: number }) => QueryErrorLocation, locationResolver: (location: { start: number; end: number }) => QueryErrorLocation,
): QueryError[] | null { ): QueryError[] | null {
let message: string | undefined; if (typeof error === "object" && "message" in error) {
if (typeof error === "object" && "message" in error && typeof error.message === "string") { error = error.message;
message = error.message; }
} else {
// Unsupported error format. if (typeof error !== "string") {
return null; return null;
} }
// Assign to a new variable because of a TypeScript flow typing quirk, see below. // Assign to a new variable because of a TypeScript flow typing quirk, see below.
let message = error;
if (message.startsWith("Message: ")) { if (message.startsWith("Message: ")) {
// Reassigning this to 'error' restores the original type of 'error', which is 'unknown'. // Reassigning this to 'error' restores the original type of 'error', which is 'unknown'.
// So we use a separate variable to avoid this. // So we use a separate variable to avoid this.
@@ -228,15 +196,12 @@ export default class QueryError {
try { try {
parsed = JSON.parse(message); parsed = JSON.parse(message);
} catch (e) { } catch (e) {
// The message doesn't contain a nested error. // Not a query error.
return [QueryError.read(error, locationResolver)]; return null;
} }
if (typeof parsed === "object") { if (typeof parsed === "object" && "errors" in parsed && Array.isArray(parsed.errors)) {
if ("errors" in parsed && Array.isArray(parsed.errors)) { return parsed.errors.map((e) => QueryError.read(e, locationResolver)).filter((e) => e !== null);
return parsed.errors.map((e) => QueryError.read(e, locationResolver)).filter((e) => e !== null);
}
return [QueryError.read(parsed, locationResolver)];
} }
return null; return null;
} }

View File

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

View File

@@ -49,12 +49,12 @@ export interface ConfigContext {
ARCADIA_ENDPOINT: string; ARCADIA_ENDPOINT: string;
ARCADIA_LIVY_ENDPOINT_DNS_ZONE: string; ARCADIA_LIVY_ENDPOINT_DNS_ZONE: string;
BACKEND_ENDPOINT?: string; BACKEND_ENDPOINT?: string;
PORTAL_BACKEND_ENDPOINT: string; PORTAL_BACKEND_ENDPOINT?: string;
NEW_BACKEND_APIS?: BackendApi[]; NEW_BACKEND_APIS?: BackendApi[];
MONGO_BACKEND_ENDPOINT?: string; MONGO_BACKEND_ENDPOINT?: string;
MONGO_PROXY_ENDPOINT: string; MONGO_PROXY_ENDPOINT?: string;
NEW_MONGO_APIS?: string[]; NEW_MONGO_APIS?: string[];
CASSANDRA_PROXY_ENDPOINT: string; CASSANDRA_PROXY_ENDPOINT?: string;
NEW_CASSANDRA_APIS?: string[]; NEW_CASSANDRA_APIS?: string[];
PROXY_PATH?: string; PROXY_PATH?: string;
JUNO_ENDPOINT: string; JUNO_ENDPOINT: string;
@@ -115,7 +115,7 @@ let configContext: Readonly<ConfigContext> = {
"deleteDocument", "deleteDocument",
"createCollectionWithProxy", "createCollectionWithProxy",
"legacyMongoShell", "legacyMongoShell",
// "bulkdelete", "bulkdelete",
], ],
CASSANDRA_PROXY_ENDPOINT: CassandraProxyEndpoints.Prod, CASSANDRA_PROXY_ENDPOINT: CassandraProxyEndpoints.Prod,
NEW_CASSANDRA_APIS: ["postQuery", "createOrDelete", "getKeys", "getSchema"], NEW_CASSANDRA_APIS: ["postQuery", "createOrDelete", "getKeys", "getSchema"],

View File

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

View File

@@ -1,14 +1,23 @@
/** /**
* React component for Command button component. * React component for Command button component.
*/ */
import Explorer from "Explorer/Explorer";
import { KeyboardAction } from "KeyboardShortcuts"; import { KeyboardAction } from "KeyboardShortcuts";
import * as React from "react"; import * as React from "react";
import CollapseChevronDownIcon from "../../../../images/QueryBuilder/CollapseChevronDown_16x.png";
import { KeyCodes } from "../../../Common/Constants";
import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
import * as StringUtils from "../../../Utils/StringUtils";
/** /**
* Options for this component * Options for this component
*/ */
export interface CommandButtonComponentProps { export interface CommandButtonComponentProps {
/**
* font icon name for the button
*/
iconName?: string;
/** /**
* image source for the button icon * image source for the button icon
*/ */
@@ -22,7 +31,7 @@ export interface CommandButtonComponentProps {
/** /**
* Click handler for command button click * Click handler for command button click
*/ */
onCommandClick?: (e: React.SyntheticEvent | KeyboardEvent, container: Explorer) => void; onCommandClick?: (e: React.SyntheticEvent | KeyboardEvent) => void;
/** /**
* Label for the button * Label for the button
@@ -111,3 +120,157 @@ export interface CommandButtonComponentProps {
*/ */
keyboardAction?: KeyboardAction; keyboardAction?: KeyboardAction;
} }
export class CommandButtonComponent extends React.Component<CommandButtonComponentProps> {
private dropdownElt: HTMLElement;
private expandButtonElt: HTMLElement;
public componentDidUpdate(): void {
if (!this.dropdownElt || !this.expandButtonElt) {
return;
}
$(this.dropdownElt).offset({ left: $(this.expandButtonElt).offset().left });
}
private onKeyPress(event: React.KeyboardEvent): boolean {
if (event.keyCode === KeyCodes.Space || event.keyCode === KeyCodes.Enter) {
this.commandClickCallback && this.commandClickCallback(event);
event.stopPropagation();
return false;
}
return true;
}
private onLauncherKeyDown(event: React.KeyboardEvent<HTMLDivElement>): boolean {
if (event.keyCode === KeyCodes.DownArrow) {
$(this.dropdownElt).hide();
$(this.dropdownElt).show().focus();
event.stopPropagation();
return false;
}
if (event.keyCode === KeyCodes.UpArrow) {
$(this.dropdownElt).hide();
event.stopPropagation();
return false;
}
return true;
}
private getCommandButtonId(): string {
if (this.props.id) {
return this.props.id;
} else {
return `commandButton-${StringUtils.stripSpacesFromString(this.props.commandButtonLabel)}`;
}
}
public static renderButton(options: CommandButtonComponentProps, key?: string): JSX.Element {
return <CommandButtonComponent key={key} {...options} />;
}
private commandClickCallback(e: React.SyntheticEvent): void {
if (this.props.disabled) {
return;
}
// TODO Query component's parent, not document
const el = document.querySelector(".commandDropdownContainer") as HTMLElement;
if (el) {
el.style.display = "none";
}
this.props.onCommandClick(e);
TelemetryProcessor.trace(Action.SelectItem, ActionModifiers.Mark, {
commandButtonClicked: this.props.commandButtonLabel,
});
}
private renderChildren(): JSX.Element {
if (!this.props.children || this.props.children.length < 1) {
return <React.Fragment />;
}
return (
<div
className="commandExpand"
tabIndex={0}
ref={(ref: HTMLElement) => {
this.expandButtonElt = ref;
}}
onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>) => this.onLauncherKeyDown(e)}
>
<div className="commandDropdownLauncher">
<span className="partialSplitter" />
<span className="expandDropdown">
<img src={CollapseChevronDownIcon} />
</span>
</div>
<div
className="commandDropdownContainer"
ref={(ref: HTMLElement) => {
this.dropdownElt = ref;
}}
>
<div className="commandDropdown">
{this.props.children.map((c: CommandButtonComponentProps, index: number): JSX.Element => {
return CommandButtonComponent.renderButton(c, `${index}`);
})}
</div>
</div>
</div>
);
}
public static renderLabel(
props: CommandButtonComponentProps,
key?: string,
refct?: (input: HTMLElement) => void,
): JSX.Element {
if (!props.commandButtonLabel) {
return <React.Fragment />;
}
return (
<span className="commandLabel" key={key} ref={refct}>
{props.commandButtonLabel}
</span>
);
}
public render(): JSX.Element {
let mainClassName = "commandButtonComponent";
if (this.props.disabled) {
mainClassName += " commandDisabled";
}
if (this.props.isSelected) {
mainClassName += " selectedButton";
}
let contentClassName = "commandContent";
if (this.props.children && this.props.children.length > 0) {
contentClassName += " hasHiddenItems";
}
return (
<div className="commandButtonReact">
<span
className={mainClassName}
role="menuitem"
tabIndex={this.props.tabIndex}
onKeyPress={(e: React.KeyboardEvent<HTMLSpanElement>) => this.onKeyPress(e)}
title={this.props.tooltipText}
id={this.getCommandButtonId()}
aria-disabled={this.props.disabled}
aria-haspopup={this.props.hasPopup}
aria-label={this.props.ariaLabel}
onClick={(e: React.MouseEvent<HTMLSpanElement>) => this.commandClickCallback(e)}
>
<div className={contentClassName}>
<img className="commandIcon" src={this.props.iconSrc} alt={this.props.iconAlt} />
{CommandButtonComponent.renderLabel(this.props)}
</div>
</span>
{this.props.children && this.renderChildren()}
</div>
);
}
}

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,6 @@ import {
ContainerVectorPolicyComponent, ContainerVectorPolicyComponent,
ContainerVectorPolicyComponentProps, ContainerVectorPolicyComponentProps,
} from "Explorer/Controls/Settings/SettingsSubComponents/ContainerVectorPolicyComponent"; } from "Explorer/Controls/Settings/SettingsSubComponents/ContainerVectorPolicyComponent";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { useDatabases } from "Explorer/useDatabases"; import { useDatabases } from "Explorer/useDatabases";
import { isVectorSearchEnabled } from "Utils/CapabilityUtils"; import { isVectorSearchEnabled } from "Utils/CapabilityUtils";
import { isRunningOnPublicCloud } from "Utils/CloudUtils"; import { isRunningOnPublicCloud } from "Utils/CloudUtils";
@@ -33,6 +32,7 @@ import {
PartitionKeyComponent, PartitionKeyComponent,
PartitionKeyComponentProps, PartitionKeyComponentProps,
} from "../../Controls/Settings/SettingsSubComponents/PartitionKeyComponent"; } from "../../Controls/Settings/SettingsSubComponents/PartitionKeyComponent";
import { useCommandBar } from "../../Menus/CommandBar/CommandBarComponentAdapter";
import { SettingsTabV2 } from "../../Tabs/SettingsTabV2"; import { SettingsTabV2 } from "../../Tabs/SettingsTabV2";
import "./SettingsComponent.less"; import "./SettingsComponent.less";
import { mongoIndexingPolicyAADError } from "./SettingsRenderUtils"; import { mongoIndexingPolicyAADError } from "./SettingsRenderUtils";
@@ -130,6 +130,7 @@ export interface SettingsComponentState {
conflictResolutionPolicyProcedureBaseline: string; conflictResolutionPolicyProcedureBaseline: string;
isConflictResolutionDirty: boolean; isConflictResolutionDirty: boolean;
initialNotification: DataModels.Notification;
selectedTab: SettingsV2TabTypes; selectedTab: SettingsV2TabTypes;
} }
@@ -228,6 +229,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
conflictResolutionPolicyProcedureBaseline: undefined, conflictResolutionPolicyProcedureBaseline: undefined,
isConflictResolutionDirty: false, isConflictResolutionDirty: false,
initialNotification: undefined,
selectedTab: SettingsV2TabTypes.ScaleTab, selectedTab: SettingsV2TabTypes.ScaleTab,
}; };
@@ -1050,6 +1052,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
onMaxAutoPilotThroughputChange: this.onMaxAutoPilotThroughputChange, onMaxAutoPilotThroughputChange: this.onMaxAutoPilotThroughputChange,
onScaleSaveableChange: this.onScaleSaveableChange, onScaleSaveableChange: this.onScaleSaveableChange,
onScaleDiscardableChange: this.onScaleDiscardableChange, onScaleDiscardableChange: this.onScaleDiscardableChange,
initialNotification: this.props.settingsTab.pendingNotification(),
throughputError: this.state.throughputError, throughputError: this.state.throughputError,
}; };

View File

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

View File

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

View File

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

View File

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

View File

@@ -107,7 +107,7 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
const onOpenChange = useCallback( const onOpenChange = useCallback(
(_: TreeOpenChangeEvent, data: TreeOpenChangeData) => { (_: TreeOpenChangeEvent, data: TreeOpenChangeData) => {
if (data.type === "Click" && node.onClick) { if (data.type === "Click" && !isBranch && node.onClick) {
node.onClick(); node.onClick();
} }
if (!node.isExpanded && data.open && node.onExpanded) { if (!node.isExpanded && data.open && node.onExpanded) {
@@ -119,7 +119,7 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
node.onCollapsed?.(); node.onCollapsed?.();
} }
}, },
[node, setIsLoading], [isBranch, node, setIsLoading],
); );
const onMenuOpenChange = useCallback( const onMenuOpenChange = useCallback(

View File

@@ -1,11 +1,9 @@
import * as msal from "@azure/msal-browser"; import * as msal from "@azure/msal-browser";
import { Link } from "@fluentui/react/lib/Link"; import { Link } from "@fluentui/react/lib/Link";
import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility"; import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility";
import { Environment, getEnvironment } from "Common/EnvironmentUtility";
import { sendMessage } from "Common/MessageHandler"; import { sendMessage } from "Common/MessageHandler";
import { Platform, configContext } from "ConfigContext"; import { Platform, configContext } from "ConfigContext";
import { MessageTypes } from "Contracts/ExplorerContracts"; import { MessageTypes } from "Contracts/ExplorerContracts";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane"; import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
import { getCopilotEnabled, isCopilotFeatureRegistered } from "Explorer/QueryCopilot/Shared/QueryCopilotClient"; import { getCopilotEnabled, isCopilotFeatureRegistered } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { IGalleryItem } from "Juno/JunoClient"; import { IGalleryItem } from "Juno/JunoClient";
@@ -48,6 +46,7 @@ import { useTabs } from "../hooks/useTabs";
import "./ComponentRegisterer"; import "./ComponentRegisterer";
import { DialogProps, useDialog } from "./Controls/Dialog"; import { DialogProps, useDialog } from "./Controls/Dialog";
import { GalleryTab as GalleryTabKind } from "./Controls/NotebookGallery/GalleryViewerComponent"; import { GalleryTab as GalleryTabKind } from "./Controls/NotebookGallery/GalleryViewerComponent";
import { useCommandBar } from "./Menus/CommandBar/CommandBarComponentAdapter";
import * as FileSystemUtil from "./Notebook/FileSystemUtil"; import * as FileSystemUtil from "./Notebook/FileSystemUtil";
import { SnapshotRequest } from "./Notebook/NotebookComponent/types"; import { SnapshotRequest } from "./Notebook/NotebookComponent/types";
import { NotebookContentItem, NotebookContentItemType } from "./Notebook/NotebookContentItem"; import { NotebookContentItem, NotebookContentItemType } from "./Notebook/NotebookContentItem";
@@ -1179,11 +1178,7 @@ export default class Explorer {
} }
public async configureCopilot(): Promise<void> { public async configureCopilot(): Promise<void> {
if ( if (userContext.apiType !== "SQL" || !userContext.subscriptionId) {
userContext.apiType !== "SQL" ||
!userContext.subscriptionId ||
![Environment.Development, Environment.Mpac, Environment.Prod].includes(getEnvironment())
) {
return; return;
} }
const copilotEnabledPromise = getCopilotEnabled(); const copilotEnabledPromise = getCopilotEnabled();

View File

@@ -4,51 +4,83 @@
* and update any knockout observables passed from the parent. * and update any knockout observables passed from the parent.
*/ */
import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react"; import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react";
import {
createPlatformButtons,
createStaticCommandBarButtons,
} from "Explorer/Menus/CommandBar/CommandBarComponentButtonFactory";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { useNotebook } from "Explorer/Notebook/useNotebook"; import { useNotebook } from "Explorer/Notebook/useNotebook";
import { KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts"; import { KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
import { userContext } from "UserContext";
import * as React from "react"; import * as React from "react";
import create, { UseStore } from "zustand";
import { ConnectionStatusType, PoolIdType } from "../../../Common/Constants"; import { ConnectionStatusType, PoolIdType } from "../../../Common/Constants";
import { StyleConstants } from "../../../Common/StyleConstants"; import { StyleConstants } from "../../../Common/StyleConstants";
import { Platform, configContext } from "../../../ConfigContext"; import { Platform, configContext } from "../../../ConfigContext";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import Explorer from "../../Explorer"; import Explorer from "../../Explorer";
import { useSelectedNode } from "../../useSelectedNode"; import { useSelectedNode } from "../../useSelectedNode";
import * as CommandBarComponentButtonFactory from "./CommandBarComponentButtonFactory";
import * as CommandBarUtil from "./CommandBarUtil"; import * as CommandBarUtil from "./CommandBarUtil";
interface Props { interface Props {
container: Explorer; container: Explorer;
} }
export interface CommandBarStore {
contextButtons: CommandButtonComponentProps[];
setContextButtons: (contextButtons: CommandButtonComponentProps[]) => void;
isHidden: boolean;
setIsHidden: (isHidden: boolean) => void;
}
export const useCommandBar: UseStore<CommandBarStore> = create((set) => ({
contextButtons: [],
setContextButtons: (contextButtons: CommandButtonComponentProps[]) => set((state) => ({ ...state, contextButtons })),
isHidden: false,
setIsHidden: (isHidden: boolean) => set((state) => ({ ...state, isHidden })),
}));
export const CommandBar: React.FC<Props> = ({ container }: Props) => { export const CommandBar: React.FC<Props> = ({ container }: Props) => {
const selectedNodeState = useSelectedNode(); const selectedNodeState = useSelectedNode();
const contextButtons = useCommandBar((state) => state.contextButtons); const buttons = useCommandBar((state) => state.contextButtons);
const isHidden = useCommandBar((state) => state.isHidden); const isHidden = useCommandBar((state) => state.isHidden);
const backgroundColor = StyleConstants.BaseLight; const backgroundColor = StyleConstants.BaseLight;
const setKeyboardHandlers = useKeyboardActionGroup(KeyboardActionGroup.COMMAND_BAR); const setKeyboardHandlers = useKeyboardActionGroup(KeyboardActionGroup.COMMAND_BAR);
const staticButtons = createStaticCommandBarButtons(selectedNodeState);
const platformButtons = createPlatformButtons();
const uiFabricStaticButtons = CommandBarUtil.convertButton(staticButtons, backgroundColor, container); if (userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo") {
if (contextButtons?.length > 0) { const buttons =
userContext.apiType === "Postgres"
? CommandBarComponentButtonFactory.createPostgreButtons(container)
: CommandBarComponentButtonFactory.createVCoreMongoButtons(container);
return (
<div className="commandBarContainer" style={{ display: isHidden ? "none" : "initial" }}>
<FluentCommandBar
ariaLabel="Use left and right arrow keys to navigate between commands"
items={CommandBarUtil.convertButton(buttons, backgroundColor)}
styles={{
root: { backgroundColor: backgroundColor },
}}
overflowButtonProps={{ ariaLabel: "More commands" }}
/>
</div>
);
}
const staticButtons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(container, selectedNodeState);
const contextButtons = (buttons || []).concat(
CommandBarComponentButtonFactory.createContextCommandBarButtons(container, selectedNodeState),
);
const controlButtons = CommandBarComponentButtonFactory.createControlCommandBarButtons(container);
const uiFabricStaticButtons = CommandBarUtil.convertButton(staticButtons, backgroundColor);
if (buttons && buttons.length > 0) {
uiFabricStaticButtons.forEach((btn: ICommandBarItemProps) => (btn.iconOnly = true)); uiFabricStaticButtons.forEach((btn: ICommandBarItemProps) => (btn.iconOnly = true));
} }
const uiFabricTabsButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton( const uiFabricTabsButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton(contextButtons, backgroundColor);
contextButtons || [],
backgroundColor,
container,
);
if (uiFabricTabsButtons.length > 0) { if (uiFabricTabsButtons.length > 0) {
uiFabricStaticButtons.push(CommandBarUtil.createDivider("commandBarDivider")); uiFabricStaticButtons.push(CommandBarUtil.createDivider("commandBarDivider"));
} }
const uiFabricPlatformButtons = CommandBarUtil.convertButton(platformButtons || [], backgroundColor, container); const uiFabricControlButtons = CommandBarUtil.convertButton(controlButtons, backgroundColor);
uiFabricPlatformButtons.forEach((btn: ICommandBarItemProps) => (btn.iconOnly = true)); uiFabricControlButtons.forEach((btn: ICommandBarItemProps) => (btn.iconOnly = true));
const connectionInfo = useNotebook((state) => state.connectionInfo); const connectionInfo = useNotebook((state) => state.connectionInfo);
@@ -56,7 +88,7 @@ export const CommandBar: React.FC<Props> = ({ container }: Props) => {
(useNotebook.getState().isPhoenixNotebooks || useNotebook.getState().isPhoenixFeatures) && (useNotebook.getState().isPhoenixNotebooks || useNotebook.getState().isPhoenixFeatures) &&
connectionInfo?.status !== ConnectionStatusType.Connect connectionInfo?.status !== ConnectionStatusType.Connect
) { ) {
uiFabricPlatformButtons.unshift( uiFabricControlButtons.unshift(
CommandBarUtil.createConnectionStatus(container, PoolIdType.DefaultPoolId, "connectionStatus"), CommandBarUtil.createConnectionStatus(container, PoolIdType.DefaultPoolId, "connectionStatus"),
); );
} }
@@ -75,8 +107,8 @@ export const CommandBar: React.FC<Props> = ({ container }: Props) => {
}, },
}; };
const allButtons = staticButtons.concat(contextButtons).concat(platformButtons); const allButtons = staticButtons.concat(contextButtons).concat(controlButtons);
const keyboardHandlers = CommandBarUtil.createKeyboardHandlers(allButtons, container); const keyboardHandlers = CommandBarUtil.createKeyboardHandlers(allButtons);
setKeyboardHandlers(keyboardHandlers); setKeyboardHandlers(keyboardHandlers);
return ( return (
@@ -84,7 +116,7 @@ export const CommandBar: React.FC<Props> = ({ container }: Props) => {
<FluentCommandBar <FluentCommandBar
ariaLabel="Use left and right arrow keys to navigate between commands" ariaLabel="Use left and right arrow keys to navigate between commands"
items={uiFabricStaticButtons.concat(uiFabricTabsButtons)} items={uiFabricStaticButtons.concat(uiFabricTabsButtons)}
farItems={uiFabricPlatformButtons} farItems={uiFabricControlButtons}
styles={rootStyle} styles={rootStyle}
overflowButtonProps={{ ariaLabel: "More commands" }} overflowButtonProps={{ ariaLabel: "More commands" }}
/> />

View File

@@ -3,12 +3,15 @@ import { AuthType } from "../../../AuthType";
import { DatabaseAccount } from "../../../Contracts/DataModels"; import { DatabaseAccount } from "../../../Contracts/DataModels";
import { CollectionBase } from "../../../Contracts/ViewModels"; import { CollectionBase } from "../../../Contracts/ViewModels";
import { updateUserContext } from "../../../UserContext"; import { updateUserContext } from "../../../UserContext";
import Explorer from "../../Explorer";
import { useNotebook } from "../../Notebook/useNotebook"; import { useNotebook } from "../../Notebook/useNotebook";
import { useDatabases } from "../../useDatabases"; import { useDatabases } from "../../useDatabases";
import { useSelectedNode } from "../../useSelectedNode"; import { useSelectedNode } from "../../useSelectedNode";
import * as CommandBarComponentButtonFactory from "./CommandBarComponentButtonFactory"; import * as CommandBarComponentButtonFactory from "./CommandBarComponentButtonFactory";
describe("CommandBarComponentButtonFactory tests", () => { describe("CommandBarComponentButtonFactory tests", () => {
let mockExplorer: Explorer;
afterEach(() => useSelectedNode.getState().setSelectedNode(undefined)); afterEach(() => useSelectedNode.getState().setSelectedNode(undefined));
describe("Enable Azure Synapse Link Button", () => { describe("Enable Azure Synapse Link Button", () => {
@@ -16,6 +19,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
const selectedNodeState = useSelectedNode.getState(); const selectedNodeState = useSelectedNode.getState();
beforeAll(() => { beforeAll(() => {
mockExplorer = {} as Explorer;
updateUserContext({ updateUserContext({
databaseAccount: { databaseAccount: {
properties: { properties: {
@@ -26,7 +30,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
}); });
it("Button should be visible", () => { it("Button should be visible", () => {
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(selectedNodeState); const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const enableAzureSynapseLinkBtn = buttons.find( const enableAzureSynapseLinkBtn = buttons.find(
(button) => button.commandButtonLabel === enableAzureSynapseLinkBtnLabel, (button) => button.commandButtonLabel === enableAzureSynapseLinkBtnLabel,
); );
@@ -42,7 +46,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
} as DatabaseAccount, } as DatabaseAccount,
}); });
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(selectedNodeState); const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const enableAzureSynapseLinkBtn = buttons.find( const enableAzureSynapseLinkBtn = buttons.find(
(button) => button.commandButtonLabel === enableAzureSynapseLinkBtnLabel, (button) => button.commandButtonLabel === enableAzureSynapseLinkBtnLabel,
); );
@@ -58,7 +62,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
} as DatabaseAccount, } as DatabaseAccount,
}); });
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(selectedNodeState); const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const enableAzureSynapseLinkBtn = buttons.find( const enableAzureSynapseLinkBtn = buttons.find(
(button) => button.commandButtonLabel === enableAzureSynapseLinkBtnLabel, (button) => button.commandButtonLabel === enableAzureSynapseLinkBtnLabel,
); );
@@ -71,6 +75,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
const selectedNodeState = useSelectedNode.getState(); const selectedNodeState = useSelectedNode.getState();
beforeAll(() => { beforeAll(() => {
mockExplorer = {} as Explorer;
updateUserContext({ updateUserContext({
databaseAccount: { databaseAccount: {
properties: { properties: {
@@ -103,7 +108,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
}, },
} as DatabaseAccount, } as DatabaseAccount,
}); });
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(selectedNodeState); const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const openCassandraShellBtn = buttons.find((button) => button.commandButtonLabel === openCassandraShellBtnLabel); const openCassandraShellBtn = buttons.find((button) => button.commandButtonLabel === openCassandraShellBtnLabel);
expect(openCassandraShellBtn).toBeUndefined(); expect(openCassandraShellBtn).toBeUndefined();
}); });
@@ -113,13 +118,13 @@ describe("CommandBarComponentButtonFactory tests", () => {
portalEnv: "mooncake", portalEnv: "mooncake",
}); });
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(selectedNodeState); const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const openCassandraShellBtn = buttons.find((button) => button.commandButtonLabel === openCassandraShellBtnLabel); const openCassandraShellBtn = buttons.find((button) => button.commandButtonLabel === openCassandraShellBtnLabel);
expect(openCassandraShellBtn).toBeUndefined(); expect(openCassandraShellBtn).toBeUndefined();
}); });
it("Notebooks is not enabled and is unavailable - button should be shown and disabled", () => { it("Notebooks is not enabled and is unavailable - button should be shown and disabled", () => {
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(selectedNodeState); const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const openCassandraShellBtn = buttons.find((button) => button.commandButtonLabel === openCassandraShellBtnLabel); const openCassandraShellBtn = buttons.find((button) => button.commandButtonLabel === openCassandraShellBtnLabel);
expect(openCassandraShellBtn).toBeUndefined(); expect(openCassandraShellBtn).toBeUndefined();
}); });
@@ -129,8 +134,12 @@ describe("CommandBarComponentButtonFactory tests", () => {
const openPostgresShellButtonLabel = "Open PSQL shell"; const openPostgresShellButtonLabel = "Open PSQL shell";
const openVCoreMongoShellButtonLabel = "Open MongoDB (vCore) shell"; const openVCoreMongoShellButtonLabel = "Open MongoDB (vCore) shell";
beforeAll(() => {
mockExplorer = {} as Explorer;
});
it("creates Postgres shell button", () => { it("creates Postgres shell button", () => {
const buttons = CommandBarComponentButtonFactory.createPostgreButtons(); const buttons = CommandBarComponentButtonFactory.createPostgreButtons(mockExplorer);
const openPostgresShellButton = buttons.find( const openPostgresShellButton = buttons.find(
(button) => button.commandButtonLabel === openPostgresShellButtonLabel, (button) => button.commandButtonLabel === openPostgresShellButtonLabel,
); );
@@ -138,7 +147,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
}); });
it("creates vCore Mongo shell button", () => { it("creates vCore Mongo shell button", () => {
const buttons = CommandBarComponentButtonFactory.createVCoreMongoButtons(); const buttons = CommandBarComponentButtonFactory.createVCoreMongoButtons(mockExplorer);
const openVCoreMongoShellButton = buttons.find( const openVCoreMongoShellButton = buttons.find(
(button) => button.commandButtonLabel === openVCoreMongoShellButtonLabel, (button) => button.commandButtonLabel === openVCoreMongoShellButtonLabel,
); );
@@ -153,6 +162,8 @@ describe("CommandBarComponentButtonFactory tests", () => {
const selectedNodeState = useSelectedNode.getState(); const selectedNodeState = useSelectedNode.getState();
beforeAll(() => { beforeAll(() => {
mockExplorer = {} as Explorer;
updateUserContext({ updateUserContext({
authType: AuthType.ResourceToken, authType: AuthType.ResourceToken,
}); });
@@ -164,7 +175,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
kind: "DocumentDB", kind: "DocumentDB",
} as DatabaseAccount, } as DatabaseAccount,
}); });
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(selectedNodeState); const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
expect(buttons.length).toBe(2); expect(buttons.length).toBe(2);
expect(buttons[0].commandButtonLabel).toBe("New SQL Query"); expect(buttons[0].commandButtonLabel).toBe("New SQL Query");
expect(buttons[0].disabled).toBe(false); expect(buttons[0].disabled).toBe(false);

View File

@@ -21,6 +21,7 @@ import { userContext } from "../../../UserContext";
import { isRunningOnNationalCloud } from "../../../Utils/CloudUtils"; import { isRunningOnNationalCloud } from "../../../Utils/CloudUtils";
import { useSidePanel } from "../../../hooks/useSidePanel"; import { useSidePanel } from "../../../hooks/useSidePanel";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import Explorer from "../../Explorer";
import { useNotebook } from "../../Notebook/useNotebook"; import { useNotebook } from "../../Notebook/useNotebook";
import { OpenFullScreen } from "../../OpenFullScreen"; import { OpenFullScreen } from "../../OpenFullScreen";
import { BrowseQueriesPane } from "../../Panes/BrowseQueriesPane/BrowseQueriesPane"; import { BrowseQueriesPane } from "../../Panes/BrowseQueriesPane/BrowseQueriesPane";
@@ -31,20 +32,19 @@ import { SelectedNodeState, useSelectedNode } from "../../useSelectedNode";
let counter = 0; let counter = 0;
export function createStaticCommandBarButtons(selectedNodeState: SelectedNodeState): CommandButtonComponentProps[] { export function createStaticCommandBarButtons(
if (userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo") { container: Explorer,
return userContext.apiType === "Postgres" ? createPostgreButtons() : createVCoreMongoButtons(); selectedNodeState: SelectedNodeState,
} ): CommandButtonComponentProps[] {
if (userContext.authType === AuthType.ResourceToken) { if (userContext.authType === AuthType.ResourceToken) {
return createStaticCommandBarButtonsForResourceToken(selectedNodeState); return createStaticCommandBarButtonsForResourceToken(container, selectedNodeState);
} }
const buttons: CommandButtonComponentProps[] = []; const buttons: CommandButtonComponentProps[] = [];
// Avoid starting with a divider // Avoid starting with a divider
const addDivider = () => { const addDivider = () => {
if (buttons.length > 0 && !buttons[buttons.length - 1].isDivider) { if (buttons.length > 0) {
buttons.push(createDivider()); buttons.push(createDivider());
} }
}; };
@@ -54,7 +54,7 @@ export function createStaticCommandBarButtons(selectedNodeState: SelectedNodeSta
userContext.apiType !== "Tables" && userContext.apiType !== "Tables" &&
userContext.apiType !== "Cassandra" userContext.apiType !== "Cassandra"
) { ) {
const addSynapseLink = createOpenSynapseLinkDialogButton(); const addSynapseLink = createOpenSynapseLinkDialogButton(container);
if (addSynapseLink) { if (addSynapseLink) {
addDivider(); addDivider();
buttons.push(addSynapseLink); buttons.push(addSynapseLink);
@@ -67,9 +67,9 @@ export function createStaticCommandBarButtons(selectedNodeState: SelectedNodeSta
const aadTokenUpdated = useDataPlaneRbac((state) => state.aadTokenUpdated); const aadTokenUpdated = useDataPlaneRbac((state) => state.aadTokenUpdated);
useEffect(() => { useEffect(() => {
const buttonProps = createLoginForEntraIDButton(); const buttonProps = createLoginForEntraIDButton(container);
setLoginButtonProps(buttonProps); setLoginButtonProps(buttonProps);
}, [dataPlaneRbacEnabled, aadTokenUpdated]); }, [dataPlaneRbacEnabled, aadTokenUpdated, container]);
if (loginButtonProps) { if (loginButtonProps) {
addDivider(); addDivider();
@@ -87,8 +87,8 @@ export function createStaticCommandBarButtons(selectedNodeState: SelectedNodeSta
} }
if (isQuerySupported && selectedNodeState.findSelectedCollection() && configContext.platform !== Platform.Fabric) { if (isQuerySupported && selectedNodeState.findSelectedCollection() && configContext.platform !== Platform.Fabric) {
const openQueryBtn = createOpenQueryButton(); const openQueryBtn = createOpenQueryButton(container);
openQueryBtn.children = [createOpenQueryButton(), createOpenQueryFromDiskButton()]; openQueryBtn.children = [createOpenQueryButton(container), createOpenQueryFromDiskButton()];
buttons.push(openQueryBtn); buttons.push(openQueryBtn);
} }
@@ -103,7 +103,6 @@ export function createStaticCommandBarButtons(selectedNodeState: SelectedNodeSta
selectedCollection && selectedCollection.onNewStoredProcedureClick(selectedCollection); selectedCollection && selectedCollection.onNewStoredProcedureClick(selectedCollection);
}, },
commandButtonLabel: label, commandButtonLabel: label,
tooltipText: userContext.features.commandBarV2 ? "New..." : label,
ariaLabel: label, ariaLabel: label,
hasPopup: true, hasPopup: true,
disabled: disabled:
@@ -116,12 +115,21 @@ export function createStaticCommandBarButtons(selectedNodeState: SelectedNodeSta
} }
} }
return buttons;
}
export function createContextCommandBarButtons(
container: Explorer,
selectedNodeState: SelectedNodeState,
): CommandButtonComponentProps[] {
const buttons: CommandButtonComponentProps[] = [];
if (!selectedNodeState.isDatabaseNodeOrNoneSelected() && userContext.apiType === "Mongo") { if (!selectedNodeState.isDatabaseNodeOrNoneSelected() && userContext.apiType === "Mongo") {
const label = useNotebook.getState().isShellEnabled ? "Open Mongo Shell" : "New Shell"; const label = useNotebook.getState().isShellEnabled ? "Open Mongo Shell" : "New Shell";
const newMongoShellBtn: CommandButtonComponentProps = { const newMongoShellBtn: CommandButtonComponentProps = {
iconSrc: HostedTerminalIcon, iconSrc: HostedTerminalIcon,
iconAlt: label, iconAlt: label,
onCommandClick: (_, container) => { onCommandClick: () => {
const selectedCollection: ViewModels.Collection = selectedNodeState.findSelectedCollection(); const selectedCollection: ViewModels.Collection = selectedNodeState.findSelectedCollection();
if (useNotebook.getState().isShellEnabled) { if (useNotebook.getState().isShellEnabled) {
container.openNotebookTerminal(ViewModels.TerminalKind.Mongo); container.openNotebookTerminal(ViewModels.TerminalKind.Mongo);
@@ -133,7 +141,6 @@ export function createStaticCommandBarButtons(selectedNodeState: SelectedNodeSta
ariaLabel: label, ariaLabel: label,
hasPopup: true, hasPopup: true,
}; };
addDivider();
buttons.push(newMongoShellBtn); buttons.push(newMongoShellBtn);
} }
@@ -146,27 +153,25 @@ export function createStaticCommandBarButtons(selectedNodeState: SelectedNodeSta
const newCassandraShellButton: CommandButtonComponentProps = { const newCassandraShellButton: CommandButtonComponentProps = {
iconSrc: HostedTerminalIcon, iconSrc: HostedTerminalIcon,
iconAlt: label, iconAlt: label,
onCommandClick: (_, container) => { onCommandClick: () => {
container.openNotebookTerminal(ViewModels.TerminalKind.Cassandra); container.openNotebookTerminal(ViewModels.TerminalKind.Cassandra);
}, },
commandButtonLabel: label, commandButtonLabel: label,
ariaLabel: label, ariaLabel: label,
hasPopup: true, hasPopup: true,
}; };
addDivider();
buttons.push(newCassandraShellButton); buttons.push(newCassandraShellButton);
} }
return buttons; return buttons;
} }
export function createPlatformButtons(): CommandButtonComponentProps[] { export function createControlCommandBarButtons(container: Explorer): CommandButtonComponentProps[] {
const buttons: CommandButtonComponentProps[] = [ const buttons: CommandButtonComponentProps[] = [
{ {
iconSrc: SettingsIcon, iconSrc: SettingsIcon,
iconAlt: "Settings", iconAlt: "Settings",
onCommandClick: (_, container) => onCommandClick: () => useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={container} />),
useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={container} />),
commandButtonLabel: undefined, commandButtonLabel: undefined,
ariaLabel: "Settings", ariaLabel: "Settings",
tooltipText: "Settings", tooltipText: "Settings",
@@ -202,7 +207,7 @@ export function createPlatformButtons(): CommandButtonComponentProps[] {
const feedbackButtonOptions: CommandButtonComponentProps = { const feedbackButtonOptions: CommandButtonComponentProps = {
iconSrc: FeedbackIcon, iconSrc: FeedbackIcon,
iconAlt: label, iconAlt: label,
onCommandClick: (_, container) => container.openCESCVAFeedbackBlade(), onCommandClick: () => container.openCESCVAFeedbackBlade(),
commandButtonLabel: undefined, commandButtonLabel: undefined,
ariaLabel: label, ariaLabel: label,
tooltipText: label, tooltipText: label,
@@ -234,7 +239,7 @@ function areScriptsSupported(): boolean {
); );
} }
function createOpenSynapseLinkDialogButton(): CommandButtonComponentProps { function createOpenSynapseLinkDialogButton(container: Explorer): CommandButtonComponentProps {
if (configContext.platform === Platform.Emulator) { if (configContext.platform === Platform.Emulator) {
return undefined; return undefined;
} }
@@ -252,7 +257,7 @@ function createOpenSynapseLinkDialogButton(): CommandButtonComponentProps {
return { return {
iconSrc: SynapseIcon, iconSrc: SynapseIcon,
iconAlt: label, iconAlt: label,
onCommandClick: (_, container) => container.openEnableSynapseLinkDialog(), onCommandClick: () => container.openEnableSynapseLinkDialog(),
commandButtonLabel: label, commandButtonLabel: label,
hasPopup: false, hasPopup: false,
disabled: disabled:
@@ -261,12 +266,12 @@ function createOpenSynapseLinkDialogButton(): CommandButtonComponentProps {
}; };
} }
function createLoginForEntraIDButton(): CommandButtonComponentProps { function createLoginForEntraIDButton(container: Explorer): CommandButtonComponentProps {
if (configContext.platform !== Platform.Portal) { if (configContext.platform !== Platform.Portal) {
return undefined; return undefined;
} }
const handleCommandClick: CommandButtonComponentProps["onCommandClick"] = async (_, container) => { const handleCommandClick = async () => {
await container.openLoginForEntraIDPopUp(); await container.openLoginForEntraIDPopUp();
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: true }); useDataPlaneRbac.setState({ dataPlaneRbacEnabled: true });
}; };
@@ -393,14 +398,13 @@ export function createScriptCommandButtons(selectedNodeState: SelectedNodeState)
return buttons; return buttons;
} }
function createOpenQueryButton(): CommandButtonComponentProps { function createOpenQueryButton(container: Explorer): CommandButtonComponentProps {
const label = "Open Query"; const label = "Open Query";
return { return {
iconSrc: BrowseQueriesIcon, iconSrc: BrowseQueriesIcon,
iconAlt: label, iconAlt: label,
tooltipText: userContext.features.commandBarV2 ? "Open Query..." : "Open Query",
keyboardAction: KeyboardAction.OPEN_QUERY, keyboardAction: KeyboardAction.OPEN_QUERY,
onCommandClick: (_, container) => onCommandClick: () =>
useSidePanel.getState().openSidePanel("Open Saved Queries", <BrowseQueriesPane explorer={container} />), useSidePanel.getState().openSidePanel("Open Saved Queries", <BrowseQueriesPane explorer={container} />),
commandButtonLabel: label, commandButtonLabel: label,
ariaLabel: label, ariaLabel: label,
@@ -423,7 +427,10 @@ function createOpenQueryFromDiskButton(): CommandButtonComponentProps {
}; };
} }
function createOpenTerminalButtonByKind(terminalKind: ViewModels.TerminalKind): CommandButtonComponentProps { function createOpenTerminalButtonByKind(
container: Explorer,
terminalKind: ViewModels.TerminalKind,
): CommandButtonComponentProps {
const terminalFriendlyName = (): string => { const terminalFriendlyName = (): string => {
switch (terminalKind) { switch (terminalKind) {
case ViewModels.TerminalKind.Cassandra: case ViewModels.TerminalKind.Cassandra:
@@ -446,7 +453,7 @@ function createOpenTerminalButtonByKind(terminalKind: ViewModels.TerminalKind):
return { return {
iconSrc: HostedTerminalIcon, iconSrc: HostedTerminalIcon,
iconAlt: label, iconAlt: label,
onCommandClick: (_, container) => { onCommandClick: () => {
if (useNotebook.getState().isNotebookEnabled) { if (useNotebook.getState().isNotebookEnabled) {
container.openNotebookTerminal(terminalKind); container.openNotebookTerminal(terminalKind);
} }
@@ -460,10 +467,11 @@ function createOpenTerminalButtonByKind(terminalKind: ViewModels.TerminalKind):
} }
function createStaticCommandBarButtonsForResourceToken( function createStaticCommandBarButtonsForResourceToken(
container: Explorer,
selectedNodeState: SelectedNodeState, selectedNodeState: SelectedNodeState,
): CommandButtonComponentProps[] { ): CommandButtonComponentProps[] {
const newSqlQueryBtn = createNewSQLQueryButton(selectedNodeState); const newSqlQueryBtn = createNewSQLQueryButton(selectedNodeState);
const openQueryBtn = createOpenQueryButton(); const openQueryBtn = createOpenQueryButton(container);
const resourceTokenCollection: ViewModels.CollectionBase = useDatabases.getState().resourceTokenCollection; const resourceTokenCollection: ViewModels.CollectionBase = useDatabases.getState().resourceTokenCollection;
const isResourceTokenCollectionNodeSelected: boolean = const isResourceTokenCollectionNodeSelected: boolean =
@@ -476,20 +484,20 @@ function createStaticCommandBarButtonsForResourceToken(
openQueryBtn.disabled = !isResourceTokenCollectionNodeSelected; openQueryBtn.disabled = !isResourceTokenCollectionNodeSelected;
if (!openQueryBtn.disabled) { if (!openQueryBtn.disabled) {
openQueryBtn.children = [createOpenQueryButton(), createOpenQueryFromDiskButton()]; openQueryBtn.children = [createOpenQueryButton(container), createOpenQueryFromDiskButton()];
} }
return [newSqlQueryBtn, openQueryBtn]; return [newSqlQueryBtn, openQueryBtn];
} }
export function createPostgreButtons(): CommandButtonComponentProps[] { export function createPostgreButtons(container: Explorer): CommandButtonComponentProps[] {
const openPostgreShellBtn = createOpenTerminalButtonByKind(ViewModels.TerminalKind.Postgres); const openPostgreShellBtn = createOpenTerminalButtonByKind(container, ViewModels.TerminalKind.Postgres);
return [openPostgreShellBtn]; return [openPostgreShellBtn];
} }
export function createVCoreMongoButtons(): CommandButtonComponentProps[] { export function createVCoreMongoButtons(container: Explorer): CommandButtonComponentProps[] {
const openVCoreMongoTerminalButton = createOpenTerminalButtonByKind(ViewModels.TerminalKind.VCoreMongo); const openVCoreMongoTerminalButton = createOpenTerminalButtonByKind(container, ViewModels.TerminalKind.VCoreMongo);
return [openVCoreMongoTerminalButton]; return [openVCoreMongoTerminalButton];
} }

View File

@@ -1,10 +1,8 @@
import { ICommandBarItemProps } from "@fluentui/react"; import { ICommandBarItemProps } from "@fluentui/react";
import Explorer from "Explorer/Explorer";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import * as CommandBarUtil from "./CommandBarUtil"; import * as CommandBarUtil from "./CommandBarUtil";
describe("CommandBarUtil tests", () => { describe("CommandBarUtil tests", () => {
const mockExplorer = {} as Explorer;
const createButton = (): CommandButtonComponentProps => { const createButton = (): CommandButtonComponentProps => {
return { return {
iconSrc: "icon", iconSrc: "icon",
@@ -24,7 +22,7 @@ describe("CommandBarUtil tests", () => {
const btn = createButton(); const btn = createButton();
const backgroundColor = "backgroundColor"; const backgroundColor = "backgroundColor";
const converteds = CommandBarUtil.convertButton([btn], backgroundColor, mockExplorer); const converteds = CommandBarUtil.convertButton([btn], backgroundColor);
expect(converteds.length).toBe(1); expect(converteds.length).toBe(1);
const converted = converteds[0]; const converted = converteds[0];
expect(converted.split).toBe(undefined); expect(converted.split).toBe(undefined);
@@ -48,7 +46,7 @@ describe("CommandBarUtil tests", () => {
btn.children.push(child); btn.children.push(child);
} }
const converteds = CommandBarUtil.convertButton([btn], "backgroundColor", mockExplorer); const converteds = CommandBarUtil.convertButton([btn], "backgroundColor");
expect(converteds.length).toBe(1); expect(converteds.length).toBe(1);
const converted = converteds[0]; const converted = converteds[0];
expect(converted.split).toBe(true); expect(converted.split).toBe(true);
@@ -64,7 +62,7 @@ describe("CommandBarUtil tests", () => {
btns.push(createButton()); btns.push(createButton());
} }
const converteds = CommandBarUtil.convertButton(btns, "backgroundColor", mockExplorer); const converteds = CommandBarUtil.convertButton(btns, "backgroundColor");
const uniqueKeys = converteds const uniqueKeys = converteds
.map((btn: ICommandBarItemProps) => btn.key) .map((btn: ICommandBarItemProps) => btn.key)
.filter((value: string, index: number, self: string[]) => self.indexOf(value) === index); .filter((value: string, index: number, self: string[]) => self.indexOf(value) === index);
@@ -76,10 +74,10 @@ describe("CommandBarUtil tests", () => {
const backgroundColor = "backgroundColor"; const backgroundColor = "backgroundColor";
btn.commandButtonLabel = undefined; btn.commandButtonLabel = undefined;
let converted = CommandBarUtil.convertButton([btn], backgroundColor, mockExplorer)[0]; let converted = CommandBarUtil.convertButton([btn], backgroundColor)[0];
expect(converted.text).toEqual(btn.tooltipText); expect(converted.text).toEqual(btn.tooltipText);
converted = CommandBarUtil.convertButton([btn], backgroundColor, mockExplorer)[0]; converted = CommandBarUtil.convertButton([btn], backgroundColor)[0];
delete btn.commandButtonLabel; delete btn.commandButtonLabel;
expect(converted.text).toEqual(btn.tooltipText); expect(converted.text).toEqual(btn.tooltipText);
}); });

View File

@@ -25,11 +25,7 @@ import { MemoryTracker } from "./MemoryTrackerComponent";
* Convert our NavbarButtonConfig to UI Fabric buttons * Convert our NavbarButtonConfig to UI Fabric buttons
* @param btns * @param btns
*/ */
export const convertButton = ( export const convertButton = (btns: CommandButtonComponentProps[], backgroundColor: string): ICommandBarItemProps[] => {
btns: CommandButtonComponentProps[],
backgroundColor: string,
container: Explorer,
): ICommandBarItemProps[] => {
const buttonHeightPx = const buttonHeightPx =
configContext.platform == Platform.Fabric configContext.platform == Platform.Fabric
? StyleConstants.FabricCommandBarButtonHeight ? StyleConstants.FabricCommandBarButtonHeight
@@ -58,14 +54,15 @@ export const convertButton = (
iconProps: { iconProps: {
style: { style: {
width: StyleConstants.CommandBarIconWidth, // 16 width: StyleConstants.CommandBarIconWidth, // 16
alignSelf: undefined, alignSelf: btn.iconName ? "baseline" : undefined,
filter: getFilter(btn.disabled), filter: getFilter(btn.disabled),
}, },
imageProps: btn.iconSrc ? { src: btn.iconSrc, alt: btn.iconAlt } : undefined, imageProps: btn.iconSrc ? { src: btn.iconSrc, alt: btn.iconAlt } : undefined,
iconName: btn.iconName,
}, },
onClick: btn.onCommandClick onClick: btn.onCommandClick
? (ev?: React.MouseEvent<HTMLElement, MouseEvent> | React.KeyboardEvent<HTMLElement>) => { ? (ev?: React.MouseEvent<HTMLElement, MouseEvent> | React.KeyboardEvent<HTMLElement>) => {
btn.onCommandClick(ev, container); btn.onCommandClick(ev);
let copilotEnabled = false; let copilotEnabled = false;
if (useQueryCopilot.getState().copilotEnabled && useQueryCopilot.getState().copilotUserDBEnabled) { if (useQueryCopilot.getState().copilotEnabled && useQueryCopilot.getState().copilotUserDBEnabled) {
copilotEnabled = useQueryCopilot.getState().copilotEnabledforExecution; copilotEnabled = useQueryCopilot.getState().copilotEnabledforExecution;
@@ -138,7 +135,7 @@ export const convertButton = (
result.split = true; result.split = true;
result.subMenuProps = { result.subMenuProps = {
items: convertButton(btn.children, backgroundColor, container), items: convertButton(btn.children, backgroundColor),
styles: { styles: {
list: { list: {
// TODO Figure out how to do it the proper way with subComponentStyles. // TODO Figure out how to do it the proper way with subComponentStyles.
@@ -189,7 +186,7 @@ export const convertButton = (
option?: IDropdownOption, option?: IDropdownOption,
index?: number, index?: number,
): void => { ): void => {
btn.children[index].onCommandClick(event, container); btn.children[index].onCommandClick(event);
TelemetryProcessor.trace(Action.ClickCommandBarButton, ActionModifiers.Mark, { label: option.text }); TelemetryProcessor.trace(Action.ClickCommandBarButton, ActionModifiers.Mark, { label: option.text });
}; };
@@ -240,17 +237,14 @@ export const createConnectionStatus = (container: Explorer, poolId: PoolIdType,
}; };
}; };
export function createKeyboardHandlers( export function createKeyboardHandlers(allButtons: CommandButtonComponentProps[]): KeyboardHandlerMap {
allButtons: CommandButtonComponentProps[],
container: Explorer,
): KeyboardHandlerMap {
const handlers: KeyboardHandlerMap = {}; const handlers: KeyboardHandlerMap = {};
function createHandlers(buttons: CommandButtonComponentProps[]) { function createHandlers(buttons: CommandButtonComponentProps[]) {
buttons.forEach((button) => { buttons.forEach((button) => {
if (!button.disabled && button.keyboardAction) { if (!button.disabled && button.keyboardAction) {
handlers[button.keyboardAction] = (e) => { handlers[button.keyboardAction] = (e) => {
button.onCommandClick(e, container); button.onCommandClick(e);
// If the handler is bound, it means the button is visible and enabled, so we should prevent the default action // If the handler is bound, it means the button is visible and enabled, so we should prevent the default action
return true; return true;

View File

@@ -1,16 +0,0 @@
import { CommandButtonComponentProps } from "Explorer/Controls/CommandButton/CommandButtonComponent";
import create, { UseStore } from "zustand";
export interface CommandBarStore {
contextButtons: CommandButtonComponentProps[];
setContextButtons: (contextButtons: CommandButtonComponentProps[]) => void;
isHidden: boolean;
setIsHidden: (isHidden: boolean) => void;
}
export const useCommandBar: UseStore<CommandBarStore> = create((set) => ({
contextButtons: [],
setContextButtons: (contextButtons: CommandButtonComponentProps[]) => set((state) => ({ ...state, contextButtons })),
isHidden: false,
setIsHidden: (isHidden: boolean) => set((state) => ({ ...state, isHidden })),
}));

View File

@@ -1,159 +0,0 @@
import {
makeStyles,
Menu,
MenuButton,
MenuItem,
MenuList,
MenuPopover,
MenuTrigger,
Toolbar,
ToolbarButton,
ToolbarDivider,
ToolbarGroup,
Tooltip,
} from "@fluentui/react-components";
import { CommandButtonComponentProps } from "Explorer/Controls/CommandButton/CommandButtonComponent";
import Explorer from "Explorer/Explorer";
import {
createPlatformButtons,
createStaticCommandBarButtons,
} from "Explorer/Menus/CommandBar/CommandBarComponentButtonFactory";
import { createKeyboardHandlers } from "Explorer/Menus/CommandBar/CommandBarUtil";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { CosmosFluentProvider, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil";
import { useSelectedNode } from "Explorer/useSelectedNode";
import { KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
import React, { MouseEventHandler } from "react";
const useToolbarStyles = makeStyles({
toolbar: {
height: tokens.layoutRowHeight,
justifyContent: "space-between", // Ensures that the two toolbar groups are at opposite ends of the toolbar
...cosmosShorthands.borderBottom(),
},
toolbarGroup: {
display: "flex",
},
});
export interface CommandBarV2Props {
explorer: Explorer;
}
export const CommandBarV2: React.FC<CommandBarV2Props> = ({ explorer }: CommandBarV2Props) => {
const styles = useToolbarStyles();
const selectedNodeState = useSelectedNode();
const contextButtons = useCommandBar((state) => state.contextButtons);
const isHidden = useCommandBar((state) => state.isHidden);
const setKeyboardHandlers = useKeyboardActionGroup(KeyboardActionGroup.COMMAND_BAR);
const staticButtons = createStaticCommandBarButtons(selectedNodeState);
const platformButtons = createPlatformButtons();
if (isHidden) {
setKeyboardHandlers({});
return null;
}
const allButtons = staticButtons.concat(contextButtons).concat(platformButtons);
const keyboardHandlers = createKeyboardHandlers(allButtons, explorer);
setKeyboardHandlers(keyboardHandlers);
return (
<CosmosFluentProvider>
<Toolbar className={styles.toolbar}>
<ToolbarGroup role="presentation" className={styles.toolbarGroup}>
{staticButtons.map((button, index) =>
renderButton(explorer, button, `static-${index}`, contextButtons?.length > 0),
)}
{staticButtons.length > 0 && contextButtons?.length > 0 && <ToolbarDivider />}
{contextButtons.map((button, index) => renderButton(explorer, button, `context-${index}`, false))}
</ToolbarGroup>
<ToolbarGroup role="presentation">
{platformButtons.map((button, index) => renderButton(explorer, button, `platform-${index}`, true))}
</ToolbarGroup>
</Toolbar>
</CosmosFluentProvider>
);
};
// This allows us to migrate individual buttons over to using a JSX.Element for the icon, without requiring us to change them all at once.
function renderIcon(iconSrcOrElement: string | JSX.Element, alt?: string): JSX.Element {
if (typeof iconSrcOrElement === "string") {
return <img src={iconSrcOrElement} alt={alt} />;
}
return iconSrcOrElement;
}
function renderButton(
explorer: Explorer,
btn: CommandButtonComponentProps,
key: string,
iconOnly: boolean,
): JSX.Element {
if (btn.isDivider) {
return <ToolbarDivider key={key} />;
}
const hasChildren = !!btn.children && btn.children.length > 0;
const label = btn.commandButtonLabel || btn.tooltipText;
const tooltip = btn.tooltipText || (iconOnly ? label : undefined);
const onClick: MouseEventHandler | undefined =
btn.onCommandClick && !hasChildren ? (e) => btn.onCommandClick(e, explorer) : undefined;
// We don't know which element will be the top-level element, so just slap a key on all of 'em
let button = hasChildren ? (
<MenuButton key={key} appearance="subtle" aria-label={btn.ariaLabel} icon={renderIcon(btn.iconSrc, btn.iconAlt)}>
{!iconOnly && label}
</MenuButton>
) : (
<ToolbarButton key={key} aria-label={btn.ariaLabel} onClick={onClick} icon={renderIcon(btn.iconSrc, btn.iconAlt)}>
{!iconOnly && label}
</ToolbarButton>
);
if (tooltip) {
button = (
<Tooltip key={key} content={tooltip} relationship="description" withArrow>
{button}
</Tooltip>
);
}
if (hasChildren) {
button = (
<Menu key={key}>
<MenuTrigger disableButtonEnhancement>{button}</MenuTrigger>
<MenuPopover>
<MenuList>{btn.children.map((child, index) => renderMenuItem(explorer, child, index.toString()))}</MenuList>
</MenuPopover>
</Menu>
);
}
return button;
}
function renderMenuItem(explorer: Explorer, btn: CommandButtonComponentProps, key: string): JSX.Element {
const hasChildren = !!btn.children && btn.children.length > 0;
const onClick: MouseEventHandler | undefined = btn.onCommandClick
? (e) => btn.onCommandClick(e, explorer)
: undefined;
const item = (
<MenuItem key={key} onClick={onClick} icon={renderIcon(btn.iconSrc, btn.iconAlt)}>
{btn.commandButtonLabel || btn.tooltipText}
</MenuItem>
);
if (hasChildren) {
return (
<Menu>
<MenuTrigger disableButtonEnhancement>{item}</MenuTrigger>
<MenuPopover>
<MenuList>{btn.children.map((child, index) => renderMenuItem(explorer, child, index.toString()))}</MenuList>
</MenuPopover>
</Menu>
);
}
return item;
}

View File

@@ -1,13 +1,13 @@
import { clear, collectionWasOpened, getItems, Type } from "Explorer/MostRecentActivity/MostRecentActivity";
import { observable } from "knockout"; import { observable } from "knockout";
import { mostRecentActivity } from "./MostRecentActivity";
describe("MostRecentActivity", () => { describe("MostRecentActivity", () => {
const accountName = "some account"; const accountId = "some account";
beforeEach(() => clear(accountName)); beforeEach(() => mostRecentActivity.clear(accountId));
it("Has no items at first", () => { it("Has no items at first", () => {
expect(getItems(accountName)).toStrictEqual([]); expect(mostRecentActivity.getItems(accountId)).toStrictEqual([]);
}); });
it("Can record collections being opened", () => { it("Can record collections being opened", () => {
@@ -18,9 +18,9 @@ describe("MostRecentActivity", () => {
databaseId, databaseId,
}; };
collectionWasOpened(accountName, collection); mostRecentActivity.collectionWasOpened(accountId, collection);
const activity = getItems(accountName); const activity = mostRecentActivity.getItems(accountId);
expect(activity).toEqual([ expect(activity).toEqual([
expect.objectContaining({ expect.objectContaining({
collectionId, collectionId,
@@ -29,24 +29,58 @@ describe("MostRecentActivity", () => {
]); ]);
}); });
it("Does not store duplicate entries", () => { it("Can record notebooks being opened", () => {
const collectionId = "some collection"; const name = "some notebook";
const databaseId = "some database"; const path = "some path";
const collection = { const notebook = { name, path };
id: observable(collectionId),
databaseId,
};
collectionWasOpened(accountName, collection); mostRecentActivity.notebookWasItemOpened(accountId, notebook);
collectionWasOpened(accountName, collection);
const activity = getItems(accountName); const activity = mostRecentActivity.getItems(accountId);
expect(activity).toEqual([ expect(activity).toEqual([expect.objectContaining(notebook)]);
expect.objectContaining({ });
type: Type.OpenCollection,
collectionId, it("Filters out duplicates", () => {
databaseId, const name = "some notebook";
}), const path = "some path";
]); const notebook = { name, path };
const sameNotebook = { name, path };
mostRecentActivity.notebookWasItemOpened(accountId, notebook);
mostRecentActivity.notebookWasItemOpened(accountId, sameNotebook);
const activity = mostRecentActivity.getItems(accountId);
expect(activity.length).toEqual(1);
expect(activity).toEqual([expect.objectContaining(notebook)]);
});
it("Allows for multiple accounts", () => {
const name = "some notebook";
const path = "some path";
const notebook = { name, path };
const anotherNotebook = { name: "Another " + name, path };
const anotherAccountId = "Another " + accountId;
mostRecentActivity.notebookWasItemOpened(accountId, notebook);
mostRecentActivity.notebookWasItemOpened(anotherAccountId, anotherNotebook);
expect(mostRecentActivity.getItems(accountId)).toEqual([expect.objectContaining(notebook)]);
expect(mostRecentActivity.getItems(anotherAccountId)).toEqual([expect.objectContaining(anotherNotebook)]);
});
it("Can store multiple distinct elements, in FIFO order", () => {
const name = "some notebook";
const path = "some path";
const first = { name, path };
const second = { name: "Another " + name, path };
const third = { name, path: "Another " + path };
mostRecentActivity.notebookWasItemOpened(accountId, first);
mostRecentActivity.notebookWasItemOpened(accountId, second);
mostRecentActivity.notebookWasItemOpened(accountId, third);
const activity = mostRecentActivity.getItems(accountId);
expect(activity).toEqual([third, second, first].map(expect.objectContaining));
}); });
}); });

View File

@@ -1,10 +1,10 @@
import { AppStateComponentNames, deleteState, loadState, saveState } from "Shared/AppStatePersistenceUtility";
import { CollectionBase } from "../../Contracts/ViewModels"; import { CollectionBase } from "../../Contracts/ViewModels";
import { LocalStorageUtility, StorageKey } from "../../Shared/StorageUtility"; import { StorageKey, LocalStorageUtility } from "../../Shared/StorageUtility";
import { NotebookContentItem } from "../Notebook/NotebookContentItem";
export enum Type { export enum Type {
OpenCollection = "OpenCollection", OpenCollection,
OpenNotebook = "OpenNotebook", OpenNotebook,
} }
export interface OpenNotebookItem { export interface OpenNotebookItem {
@@ -21,174 +21,158 @@ export interface OpenCollectionItem {
type Item = OpenNotebookItem | OpenCollectionItem; type Item = OpenNotebookItem | OpenCollectionItem;
const itemsMaxNumber: number = 5; // Update schemaVersion if you are going to change this interface
interface StoredData {
schemaVersion: string;
itemsMap: { [accountId: string]: Item[] }; // FIFO
}
/** /**
* Migrate old data to new AppState * Stores most recent activity
*/ */
const migrateOldData = () => { class MostRecentActivity {
if (LocalStorageUtility.hasItem(StorageKey.MostRecentActivity)) { private static readonly schemaVersion: string = "2";
const oldDataSchemaVersion: string = "2"; private static itemsMaxNumber: number = 5;
const rawData = LocalStorageUtility.getEntryString(StorageKey.MostRecentActivity); private storedData: StoredData;
if (rawData) { constructor() {
const oldData = JSON.parse(rawData); // Retrieve from local storage
if (oldData.schemaVersion === oldDataSchemaVersion) { if (LocalStorageUtility.hasItem(StorageKey.MostRecentActivity)) {
const itemsMap: Record<string, Item[]> = oldData.itemsMap; const rawData = LocalStorageUtility.getEntryString(StorageKey.MostRecentActivity);
Object.keys(itemsMap).forEach((accountId: string) => {
const accountName = accountId.split("/").pop(); if (!rawData) {
if (accountName) { this.storedData = MostRecentActivity.createEmptyData();
saveState( } else {
{ try {
componentName: AppStateComponentNames.MostRecentActivity, this.storedData = JSON.parse(rawData);
globalAccountName: accountName, } catch (e) {
}, console.error("Unable to parse stored most recent activity. Use empty data:", rawData);
itemsMap[accountId].map((item) => { this.storedData = MostRecentActivity.createEmptyData();
if ((item.type as unknown as number) === 0) { }
item.type = Type.OpenCollection;
} else if ((item.type as unknown as number) === 1) { // If version doesn't match or schema broke, nuke it!
item.type = Type.OpenNotebook; if (
} !this.storedData.hasOwnProperty("schemaVersion") ||
return item; this.storedData["schemaVersion"] !== MostRecentActivity.schemaVersion
}), ) {
); LocalStorageUtility.removeEntry(StorageKey.MostRecentActivity);
} this.storedData = MostRecentActivity.createEmptyData();
}); }
}
} else {
this.storedData = MostRecentActivity.createEmptyData();
}
for (let p in this.storedData.itemsMap) {
this.cleanupItems(p);
}
this.saveToLocalStorage();
}
private static createEmptyData(): StoredData {
return {
schemaVersion: MostRecentActivity.schemaVersion,
itemsMap: {},
};
}
private static isEmpty(object: any) {
return Object.keys(object).length === 0 && object.constructor === Object;
}
private saveToLocalStorage() {
if (MostRecentActivity.isEmpty(this.storedData.itemsMap)) {
if (LocalStorageUtility.hasItem(StorageKey.MostRecentActivity)) {
LocalStorageUtility.removeEntry(StorageKey.MostRecentActivity);
}
// Don't save if empty
return;
}
LocalStorageUtility.setEntryString(StorageKey.MostRecentActivity, JSON.stringify(this.storedData));
}
private addItem(accountId: string, newItem: Item): void {
// When debugging, accountId is "undefined": most recent activity cannot be saved by account. Uncomment to disable.
// if (!accountId) {
// return;
// }
// Remove duplicate
MostRecentActivity.removeDuplicate(newItem, this.storedData.itemsMap[accountId]);
this.storedData.itemsMap[accountId] = this.storedData.itemsMap[accountId] || [];
this.storedData.itemsMap[accountId].unshift(newItem);
this.cleanupItems(accountId);
this.saveToLocalStorage();
}
public getItems(accountId: string): Item[] {
return this.storedData.itemsMap[accountId] || [];
}
public collectionWasOpened(accountId: string, { id, databaseId }: Pick<CollectionBase, "id" | "databaseId">) {
const collectionId = id();
this.addItem(accountId, {
type: Type.OpenCollection,
databaseId,
collectionId,
});
}
public notebookWasItemOpened(accountId: string, { name, path }: Pick<NotebookContentItem, "name" | "path">) {
this.addItem(accountId, {
type: Type.OpenNotebook,
name,
path,
});
}
public clear(accountId: string): void {
delete this.storedData.itemsMap[accountId];
this.saveToLocalStorage();
}
/**
* Find items by doing strict comparison and remove from array if duplicate is found
* @param item
*/
private static removeDuplicate(item: Item, itemsArray: Item[]): void {
if (!itemsArray) {
return;
}
let index = -1;
for (let i = 0; i < itemsArray.length; i++) {
const currentItem = itemsArray[i];
if (JSON.stringify(currentItem) === JSON.stringify(item)) {
index = i;
break;
} }
} }
// Remove old data if (index !== -1) {
LocalStorageUtility.removeEntry(StorageKey.MostRecentActivity); itemsArray.splice(index, 1);
}
};
const addItem = (accountName: string, newItem: Item): void => {
// When debugging, accountId is "undefined": most recent activity cannot be saved by account. Uncomment to disable.
// if (!accountId) {
// return;
// }
let items =
(loadState({
componentName: AppStateComponentNames.MostRecentActivity,
globalAccountName: accountName,
}) as Item[]) || [];
// Remove duplicate
items = removeDuplicate(newItem, items);
items.unshift(newItem);
items = cleanupItems(items, accountName);
saveState(
{
componentName: AppStateComponentNames.MostRecentActivity,
globalAccountName: accountName,
},
items,
);
};
export const getItems = (accountName: string): Item[] => {
if (!accountName) {
return [];
}
return (
(loadState({
componentName: AppStateComponentNames.MostRecentActivity,
globalAccountName: accountName,
}) as Item[]) || []
);
};
export const collectionWasOpened = (
accountName: string,
{ id, databaseId }: Pick<CollectionBase, "id" | "databaseId">,
) => {
if (accountName === undefined) {
return;
}
const collectionId = id();
addItem(accountName, {
type: Type.OpenCollection,
databaseId,
collectionId,
});
};
export const clear = (accountName: string): void => {
if (!accountName) {
return;
}
deleteState({
componentName: AppStateComponentNames.MostRecentActivity,
globalAccountName: accountName,
});
};
// Sort object by key
const sortObjectKeys = (unordered: Record<string, unknown>): Record<string, unknown> => {
return Object.keys(unordered)
.sort()
.reduce((obj: Record<string, unknown>, key: string) => {
obj[key] = unordered[key];
return obj;
}, {});
};
/**
* Find items by doing strict comparison and remove from array if duplicate is found.
* Modifies the array.
* @param item
* @param itemsArray
* @returns new array
*/
const removeDuplicate = (item: Item, itemsArray: Item[]): Item[] => {
if (!itemsArray) {
return itemsArray;
}
const result: Item[] = [...itemsArray];
let index = -1;
for (let i = 0; i < result.length; i++) {
const currentItem = result[i];
if (
JSON.stringify(sortObjectKeys(currentItem as unknown as Record<string, unknown>)) ===
JSON.stringify(sortObjectKeys(item as unknown as Record<string, unknown>))
) {
index = i;
break;
} }
} }
if (index !== -1) { /**
result.splice(index, 1); * Remove unknown types
* Limit items to max number
*/
private cleanupItems(accountId: string): void {
if (!this.storedData.itemsMap.hasOwnProperty(accountId)) {
return;
}
const itemsArray = this.storedData.itemsMap[accountId]
.filter((item) => item.type in Type)
.slice(0, MostRecentActivity.itemsMaxNumber);
if (itemsArray.length === 0) {
delete this.storedData.itemsMap[accountId];
} else {
this.storedData.itemsMap[accountId] = itemsArray;
}
} }
}
return result; export const mostRecentActivity = new MostRecentActivity();
};
/**
* Remove unknown types
* Limit items to max number
* Modifies the array.
*/
const cleanupItems = (items: Item[], accountName: string): Item[] => {
if (accountName === undefined) {
return [];
}
const itemsArray = items.filter((item) => item.type in Type).slice(0, itemsMaxNumber);
if (itemsArray.length === 0) {
deleteState({
componentName: AppStateComponentNames.MostRecentActivity,
globalAccountName: accountName,
});
}
return itemsArray;
};
migrateOldData();

View File

@@ -608,16 +608,16 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
<AccordionItem value="4"> <AccordionItem value="4">
<AccordionHeader> <AccordionHeader>
<div className={styles.header}>RU Limit</div> <div className={styles.header}>RU Threshold</div>
</AccordionHeader> </AccordionHeader>
<AccordionPanel> <AccordionPanel>
<div className={styles.settingsSectionContainer}> <div className={styles.settingsSectionContainer}>
<div className={styles.settingsSectionDescription}> <div className={styles.settingsSectionDescription}>
If a query exceeds a configured RU limit, the query will be aborted. If a query exceeds a configured RU threshold, the query will be aborted.
</div> </div>
<Toggle <Toggle
styles={toggleStyles} styles={toggleStyles}
label="Enable RU limit" label="Enable RU threshold"
onChange={handleOnRUThresholdToggleChange} onChange={handleOnRUThresholdToggleChange}
defaultChecked={ruThresholdEnabled} defaultChecked={ruThresholdEnabled}
/> />
@@ -625,7 +625,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
{ruThresholdEnabled && ( {ruThresholdEnabled && (
<div className={styles.settingsSectionContainer}> <div className={styles.settingsSectionContainer}>
<SpinButton <SpinButton
label="RU Limit (RU)" label="RU Threshold (RU)"
labelPosition={Position.top} labelPosition={Position.top}
defaultValue={(ruThreshold || DefaultRUThreshold).toString()} defaultValue={(ruThreshold || DefaultRUThreshold).toString()}
min={1} min={1}

View File

@@ -154,7 +154,7 @@ exports[`Settings Pane should render Default properly 1`] = `
<div <div
className="___15c001r_0000000 fq02s40" className="___15c001r_0000000 fq02s40"
> >
RU Limit RU Threshold
</div> </div>
</AccordionHeader> </AccordionHeader>
<AccordionPanel> <AccordionPanel>
@@ -164,11 +164,11 @@ exports[`Settings Pane should render Default properly 1`] = `
<div <div
className="___10gar1i_0000000 f1fow5ox f1ugzwwg" className="___10gar1i_0000000 f1fow5ox f1ugzwwg"
> >
If a query exceeds a configured RU limit, the query will be aborted. If a query exceeds a configured RU threshold, the query will be aborted.
</div> </div>
<StyledToggleBase <StyledToggleBase
defaultChecked={true} defaultChecked={true}
label="Enable RU limit" label="Enable RU threshold"
onChange={[Function]} onChange={[Function]}
styles={ styles={
{ {
@@ -193,7 +193,7 @@ exports[`Settings Pane should render Default properly 1`] = `
decrementButtonAriaLabel="Decrease value by 1000" decrementButtonAriaLabel="Decrease value by 1000"
defaultValue="5000" defaultValue="5000"
incrementButtonAriaLabel="Increase value by 1000" incrementButtonAriaLabel="Increase value by 1000"
label="RU Limit (RU)" label="RU Threshold (RU)"
labelPosition={0} labelPosition={0}
min={1} min={1}
onChange={[Function]} onChange={[Function]}

View File

@@ -61,15 +61,7 @@ export const TableColumnSelectionPane: React.FC<TableColumnSelectionPaneProps> =
if (checked) { if (checked) {
selectedColumnIdsSet.add(id); selectedColumnIdsSet.add(id);
} else { } else {
/* selectedColumnIds may contain ids that are not in columnDefinitions, because the selected if (selectedColumnIdsSet.size === 1 && selectedColumnIdsSet.has(id)) {
* ids may have been loaded from persistence, but don't exist in the current retrieved documents.
*/
if (
Array.from(selectedColumnIdsSet).filter((id) => columnDefinitions.find((def) => def.id === id) !== undefined)
.length === 1 &&
selectedColumnIdsSet.has(id)
) {
// Don't allow unchecking the last column // Don't allow unchecking the last column
return; return;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -114,7 +114,7 @@ export class SplashScreen extends React.Component<SplashScreenProps> {
} }
private clearMostRecent = (): void => { private clearMostRecent = (): void => {
MostRecentActivity.clear(userContext.databaseAccount?.name); MostRecentActivity.mostRecentActivity.clear(userContext.databaseAccount?.id);
this.setState({}); this.setState({});
}; };
@@ -498,7 +498,7 @@ export class SplashScreen extends React.Component<SplashScreenProps> {
} }
private createRecentItems(): SplashScreenItem[] { private createRecentItems(): SplashScreenItem[] {
return MostRecentActivity.getItems(userContext.databaseAccount?.name).map((activity) => { return MostRecentActivity.mostRecentActivity.getItems(userContext.databaseAccount?.id).map((activity) => {
switch (activity.type) { switch (activity.type) {
default: { default: {
const unknownActivity: never = activity; const unknownActivity: never = activity;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,13 @@
// Definitions of State data // Definitions of State data
import { ColumnDefinition } from "Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent"; import { ColumnDefinition } from "Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent";
import { import { deleteState, loadState, saveState, saveStateDebounced } from "Shared/AppStatePersistenceUtility";
AppStateComponentNames,
deleteState,
loadState,
saveState,
saveStateDebounced,
} from "Shared/AppStatePersistenceUtility";
import { userContext } from "UserContext"; import { userContext } from "UserContext";
import * as ViewModels from "../../../Contracts/ViewModels"; import * as ViewModels from "../../../Contracts/ViewModels";
import { Action } from "../../../Shared/Telemetry/TelemetryConstants"; import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor"; import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
const componentName = AppStateComponentNames.DocumentsTab; const componentName = "DocumentsTab";
export enum SubComponentName { export enum SubComponentName {
ColumnSizes = "ColumnSizes", ColumnSizes = "ColumnSizes",
FilterHistory = "FilterHistory", FilterHistory = "FilterHistory",

View File

@@ -1,11 +1,8 @@
import { FeedResponse, ItemDefinition, Resource } from "@azure/cosmos"; import { FeedResponse, ItemDefinition, Resource } from "@azure/cosmos";
import { waitFor } from "@testing-library/react";
import { deleteDocuments } from "Common/dataAccess/deleteDocument"; import { deleteDocuments } from "Common/dataAccess/deleteDocument";
import { Platform, updateConfigContext } from "ConfigContext"; import { Platform, updateConfigContext } from "ConfigContext";
import { useDialog } from "Explorer/Controls/Dialog";
import { EditorReactProps } from "Explorer/Controls/Editor/EditorReact"; import { EditorReactProps } from "Explorer/Controls/Editor/EditorReact";
import { ProgressModalDialog } from "Explorer/Controls/ProgressModalDialog"; import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { import {
ButtonsDependencies, ButtonsDependencies,
DELETE_BUTTON_ID, DELETE_BUTTON_ID,
@@ -68,14 +65,12 @@ jest.mock("Explorer/Controls/Editor/EditorReact", () => ({
EditorReact: (props: EditorReactProps) => <>{props.content}</>, EditorReact: (props: EditorReactProps) => <>{props.content}</>,
})); }));
const mockDialogState = {
showOkCancelModalDialog: jest.fn((title: string, subText: string, okLabel: string, onOk: () => void) => onOk()),
showOkModalDialog: () => {},
};
jest.mock("Explorer/Controls/Dialog", () => ({ jest.mock("Explorer/Controls/Dialog", () => ({
useDialog: { useDialog: {
getState: jest.fn(() => mockDialogState), getState: jest.fn(() => ({
showOkCancelModalDialog: (title: string, subText: string, okLabel: string, onOk: () => void) => onOk(),
showOkModalDialog: () => {},
})),
}, },
})); }));
@@ -85,10 +80,6 @@ jest.mock("Common/dataAccess/deleteDocument", () => ({
), ),
})); }));
jest.mock("Explorer/Controls/ProgressModalDialog", () => ({
ProgressModalDialog: jest.fn(() => <></>),
}));
async function waitForComponentToPaint<P = unknown>(wrapper: ReactWrapper<P> | ShallowWrapper<P>, amount = 0) { async function waitForComponentToPaint<P = unknown>(wrapper: ReactWrapper<P> | ShallowWrapper<P>, amount = 0) {
let newWrapper; let newWrapper;
await act(async () => { await act(async () => {
@@ -461,7 +452,7 @@ describe("Documents tab (noSql API)", () => {
useCommandBar useCommandBar
.getState() .getState()
.contextButtons.find((button) => button.id === NEW_DOCUMENT_BUTTON_ID) .contextButtons.find((button) => button.id === NEW_DOCUMENT_BUTTON_ID)
.onCommandClick(undefined, undefined); .onCommandClick(undefined);
}); });
expect(wrapper.findWhere((node) => node.text().includes("replace_with_new_document_id")).exists()).toBeTruthy(); expect(wrapper.findWhere((node) => node.text().includes("replace_with_new_document_id")).exists()).toBeTruthy();
}); });
@@ -471,36 +462,14 @@ describe("Documents tab (noSql API)", () => {
useCommandBar useCommandBar
.getState() .getState()
.contextButtons.find((button) => button.id === NEW_DOCUMENT_BUTTON_ID) .contextButtons.find((button) => button.id === NEW_DOCUMENT_BUTTON_ID)
.onCommandClick(undefined, undefined); .onCommandClick(undefined);
}); });
expect(useCommandBar.getState().contextButtons.find((button) => button.id === SAVE_BUTTON_ID)).toBeDefined(); expect(useCommandBar.getState().contextButtons.find((button) => button.id === SAVE_BUTTON_ID)).toBeDefined();
expect(useCommandBar.getState().contextButtons.find((button) => button.id === DISCARD_BUTTON_ID)).toBeDefined(); expect(useCommandBar.getState().contextButtons.find((button) => button.id === DISCARD_BUTTON_ID)).toBeDefined();
}); });
it("clicking Delete Document asks for confirmation", async () => { it("clicking Delete Document asks for confirmation", () => {
act(async () => {
await useCommandBar
.getState()
.contextButtons.find((button) => button.id === DELETE_BUTTON_ID)
.onCommandClick(undefined, undefined);
});
expect(useDialog.getState().showOkCancelModalDialog).toHaveBeenCalled();
});
it("clicking Delete Document for NoSql shows progress dialog", () => {
act(() => {
useCommandBar
.getState()
.contextButtons.find((button) => button.id === DELETE_BUTTON_ID)
.onCommandClick(undefined, undefined);
});
expect(ProgressModalDialog).toHaveBeenCalled();
});
it("clicking Delete Document eventually calls delete client api", () => {
const mockDeleteDocuments = deleteDocuments as jest.Mock; const mockDeleteDocuments = deleteDocuments as jest.Mock;
mockDeleteDocuments.mockClear(); mockDeleteDocuments.mockClear();
@@ -508,11 +477,10 @@ describe("Documents tab (noSql API)", () => {
useCommandBar useCommandBar
.getState() .getState()
.contextButtons.find((button) => button.id === DELETE_BUTTON_ID) .contextButtons.find((button) => button.id === DELETE_BUTTON_ID)
.onCommandClick(undefined, undefined); .onCommandClick(undefined);
}); });
// The implementation uses setTimeout, so wait for it to finish expect(mockDeleteDocuments).toHaveBeenCalled();
waitFor(() => expect(mockDeleteDocuments).toHaveBeenCalled());
}); });
}); });
}); });

View File

@@ -1,15 +1,5 @@
import { Item, ItemDefinition, PartitionKey, PartitionKeyDefinition, QueryIterator, Resource } from "@azure/cosmos"; import { Item, ItemDefinition, PartitionKey, PartitionKeyDefinition, QueryIterator, Resource } from "@azure/cosmos";
import { import { Button, Input, TableRowId, makeStyles, shorthands } from "@fluentui/react-components";
Button,
Input,
Link,
MessageBar,
MessageBarBody,
MessageBarTitle,
TableRowId,
makeStyles,
shorthands,
} from "@fluentui/react-components";
import { Dismiss16Filled } from "@fluentui/react-icons"; import { Dismiss16Filled } from "@fluentui/react-icons";
import { KeyCodes, QueryCopilotSampleContainerId, QueryCopilotSampleDatabaseId } from "Common/Constants"; import { KeyCodes, QueryCopilotSampleContainerId, QueryCopilotSampleDatabaseId } from "Common/Constants";
import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils"; import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
@@ -26,9 +16,8 @@ import { Platform, configContext } from "ConfigContext";
import { CommandButtonComponentProps } from "Explorer/Controls/CommandButton/CommandButtonComponent"; import { CommandButtonComponentProps } from "Explorer/Controls/CommandButton/CommandButtonComponent";
import { useDialog } from "Explorer/Controls/Dialog"; import { useDialog } from "Explorer/Controls/Dialog";
import { EditorReact } from "Explorer/Controls/Editor/EditorReact"; import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
import { ProgressModalDialog } from "Explorer/Controls/ProgressModalDialog";
import Explorer from "Explorer/Explorer"; import Explorer from "Explorer/Explorer";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar"; import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities"; import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import { import {
ColumnsSelection, ColumnsSelection,
@@ -46,7 +35,7 @@ import { QueryConstants } from "Shared/Constants";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility"; import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { Action } from "Shared/Telemetry/TelemetryConstants"; import { Action } from "Shared/Telemetry/TelemetryConstants";
import { userContext } from "UserContext"; import { userContext } from "UserContext";
import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils"; import { logConsoleError } from "Utils/NotificationConsoleUtils";
import { Allotment } from "allotment"; import { Allotment } from "allotment";
import React, { KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from "react"; import React, { KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { format } from "react-string-format"; import { format } from "react-string-format";
@@ -71,9 +60,6 @@ import TabsBase from "../TabsBase";
import { ColumnDefinition, 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 MAX_FILTER_HISTORY_COUNT = 100; // Datalist will become scrollable, so we can afford to keep more items than fit on the screen
const NO_SQL_THROTTLING_DOC_URL =
"https://learn.microsoft.com/azure/cosmos-db/nosql/troubleshoot-request-rate-too-large";
const MONGO_THROTTLING_DOC_URL = "https://learn.microsoft.com/azure/cosmos-db/mongodb/prevent-rate-limiting-errors";
const loadMoreHeight = LayoutConstants.rowHeight; const loadMoreHeight = LayoutConstants.rowHeight;
export const useDocumentsTabStyles = makeStyles({ export const useDocumentsTabStyles = makeStyles({
@@ -105,13 +91,6 @@ export const useDocumentsTabStyles = makeStyles({
tableCell: { tableCell: {
...cosmosShorthands.borderLeft(), ...cosmosShorthands.borderLeft(),
}, },
tableHeader: {
display: "flex",
},
tableHeaderFiller: {
width: "20px",
boxShadow: `0px -1px ${tokens.colorNeutralStroke2} inset`,
},
loadMore: { loadMore: {
...cosmosShorthands.borderTop(), ...cosmosShorthands.borderTop(),
display: "grid", display: "grid",
@@ -124,20 +103,6 @@ export const useDocumentsTabStyles = makeStyles({
...shorthands.outline("1px", "dotted"), ...shorthands.outline("1px", "dotted"),
}, },
}, },
floatingControlsContainer: {
position: "relative",
},
floatingControls: {
position: "absolute",
top: "6px",
right: 0,
float: "right",
backgroundColor: "white",
zIndex: 1,
},
deleteProgressContent: {
paddingTop: tokens.spacingVerticalL,
},
}); });
export class DocumentsTabV2 extends TabsBase { export class DocumentsTabV2 extends TabsBase {
@@ -568,7 +533,7 @@ const defaultMongoFilters = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"];
type ExtendedDocumentId = DocumentId & { tableFields?: DocumentsTableComponentItem }; type ExtendedDocumentId = DocumentId & { tableFields?: DocumentsTableComponentItem };
// This is based on some heuristics // This is based on some heuristics
const calculateOffset = (columnNumber: number): number => columnNumber * 16 - 27; const calculateOffset = (columnNumber: number): number => columnNumber * 16 - 29;
// Export to expose to unit tests // Export to expose to unit tests
export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabComponentProps> = ({ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabComponentProps> = ({
@@ -637,24 +602,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
readSubComponentState<FilterHistory>(SubComponentName.FilterHistory, _collection, [] as FilterHistory), readSubComponentState<FilterHistory>(SubComponentName.FilterHistory, _collection, [] as FilterHistory),
); );
// For progress bar for bulk delete (noSql)
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = React.useState(false);
const [bulkDeleteProcess, setBulkDeleteProcess] = useState<{
pendingIds: DocumentId[];
successfulIds: DocumentId[];
throttledIds: DocumentId[];
failedIds: DocumentId[];
beforeExecuteMs: number; // Delay before executing delete. Used for retrying throttling after a specified delay
hasBeenThrottled: boolean; // Keep track if the operation has been throttled at least once
}>(undefined);
const [bulkDeleteOperation, setBulkDeleteOperation] = useState<{
onCompleted: (documentIds: DocumentId[]) => void;
onFailed: (reason?: unknown) => void;
count: number;
collection: CollectionBase;
}>(undefined);
const [bulkDeleteMode, setBulkDeleteMode] = useState<"inProgress" | "completed" | "aborting" | "aborted">(undefined);
const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB); const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB);
useEffect(() => { useEffect(() => {
@@ -680,99 +627,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
} }
}, [documentIds, clickedRowIndex, editorState]); }, [documentIds, clickedRowIndex, editorState]);
/**
* Recursively delete all documents by retrying throttled requests (429).
* This only works for NoSQL, because the bulk response includes status for each delete document request.
* Recursion is implemented using React useEffect (as opposed to recursively calling setTimeout), because it
* has to update the <ProgressModalDialog> or check if the user is aborting the operation via state React
* variables.
*
* Inputs are the bulkDeleteOperation, bulkDeleteProcess and bulkDeleteMode state variables.
* When the bulkDeleteProcess changes, the function in the useEffect is triggered and checks if the process
* was aborted or completed, which will resolve the promise.
* Otherwise, it will attempt to delete documents of the pending and throttled ids arrays.
* Once deletion is completed, the function updates bulkDeleteProcess with the results, which will trigger
* the function to be called again.
*/
useEffect(() => {
if (!bulkDeleteOperation || !bulkDeleteProcess || !bulkDeleteMode) {
return;
}
if (bulkDeleteMode === "completed" || bulkDeleteMode === "aborted") {
// no op in the case function is called again
return;
}
if (
(bulkDeleteProcess.pendingIds.length === 0 && bulkDeleteProcess.throttledIds.length === 0) ||
bulkDeleteMode === "aborting"
) {
// Successfully deleted all documents or operation was aborted
bulkDeleteOperation.onCompleted(bulkDeleteProcess.successfulIds);
setBulkDeleteMode(bulkDeleteMode === "aborting" ? "aborted" : "completed");
return;
}
// Start deleting documents or retry throttled requests
const newPendingIds = bulkDeleteProcess.pendingIds.concat(bulkDeleteProcess.throttledIds);
const timeout = bulkDeleteProcess.beforeExecuteMs || 0;
setTimeout(() => {
deleteNoSqlDocuments(bulkDeleteOperation.collection, [...newPendingIds])
.then((deleteResult) => {
let retryAfterMilliseconds = 0;
const newSuccessful: DocumentId[] = [];
const newThrottled: DocumentId[] = [];
const newFailed: DocumentId[] = [];
deleteResult.forEach((result) => {
if (result.statusCode === Constants.HttpStatusCodes.NoContent) {
newSuccessful.push(result.documentId);
} else if (result.statusCode === Constants.HttpStatusCodes.TooManyRequests) {
newThrottled.push(result.documentId);
retryAfterMilliseconds = Math.max(result.retryAfterMilliseconds, retryAfterMilliseconds);
} else if (result.statusCode >= 400) {
newFailed.push(result.documentId);
logConsoleError(
`Failed to delete document ${result.documentId.id} with status code ${result.statusCode}`,
);
}
});
logConsoleInfo(`Successfully deleted ${newSuccessful.length} document(s)`);
if (newThrottled.length > 0) {
logConsoleError(
`Failed to delete ${newThrottled.length} document(s) due to "Request too large" (429) error. Retrying...`,
);
}
// Update result of the bulk delete: method is called again, because the state variables changed
// it will decide at the next call what to do
setBulkDeleteProcess((prev) => ({
pendingIds: [],
successfulIds: prev.successfulIds.concat(newSuccessful),
throttledIds: newThrottled,
failedIds: prev.failedIds.concat(newFailed),
beforeExecuteMs: retryAfterMilliseconds,
hasBeenThrottled: prev.hasBeenThrottled || newThrottled.length > 0,
}));
})
.catch((error) => {
console.error("Error deleting documents", error);
setBulkDeleteProcess((prev) => ({
pendingIds: [],
throttledIds: [],
successfulIds: prev.successfulIds,
failedIds: prev.failedIds.concat(prev.pendingIds),
beforeExecuteMs: undefined,
hasBeenThrottled: prev.hasBeenThrottled,
}));
bulkDeleteOperation.onFailed(error);
});
}, timeout);
}, [bulkDeleteOperation, bulkDeleteProcess, bulkDeleteMode]);
const applyFilterButton = { const applyFilterButton = {
enabled: true, enabled: true,
visible: true, visible: true,
@@ -1028,10 +882,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
); );
}, },
) )
.then(() => { .then(() => setSelectedRows(new Set([documentIds.length - 1])))
setSelectedRows(new Set([documentIds.length - 1]));
setClickedRowIndex(documentIds.length - 1);
})
.finally(() => setIsExecuting(false)); .finally(() => setIsExecuting(false));
}, [ }, [
onExecutionErrorChange, onExecutionErrorChange,
@@ -1125,36 +976,8 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
setSelectedDocumentContent(selectedDocumentContentBaseline); setSelectedDocumentContent(selectedDocumentContentBaseline);
}, [initialDocumentContent, selectedDocumentContentBaseline, setSelectedDocumentContent]); }, [initialDocumentContent, selectedDocumentContentBaseline, setSelectedDocumentContent]);
/**
* Trigger a useEffect() to bulk delete noSql documents
* @param collection
* @param documentIds
* @returns
*/
const _bulkDeleteNoSqlDocuments = (collection: CollectionBase, documentIds: DocumentId[]): Promise<DocumentId[]> =>
new Promise<DocumentId[]>((resolve, reject) => {
setBulkDeleteOperation({
onCompleted: resolve,
onFailed: reject,
count: documentIds.length,
collection,
});
setBulkDeleteProcess({
pendingIds: [...documentIds],
throttledIds: [],
successfulIds: [],
failedIds: [],
beforeExecuteMs: 0,
hasBeenThrottled: false,
});
setIsBulkDeleteDialogOpen(true);
setBulkDeleteMode("inProgress");
});
/** /**
* Implementation using bulk delete NoSQL API * Implementation using bulk delete NoSQL API
* @param list of document ids to delete
* @returns Promise of list of deleted document ids
*/ */
const _deleteDocuments = useCallback( const _deleteDocuments = useCallback(
async (toDeleteDocumentIds: DocumentId[]): Promise<DocumentId[]> => { async (toDeleteDocumentIds: DocumentId[]): Promise<DocumentId[]> => {
@@ -1165,33 +988,20 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
}); });
setIsExecuting(true); setIsExecuting(true);
let deletePromise; // TODO: Once JS SDK Bug fix for bulk deleting legacy containers (whose systemKey==1) is released:
if (!isPreferredApiMongoDB) { // Remove the check for systemKey, remove call to deleteNoSqlDocument(). deleteNoSqlDocuments() should always be called.
if (partitionKey.systemKey) { const _deleteNoSqlDocuments = async (
// ---------------------------------------------------------------------------------------------------- collection: CollectionBase,
// TODO: Once JS SDK Bug fix for bulk deleting legacy containers (whose systemKey==1) is released: toDeleteDocumentIds: DocumentId[],
// Remove the check for systemKey, remove call to deleteNoSqlDocument(). deleteNoSqlDocuments() should ): Promise<DocumentId[]> => {
// always be called for NoSQL. return partitionKey.systemKey
deletePromise = deleteNoSqlDocument(_collection, toDeleteDocumentIds[0]).then(() => { ? deleteNoSqlDocument(collection, toDeleteDocumentIds[0]).then(() => [toDeleteDocumentIds[0]])
useDialog.getState().showOkModalDialog("Delete document", "Document successfully deleted."); : deleteNoSqlDocuments(collection, toDeleteDocumentIds);
return [toDeleteDocumentIds[0]]; };
});
// ---------------------------------------------------------------------------------------------------- const deletePromise = !isPreferredApiMongoDB
} else { ? _deleteNoSqlDocuments(_collection, toDeleteDocumentIds)
deletePromise = _bulkDeleteNoSqlDocuments(_collection, toDeleteDocumentIds); : MongoProxyClient.deleteDocuments(
}
} else {
if (isMongoBulkDeleteDisabled) {
// TODO: Once new mongo proxy is available for all users, remove the call for MongoProxyClient.deleteDocument().
// MongoProxyClient.deleteDocuments() should be called for all users.
deletePromise = MongoProxyClient.deleteDocument(
_collection.databaseId,
_collection as ViewModels.Collection,
toDeleteDocumentIds[0],
).then(() => [toDeleteDocumentIds[0]]);
// ----------------------------------------------------------------------------------------------------
} else {
deletePromise = MongoProxyClient.deleteDocuments(
_collection.databaseId, _collection.databaseId,
_collection as ViewModels.Collection, _collection as ViewModels.Collection,
toDeleteDocumentIds, toDeleteDocumentIds,
@@ -1201,8 +1011,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
} }
throw new Error(`Delete failed with deletedCount: ${deletedCount} and isAcknowledged: ${isAcknowledged}`); throw new Error(`Delete failed with deletedCount: ${deletedCount} and isAcknowledged: ${isAcknowledged}`);
}); });
}
}
return deletePromise return deletePromise
.then( .then(
@@ -1233,11 +1041,9 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
throw error; throw error;
}, },
) )
.finally(() => { .finally(() => setIsExecuting(false));
setIsExecuting(false);
});
}, },
[_collection, isPreferredApiMongoDB, onExecutionErrorChange, tabTitle, partitionKey.systemKey], [_collection, isPreferredApiMongoDB, onExecutionErrorChange, tabTitle],
); );
const deleteDocuments = useCallback( const deleteDocuments = useCallback(
@@ -1255,25 +1061,14 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
setClickedRowIndex(undefined); setClickedRowIndex(undefined);
setSelectedRows(new Set()); setSelectedRows(new Set());
setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected); setEditorState(ViewModels.DocumentExplorerState.noDocumentSelected);
useDialog
.getState()
.showOkModalDialog("Delete documents", `${deletedIds.length} document(s) successfully deleted.`);
}, },
(error: Error) => { (error: Error) =>
if (error instanceof MongoProxyClient.ThrottlingError) { useDialog
useDialog .getState()
.getState() .showOkModalDialog("Delete documents", `Deleting document(s) failed (${error.message})`),
.showOkModalDialog(
"Delete documents",
`Some documents failed to delete due to a rate limiting error. Please try again later. To prevent this in the future, consider increasing the throughput on your container or database.`,
{
linkText: "Learn More",
linkUrl: MONGO_THROTTLING_DOC_URL,
},
);
} else {
useDialog
.getState()
.showOkModalDialog("Delete documents", `Deleting document(s) failed (${error.message})`);
}
},
) )
.finally(() => setIsExecuting(false)); .finally(() => setIsExecuting(false));
}, },
@@ -2058,26 +1853,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
[createIterator, filterContent], [createIterator, filterContent],
); );
/**
* While retrying, display: retrying now.
* If completed and all documents were deleted, display: all documents deleted.
* @returns 429 warning message
*/
const get429WarningMessageNoSql = (): string => {
let message = 'Some delete requests failed due to a "Request too large" exception (429)';
if (bulkDeleteOperation.count === bulkDeleteProcess.successfulIds.length) {
message += ", but were successfully retried.";
} else if (bulkDeleteMode === "inProgress" || bulkDeleteMode === "aborting") {
message += ". Retrying now.";
} else {
message += ".";
}
return (message +=
" To prevent this in the future, consider increasing the throughput on your container or database.");
};
const onColumnSelectionChange = (newSelectedColumnIds: string[]): void => { const onColumnSelectionChange = (newSelectedColumnIds: string[]): void => {
// Do not allow to unselecting all columns // Do not allow to unselecting all columns
if (newSelectedColumnIds.length === 0) { if (newSelectedColumnIds.length === 0) {
@@ -2113,13 +1888,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
} }
}, [prevSelectedColumnIds, refreshDocumentsGrid, selectedColumnIds]); }, [prevSelectedColumnIds, refreshDocumentsGrid, selectedColumnIds]);
// TODO: remove isMongoBulkDeleteDisabled when new mongo proxy is enabled for all users
// TODO: remove partitionKey.systemKey when JS SDK bug is fixed
const isMongoBulkDeleteDisabled = !MongoProxyClient.useMongoProxyEndpoint("bulkdelete");
const isBulkDeleteDisabled =
(partitionKey.systemKey && !isPreferredApiMongoDB) || (isPreferredApiMongoDB && isMongoBulkDeleteDisabled);
// -------------------------------------------------------
return ( return (
<CosmosFluentProvider className={styles.container}> <CosmosFluentProvider className={styles.container}>
<div className="tab-pane active" role="tabpanel" style={{ display: "flex" }}> <div className="tab-pane active" role="tabpanel" style={{ display: "flex" }}>
@@ -2239,8 +2007,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
selectedColumnIds={selectedColumnIds} selectedColumnIds={selectedColumnIds}
columnDefinitions={columnDefinitions} columnDefinitions={columnDefinitions}
isRowSelectionDisabled={ isRowSelectionDisabled={
isBulkDeleteDisabled || configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly
(configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly)
} }
onColumnSelectionChange={onColumnSelectionChange} onColumnSelectionChange={onColumnSelectionChange}
defaultColumnSelection={getInitialColumnSelection()} defaultColumnSelection={getInitialColumnSelection()}
@@ -2284,52 +2051,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
</Allotment> </Allotment>
</div> </div>
</div> </div>
{bulkDeleteOperation && (
<ProgressModalDialog
isOpen={isBulkDeleteDialogOpen}
dismissText="Abort"
onDismiss={() => {
setIsBulkDeleteDialogOpen(false);
setBulkDeleteOperation(undefined);
}}
onCancel={() => setBulkDeleteMode("aborting")}
title={`Deleting ${bulkDeleteOperation.count} document(s)`}
message={`Successfully deleted ${bulkDeleteProcess.successfulIds.length} document(s).`}
maxValue={bulkDeleteOperation.count}
value={bulkDeleteProcess.successfulIds.length}
mode={bulkDeleteMode}
>
<div className={styles.deleteProgressContent}>
{(bulkDeleteMode === "aborting" || bulkDeleteMode === "aborted") && (
<div style={{ paddingBottom: tokens.spacingVerticalL }}>Deleting document(s) was aborted.</div>
)}
{(bulkDeleteProcess.failedIds.length > 0 ||
(bulkDeleteProcess.throttledIds.length > 0 && bulkDeleteMode !== "inProgress")) && (
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalL }}>
<MessageBarBody>
<MessageBarTitle>Error</MessageBarTitle>
Failed to delete{" "}
{bulkDeleteMode === "inProgress"
? bulkDeleteProcess.failedIds.length
: bulkDeleteProcess.failedIds.length + bulkDeleteProcess.throttledIds.length}{" "}
document(s).
</MessageBarBody>
</MessageBar>
)}
{bulkDeleteProcess.hasBeenThrottled && (
<MessageBar intent="warning">
<MessageBarBody>
<MessageBarTitle>Warning</MessageBarTitle>
{get429WarningMessageNoSql()}{" "}
<Link href={NO_SQL_THROTTLING_DOC_URL} target="_blank">
Learn More
</Link>
</MessageBarBody>
</MessageBar>
)}
</div>
</ProgressModalDialog>
)}
</CosmosFluentProvider> </CosmosFluentProvider>
); );
}; };

View File

@@ -1,7 +1,7 @@
import { deleteDocuments } from "Common/MongoProxyClient"; import { deleteDocuments } from "Common/MongoProxyClient";
import { Platform, updateConfigContext } from "ConfigContext"; import { Platform, updateConfigContext } from "ConfigContext";
import { EditorReactProps } from "Explorer/Controls/Editor/EditorReact"; import { EditorReactProps } from "Explorer/Controls/Editor/EditorReact";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar"; import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { import {
DELETE_BUTTON_ID, DELETE_BUTTON_ID,
DISCARD_BUTTON_ID, DISCARD_BUTTON_ID,
@@ -49,9 +49,7 @@ jest.mock("Common/MongoProxyClient", () => ({
id: "id1", id: "id1",
}), }),
), ),
deleteDocuments: jest.fn(() => Promise.resolve({ deleteCount: 0, isAcknowledged: true })), deleteDocuments: jest.fn(() => Promise.resolve()),
ThrottlingError: Error,
useMongoProxyEndpoint: jest.fn(() => true),
})); }));
jest.mock("Explorer/Controls/Editor/EditorReact", () => ({ jest.mock("Explorer/Controls/Editor/EditorReact", () => ({
@@ -163,7 +161,7 @@ describe("Documents tab (Mongo API)", () => {
useCommandBar useCommandBar
.getState() .getState()
.contextButtons.find((button) => button.id === NEW_DOCUMENT_BUTTON_ID) .contextButtons.find((button) => button.id === NEW_DOCUMENT_BUTTON_ID)
.onCommandClick(undefined, undefined); .onCommandClick(undefined);
}); });
expect(wrapper.findWhere((node) => node.text().includes("replace_with_new_document_id")).exists()).toBeTruthy(); expect(wrapper.findWhere((node) => node.text().includes("replace_with_new_document_id")).exists()).toBeTruthy();
}); });
@@ -173,14 +171,14 @@ describe("Documents tab (Mongo API)", () => {
useCommandBar useCommandBar
.getState() .getState()
.contextButtons.find((button) => button.id === NEW_DOCUMENT_BUTTON_ID) .contextButtons.find((button) => button.id === NEW_DOCUMENT_BUTTON_ID)
.onCommandClick(undefined, undefined); .onCommandClick(undefined);
}); });
expect(useCommandBar.getState().contextButtons.find((button) => button.id === SAVE_BUTTON_ID)).toBeDefined(); expect(useCommandBar.getState().contextButtons.find((button) => button.id === SAVE_BUTTON_ID)).toBeDefined();
expect(useCommandBar.getState().contextButtons.find((button) => button.id === DISCARD_BUTTON_ID)).toBeDefined(); expect(useCommandBar.getState().contextButtons.find((button) => button.id === DISCARD_BUTTON_ID)).toBeDefined();
}); });
it("clicking Delete Document eventually calls delete client api", () => { it("clicking Delete Document asks for confirmation", () => {
const mockDeleteDocuments = deleteDocuments as jest.Mock; const mockDeleteDocuments = deleteDocuments as jest.Mock;
mockDeleteDocuments.mockClear(); mockDeleteDocuments.mockClear();
@@ -188,7 +186,7 @@ describe("Documents tab (Mongo API)", () => {
useCommandBar useCommandBar
.getState() .getState()
.contextButtons.find((button) => button.id === DELETE_BUTTON_ID) .contextButtons.find((button) => button.id === DELETE_BUTTON_ID)
.onCommandClick(undefined, undefined); .onCommandClick(undefined);
}); });
expect(mockDeleteDocuments).toHaveBeenCalled(); expect(mockDeleteDocuments).toHaveBeenCalled();

View File

@@ -38,7 +38,6 @@ import {
TextSortDescendingRegular, TextSortDescendingRegular,
} from "@fluentui/react-icons"; } from "@fluentui/react-icons";
import { NormalizedEventKey } from "Common/Constants"; import { NormalizedEventKey } from "Common/Constants";
import { Environment, getEnvironment } from "Common/EnvironmentUtility";
import { TableColumnSelectionPane } from "Explorer/Panes/TableColumnSelectionPane/TableColumnSelectionPane"; import { TableColumnSelectionPane } from "Explorer/Panes/TableColumnSelectionPane/TableColumnSelectionPane";
import { import {
ColumnSizesMap, ColumnSizesMap,
@@ -51,6 +50,7 @@ import {
import { INITIAL_SELECTED_ROW_INDEX, useDocumentsTabStyles } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2"; import { INITIAL_SELECTED_ROW_INDEX, useDocumentsTabStyles } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2";
import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper"; import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
import { LayoutConstants } from "Explorer/Theme/ThemeUtil"; import { LayoutConstants } from "Explorer/Theme/ThemeUtil";
import { userContext } from "UserContext";
import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils"; import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils";
import { useSidePanel } from "hooks/useSidePanel"; import { useSidePanel } from "hooks/useSidePanel";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
@@ -228,7 +228,7 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
<MenuItem key="refresh" icon={<ArrowClockwise16Regular />} onClick={onRefreshTable}> <MenuItem key="refresh" icon={<ArrowClockwise16Regular />} onClick={onRefreshTable}>
Refresh Refresh
</MenuItem> </MenuItem>
{[Environment.Development, Environment.Mpac].includes(getEnvironment()) && ( {userContext.features.enableDocumentsTableColumnSelection && (
<> <>
<MenuItem <MenuItem
icon={<TextSortAscendingRegular />} icon={<TextSortAscendingRegular />}
@@ -251,34 +251,33 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
</MenuItem> </MenuItem>
)} )}
<MenuDivider /> <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>
)}
</> </>
)} )}
<MenuItem
key="keyboardresize"
icon={<TableResizeColumnRegular />}
onClick={columnSizing.enableKeyboardMode(column.id)}
>
Resize with left/right arrow keys
</MenuItem>
{[Environment.Development, Environment.Mpac].includes(getEnvironment()) &&
!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> </MenuList>
</MenuPopover> </MenuPopover>
</Menu> </Menu>
@@ -472,8 +471,8 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
}; };
return ( return (
<Table noNativeElements {...tableProps}> <Table noNativeElements sortable {...tableProps}>
<TableHeader className={styles.tableHeader}> <TableHeader>
<TableRow className={styles.tableRow} style={{ width: size ? size.width - 15 : "100%" }}> <TableRow className={styles.tableRow} style={{ width: size ? size.width - 15 : "100%" }}>
{!isSelectionDisabled && ( {!isSelectionDisabled && (
<TableSelectionCell <TableSelectionCell
@@ -495,7 +494,6 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
</TableHeaderCell> </TableHeaderCell>
))} ))}
</TableRow> </TableRow>
<div className={styles.tableHeaderFiller}></div>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<List <List

View File

@@ -61,7 +61,7 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
style={ style={
{ {
"height": "100%", "height": "100%",
"width": "calc(100% + -11px)", "width": "calc(100% + -13px)",
} }
} }
> >

View File

@@ -57,6 +57,7 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
noNativeElements={true} noNativeElements={true}
role="grid" role="grid"
size="small" size="small"
sortable={true}
style={ style={
{ {
"minWidth": "fit-content", "minWidth": "fit-content",
@@ -74,11 +75,9 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
} }
} }
> >
<TableHeader <TableHeader>
className="___1gzszts_0000000 f22iagw"
>
<div <div
className="fui-TableHeader ___1gzszts_39qb7g0 f22iagw" className="fui-TableHeader ___oeyxrt0_1baslyg ftgm304"
role="rowgroup" role="rowgroup"
> >
<TableRow <TableRow
@@ -99,9 +98,6 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
} }
/> />
</TableRow> </TableRow>
<div
className="___1ndi7nn_0000000 f64fuq3 f1ppkcfa"
/>
</div> </div>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -522,6 +518,7 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
noNativeElements={true} noNativeElements={true}
role="grid" role="grid"
size="small" size="small"
sortable={true}
style={ style={
{ {
"minWidth": "fit-content", "minWidth": "fit-content",
@@ -539,11 +536,9 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
} }
} }
> >
<TableHeader <TableHeader>
className="___1gzszts_0000000 f22iagw"
>
<div <div
className="fui-TableHeader ___1gzszts_39qb7g0 f22iagw" className="fui-TableHeader ___oeyxrt0_1baslyg ftgm304"
role="rowgroup" role="rowgroup"
> >
<TableRow <TableRow
@@ -606,9 +601,6 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
</TableSelectionCell> </TableSelectionCell>
</div> </div>
</TableRow> </TableRow>
<div
className="___1ndi7nn_0000000 f64fuq3 f1ppkcfa"
/>
</div> </div>
</TableHeader> </TableHeader>
<TableBody> <TableBody>

View File

@@ -152,6 +152,7 @@ export default class NotebookTabV2 extends NotebookTabBase {
ariaLabel: saveLabel, ariaLabel: saveLabel,
children: saveButtonChildren.length && [ children: saveButtonChildren.length && [
{ {
iconName: "Save",
onCommandClick: () => this.notebookComponentAdapter.notebookSave(), onCommandClick: () => this.notebookComponentAdapter.notebookSave(),
commandButtonLabel: saveLabel, commandButtonLabel: saveLabel,
hasPopup: false, hasPopup: false,

View File

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

View File

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

View File

@@ -6,11 +6,9 @@ import { SplitterDirection } from "Common/Splitter";
import { Platform, configContext } from "ConfigContext"; import { Platform, configContext } from "ConfigContext";
import { useDialog } from "Explorer/Controls/Dialog"; import { useDialog } from "Explorer/Controls/Dialog";
import { monaco } from "Explorer/LazyMonaco"; import { monaco } from "Explorer/LazyMonaco";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { QueryCopilotFeedbackModal } from "Explorer/QueryCopilot/Modal/QueryCopilotFeedbackModal"; import { QueryCopilotFeedbackModal } from "Explorer/QueryCopilot/Modal/QueryCopilotFeedbackModal";
import { useCopilotStore } from "Explorer/QueryCopilot/QueryCopilotContext"; import { useCopilotStore } from "Explorer/QueryCopilot/QueryCopilotContext";
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar"; import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
import { readCopilotToggleStatus, saveCopilotToggleStatus } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import { OnExecuteQueryClick, QueryDocumentsPerPage } from "Explorer/QueryCopilot/Shared/QueryCopilotClient"; import { OnExecuteQueryClick, QueryDocumentsPerPage } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
import { QueryCopilotSidebar } from "Explorer/QueryCopilot/V2/Sidebar/QueryCopilotSidebar"; import { QueryCopilotSidebar } from "Explorer/QueryCopilot/V2/Sidebar/QueryCopilotSidebar";
import { QueryResultSection } from "Explorer/Tabs/QueryTab/QueryResultSection"; import { QueryResultSection } from "Explorer/Tabs/QueryTab/QueryResultSection";
@@ -48,6 +46,7 @@ import { queryDocuments } from "../../../Common/dataAccess/queryDocuments";
import { queryDocumentsPage } from "../../../Common/dataAccess/queryDocumentsPage"; import { queryDocumentsPage } from "../../../Common/dataAccess/queryDocumentsPage";
import * as DataModels from "../../../Contracts/DataModels"; import * as DataModels from "../../../Contracts/DataModels";
import * as ViewModels from "../../../Contracts/ViewModels"; import * as ViewModels from "../../../Contracts/ViewModels";
import * as StringUtility from "../../../Shared/StringUtility";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor"; import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
import { userContext } from "../../../UserContext"; import { userContext } from "../../../UserContext";
import * as QueryUtils from "../../../Utils/QueryUtils"; import * as QueryUtils from "../../../Utils/QueryUtils";
@@ -55,6 +54,7 @@ import { useSidePanel } from "../../../hooks/useSidePanel";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import { EditorReact } from "../../Controls/Editor/EditorReact"; import { EditorReact } from "../../Controls/Editor/EditorReact";
import Explorer from "../../Explorer"; import Explorer from "../../Explorer";
import { useCommandBar } from "../../Menus/CommandBar/CommandBarComponentAdapter";
import { BrowseQueriesPane } from "../../Panes/BrowseQueriesPane/BrowseQueriesPane"; import { BrowseQueriesPane } from "../../Panes/BrowseQueriesPane/BrowseQueriesPane";
import { SaveQueryPane } from "../../Panes/SaveQueryPane/SaveQueryPane"; import { SaveQueryPane } from "../../Panes/SaveQueryPane/SaveQueryPane";
import TabsBase from "../TabsBase"; import TabsBase from "../TabsBase";
@@ -209,7 +209,13 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
private _queryCopilotActive(): boolean { private _queryCopilotActive(): boolean {
if (this.props.copilotEnabled) { if (this.props.copilotEnabled) {
return readCopilotToggleStatus(userContext.databaseAccount); const cachedCopilotToggleStatus: string = localStorage.getItem(
`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`,
);
const copilotInitialActive: boolean = cachedCopilotToggleStatus
? StringUtility.toBoolean(cachedCopilotToggleStatus)
: true;
return copilotInitialActive;
} }
return false; return false;
} }
@@ -578,7 +584,7 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
private _toggleCopilot = (active: boolean) => { private _toggleCopilot = (active: boolean) => {
this.setState({ copilotActive: active }); this.setState({ copilotActive: active });
useQueryCopilot.getState().setCopilotEnabledforExecution(active); useQueryCopilot.getState().setCopilotEnabledforExecution(active);
saveCopilotToggleStatus(userContext.databaseAccount, active); localStorage.setItem(`${userContext.databaseAccount?.id}-queryCopilotToggleStatus`, active.toString());
TelemetryProcessor.traceSuccess(active ? Action.ActivateQueryCopilot : Action.DeactivateQueryCopilot, { TelemetryProcessor.traceSuccess(active ? Action.ActivateQueryCopilot : Action.DeactivateQueryCopilot, {
databaseName: this.props.collection.databaseId, databaseName: this.props.collection.databaseId,

View File

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

View File

@@ -1,6 +1,5 @@
import { Resource, StoredProcedureDefinition } from "@azure/cosmos"; import { Resource, StoredProcedureDefinition } from "@azure/cosmos";
import { Pivot, PivotItem } from "@fluentui/react"; import { Pivot, PivotItem } from "@fluentui/react";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { KeyboardAction } from "KeyboardShortcuts"; import { KeyboardAction } from "KeyboardShortcuts";
import React from "react"; import React from "react";
import ExecuteQueryIcon from "../../../../images/ExecuteQuery.svg"; import ExecuteQueryIcon from "../../../../images/ExecuteQuery.svg";
@@ -16,6 +15,7 @@ import { useTabs } from "../../../hooks/useTabs";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import { EditorReact } from "../../Controls/Editor/EditorReact"; import { EditorReact } from "../../Controls/Editor/EditorReact";
import Explorer from "../../Explorer"; import Explorer from "../../Explorer";
import { useCommandBar } from "../../Menus/CommandBar/CommandBarComponentAdapter";
import StoredProcedure from "../../Tree/StoredProcedure"; import StoredProcedure from "../../Tree/StoredProcedure";
import { useSelectedNode } from "../../useSelectedNode"; import { useSelectedNode } from "../../useSelectedNode";
import ScriptTabBase from "../ScriptTabBase"; import ScriptTabBase from "../ScriptTabBase";

View File

@@ -1,13 +1,12 @@
import { IMessageBarStyles, MessageBar, MessageBarButton, MessageBarType } from "@fluentui/react"; import { IMessageBarStyles, Link, MessageBar, MessageBarButton, MessageBarType } from "@fluentui/react";
import { CassandraProxyEndpoints, MongoProxyEndpoints } from "Common/Constants"; import { CassandraProxyEndpoints, MongoProxyEndpoints } from "Common/Constants";
import { sendMessage } from "Common/MessageHandler"; import { sendMessage } from "Common/MessageHandler";
import { configContext } from "ConfigContext"; import { Platform, configContext } from "ConfigContext";
import { IpRule } from "Contracts/DataModels"; import { IpRule } from "Contracts/DataModels";
import { MessageTypes } from "Contracts/ExplorerContracts"; import { MessageTypes } from "Contracts/ExplorerContracts";
import { CollectionTabKind } from "Contracts/ViewModels"; import { CollectionTabKind } from "Contracts/ViewModels";
import Explorer from "Explorer/Explorer"; import Explorer from "Explorer/Explorer";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar"; import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { CommandBarV2 } from "Explorer/Menus/CommandBarV2/CommandBarV2";
import { QueryCopilotTab } from "Explorer/QueryCopilot/QueryCopilotTab"; import { QueryCopilotTab } from "Explorer/QueryCopilot/QueryCopilotTab";
import { SplashScreen } from "Explorer/SplashScreen/SplashScreen"; import { SplashScreen } from "Explorer/SplashScreen/SplashScreen";
import { ConnectTab } from "Explorer/Tabs/ConnectTab"; import { ConnectTab } from "Explorer/Tabs/ConnectTab";
@@ -17,6 +16,7 @@ import { VcoreMongoConnectTab } from "Explorer/Tabs/VCoreMongoConnectTab";
import { VcoreMongoQuickstartTab } from "Explorer/Tabs/VCoreMongoQuickstartTab"; import { VcoreMongoQuickstartTab } from "Explorer/Tabs/VCoreMongoQuickstartTab";
import { LayoutConstants } from "Explorer/Theme/ThemeUtil"; import { LayoutConstants } from "Explorer/Theme/ThemeUtil";
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts"; import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
import { hasRUThresholdBeenConfigured } from "Shared/StorageUtility";
import { userContext } from "UserContext"; import { userContext } from "UserContext";
import { CassandraProxyOutboundIPs, MongoProxyOutboundIPs, PortalBackendIPs } from "Utils/EndpointUtils"; import { CassandraProxyOutboundIPs, MongoProxyOutboundIPs, PortalBackendIPs } from "Utils/EndpointUtils";
import { useTeachingBubble } from "hooks/useTeachingBubble"; import { useTeachingBubble } from "hooks/useTeachingBubble";
@@ -37,6 +37,9 @@ interface TabsProps {
export const Tabs = ({ explorer }: TabsProps): JSX.Element => { export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
const { openedTabs, openedReactTabs, activeTab, activeReactTab, networkSettingsWarning } = useTabs(); const { openedTabs, openedReactTabs, activeTab, activeReactTab, networkSettingsWarning } = useTabs();
const [showRUThresholdMessageBar, setShowRUThresholdMessageBar] = useState<boolean>(
userContext.apiType === "SQL" && configContext.platform !== Platform.Fabric && !hasRUThresholdBeenConfigured(),
);
const [ const [
showMongoAndCassandraProxiesNetworkSettingsWarningState, showMongoAndCassandraProxiesNetworkSettingsWarningState,
setShowMongoAndCassandraProxiesNetworkSettingsWarningState, setShowMongoAndCassandraProxiesNetworkSettingsWarningState,
@@ -84,6 +87,29 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
{networkSettingsWarning} {networkSettingsWarning}
</MessageBar> </MessageBar>
)} )}
{showRUThresholdMessageBar && (
<MessageBar
messageBarType={MessageBarType.info}
onDismiss={() => {
setShowRUThresholdMessageBar(false);
}}
styles={{
...defaultMessageBarStyles,
innerText: {
fontWeight: "bold",
},
}}
>
{`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"
target="_blank"
>
Learn More
</Link>
</MessageBar>
)}
{showMongoAndCassandraProxiesNetworkSettingsWarningState && ( {showMongoAndCassandraProxiesNetworkSettingsWarningState && (
<MessageBar <MessageBar
messageBarType={MessageBarType.warning} messageBarType={MessageBarType.warning}
@@ -107,7 +133,6 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
</ul> </ul>
</div> </div>
<div className="tabPanesContainer"> <div className="tabPanesContainer">
{userContext.features.commandBarV2 && <CommandBarV2 explorer={explorer} />}
{activeReactTab !== undefined && getReactTabContent(activeReactTab, explorer)} {activeReactTab !== undefined && getReactTabContent(activeReactTab, explorer)}
{openedTabs.map((tab) => ( {openedTabs.map((tab) => (
<TabPane key={tab.tabId} tab={tab} active={activeTab === tab} /> <TabPane key={tab.tabId} tab={tab} active={activeTab === tab} />

View File

@@ -1,8 +1,8 @@
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { KeyboardActionGroup, clearKeyboardActionGroup } from "KeyboardShortcuts"; import { KeyboardActionGroup, clearKeyboardActionGroup } from "KeyboardShortcuts";
import * as ko from "knockout"; import * as ko from "knockout";
import * as Constants from "../../Common/Constants"; import * as Constants from "../../Common/Constants";
import * as ThemeUtility from "../../Common/ThemeUtility"; import * as ThemeUtility from "../../Common/ThemeUtility";
import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels"; import * as ViewModels from "../../Contracts/ViewModels";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants"; import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
@@ -10,6 +10,7 @@ import { useNotificationConsole } from "../../hooks/useNotificationConsole";
import { useTabs } from "../../hooks/useTabs"; import { useTabs } from "../../hooks/useTabs";
import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent"; import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import { WaitsForTemplateViewModel } from "../WaitsForTemplateViewModel"; import { WaitsForTemplateViewModel } from "../WaitsForTemplateViewModel";
import { useSelectedNode } from "../useSelectedNode"; import { useSelectedNode } from "../useSelectedNode";
// TODO: Use specific actions for logging telemetry data // TODO: Use specific actions for logging telemetry data
@@ -27,6 +28,7 @@ export default class TabsBase extends WaitsForTemplateViewModel {
public tabPath: ko.Observable<string>; public tabPath: ko.Observable<string>;
public isExecutionError = ko.observable(false); public isExecutionError = ko.observable(false);
public isExecuting = ko.observable(false); public isExecuting = ko.observable(false);
public pendingNotification?: ko.Observable<DataModels.Notification>;
protected _theme: string; protected _theme: string;
public onLoadStartKey: number; public onLoadStartKey: number;
@@ -43,6 +45,7 @@ export default class TabsBase extends WaitsForTemplateViewModel {
this.tabPath = this.tabPath =
this.collection && this.collection &&
ko.observable<string>(`${this.collection.databaseId}>${this.collection.id()}>${options.title}`); ko.observable<string>(`${this.collection.databaseId}>${this.collection.id()}>${options.title}`);
this.pendingNotification = ko.observable<DataModels.Notification>(undefined);
this.onLoadStartKey = options.onLoadStartKey; this.onLoadStartKey = options.onLoadStartKey;
this.closeTabButton = { this.closeTabButton = {
enabled: ko.computed<boolean>(() => { enabled: ko.computed<boolean>(() => {

View File

@@ -1,6 +1,5 @@
import { TriggerDefinition } from "@azure/cosmos"; import { TriggerDefinition } from "@azure/cosmos";
import { Dropdown, IDropdownOption, Label, TextField } from "@fluentui/react"; import { Dropdown, IDropdownOption, Label, TextField } from "@fluentui/react";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { KeyboardAction } from "KeyboardShortcuts"; import { KeyboardAction } from "KeyboardShortcuts";
import React, { Component } from "react"; import React, { Component } from "react";
import DiscardIcon from "../../../images/discard.svg"; import DiscardIcon from "../../../images/discard.svg";
@@ -15,6 +14,7 @@ import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { SqlTriggerResource } from "../../Utils/arm/generatedClients/cosmos/types"; import { SqlTriggerResource } from "../../Utils/arm/generatedClients/cosmos/types";
import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent"; import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent";
import { EditorReact } from "../Controls/Editor/EditorReact"; import { EditorReact } from "../Controls/Editor/EditorReact";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import TriggerTab from "./TriggerTab"; import TriggerTab from "./TriggerTab";
const triggerTypeOptions: IDropdownOption[] = [ const triggerTypeOptions: IDropdownOption[] = [

View File

@@ -1,6 +1,5 @@
import { UserDefinedFunctionDefinition } from "@azure/cosmos"; import { UserDefinedFunctionDefinition } from "@azure/cosmos";
import { Label, TextField } from "@fluentui/react"; import { Label, TextField } from "@fluentui/react";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { KeyboardAction } from "KeyboardShortcuts"; import { KeyboardAction } from "KeyboardShortcuts";
import React, { Component } from "react"; import React, { Component } from "react";
import DiscardIcon from "../../../images/discard.svg"; import DiscardIcon from "../../../images/discard.svg";
@@ -14,6 +13,7 @@ import { Action } from "../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent"; import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent";
import { EditorReact } from "../Controls/Editor/EditorReact"; import { EditorReact } from "../Controls/Editor/EditorReact";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import UserDefinedFunctionTab from "./UserDefinedFunctionTab"; import UserDefinedFunctionTab from "./UserDefinedFunctionTab";
interface IUserDefinedFunctionTabContentState { interface IUserDefinedFunctionTabContentState {

View File

@@ -1,11 +1,12 @@
import { Resource, StoredProcedureDefinition, TriggerDefinition, UserDefinedFunctionDefinition } from "@azure/cosmos"; import { Resource, StoredProcedureDefinition, TriggerDefinition, UserDefinedFunctionDefinition } from "@azure/cosmos";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { useNotebook } from "Explorer/Notebook/useNotebook"; import { useNotebook } from "Explorer/Notebook/useNotebook";
import { DocumentsTabV2 } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2"; import { DocumentsTabV2 } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2";
import * as ko from "knockout"; import * as ko from "knockout";
import * as _ from "underscore"; import * as _ from "underscore";
import * as Constants from "../../Common/Constants"; import * as Constants from "../../Common/Constants";
import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils"; import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils";
import * as Logger from "../../Common/Logger";
import { fetchPortalNotifications } from "../../Common/PortalNotifications";
import { bulkCreateDocument } from "../../Common/dataAccess/bulkCreateDocument"; import { bulkCreateDocument } from "../../Common/dataAccess/bulkCreateDocument";
import { createDocument } from "../../Common/dataAccess/createDocument"; import { createDocument } from "../../Common/dataAccess/createDocument";
import { getCollectionUsageSizeInKB } from "../../Common/dataAccess/getCollectionDataUsageSize"; import { getCollectionUsageSizeInKB } from "../../Common/dataAccess/getCollectionDataUsageSize";
@@ -24,6 +25,7 @@ import { logConsoleInfo } from "../../Utils/NotificationConsoleUtils";
import { SqlTriggerResource } from "../../Utils/arm/generatedClients/cosmos/types"; import { SqlTriggerResource } from "../../Utils/arm/generatedClients/cosmos/types";
import { useTabs } from "../../hooks/useTabs"; import { useTabs } from "../../hooks/useTabs";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import { CassandraAPIDataClient, CassandraTableKey, CassandraTableKeys } from "../Tables/TableDataClient"; import { CassandraAPIDataClient, CassandraTableKey, CassandraTableKeys } from "../Tables/TableDataClient";
import ConflictsTab from "../Tabs/ConflictsTab"; import ConflictsTab from "../Tabs/ConflictsTab";
import GraphTab from "../Tabs/GraphTab"; import GraphTab from "../Tabs/GraphTab";
@@ -1018,6 +1020,41 @@ export default class Collection implements ViewModels.Collection {
this.uploadFiles(event.originalEvent.dataTransfer.files); this.uploadFiles(event.originalEvent.dataTransfer.files);
} }
public async getPendingThroughputSplitNotification(): Promise<DataModels.Notification> {
if (!this.container) {
return undefined;
}
try {
const notifications: DataModels.Notification[] = await fetchPortalNotifications();
if (!notifications || notifications.length === 0) {
return undefined;
}
return _.find(notifications, (notification: DataModels.Notification) => {
const throughputUpdateRegExp: RegExp = new RegExp("Throughput update (.*) in progress");
return (
notification.kind === "message" &&
notification.collectionName === this.id() &&
notification.description &&
throughputUpdateRegExp.test(notification.description)
);
});
} catch (error) {
Logger.logError(
JSON.stringify({
error: getErrorMessage(error),
accountName: userContext?.databaseAccount,
databaseName: this.databaseId,
collectionName: this.id(),
}),
"Settings tree node",
);
return undefined;
}
}
public async uploadFiles(files: FileList): Promise<{ data: UploadDetailsRecord[] }> { public async uploadFiles(files: FileList): Promise<{ data: UploadDetailsRecord[] }> {
const data = await Promise.all(Array.from(files).map((file) => this.uploadFile(file))); const data = await Promise.all(Array.from(files).map((file) => this.uploadFile(file)));

View File

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

View File

@@ -1,6 +1,4 @@
import { TreeNodeMenuItem } from "Explorer/Controls/TreeComponent/TreeNodeComponent"; import { TreeNodeMenuItem } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { collectionWasOpened } from "Explorer/MostRecentActivity/MostRecentActivity";
import { shouldShowScriptNodes } from "Explorer/Tree/treeNodeUtil"; import { shouldShowScriptNodes } from "Explorer/Tree/treeNodeUtil";
import { getItemName } from "Utils/APITypeUtils"; import { getItemName } from "Utils/APITypeUtils";
import * as ko from "knockout"; import * as ko from "knockout";
@@ -29,6 +27,8 @@ import * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFacto
import { useDialog } from "../Controls/Dialog"; import { useDialog } from "../Controls/Dialog";
import { LegacyTreeComponent, LegacyTreeNode } from "../Controls/TreeComponent/LegacyTreeComponent"; import { LegacyTreeComponent, LegacyTreeNode } from "../Controls/TreeComponent/LegacyTreeComponent";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import { mostRecentActivity } from "../MostRecentActivity/MostRecentActivity";
import { NotebookContentItem, NotebookContentItemType } from "../Notebook/NotebookContentItem"; import { NotebookContentItem, NotebookContentItemType } from "../Notebook/NotebookContentItem";
import { NotebookUtil } from "../Notebook/NotebookUtil"; import { NotebookUtil } from "../Notebook/NotebookUtil";
import { useNotebook } from "../Notebook/useNotebook"; import { useNotebook } from "../Notebook/useNotebook";
@@ -229,7 +229,7 @@ export class ResourceTreeAdapter implements ReactAdapter {
onClick: () => { onClick: () => {
collection.openTab(); collection.openTab();
// push to most recent // push to most recent
collectionWasOpened(userContext.databaseAccount?.name, collection); mostRecentActivity.collectionWasOpened(userContext.databaseAccount?.id, collection);
}, },
isSelected: () => isSelected: () =>
useSelectedNode useSelectedNode

View File

@@ -1,4 +1,4 @@
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar"; import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import TabsBase from "Explorer/Tabs/TabsBase"; import TabsBase from "Explorer/Tabs/TabsBase";
import { useSelectedNode } from "Explorer/useSelectedNode"; import { useSelectedNode } from "Explorer/useSelectedNode";
import { useTabs } from "hooks/useTabs"; import { useTabs } from "hooks/useTabs";

View File

@@ -2,7 +2,7 @@ import { CapabilityNames } from "Common/Constants";
import { Platform, updateConfigContext } from "ConfigContext"; import { Platform, updateConfigContext } from "ConfigContext";
import { TreeNode } from "Explorer/Controls/TreeComponent/TreeNodeComponent"; import { TreeNode } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import Explorer from "Explorer/Explorer"; import Explorer from "Explorer/Explorer";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar"; import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { useNotebook } from "Explorer/Notebook/useNotebook"; import { useNotebook } from "Explorer/Notebook/useNotebook";
import { DeleteDatabaseConfirmationPanel } from "Explorer/Panes/DeleteDatabaseConfirmationPanel"; import { DeleteDatabaseConfirmationPanel } from "Explorer/Panes/DeleteDatabaseConfirmationPanel";
import TabsBase from "Explorer/Tabs/TabsBase"; import TabsBase from "Explorer/Tabs/TabsBase";

View File

@@ -1,7 +1,5 @@
import { DatabaseRegular, DocumentMultipleRegular, SettingsRegular } from "@fluentui/react-icons"; import { DatabaseRegular, DocumentMultipleRegular, SettingsRegular } from "@fluentui/react-icons";
import { TreeNode } from "Explorer/Controls/TreeComponent/TreeNodeComponent"; import { TreeNode } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import { useCommandBar } from "Explorer/Menus/CommandBar/useCommandBar";
import { collectionWasOpened } from "Explorer/MostRecentActivity/MostRecentActivity";
import TabsBase from "Explorer/Tabs/TabsBase"; import TabsBase from "Explorer/Tabs/TabsBase";
import StoredProcedure from "Explorer/Tree/StoredProcedure"; import StoredProcedure from "Explorer/Tree/StoredProcedure";
import Trigger from "Explorer/Tree/Trigger"; import Trigger from "Explorer/Tree/Trigger";
@@ -18,6 +16,8 @@ import * as ViewModels from "../../Contracts/ViewModels";
import { userContext } from "../../UserContext"; import { userContext } from "../../UserContext";
import * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFactory"; import * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFactory";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import { mostRecentActivity } from "../MostRecentActivity/MostRecentActivity";
import { useNotebook } from "../Notebook/useNotebook"; import { useNotebook } from "../Notebook/useNotebook";
import { useSelectedNode } from "../useSelectedNode"; import { useSelectedNode } from "../useSelectedNode";
@@ -98,7 +98,7 @@ export const createResourceTokenTreeNodes = (collection: ViewModels.CollectionBa
onClick: () => { onClick: () => {
collection.onDocumentDBDocumentsClick(); collection.onDocumentDBDocumentsClick();
// push to most recent // push to most recent
collectionWasOpened(userContext.databaseAccount?.name, collection); mostRecentActivity.collectionWasOpened(userContext.databaseAccount?.id, collection);
}, },
isSelected: () => isSelected: () =>
useSelectedNode useSelectedNode
@@ -234,7 +234,7 @@ export const buildCollectionNode = (
useSelectedNode.getState().setSelectedNode(collection); useSelectedNode.getState().setSelectedNode(collection);
collection.openTab(); collection.openTab();
// push to most recent // push to most recent
collectionWasOpened(userContext.databaseAccount?.name, collection); mostRecentActivity.collectionWasOpened(userContext.databaseAccount?.id, collection);
}, },
onExpanded: async () => { onExpanded: async () => {
// Rewritten version of expandCollapseCollection // Rewritten version of expandCollapseCollection
@@ -282,7 +282,7 @@ const buildCollectionNodeChildren = (
onClick: () => { onClick: () => {
collection.openTab(); collection.openTab();
// push to most recent // push to most recent
collectionWasOpened(userContext.databaseAccount?.name, collection); mostRecentActivity.collectionWasOpened(userContext.databaseAccount?.id, collection);
}, },
isSelected: () => isSelected: () =>
useSelectedNode useSelectedNode

View File

@@ -20,11 +20,9 @@ import "../externals/jquery.typeahead.min.css";
import "../externals/jquery.typeahead.min.js"; import "../externals/jquery.typeahead.min.js";
// Image Dependencies // Image Dependencies
import { Platform } from "ConfigContext"; import { Platform } from "ConfigContext";
import { CommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { QueryCopilotCarousel } from "Explorer/QueryCopilot/CopilotCarousel"; import { QueryCopilotCarousel } from "Explorer/QueryCopilot/CopilotCarousel";
import { SidebarContainer } from "Explorer/Sidebar"; import { SidebarContainer } from "Explorer/Sidebar";
import { KeyboardShortcutRoot } from "KeyboardShortcuts"; import { KeyboardShortcutRoot } from "KeyboardShortcuts";
import { userContext } from "UserContext";
import "allotment/dist/style.css"; import "allotment/dist/style.css";
import "../images/CosmosDB_rgb_ui_lighttheme.ico"; import "../images/CosmosDB_rgb_ui_lighttheme.ico";
import hdeConnectImage from "../images/HdeConnectCosmosDB.svg"; import hdeConnectImage from "../images/HdeConnectCosmosDB.svg";
@@ -50,6 +48,7 @@ import "./Explorer/Controls/Notebook/NotebookTerminalComponent.less";
import "./Explorer/Controls/TreeComponent/treeComponent.less"; import "./Explorer/Controls/TreeComponent/treeComponent.less";
import "./Explorer/Graph/GraphExplorerComponent/graphExplorer.less"; import "./Explorer/Graph/GraphExplorerComponent/graphExplorer.less";
import "./Explorer/Menus/CommandBar/CommandBarComponent.less"; import "./Explorer/Menus/CommandBar/CommandBarComponent.less";
import { CommandBar } from "./Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import "./Explorer/Menus/CommandBar/ConnectionStatusComponent.less"; import "./Explorer/Menus/CommandBar/ConnectionStatusComponent.less";
import "./Explorer/Menus/CommandBar/MemoryTrackerComponent.less"; import "./Explorer/Menus/CommandBar/MemoryTrackerComponent.less";
import "./Explorer/Menus/NotificationConsole/NotificationConsole.less"; import "./Explorer/Menus/NotificationConsole/NotificationConsole.less";
@@ -87,7 +86,7 @@ const App: React.FunctionComponent = () => {
<div id="divExplorer" className="flexContainer hideOverflows"> <div id="divExplorer" className="flexContainer hideOverflows">
<div id="freeTierTeachingBubble"> </div> <div id="freeTierTeachingBubble"> </div>
{/* Main Command Bar - Start */} {/* Main Command Bar - Start */}
{!userContext.features.commandBarV2 && <CommandBar container={explorer} />} <CommandBar container={explorer} />
{/* Collections Tree and Tabs - Begin */} {/* Collections Tree and Tabs - Begin */}
<SidebarContainer explorer={explorer} /> <SidebarContainer explorer={explorer} />
{/* Collections Tree and Tabs - End */} {/* Collections Tree and Tabs - End */}

View File

@@ -1,18 +1,22 @@
import * as React from "react"; import * as React from "react";
import { CommandButtonComponent } from "../../../Explorer/Controls/CommandButton/CommandButtonComponent";
import FeedbackIcon from "../../../../images/Feedback.svg"; import FeedbackIcon from "../../../../images/Feedback.svg";
const onClick = () => {
window.open("https://aka.ms/cosmosdbfeedback?subject=Cosmos%20DB%20Hosted%20Data%20Explorer%20Feedback");
};
export const FeedbackCommandButton: React.FunctionComponent = () => { export const FeedbackCommandButton: React.FunctionComponent = () => {
return ( return (
<div className="feedbackConnectSettingIcons"> <div className="feedbackConnectSettingIcons">
<div className="commandButtonReact"> <CommandButtonComponent
<a href="#" title="Send feedback" aria-haspopup="dialog" onClick={onClick}> id="commandbutton-feedback"
<img src={FeedbackIcon} alt="Send feedback" /> iconSrc={FeedbackIcon}
</a> iconAlt="feeback button"
</div> onCommandClick={() =>
window.open("https://aka.ms/cosmosdbfeedback?subject=Cosmos%20DB%20Hosted%20Data%20Explorer%20Feedback")
}
ariaLabel="feeback button"
tooltipText="Send feedback"
hasPopup={true}
disabled={false}
/>
</div> </div>
); );
}; };

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,4 @@
import { import { createKeyFromPath, deleteState, loadState, MAX_ENTRY_NB, saveState } from "Shared/AppStatePersistenceUtility";
AppStateComponentNames,
createKeyFromPath,
deleteState,
loadState,
MAX_ENTRY_NB,
PATH_SEPARATOR,
saveState,
} from "Shared/AppStatePersistenceUtility";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility"; import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
jest.mock("Shared/StorageUtility", () => ({ jest.mock("Shared/StorageUtility", () => ({
@@ -21,7 +13,7 @@ jest.mock("Shared/StorageUtility", () => ({
describe("AppStatePersistenceUtility", () => { describe("AppStatePersistenceUtility", () => {
const storePath = { const storePath = {
componentName: AppStateComponentNames.DocumentsTab, componentName: "a",
subComponentName: "b", subComponentName: "b",
globalAccountName: "c", globalAccountName: "c",
databaseName: "d", databaseName: "d",
@@ -174,27 +166,5 @@ describe("AppStatePersistenceUtility", () => {
expect(key).toContain(storePath.databaseName); expect(key).toContain(storePath.databaseName);
expect(key).toContain(storePath.containerName); expect(key).toContain(storePath.containerName);
}); });
it("should handle components that include special characters", () => {
const storePath = {
componentName: AppStateComponentNames.DocumentsTab,
subComponentName: 'd"e"f',
globalAccountName: "g:hi{j",
databaseName: "a/b/c",
containerName: "https://blahblah.document.azure.com:443/",
};
const key = createKeyFromPath(storePath);
const segments = key.split(PATH_SEPARATOR);
expect(segments.length).toEqual(6); // There should be 5 segments
expect(segments[0]).toBe("");
const expectSubstringsInValue = (value: string, subStrings: string[]): boolean =>
subStrings.every((subString) => value.includes(subString));
expect(expectSubstringsInValue(segments[2], ["d", "e", "f"])).toBe(true);
expect(expectSubstringsInValue(segments[3], ["g", "hi", "j"])).toBe(true);
expect(expectSubstringsInValue(segments[4], ["a", "b", "c"])).toBe(true);
expect(expectSubstringsInValue(segments[5], ["https", "blahblah", "document", "com", "443"])).toBe(true);
});
}); });
}); });

View File

@@ -1,13 +1,8 @@
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility"; import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
// The component name whose state is being saved. Component name must not include special characters. // The component name whose state is being saved. Component name must not include special characters.
export enum AppStateComponentNames { export type ComponentName = "DocumentsTab";
DocumentsTab = "DocumentsTab",
MostRecentActivity = "MostRecentActivity",
QueryCopilot = "QueryCopilot",
}
export const PATH_SEPARATOR = "/"; // export for testing purposes
const SCHEMA_VERSION = 1; const SCHEMA_VERSION = 1;
// Export for testing purposes // Export for testing purposes
@@ -19,9 +14,8 @@ export interface StateData {
data: unknown; data: unknown;
} }
// Export for testing purposes type StorePath = {
export type StorePath = { componentName: string;
componentName: AppStateComponentNames;
subComponentName?: string; subComponentName?: string;
globalAccountName?: string; globalAccountName?: string;
databaseName?: string; databaseName?: string;
@@ -35,7 +29,6 @@ export const loadState = (path: StorePath): unknown => {
const key = createKeyFromPath(path); const key = createKeyFromPath(path);
return appState[key]?.data; return appState[key]?.data;
}; };
export const saveState = (path: StorePath, state: unknown): void => { export const saveState = (path: StorePath, state: unknown): void => {
// Retrieve state object // Retrieve state object
const appState = const appState =
@@ -67,10 +60,6 @@ export const deleteState = (path: StorePath): void => {
LocalStorageUtility.setEntryObject(StorageKey.AppState, appState); LocalStorageUtility.setEntryObject(StorageKey.AppState, appState);
}; };
export const hasState = (path: StorePath): boolean => {
return loadState(path) !== undefined;
};
// This is for high-frequency state changes // This is for high-frequency state changes
let timeoutId: NodeJS.Timeout | undefined; let timeoutId: NodeJS.Timeout | undefined;
export const saveStateDebounced = (path: StorePath, state: unknown, debounceDelayMs = 1000): void => { export const saveStateDebounced = (path: StorePath, state: unknown, debounceDelayMs = 1000): void => {
@@ -98,10 +87,16 @@ const orderedPathSegments: (keyof StorePath)[] = [
* @param path * @param path
*/ */
export const createKeyFromPath = (path: StorePath): string => { export const createKeyFromPath = (path: StorePath): string => {
let key = `${PATH_SEPARATOR}${encodeURIComponent(path.componentName)}`; // ComponentName is always there if (path.componentName.includes("/")) {
throw new Error(`Invalid component name: ${path.componentName}`);
}
let key = `/${path.componentName}`; // ComponentName is always there
orderedPathSegments.forEach((segment) => { orderedPathSegments.forEach((segment) => {
const segmentValue = path[segment as keyof StorePath]; const segmentValue = path[segment as keyof StorePath];
key += `${PATH_SEPARATOR}${segmentValue !== undefined ? encodeURIComponent(segmentValue) : ""}`; if (segmentValue.includes("/")) {
throw new Error(`Invalid setting path segment: ${segment}`);
}
key += `/${segmentValue !== undefined ? segmentValue : ""}`;
}); });
return key; return key;
}; };

View File

@@ -24,7 +24,7 @@ export enum StorageKey {
MaxDegreeOfParellism, MaxDegreeOfParellism,
IsGraphAutoVizDisabled, IsGraphAutoVizDisabled,
TenantId, TenantId,
MostRecentActivity, // deprecated MostRecentActivity,
SetPartitionKeyUndefined, SetPartitionKeyUndefined,
GalleryCalloutDismissed, GalleryCalloutDismissed,
VisitedAccounts, VisitedAccounts,

View File

@@ -192,11 +192,6 @@ export function useNewPortalBackendEndpoint(backendApi: string): boolean {
PortalBackendEndpoints.Fairfax, PortalBackendEndpoints.Fairfax,
PortalBackendEndpoints.Mooncake, PortalBackendEndpoints.Mooncake,
], ],
[BackendApi.SampleData]: [
PortalBackendEndpoints.Development,
PortalBackendEndpoints.Mpac,
PortalBackendEndpoints.Prod,
],
}; };
if (!newBackendApiEnvironmentMap[backendApi] || !configContext.PORTAL_BACKEND_ENDPOINT) { if (!newBackendApiEnvironmentMap[backendApi] || !configContext.PORTAL_BACKEND_ENDPOINT) {

View File

@@ -4,28 +4,7 @@ import * as sinon from "sinon";
import * as DataModels from "../Contracts/DataModels"; import * as DataModels from "../Contracts/DataModels";
import * as ViewModels from "../Contracts/ViewModels"; import * as ViewModels from "../Contracts/ViewModels";
import * as QueryUtils from "./QueryUtils"; import * as QueryUtils from "./QueryUtils";
import { defaultQueryFields, extractPartitionKeyValues, getValueForPath } from "./QueryUtils"; import { extractPartitionKeyValues } from "./QueryUtils";
const documentContent = {
"Volcano Name": "Adams",
Country: "United States",
Region: "US-Washington",
Location: {
type: "Point",
coordinates: [-121.49, 46.206],
},
Elevation: 3742,
Type: "Stratovolcano",
Category: "",
Status: "Tephrochronology",
"Last Known Eruption": "Last known eruption from A.D. 1-1499, inclusive",
id: "9e3c494e-8367-3f50-1f56-8c6fcb961363",
_rid: "xzo0AJRYUxUFAAAAAAAAAA==",
_self: "dbs/xzo0AA==/colls/xzo0AJRYUxU=/docs/xzo0AJRYUxUFAAAAAAAAAA==/",
_etag: '"ce00fa43-0000-0100-0000-652840440000"',
_attachments: "attachments/",
_ts: 1697136708,
};
describe("Query Utils", () => { describe("Query Utils", () => {
const generatePartitionKeyForPath = (path: string): DataModels.PartitionKey => { const generatePartitionKeyForPath = (path: string): DataModels.PartitionKey => {
@@ -75,20 +54,6 @@ describe("Query Utils", () => {
expect(partitionProjection).toContain('c["\\\\\\"a\\\\\\""]'); expect(partitionProjection).toContain('c["\\\\\\"a\\\\\\""]');
}); });
it("should always include the default fields", () => {
const query: string = QueryUtils.buildDocumentsQuery("", [], generatePartitionKeyForPath("/a"), []);
defaultQueryFields.forEach((field) => {
expect(query).toContain(`c.${field}`);
});
});
it("should always include the default fields even if they are themselves partition key fields", () => {
const query: string = QueryUtils.buildDocumentsQuery("", ["id"], generatePartitionKeyForPath("/id"), ["id"]);
expect(query).toContain("c.id");
});
}); });
describe("queryPagesUntilContentPresent()", () => { describe("queryPagesUntilContentPresent()", () => {
@@ -132,30 +97,28 @@ describe("Query Utils", () => {
}); });
}); });
describe("getValueForPath", () => {
it("should return the correct value for a simple path", () => {
const pathSegments = ["Volcano Name"];
expect(getValueForPath(documentContent, pathSegments)).toBe("Adams");
});
it("should return the correct value for a nested path", () => {
const pathSegments = ["Location", "coordinates"];
expect(getValueForPath(documentContent, pathSegments)).toEqual([-121.49, 46.206]);
});
it("should return undefined for a non-existing path", () => {
const pathSegments = ["NonExistent", "Path"];
expect(getValueForPath(documentContent, pathSegments)).toBeUndefined();
});
it("should return undefined for an invalid path", () => {
const pathSegments = ["Location", "InvalidKey"];
expect(getValueForPath(documentContent, pathSegments)).toBeUndefined();
});
it("should return the root object if pathSegments is empty", () => {
const pathSegments: string[] = [];
expect(getValueForPath(documentContent, pathSegments)).toBeUndefined();
});
});
describe("extractPartitionKey", () => { describe("extractPartitionKey", () => {
const documentContent = {
"Volcano Name": "Adams",
Country: "United States",
Region: "US-Washington",
Location: {
type: "Point",
coordinates: [-121.49, 46.206],
},
Elevation: 3742,
Type: "Stratovolcano",
Category: "",
Status: "Tephrochronology",
"Last Known Eruption": "Last known eruption from A.D. 1-1499, inclusive",
id: "9e3c494e-8367-3f50-1f56-8c6fcb961363",
_rid: "xzo0AJRYUxUFAAAAAAAAAA==",
_self: "dbs/xzo0AA==/colls/xzo0AJRYUxU=/docs/xzo0AJRYUxUFAAAAAAAAAA==/",
_etag: '"ce00fa43-0000-0100-0000-652840440000"',
_attachments: "attachments/",
_ts: 1697136708,
};
it("should extract single partition key value", () => { it("should extract single partition key value", () => {
const singlePartitionKeyDefinition: PartitionKeyDefinition = { const singlePartitionKeyDefinition: PartitionKeyDefinition = {
kind: PartitionKeyKind.Hash, kind: PartitionKeyKind.Hash,
@@ -212,18 +175,5 @@ describe("Query Utils", () => {
); );
expect(partitionKeyValues.length).toBe(0); expect(partitionKeyValues.length).toBe(0);
}); });
it("should extract all partition key values for hierarchical and nested partition keys", () => {
const mixedPartitionKeyDefinition: PartitionKeyDefinition = {
kind: PartitionKeyKind.MultiHash,
paths: ["/Country", "/Location/type"],
};
const partitionKeyValues: PartitionKey[] = extractPartitionKeyValues(
documentContent,
mixedPartitionKeyDefinition,
);
expect(partitionKeyValues.length).toBe(2);
expect(partitionKeyValues).toEqual(["United States", "Point"]);
});
}); });
}); });

View File

@@ -11,13 +11,12 @@ export function buildDocumentsQuery(
additionalField: string[] = [], additionalField: string[] = [],
): string { ): string {
const fieldSet = new Set<string>(defaultQueryFields); const fieldSet = new Set<string>(defaultQueryFields);
additionalField.forEach((prop) => { additionalField.forEach((prop) => fieldSet.add(prop));
if (!partitionKeyProperties.includes(prop)) {
fieldSet.add(prop);
}
});
const objectListSpec = [...fieldSet].map((prop) => `c.${prop}`).join(","); const objectListSpec = [...fieldSet]
.filter((f) => !partitionKeyProperties.includes(f))
.map((prop) => `c.${prop}`)
.join(",");
let query = let query =
partitionKeyProperties && partitionKeyProperties.length > 0 partitionKeyProperties && partitionKeyProperties.length > 0
? `select ${objectListSpec}, [${buildDocumentsQueryPartitionProjections( ? `select ${objectListSpec}, [${buildDocumentsQueryPartitionProjections(
@@ -95,24 +94,6 @@ export const queryPagesUntilContentPresent = async (
return await doRequest(firstItemIndex); return await doRequest(firstItemIndex);
}; };
/* eslint-disable @typescript-eslint/no-explicit-any */
export const getValueForPath = (content: any, pathSegments: string[]): any => {
if (pathSegments.length === 0) {
return undefined;
}
let currentValue = content;
for (const segment of pathSegments) {
if (!currentValue || currentValue[segment] === undefined) {
return undefined;
}
currentValue = currentValue[segment];
}
return currentValue;
};
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
export const extractPartitionKeyValues = ( export const extractPartitionKeyValues = (
documentContent: any, documentContent: any,
@@ -123,15 +104,11 @@ export const extractPartitionKeyValues = (
} }
const partitionKeyValues: PartitionKey[] = []; const partitionKeyValues: PartitionKey[] = [];
partitionKeyDefinition.paths.forEach((partitionKeyPath: string) => { partitionKeyDefinition.paths.forEach((partitionKeyPath: string) => {
const pathSegments: string[] = partitionKeyPath.substring(1).split("/"); const partitionKeyPathWithoutSlash: string = partitionKeyPath.substring(1);
const value = getValueForPath(documentContent, pathSegments); if (documentContent[partitionKeyPathWithoutSlash] !== undefined) {
partitionKeyValues.push(documentContent[partitionKeyPathWithoutSlash]);
if (value !== undefined) {
partitionKeyValues.push(value);
} }
}); });
return partitionKeyValues; return partitionKeyValues;
}; };

View File

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

View File

@@ -12,7 +12,11 @@ import {
subscriptionId, subscriptionId,
} from "../fx"; } 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 credentials = await getAzureCLICredentials();
const armClient = new CosmosDBManagementClient(credentials, subscriptionId); const armClient = new CosmosDBManagementClient(credentials, subscriptionId);
const accountName = getAccountName(TestAccount.SQL); const accountName = getAccountName(TestAccount.SQL);
@@ -34,9 +38,7 @@ test("SQL account using Resource token", async ({ page }) => {
}); });
await expect(containerPermission).toBeDefined(); await expect(containerPermission).toBeDefined();
const resourceTokenConnectionString = `AccountEndpoint=${account.documentEndpoint};DatabaseId=${ const resourceTokenConnectionString = `AccountEndpoint=${account.documentEndpoint};DatabaseId=${database.id};CollectionId=${container.id};${containerPermission!._token}`;
database.id
};CollectionId=${container.id};${containerPermission!._token}`;
await page.goto("https://localhost:1234/hostedExplorer.html"); await page.goto("https://localhost:1234/hostedExplorer.html");
const switchConnectionLink = page.getByTestId("Link:SwitchConnectionType"); const switchConnectionLink = page.getByTestId("Link:SwitchConnectionType");