Error rendering improvements (#1887)

This commit is contained in:
Ashley Stanton-Nurse
2024-08-15 13:29:57 -07:00
committed by GitHub
parent cc89691da3
commit 805a4ae168
40 changed files with 2393 additions and 1261 deletions

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -158,9 +158,9 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
node.iconSrc
)
) : openItems.includes(treeNodeId) ? (
<ChevronDown20Regular />
<ChevronDown20Regular data-test="TreeNode/CollapseIcon" />
) : (
<ChevronRight20Regular />
<ChevronRight20Regular data-text="TreeNode/ExpandIcon" />
);
const treeItem = (
@@ -205,7 +205,7 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
<span className={treeStyles.nodeLabel}>{node.label}</span>
</TreeItemLayout>
{!node.isLoading && node.children?.length > 0 && (
<Tree className={treeStyles.tree}>
<Tree data-test={`Tree:${treeNodeId}`} className={treeStyles.tree}>
{getSortedChildren(node).map((childNode: TreeNode) => (
<TreeNodeComponent
openItems={openItems}

View File

@@ -12,7 +12,11 @@ exports[`TreeNodeComponent does not render children if the node is loading 1`] =
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={<ChevronRight20Regular />}
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
>
<span
className=""
@@ -133,6 +137,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg
aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -161,6 +166,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg
aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -212,6 +218,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg
aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -236,6 +243,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</div>
<div
class="fui-Tree rnv2ez3 ___jy13a00_lpffjy0 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
data-test="Tree:root"
role="tree"
>
<div
@@ -258,6 +266,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg
aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -301,6 +310,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg
aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -375,7 +385,11 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={<ChevronRight20Regular />}
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
>
<div
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
@@ -385,10 +399,13 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
aria-hidden={true}
className="fui-TreeItemLayout__expandIcon rh4pu5o"
>
<ChevronRight20Regular>
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
>
<svg
aria-hidden={true}
className="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -415,6 +432,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</TreeItemLayout>
<Tree
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
data-test="Tree:root"
>
<TreeProvider
value={
@@ -482,6 +500,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
>
<div
className="fui-Tree rnv2ez3 ___jy13a00_lpffjy0 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
data-test="Tree:root"
role="tree"
>
<TreeNodeComponent
@@ -549,6 +568,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg
aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -577,6 +597,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg
aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -628,6 +649,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg
aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -660,7 +682,11 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child1Label"
expandIcon={<ChevronRight20Regular />}
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
>
<div
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
@@ -670,10 +696,13 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
aria-hidden={true}
className="fui-TreeItemLayout__expandIcon rh4pu5o"
>
<ChevronRight20Regular>
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
>
<svg
aria-hidden={true}
className="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -700,6 +729,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
</TreeItemLayout>
<Tree
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
data-test="Tree:root/child1Label"
>
<TreeProvider
value={
@@ -772,6 +802,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg
aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -800,6 +831,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg
aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -851,6 +883,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<svg
aria-hidden="true"
class="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -883,7 +916,11 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root/child2LoadingLabel"
expandIcon={<ChevronRight20Regular />}
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
>
<div
className="fui-TreeItemLayout r1bx0xiv ___dxcrnh0_1vtp8mg fk6fouc fkhj508 figsok6 f1i3iumi f1k1erfc fbv8p0b f1f09k3d fg706s2 frpde29 f1n8cmsf f1ktbui8 f1do9gdl"
@@ -893,10 +930,13 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
aria-hidden={true}
className="fui-TreeItemLayout__expandIcon rh4pu5o"
>
<ChevronRight20Regular>
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
>
<svg
aria-hidden={true}
className="___12fm75w_v8ls9a0 f1w7gpdv fez10in fg4l7m0"
data-text="TreeNode/ExpandIcon"
fill="currentColor"
height="20"
viewBox="0 0 20 20"
@@ -1202,7 +1242,11 @@ exports[`TreeNodeComponent renders a node as expandable if it has empty, but def
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={<ChevronRight20Regular />}
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
>
<span
className=""
@@ -1379,7 +1423,11 @@ exports[`TreeNodeComponent renders selected parent node as selected if no descen
actions={false}
className="___kqkdor0_ihxn0o0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1nfm20t f1do9gdl"
data-test="TreeNode:root"
expandIcon={<ChevronRight20Regular />}
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
>
<span
className=""
@@ -1389,6 +1437,7 @@ exports[`TreeNodeComponent renders selected parent node as selected if no descen
</TreeItemLayout>
<Tree
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
data-test="Tree:root"
>
<TreeNodeComponent
key="child1Label"
@@ -1450,7 +1499,11 @@ exports[`TreeNodeComponent renders selected parent node as unselected if any des
actions={false}
className="___1kqyw53_iy2icj0 fkhj508 fbv8p0b f1f09k3d fg706s2 frpde29 f1k1erfc f1n8cmsf f1ktbui8 f1do9gdl"
data-test="TreeNode:root"
expandIcon={<ChevronRight20Regular />}
expandIcon={
<ChevronRight20Regular
data-text="TreeNode/ExpandIcon"
/>
}
>
<span
className=""
@@ -1460,6 +1513,7 @@ exports[`TreeNodeComponent renders selected parent node as unselected if any des
</TreeItemLayout>
<Tree
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
data-test="Tree:root"
>
<TreeNodeComponent
key="child1Label"

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,7 @@ import {
} from "@fluentui/react";
import { HttpStatusCodes } from "Common/Constants";
import { handleError } from "Common/ErrorHandlingUtils";
import QueryError, { QueryErrorSeverity } from "Common/QueryError";
import { createUri } from "Common/UrlUtility";
import { CopyPopup } from "Explorer/QueryCopilot/Popup/CopyPopup";
import { DeletePopup } from "Explorer/QueryCopilot/Popup/DeletePopup";
@@ -105,8 +106,8 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
setShowErrorMessageBar,
setGeneratedQueryComments,
setQueryResults,
setErrorMessage,
errorMessage,
setErrors,
errors,
} = useCopilotStore();
const sampleProps: SamplePromptsProps = {
@@ -179,7 +180,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
const resetQueryResults = (): void => {
setQueryResults(null);
setErrorMessage("");
setErrors([]);
};
const generateSQLQuery = async (): Promise<void> => {
@@ -243,7 +244,12 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
handleError(JSON.stringify(generateSQLQueryResponse), "copilotTooManyRequestError");
useTabs.getState().setIsQueryErrorThrown(true);
setShowErrorMessageBar(true);
setErrorMessage("Ratelimit exceeded 5 per 1 minute. Please try again after sometime");
setErrors([
new QueryError(
"Ratelimit exceeded 5 per 1 minute. Please try again after sometime",
QueryErrorSeverity.Error,
),
]);
TelemetryProcessor.traceFailure(Action.QueryGenerationFromCopilotPrompt, {
databaseName: databaseId,
collectionId: containerId,
@@ -514,7 +520,9 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
</Link>
{showErrorMessageBar && (
<MessageBar messageBarType={MessageBarType.error}>
{errorMessage ? errorMessage : "We ran into an error and were not able to execute query."}
{errors.length > 0
? errors[0].message
: "We ran into an error and were not able to execute query."}
</MessageBar>
)}
{showInvalidQueryMessageBar && (

View File

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

View File

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

View File

@@ -274,6 +274,7 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
<div className={styles.floatingControls}>
<button
type="button"
data-test="Sidebar/RefreshButton"
className={styles.floatingControlButton}
disabled={loading}
title="Refresh"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -299,11 +299,15 @@ function TabPane({ tab, active }: { tab: Tab; active: boolean }) {
if (tab) {
if ("render" in tab) {
return <div {...attrs}>{tab.render()}</div>;
return (
<div data-test={`Tab:${tab.tabId}`} {...attrs}>
{tab.render()}
</div>
);
}
}
return <div {...attrs} ref={ref} data-bind="html:html" />;
return <div data-test={`Tab:${tab.tabId}`} {...attrs} ref={ref} data-bind="html:html" />;
}
const onKeyPressReactTab = (e: KeyboardEvent, tabKind: ReactTabKind): void => {

View File

@@ -1,6 +1,8 @@
import {
BrandVariants,
ComponentProps,
FluentProvider,
FluentProviderSlots,
Theme,
createLightTheme,
makeStyles,
@@ -10,16 +12,19 @@ import {
webLightTheme,
} from "@fluentui/react-components";
import { Platform, configContext } from "ConfigContext";
import React, { PropsWithChildren } from "react";
import React from "react";
import { appThemeFabricTealBrandRamp } from "../../Platform/Fabric/FabricTheme";
export const LayoutConstants = {
rowHeight: 36,
};
export type CosmosFluentProviderProps = PropsWithChildren<{
className?: string;
}>;
// Our CosmosFluentProvider has the same props as a FluentProvider.
export type CosmosFluentProviderProps = Omit<ComponentProps<FluentProviderSlots, "root">, "dir">;
// PropsWithChildren<{
// className?: string;
// }>;
const useDefaultRootStyles = makeStyles({
fluentProvider: {
@@ -32,15 +37,37 @@ const useDefaultRootStyles = makeStyles({
},
});
export const CosmosFluentProvider: React.FC<CosmosFluentProviderProps> = ({ children, className }) => {
const FluentProviderContext = React.createContext({
isInFluentProvider: false,
});
export const CosmosFluentProvider: React.FC<CosmosFluentProviderProps> = ({ children, className, ...props }) => {
// We use a React context to ensure that nested CosmosFluentProviders don't create nested FluentProviders.
// This helps during the transition from Fluent UI 8 to Fluent UI 9.
// As we convert components to Fluent UI 9, if we end up with nested FluentProviders, the inner FluentProvider will be a no-op.
const { isInFluentProvider } = React.useContext(FluentProviderContext);
const styles = useDefaultRootStyles();
if (isInFluentProvider) {
// We're already in a fluent context, don't create another.
console.warn("Nested CosmosFluentProvider detected. This is likely a bug.");
return (
<div className={className} {...props}>
{children}
</div>
);
}
return (
<FluentProvider
theme={getPlatformTheme(configContext.platform)}
className={mergeClasses(styles.fluentProvider, className)}
>
{children}
</FluentProvider>
<FluentProviderContext.Provider value={{ isInFluentProvider: true }}>
<FluentProvider
theme={getPlatformTheme(configContext.platform)}
className={mergeClasses(styles.fluentProvider, className)}
{...props}
>
{children}
</FluentProvider>
</FluentProviderContext.Provider>
);
};

View File

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

View File

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