mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-21 18:01:39 +00:00
Merge branch 'master' into users/languy/save-documentstab-prefs
This commit is contained in:
@@ -41,6 +41,10 @@ export interface DatabaseContextMenuButtonParams {
|
||||
* New resource tree (in ReactJS)
|
||||
*/
|
||||
export const createDatabaseContextMenu = (container: Explorer, databaseId: string): TreeNodeMenuItem[] => {
|
||||
if (configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const items: TreeNodeMenuItem[] = [
|
||||
{
|
||||
iconSrc: AddCollectionIcon,
|
||||
|
||||
@@ -3,6 +3,37 @@ import * as React from "react";
|
||||
import { loadMonaco, monaco } from "../../LazyMonaco";
|
||||
// import "./EditorReact.less";
|
||||
|
||||
// In development, add a function to window to allow us to get the editor instance for a given element
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const win = window as any;
|
||||
win._monaco_getEditorForElement =
|
||||
win._monaco_getEditorForElement ||
|
||||
((element: HTMLElement) => {
|
||||
const editorId = element.dataset["monacoEditorId"];
|
||||
if (!editorId || !win.__monaco_editors || typeof win.__monaco_editors !== "object") {
|
||||
return null;
|
||||
}
|
||||
return win.__monaco_editors[editorId];
|
||||
});
|
||||
|
||||
win._monaco_getEditorContentForElement =
|
||||
win._monaco_getEditorContentForElement ||
|
||||
((element: HTMLElement) => {
|
||||
const editor = win._monaco_getEditorForElement(element);
|
||||
return editor ? editor.getValue() : null;
|
||||
});
|
||||
|
||||
win._monaco_setEditorContentForElement =
|
||||
win._monaco_setEditorContentForElement ||
|
||||
((element: HTMLElement, text: string) => {
|
||||
const editor = win._monaco_getEditorForElement(element);
|
||||
if (editor) {
|
||||
editor.setValue(text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interface EditorReactStates {
|
||||
showEditor: boolean;
|
||||
}
|
||||
@@ -11,7 +42,7 @@ export interface EditorReactProps {
|
||||
content: string;
|
||||
isReadOnly: boolean;
|
||||
ariaLabel: string; // Sets what will be read to the user to define the control
|
||||
onContentSelected?: (selectedContent: string) => void; // Called when text is selected
|
||||
onContentSelected?: (selectedContent: string, selection: monaco.Selection) => void; // Called when text is selected
|
||||
onContentChanged?: (newContent: string) => void; // Called when text is changed
|
||||
theme?: string; // Monaco editor theme
|
||||
wordWrap?: monaco.editor.IEditorOptions["wordWrap"];
|
||||
@@ -25,6 +56,7 @@ export interface EditorReactProps {
|
||||
className?: string;
|
||||
spinnerClassName?: string;
|
||||
|
||||
modelMarkers?: monaco.editor.IMarkerData[];
|
||||
enableWordWrapContextMenuItem?: boolean; // Enable/Disable "Word Wrap" context menu item
|
||||
onWordWrapChanged?: (wordWrap: "on" | "off") => void; // Called when word wrap is changed
|
||||
}
|
||||
@@ -32,10 +64,25 @@ export interface EditorReactProps {
|
||||
export class EditorReact extends React.Component<EditorReactProps, EditorReactStates> {
|
||||
private static readonly VIEWING_OPTIONS_GROUP_ID = "viewingoptions"; // Group ID for the context menu group
|
||||
private rootNode: HTMLElement;
|
||||
private editor: monaco.editor.IStandaloneCodeEditor;
|
||||
public editor: monaco.editor.IStandaloneCodeEditor;
|
||||
private selectionListener: monaco.IDisposable;
|
||||
|
||||
private monacoEditorOptionsWordWrap: monaco.editor.EditorOption;
|
||||
monacoApi: {
|
||||
default: typeof monaco;
|
||||
Emitter: typeof monaco.Emitter;
|
||||
MarkerTag: typeof monaco.MarkerTag;
|
||||
MarkerSeverity: typeof monaco.MarkerSeverity;
|
||||
CancellationTokenSource: typeof monaco.CancellationTokenSource;
|
||||
Uri: typeof monaco.Uri;
|
||||
KeyCode: typeof monaco.KeyCode;
|
||||
KeyMod: typeof monaco.KeyMod;
|
||||
Position: typeof monaco.Position;
|
||||
Range: typeof monaco.Range;
|
||||
Selection: typeof monaco.Selection;
|
||||
SelectionDirection: typeof monaco.SelectionDirection;
|
||||
Token: typeof monaco.Token;
|
||||
editor: typeof monaco.editor;
|
||||
languages: typeof monaco.languages;
|
||||
};
|
||||
|
||||
public constructor(props: EditorReactProps) {
|
||||
super(props);
|
||||
@@ -64,7 +111,7 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
||||
|
||||
if (this.props.content !== existingContent) {
|
||||
if (this.props.isReadOnly) {
|
||||
this.editor.setValue(this.props.content);
|
||||
this.editor.setValue(this.props.content || ""); // Monaco throws an error if you set the value to undefined.
|
||||
} else {
|
||||
this.editor.pushUndoStop();
|
||||
this.editor.executeEdits("", [
|
||||
@@ -75,6 +122,8 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
this.monacoApi.editor.setModelMarkers(this.editor.getModel(), "owner", this.props.modelMarkers || []);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
@@ -88,6 +137,7 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
||||
<Spinner size={SpinnerSize.large} className={this.props.spinnerClassName || "spinner"} />
|
||||
)}
|
||||
<div
|
||||
data-test="EditorReact/Host/Unloaded"
|
||||
className={this.props.className || "jsonEditor"}
|
||||
style={this.props.monacoContainerStyles}
|
||||
ref={(elt: HTMLElement) => this.setRef(elt)}
|
||||
@@ -98,6 +148,18 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
||||
|
||||
protected configureEditor(editor: monaco.editor.IStandaloneCodeEditor) {
|
||||
this.editor = editor;
|
||||
this.rootNode.dataset["test"] = "EditorReact/Host/Loaded";
|
||||
|
||||
// In development, we want to be able to access the editor instance from the console
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
this.rootNode.dataset["monacoEditorId"] = this.editor.getId();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const win = window as any;
|
||||
|
||||
win["__monaco_editors"] = win["__monaco_editors"] || {};
|
||||
win["__monaco_editors"][this.editor.getId()] = this.editor;
|
||||
}
|
||||
|
||||
if (!this.props.isReadOnly && this.props.onContentChanged) {
|
||||
// Hooking the model's onDidChangeContent event because of some event ordering issues.
|
||||
// If a single user input causes BOTH the editor content to change AND the cursor selection to change (which is likely),
|
||||
@@ -115,7 +177,7 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
||||
this.selectionListener = this.editor.onDidChangeCursorSelection(
|
||||
(event: monaco.editor.ICursorSelectionChangedEvent) => {
|
||||
const selectedContent: string = this.editor.getModel().getValueInRange(event.selection);
|
||||
this.props.onContentSelected(selectedContent);
|
||||
this.props.onContentSelected(selectedContent, event.selection);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -130,7 +192,7 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
||||
// Method that will be executed when the action is triggered.
|
||||
// @param editor The editor instance is passed in as a convenience
|
||||
run: (ed) => {
|
||||
const newOption = ed.getOption(this.monacoEditorOptionsWordWrap) === "on" ? "off" : "on";
|
||||
const newOption = ed.getOption(this.monacoApi.editor.EditorOption.wordWrap) === "on" ? "off" : "on";
|
||||
ed.updateOptions({ wordWrap: newOption });
|
||||
this.props.onWordWrapChanged(newOption);
|
||||
},
|
||||
@@ -156,16 +218,14 @@ export class EditorReact extends React.Component<EditorReactProps, EditorReactSt
|
||||
lineDecorationsWidth: this.props.lineDecorationsWidth,
|
||||
minimap: this.props.minimap,
|
||||
scrollBeyondLastLine: this.props.scrollBeyondLastLine,
|
||||
fixedOverflowWidgets: true,
|
||||
};
|
||||
|
||||
this.rootNode.innerHTML = "";
|
||||
const lazymonaco = await loadMonaco();
|
||||
|
||||
// We can only get this constant after loading monaco lazily
|
||||
this.monacoEditorOptionsWordWrap = lazymonaco.editor.EditorOption.wordWrap;
|
||||
this.monacoApi = await loadMonaco();
|
||||
|
||||
try {
|
||||
createCallback(lazymonaco?.editor?.create(this.rootNode, options));
|
||||
createCallback(this.monacoApi.editor.create(this.rootNode, options));
|
||||
} catch (error) {
|
||||
// This could happen if the parent node suddenly disappears during create()
|
||||
console.error("Unable to create EditorReact", error);
|
||||
|
||||
37
src/Explorer/Controls/IndeterminateProgressBar.tsx
Normal file
37
src/Explorer/Controls/IndeterminateProgressBar.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ProgressBar, makeStyles } from "@fluentui/react-components";
|
||||
import React from "react";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
indeterminateProgressBarRoot: {
|
||||
"@media screen and (prefers-reduced-motion: reduce)": {
|
||||
animationIterationCount: "infinite",
|
||||
animationDuration: "3s",
|
||||
animationName: {
|
||||
"0%": {
|
||||
opacity: ".2", // matches indeterminate bar width
|
||||
},
|
||||
"50%": {
|
||||
opacity: "1",
|
||||
},
|
||||
"100%": {
|
||||
opacity: ".2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
indeterminateProgressBarBar: {
|
||||
"@media screen and (prefers-reduced-motion: reduce)": {
|
||||
maxWidth: "100%",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const IndeterminateProgressBar: React.FC = () => {
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<ProgressBar
|
||||
bar={{ className: styles.indeterminateProgressBarBar }}
|
||||
className={styles.indeterminateProgressBarRoot}
|
||||
/>
|
||||
);
|
||||
};
|
||||
68
src/Explorer/Controls/MessageBanner.tsx
Normal file
68
src/Explorer/Controls/MessageBanner.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Button, MessageBar, MessageBarActions, MessageBarBody } from "@fluentui/react-components";
|
||||
import { DismissRegular } from "@fluentui/react-icons";
|
||||
import React, { useState } from "react";
|
||||
|
||||
export enum MessageBannerState {
|
||||
/** The banner should be visible if the triggering conditions are met. */
|
||||
Allowed = "allowed",
|
||||
|
||||
/** The banner has been dismissed by the user and will not be shown until the component is recreated, even if the visibility condition is true. */
|
||||
Dismissed = "dismissed",
|
||||
|
||||
/** The banner has been supressed by the user and will not be shown at all, even if the visibility condition is true. */
|
||||
Suppressed = "suppressed",
|
||||
}
|
||||
|
||||
export type MessageBannerProps = {
|
||||
/** A CSS class for the root MessageBar component */
|
||||
className: string;
|
||||
|
||||
/** A unique ID for the message that will be used to store it's dismiss/suppress state across sessions. */
|
||||
messageId: string;
|
||||
|
||||
/** The current visibility state for the banner IGNORING the user's dimiss/suppress preference
|
||||
*
|
||||
* If this value is true but the user has dismissed the banner, the banner will NOT be shown.
|
||||
*/
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
/** A component that shows a message banner which can be dismissed by the user.
|
||||
*
|
||||
* In the future, this can also support persisting the dismissed state in local storage without requiring changes to all the components that use it.
|
||||
*
|
||||
* A message banner can be in three "states":
|
||||
* - Allowed: The banner should be visible if the triggering conditions are met.
|
||||
* - Dismissed: The banner has been dismissed by the user and will not be shown until the component is recreated, even if the visibility condition is true.
|
||||
* - Suppressed: The banner has been supressed by the user and will not be shown at all, even if the visibility condition is true.
|
||||
*
|
||||
* The "Dismissed" state represents the user clicking the "x" in the banner to dismiss it.
|
||||
* The "Suppressed" state represents the user clicking "Don't show this again".
|
||||
*/
|
||||
export const MessageBanner: React.FC<MessageBannerProps> = ({ visible, className, children }) => {
|
||||
const [state, setState] = useState<MessageBannerState>(MessageBannerState.Allowed);
|
||||
|
||||
if (state !== MessageBannerState.Allowed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageBar className={className}>
|
||||
<MessageBarBody>{children}</MessageBarBody>
|
||||
<MessageBarActions
|
||||
containerAction={
|
||||
<Button
|
||||
aria-label="dismiss"
|
||||
appearance="transparent"
|
||||
icon={<DismissRegular />}
|
||||
onClick={() => setState(MessageBannerState.Dismissed)}
|
||||
/>
|
||||
}
|
||||
></MessageBarActions>
|
||||
</MessageBar>
|
||||
);
|
||||
};
|
||||
@@ -1,28 +1,16 @@
|
||||
@import "../../../../less/Common/Constants";
|
||||
|
||||
.tabComponentContainer {
|
||||
height: 100%;
|
||||
.flex-display();
|
||||
.flex-direction();
|
||||
height: 100%;
|
||||
.flex-display();
|
||||
.flex-direction();
|
||||
|
||||
.tabSwitch {
|
||||
margin-left: @LargeSpace;
|
||||
margin-bottom: 20px;
|
||||
.tabSwitch {
|
||||
margin-left: @LargeSpace;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.tab {
|
||||
margin-right: @MediumSpace;
|
||||
}
|
||||
|
||||
.toggleSwitch {
|
||||
.toggleSwitch();
|
||||
}
|
||||
|
||||
.selectedToggle {
|
||||
.selectedToggle();
|
||||
}
|
||||
|
||||
.unselectedToggle {
|
||||
.unselectedToggle();
|
||||
}
|
||||
}
|
||||
.tab {
|
||||
margin-right: @MediumSpace;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { AccessibleElement } from "../../Controls/AccessibleElement/AccessibleElement";
|
||||
import { Pivot, PivotItem } from "@fluentui/react";
|
||||
import "./TabComponent.less";
|
||||
|
||||
export interface TabContent {
|
||||
@@ -35,58 +35,36 @@ export class TabComponent extends React.Component<TabComponentProps> {
|
||||
}
|
||||
|
||||
private setActiveTab(index: number): void {
|
||||
this.setState({ activeTabIndex: index });
|
||||
this.props.onTabIndexChange(index);
|
||||
}
|
||||
|
||||
private renderTabTitles(): JSX.Element[] {
|
||||
return this.props.tabs.map((tab: Tab, index: number) => {
|
||||
if (!tab.isVisible()) {
|
||||
return <React.Fragment key={index} />;
|
||||
}
|
||||
|
||||
let className = "toggleSwitch";
|
||||
let ariaselected;
|
||||
if (index === this.props.currentTabIndex) {
|
||||
className += " selectedToggle";
|
||||
ariaselected = true;
|
||||
} else {
|
||||
className += " unselectedToggle";
|
||||
ariaselected = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tab" key={index}>
|
||||
<AccessibleElement
|
||||
as="span"
|
||||
className={className}
|
||||
role="tab"
|
||||
onActivated={() => this.setActiveTab(index)}
|
||||
aria-label={`Select tab: ${tab.title}`}
|
||||
aria-selected={ariaselected}
|
||||
>
|
||||
{tab.title}
|
||||
</AccessibleElement>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public render(): JSX.Element {
|
||||
const currentTabContent = this.props.tabs[this.props.currentTabIndex].content;
|
||||
const { tabs, currentTabIndex, hideHeader } = this.props;
|
||||
const currentTabContent = tabs[currentTabIndex].content;
|
||||
let className = "tabComponentContent";
|
||||
if (currentTabContent.className) {
|
||||
className += ` ${currentTabContent.className}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tabComponentContainer">
|
||||
{!this.props.hideHeader && (
|
||||
<div className="tabs tabSwitch" role="tablist">
|
||||
{this.renderTabTitles()}
|
||||
</div>
|
||||
)}
|
||||
<div className={className}>{currentTabContent.render()}</div>
|
||||
<div className="tabs tabSwitch">
|
||||
{!hideHeader && (
|
||||
<Pivot
|
||||
aria-label="Tab navigation"
|
||||
selectedKey={currentTabIndex.toString()}
|
||||
linkSize="normal"
|
||||
onLinkClick={(item) => this.setActiveTab(parseInt(item?.props.itemKey || ""))}
|
||||
>
|
||||
{tabs.map((tab: Tab, index: number) => {
|
||||
if (!tab.isVisible()) {
|
||||
return null; // Skip rendering invisible tabs
|
||||
}
|
||||
return <PivotItem key={index} headerText={tab.title} itemKey={index.toString()} />;
|
||||
})}
|
||||
</Pivot>
|
||||
)}
|
||||
</div>
|
||||
<div className={className}>{tabs[currentTabIndex].content.render()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,9 @@ export const useTreeStyles = makeStyles({
|
||||
height: `var(${treeIconWidth})`,
|
||||
},
|
||||
treeItem: {},
|
||||
nodeLabel: {},
|
||||
nodeLabel: {
|
||||
whiteSpace: "nowrap", // Don't wrap text, there will be a scrollbar.
|
||||
},
|
||||
treeItemLayout: {
|
||||
fontSize: tokens.fontSizeBase300,
|
||||
height: tokens.layoutRowHeight,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -12,10 +12,14 @@ 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=""
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
>
|
||||
rootLabel
|
||||
</span>
|
||||
@@ -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"
|
||||
@@ -177,7 +183,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
class=""
|
||||
class="___1h29e9h_0000000 fz5stix"
|
||||
>
|
||||
rootLabel
|
||||
</span>
|
||||
@@ -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"
|
||||
@@ -228,7 +235,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
class=""
|
||||
class="___1h29e9h_0000000 fz5stix"
|
||||
>
|
||||
rootLabel
|
||||
</span>
|
||||
@@ -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"
|
||||
@@ -274,7 +283,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
class=""
|
||||
class="___1h29e9h_0000000 fz5stix"
|
||||
>
|
||||
child1Label
|
||||
</span>
|
||||
@@ -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"
|
||||
@@ -317,7 +327,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
class=""
|
||||
class="___1h29e9h_0000000 fz5stix"
|
||||
>
|
||||
child2LoadingLabel
|
||||
</span>
|
||||
@@ -357,7 +367,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
class=""
|
||||
class="___1h29e9h_0000000 fz5stix"
|
||||
>
|
||||
child3ExpandingLabel
|
||||
</span>
|
||||
@@ -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"
|
||||
@@ -406,7 +423,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
className="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
className=""
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
>
|
||||
rootLabel
|
||||
</span>
|
||||
@@ -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"
|
||||
@@ -593,7 +614,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
class=""
|
||||
class="___1h29e9h_0000000 fz5stix"
|
||||
>
|
||||
child1Label
|
||||
</span>
|
||||
@@ -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"
|
||||
@@ -644,7 +666,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
class=""
|
||||
class="___1h29e9h_0000000 fz5stix"
|
||||
>
|
||||
child1Label
|
||||
</span>
|
||||
@@ -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"
|
||||
@@ -691,7 +720,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
className="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
className=""
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
>
|
||||
child1Label
|
||||
</span>
|
||||
@@ -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"
|
||||
@@ -816,7 +848,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
class=""
|
||||
class="___1h29e9h_0000000 fz5stix"
|
||||
>
|
||||
child2LoadingLabel
|
||||
</span>
|
||||
@@ -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"
|
||||
@@ -867,7 +900,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
class=""
|
||||
class="___1h29e9h_0000000 fz5stix"
|
||||
>
|
||||
child2LoadingLabel
|
||||
</span>
|
||||
@@ -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"
|
||||
@@ -914,7 +954,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
className="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
className=""
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
>
|
||||
child2LoadingLabel
|
||||
</span>
|
||||
@@ -1023,7 +1063,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
class=""
|
||||
class="___1h29e9h_0000000 fz5stix"
|
||||
>
|
||||
child3ExpandingLabel
|
||||
</span>
|
||||
@@ -1071,7 +1111,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
class="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
class=""
|
||||
class="___1h29e9h_0000000 fz5stix"
|
||||
>
|
||||
child3ExpandingLabel
|
||||
</span>
|
||||
@@ -1113,7 +1153,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
|
||||
className="fui-TreeItemLayout__main rklbe47"
|
||||
>
|
||||
<span
|
||||
className=""
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
>
|
||||
child3ExpandingLabel
|
||||
</span>
|
||||
@@ -1155,7 +1195,7 @@ exports[`TreeNodeComponent renders a loading spinner if the node is loading: loa
|
||||
}
|
||||
>
|
||||
<span
|
||||
className=""
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
>
|
||||
rootLabel
|
||||
</span>
|
||||
@@ -1182,7 +1222,7 @@ exports[`TreeNodeComponent renders a loading spinner if the node is loading: loa
|
||||
}
|
||||
>
|
||||
<span
|
||||
className=""
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
>
|
||||
rootLabel
|
||||
</span>
|
||||
@@ -1202,10 +1242,14 @@ 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=""
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
>
|
||||
rootLabel
|
||||
</span>
|
||||
@@ -1280,7 +1324,7 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
|
||||
}
|
||||
>
|
||||
<span
|
||||
className=""
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
>
|
||||
rootLabel
|
||||
</span>
|
||||
@@ -1330,7 +1374,7 @@ exports[`TreeNodeComponent renders a single node 1`] = `
|
||||
}
|
||||
>
|
||||
<span
|
||||
className=""
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
>
|
||||
rootLabel
|
||||
</span>
|
||||
@@ -1359,7 +1403,7 @@ exports[`TreeNodeComponent renders an icon if the node has one 1`] = `
|
||||
}
|
||||
>
|
||||
<span
|
||||
className=""
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
>
|
||||
rootLabel
|
||||
</span>
|
||||
@@ -1379,16 +1423,21 @@ 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=""
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
>
|
||||
rootLabel
|
||||
</span>
|
||||
</TreeItemLayout>
|
||||
<Tree
|
||||
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
|
||||
data-test="Tree:root"
|
||||
>
|
||||
<TreeNodeComponent
|
||||
key="child1Label"
|
||||
@@ -1450,16 +1499,21 @@ 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=""
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
>
|
||||
rootLabel
|
||||
</span>
|
||||
</TreeItemLayout>
|
||||
<Tree
|
||||
className="___jy13a00_0000000 f1acs6jw f11qra4b fepn2xe f1nbblvp fhxm7u5 fzz4f4n"
|
||||
data-test="Tree:root"
|
||||
>
|
||||
<TreeNodeComponent
|
||||
key="child1Label"
|
||||
@@ -1531,7 +1585,7 @@ exports[`TreeNodeComponent renders single selected leaf node as selected 1`] = `
|
||||
}
|
||||
>
|
||||
<span
|
||||
className=""
|
||||
className="___1h29e9h_0000000 fz5stix"
|
||||
>
|
||||
rootLabel
|
||||
</span>
|
||||
|
||||
@@ -295,7 +295,7 @@ export default class Explorer {
|
||||
}
|
||||
|
||||
public openNPSSurveyDialog(): void {
|
||||
if (!Platform.Portal) {
|
||||
if (!Platform.Portal || !["Postgres", "SQL", "Mongo"].includes(userContext.apiType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -632,24 +632,15 @@ describe("GraphExplorer", () => {
|
||||
|
||||
it("should display RU consumption", () => {
|
||||
// Find link for query stats
|
||||
const links = wrapper.find(".toggleSwitch");
|
||||
const queryStatsTab = wrapper.find(`button[name="${GraphExplorer.QUERY_STATS_BUTTON_LABEL}"]`);
|
||||
queryStatsTab.simulate("click");
|
||||
const values = wrapper.find(".queryMetricsSummary td");
|
||||
let isRUDisplayed = false;
|
||||
for (let i = 0; i < links.length; i++) {
|
||||
const link = links.at(i);
|
||||
if (link.text() === GraphExplorer.QUERY_STATS_BUTTON_LABEL) {
|
||||
link.simulate("click");
|
||||
|
||||
const values = wrapper.find(".queryMetricsSummary td");
|
||||
for (let j = 0; j < values.length; j++) {
|
||||
if (Number(values.at(j).text()) === gVRU) {
|
||||
isRUDisplayed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
values.forEach((value) => {
|
||||
if (Number(value.text()) === gVRU) {
|
||||
isRUDisplayed = true;
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
expect(isRUDisplayed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -348,8 +348,9 @@ export class NodePropertiesComponent extends React.Component<
|
||||
as="span"
|
||||
onActivated={this.setIsDeleteConfirm.bind(this, true)}
|
||||
aria-label="Delete this vertex"
|
||||
role="button"
|
||||
>
|
||||
<img src={DeleteIcon} alt="Delete" role="button" />
|
||||
<img src={DeleteIcon} alt="Delete" aria-label="hidden" />
|
||||
</AccessibleElement>
|
||||
);
|
||||
} else {
|
||||
@@ -405,8 +406,9 @@ export class NodePropertiesComponent extends React.Component<
|
||||
as="span"
|
||||
aria-label="Edit properties"
|
||||
onActivated={expandClickHandler}
|
||||
role="button"
|
||||
>
|
||||
<img src={EditIcon} alt="Edit" role="button" />
|
||||
<img src={EditIcon} alt="Edit" aria-label="hidden" />
|
||||
</AccessibleElement>
|
||||
)}
|
||||
|
||||
|
||||
@@ -167,22 +167,18 @@ export function createContextCommandBarButtons(
|
||||
}
|
||||
|
||||
export function createControlCommandBarButtons(container: Explorer): CommandButtonComponentProps[] {
|
||||
const buttons: CommandButtonComponentProps[] =
|
||||
configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly
|
||||
? []
|
||||
: [
|
||||
{
|
||||
iconSrc: SettingsIcon,
|
||||
iconAlt: "Settings",
|
||||
onCommandClick: () =>
|
||||
useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={container} />),
|
||||
commandButtonLabel: undefined,
|
||||
ariaLabel: "Settings",
|
||||
tooltipText: "Settings",
|
||||
hasPopup: true,
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
const buttons: CommandButtonComponentProps[] = [
|
||||
{
|
||||
iconSrc: SettingsIcon,
|
||||
iconAlt: "Settings",
|
||||
onCommandClick: () => useSidePanel.getState().openSidePanel("Settings", <SettingsPane explorer={container} />),
|
||||
commandButtonLabel: undefined,
|
||||
ariaLabel: "Settings",
|
||||
tooltipText: "Settings",
|
||||
hasPopup: true,
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
const showOpenFullScreen =
|
||||
configContext.platform === Platform.Portal && !isRunningOnNationalCloud() && userContext.apiType !== "Gremlin";
|
||||
|
||||
@@ -131,6 +131,7 @@ export class NotificationConsoleComponent extends React.Component<
|
||||
</div>
|
||||
<div
|
||||
className="expandCollapseButton"
|
||||
data-test="NotificationConsole/ExpandCollapseButton"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={"console button" + (this.props.isConsoleExpanded ? " expanded" : " collapsed")}
|
||||
@@ -147,7 +148,7 @@ export class NotificationConsoleComponent extends React.Component<
|
||||
height={this.props.isConsoleExpanded ? "auto" : 0}
|
||||
onAnimationEnd={this.onConsoleWasExpanded}
|
||||
>
|
||||
<div className="notificationConsoleContents">
|
||||
<div data-test="NotificationConsole/Contents" className="notificationConsoleContents">
|
||||
<div className="notificationConsoleControls">
|
||||
<Dropdown
|
||||
label="Filter:"
|
||||
|
||||
@@ -74,6 +74,7 @@ exports[`NotificationConsoleComponent renders the console 1`] = `
|
||||
aria-expanded={true}
|
||||
aria-label="console button collapsed"
|
||||
className="expandCollapseButton"
|
||||
data-test="NotificationConsole/ExpandCollapseButton"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
@@ -109,6 +110,7 @@ exports[`NotificationConsoleComponent renders the console 1`] = `
|
||||
>
|
||||
<div
|
||||
className="notificationConsoleContents"
|
||||
data-test="NotificationConsole/Contents"
|
||||
>
|
||||
<div
|
||||
className="notificationConsoleControls"
|
||||
@@ -245,6 +247,7 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
|
||||
aria-expanded={true}
|
||||
aria-label="console button collapsed"
|
||||
className="expandCollapseButton"
|
||||
data-test="NotificationConsole/ExpandCollapseButton"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
@@ -280,6 +283,7 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
|
||||
>
|
||||
<div
|
||||
className="notificationConsoleContents"
|
||||
data-test="NotificationConsole/Contents"
|
||||
>
|
||||
<div
|
||||
className="notificationConsoleControls"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Checkbox,
|
||||
ChoiceGroup,
|
||||
DefaultButton,
|
||||
IChoiceGroupOption,
|
||||
ISpinButtonStyles,
|
||||
IToggleStyles,
|
||||
@@ -12,11 +13,15 @@ import {
|
||||
Toggle,
|
||||
TooltipHost,
|
||||
} from "@fluentui/react";
|
||||
import { makeStyles } from "@fluentui/react-components";
|
||||
import { AuthType } from "AuthType";
|
||||
import * as Constants from "Common/Constants";
|
||||
import { SplitterDirection } from "Common/Splitter";
|
||||
import { InfoTooltip } from "Common/Tooltip/InfoTooltip";
|
||||
import { Platform, configContext } from "ConfigContext";
|
||||
import { useDialog } from "Explorer/Controls/Dialog";
|
||||
import { useDatabases } from "Explorer/useDatabases";
|
||||
import { deleteAllStates } from "Shared/AppStatePersistenceUtility";
|
||||
import {
|
||||
DefaultRUThreshold,
|
||||
LocalStorageUtility,
|
||||
@@ -29,14 +34,13 @@ import * as StringUtility from "Shared/StringUtility";
|
||||
import { updateUserContext, userContext } from "UserContext";
|
||||
import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils";
|
||||
import * as PriorityBasedExecutionUtils from "Utils/PriorityBasedExecutionUtils";
|
||||
import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
|
||||
import { useQueryCopilot } from "hooks/useQueryCopilot";
|
||||
import { useSidePanel } from "hooks/useSidePanel";
|
||||
import React, { FunctionComponent, useState } from "react";
|
||||
import create, { UseStore } from "zustand";
|
||||
import Explorer from "../../Explorer";
|
||||
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
|
||||
import { AuthType } from "AuthType";
|
||||
import create, { UseStore } from "zustand";
|
||||
import { getReadOnlyKeys, listKeys } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
|
||||
|
||||
export interface DataPlaneRbacState {
|
||||
dataPlaneRbacEnabled: boolean;
|
||||
@@ -50,6 +54,13 @@ export interface DataPlaneRbacState {
|
||||
|
||||
type DataPlaneRbacStore = UseStore<Partial<DataPlaneRbacState>>;
|
||||
|
||||
const useStyles = makeStyles({
|
||||
bulletList: {
|
||||
listStyleType: "disc",
|
||||
paddingLeft: "20px",
|
||||
},
|
||||
});
|
||||
|
||||
export const useDataPlaneRbac: DataPlaneRbacStore = create(() => ({
|
||||
dataPlaneRbacEnabled: false,
|
||||
}));
|
||||
@@ -133,6 +144,9 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
const [copilotSampleDBEnabled, setCopilotSampleDBEnabled] = useState<boolean>(
|
||||
LocalStorageUtility.getEntryString(StorageKey.CopilotSampleDBEnabled) === "true",
|
||||
);
|
||||
|
||||
const styles = useStyles();
|
||||
|
||||
const explorerVersion = configContext.gitSha;
|
||||
const shouldShowQueryPageOptions = userContext.apiType === "SQL";
|
||||
const shouldShowGraphAutoVizOption = userContext.apiType === "Gremlin";
|
||||
@@ -153,43 +167,45 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
|
||||
LocalStorageUtility.setEntryNumber(StorageKey.CustomItemPerPage, customItemPerPage);
|
||||
|
||||
LocalStorageUtility.setEntryString(StorageKey.DataPlaneRbacEnabled, enableDataPlaneRBACOption);
|
||||
if (
|
||||
enableDataPlaneRBACOption === Constants.RBACOptions.setTrueRBACOption ||
|
||||
(enableDataPlaneRBACOption === Constants.RBACOptions.setAutomaticRBACOption &&
|
||||
userContext.databaseAccount.properties.disableLocalAuth)
|
||||
) {
|
||||
updateUserContext({
|
||||
dataPlaneRbacEnabled: true,
|
||||
hasDataPlaneRbacSettingChanged: true,
|
||||
});
|
||||
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: true });
|
||||
} else {
|
||||
updateUserContext({
|
||||
dataPlaneRbacEnabled: false,
|
||||
hasDataPlaneRbacSettingChanged: true,
|
||||
});
|
||||
const { databaseAccount: account, subscriptionId, resourceGroup } = userContext;
|
||||
if (!userContext.features.enableAadDataPlane && !userContext.masterKey) {
|
||||
let keys;
|
||||
try {
|
||||
keys = await listKeys(subscriptionId, resourceGroup, account.name);
|
||||
updateUserContext({
|
||||
masterKey: keys.primaryMasterKey,
|
||||
});
|
||||
} catch (error) {
|
||||
// if listKeys fail because of permissions issue, then make call to get ReadOnlyKeys
|
||||
if (error.code === "AuthorizationFailed") {
|
||||
keys = await getReadOnlyKeys(subscriptionId, resourceGroup, account.name);
|
||||
if (configContext.platform !== Platform.Fabric) {
|
||||
LocalStorageUtility.setEntryString(StorageKey.DataPlaneRbacEnabled, enableDataPlaneRBACOption);
|
||||
if (
|
||||
enableDataPlaneRBACOption === Constants.RBACOptions.setTrueRBACOption ||
|
||||
(enableDataPlaneRBACOption === Constants.RBACOptions.setAutomaticRBACOption &&
|
||||
userContext.databaseAccount.properties.disableLocalAuth)
|
||||
) {
|
||||
updateUserContext({
|
||||
dataPlaneRbacEnabled: true,
|
||||
hasDataPlaneRbacSettingChanged: true,
|
||||
});
|
||||
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: true });
|
||||
} else {
|
||||
updateUserContext({
|
||||
dataPlaneRbacEnabled: false,
|
||||
hasDataPlaneRbacSettingChanged: true,
|
||||
});
|
||||
const { databaseAccount: account, subscriptionId, resourceGroup } = userContext;
|
||||
if (!userContext.features.enableAadDataPlane && !userContext.masterKey) {
|
||||
let keys;
|
||||
try {
|
||||
keys = await listKeys(subscriptionId, resourceGroup, account.name);
|
||||
updateUserContext({
|
||||
masterKey: keys.primaryReadonlyMasterKey,
|
||||
masterKey: keys.primaryMasterKey,
|
||||
});
|
||||
} else {
|
||||
logConsoleError(`Error occurred fetching keys for the account." ${error.message}`);
|
||||
throw error;
|
||||
} catch (error) {
|
||||
// if listKeys fail because of permissions issue, then make call to get ReadOnlyKeys
|
||||
if (error.code === "AuthorizationFailed") {
|
||||
keys = await getReadOnlyKeys(subscriptionId, resourceGroup, account.name);
|
||||
updateUserContext({
|
||||
masterKey: keys.primaryReadonlyMasterKey,
|
||||
});
|
||||
} else {
|
||||
logConsoleError(`Error occurred fetching keys for the account." ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: false });
|
||||
}
|
||||
useDataPlaneRbac.setState({ dataPlaneRbacEnabled: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,55 +492,57 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{userContext.apiType === "SQL" && userContext.authType === AuthType.AAD && (
|
||||
<>
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart">
|
||||
<fieldset>
|
||||
<legend id="enableDataPlaneRBACOptions" className="settingsSectionLabel legendLabel">
|
||||
Enable Entra ID RBAC
|
||||
</legend>
|
||||
<TooltipHost
|
||||
content={
|
||||
<>
|
||||
Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable Entra
|
||||
ID RBAC.
|
||||
<a
|
||||
href="https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac#use-data-explorer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{" "}
|
||||
Learn more{" "}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Icon iconName="Info" ariaLabel="Info tooltip" className="panelInfoIcon" tabIndex={0} />
|
||||
</TooltipHost>
|
||||
{showDataPlaneRBACWarning && configContext.platform === Platform.Portal && (
|
||||
<MessageBar
|
||||
messageBarType={MessageBarType.warning}
|
||||
isMultiline={true}
|
||||
onDismiss={() => setShowDataPlaneRBACWarning(false)}
|
||||
dismissButtonAriaLabel="Close"
|
||||
{userContext.apiType === "SQL" &&
|
||||
userContext.authType === AuthType.AAD &&
|
||||
configContext.platform !== Platform.Fabric && (
|
||||
<>
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart">
|
||||
<fieldset>
|
||||
<legend id="enableDataPlaneRBACOptions" className="settingsSectionLabel legendLabel">
|
||||
Enable Entra ID RBAC
|
||||
</legend>
|
||||
<TooltipHost
|
||||
content={
|
||||
<>
|
||||
Choose Automatic to enable Entra ID RBAC automatically. True/False to force enable/disable
|
||||
Entra ID RBAC.
|
||||
<a
|
||||
href="https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-setup-rbac#use-data-explorer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{" "}
|
||||
Learn more{" "}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
>
|
||||
Please click on "Login for Entra ID RBAC" button prior to performing Entra ID RBAC
|
||||
operations
|
||||
</MessageBar>
|
||||
)}
|
||||
<ChoiceGroup
|
||||
ariaLabelledBy="enableDataPlaneRBACOptions"
|
||||
options={dataPlaneRBACOptionsList}
|
||||
styles={choiceButtonStyles}
|
||||
selectedKey={enableDataPlaneRBACOption}
|
||||
onChange={handleOnDataPlaneRBACOptionChange}
|
||||
/>
|
||||
</fieldset>
|
||||
<Icon iconName="Info" ariaLabel="Info tooltip" className="panelInfoIcon" tabIndex={0} />
|
||||
</TooltipHost>
|
||||
{showDataPlaneRBACWarning && configContext.platform === Platform.Portal && (
|
||||
<MessageBar
|
||||
messageBarType={MessageBarType.warning}
|
||||
isMultiline={true}
|
||||
onDismiss={() => setShowDataPlaneRBACWarning(false)}
|
||||
dismissButtonAriaLabel="Close"
|
||||
>
|
||||
Please click on "Login for Entra ID RBAC" button prior to performing Entra ID RBAC
|
||||
operations
|
||||
</MessageBar>
|
||||
)}
|
||||
<ChoiceGroup
|
||||
ariaLabelledBy="enableDataPlaneRBACOptions"
|
||||
options={dataPlaneRBACOptionsList}
|
||||
styles={choiceButtonStyles}
|
||||
selectedKey={enableDataPlaneRBACOption}
|
||||
onChange={handleOnDataPlaneRBACOptionChange}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{userContext.apiType === "SQL" && (
|
||||
<>
|
||||
<div className="settingsSection">
|
||||
@@ -830,6 +848,34 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart">
|
||||
<DefaultButton
|
||||
onClick={() => {
|
||||
useDialog.getState().showOkCancelModalDialog(
|
||||
"Clear History",
|
||||
undefined,
|
||||
"Are you sure you want to proceed?",
|
||||
() => deleteAllStates(),
|
||||
"Cancel",
|
||||
undefined,
|
||||
<>
|
||||
<span>
|
||||
This action will clear the all customizations for this account in this browser, including:
|
||||
</span>
|
||||
<ul className={styles.bulletList}>
|
||||
<li>Reset your customized tab layout, including the splitter positions</li>
|
||||
<li>Erase your table column preferences, including any custom columns</li>
|
||||
<li>Clear your filter history</li>
|
||||
</ul>
|
||||
</>,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Clear History
|
||||
</DefaultButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settingsSection">
|
||||
<div className="settingsSectionPart">
|
||||
<div className="settingsSectionLabel">Explorer Version</div>
|
||||
|
||||
@@ -485,6 +485,19 @@ exports[`Settings Pane should render Default properly 1`] = `
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="settingsSection"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionPart"
|
||||
>
|
||||
<CustomizedDefaultButton
|
||||
onClick={[Function]}
|
||||
>
|
||||
Clear History
|
||||
</CustomizedDefaultButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="settingsSection"
|
||||
>
|
||||
@@ -708,6 +721,19 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="settingsSection"
|
||||
>
|
||||
<div
|
||||
className="settingsSectionPart"
|
||||
>
|
||||
<CustomizedDefaultButton
|
||||
onClick={[Function]}
|
||||
>
|
||||
Clear History
|
||||
</CustomizedDefaultButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="settingsSection"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MinimalQueryIterator } from "Common/IteratorUtilities";
|
||||
import QueryError from "Common/QueryError";
|
||||
import { QueryResults } from "Contracts/ViewModels";
|
||||
import { CopilotMessage } from "Explorer/QueryCopilot/Shared/QueryCopilotInterfaces";
|
||||
import { guid } from "Explorer/Tables/Utilities";
|
||||
@@ -28,7 +29,7 @@ const CopilotProvider = ({ children }: { children: React.ReactNode }): JSX.Eleme
|
||||
showSamplePrompts: false,
|
||||
queryIterator: undefined,
|
||||
queryResults: undefined,
|
||||
errorMessage: "",
|
||||
errors: [],
|
||||
isSamplePromptsOpen: false,
|
||||
showPromptTeachingBubble: true,
|
||||
showDeletePopup: false,
|
||||
@@ -64,7 +65,7 @@ const CopilotProvider = ({ children }: { children: React.ReactNode }): JSX.Eleme
|
||||
setShowSamplePrompts: (showSamplePrompts: boolean) => set({ showSamplePrompts }),
|
||||
setQueryIterator: (queryIterator: MinimalQueryIterator | undefined) => set({ queryIterator }),
|
||||
setQueryResults: (queryResults: QueryResults | undefined) => set({ queryResults }),
|
||||
setErrorMessage: (errorMessage: string) => set({ errorMessage }),
|
||||
setErrors: (errors: QueryError[]) => set({ errors }),
|
||||
setIsSamplePromptsOpen: (isSamplePromptsOpen: boolean) => set({ isSamplePromptsOpen }),
|
||||
setShowPromptTeachingBubble: (showPromptTeachingBubble: boolean) => set({ showPromptTeachingBubble }),
|
||||
setShowDeletePopup: (showDeletePopup: boolean) => set({ showDeletePopup }),
|
||||
|
||||
@@ -18,8 +18,9 @@ import {
|
||||
Text,
|
||||
TextField,
|
||||
} from "@fluentui/react";
|
||||
import { HttpStatusCodes } from "Common/Constants";
|
||||
import { HttpStatusCodes, NormalizedEventKey } from "Common/Constants";
|
||||
import { handleError } from "Common/ErrorHandlingUtils";
|
||||
import QueryError, { QueryErrorSeverity } from "Common/QueryError";
|
||||
import { createUri } from "Common/UrlUtility";
|
||||
import { CopyPopup } from "Explorer/QueryCopilot/Popup/CopyPopup";
|
||||
import { DeletePopup } from "Explorer/QueryCopilot/Popup/DeletePopup";
|
||||
@@ -34,7 +35,7 @@ import { SamplePrompts, SamplePromptsProps } from "Explorer/QueryCopilot/Shared/
|
||||
import { Action } from "Shared/Telemetry/TelemetryConstants";
|
||||
import { userContext } from "UserContext";
|
||||
import { useQueryCopilot } from "hooks/useQueryCopilot";
|
||||
import React, { useRef, useState } from "react";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import HintIcon from "../../../images/Hint.svg";
|
||||
import RecentIcon from "../../../images/Recent.svg";
|
||||
import errorIcon from "../../../images/close-black.svg";
|
||||
@@ -70,6 +71,8 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
||||
}: QueryCopilotPromptProps): JSX.Element => {
|
||||
const [copilotTeachingBubbleVisible, setCopilotTeachingBubbleVisible] = useState<boolean>(false);
|
||||
const inputEdited = useRef(false);
|
||||
const itemRefs = useRef([]);
|
||||
const searchInputRef = useRef(null);
|
||||
const {
|
||||
openFeedbackModal,
|
||||
hideFeedbackModalForLikedQueries,
|
||||
@@ -105,10 +108,10 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
||||
setShowErrorMessageBar,
|
||||
setGeneratedQueryComments,
|
||||
setQueryResults,
|
||||
setErrorMessage,
|
||||
errorMessage,
|
||||
setErrors,
|
||||
errors,
|
||||
} = useCopilotStore();
|
||||
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||
const sampleProps: SamplePromptsProps = {
|
||||
isSamplePromptsOpen: isSamplePromptsOpen,
|
||||
setIsSamplePromptsOpen: setIsSamplePromptsOpen,
|
||||
@@ -141,6 +144,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
||||
: getSuggestedPrompts();
|
||||
const [filteredHistories, setFilteredHistories] = useState<string[]>(histories);
|
||||
const [filteredSuggestedPrompts, setFilteredSuggestedPrompts] = useState<SuggestedPrompt[]>(suggestedPrompts);
|
||||
const { UpArrow, DownArrow, Enter } = NormalizedEventKey;
|
||||
|
||||
const handleUserPromptChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
inputEdited.current = true;
|
||||
@@ -179,7 +183,7 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
||||
|
||||
const resetQueryResults = (): void => {
|
||||
setQueryResults(null);
|
||||
setErrorMessage("");
|
||||
setErrors([]);
|
||||
};
|
||||
|
||||
const generateSQLQuery = async (): Promise<void> => {
|
||||
@@ -243,7 +247,12 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
||||
handleError(JSON.stringify(generateSQLQueryResponse), "copilotTooManyRequestError");
|
||||
useTabs.getState().setIsQueryErrorThrown(true);
|
||||
setShowErrorMessageBar(true);
|
||||
setErrorMessage("Ratelimit exceeded 5 per 1 minute. Please try again after sometime");
|
||||
setErrors([
|
||||
new QueryError(
|
||||
"Ratelimit exceeded 5 per 1 minute. Please try again after sometime",
|
||||
QueryErrorSeverity.Error,
|
||||
),
|
||||
]);
|
||||
TelemetryProcessor.traceFailure(Action.QueryGenerationFromCopilotPrompt, {
|
||||
databaseName: databaseId,
|
||||
collectionId: containerId,
|
||||
@@ -301,7 +310,38 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
||||
return "Content is updated";
|
||||
}
|
||||
};
|
||||
const openSamplePrompts = () => {
|
||||
inputEdited.current = true;
|
||||
setShowSamplePrompts(true);
|
||||
};
|
||||
const totalSuggestions = useMemo(
|
||||
() => [...filteredSuggestedPrompts, ...filteredHistories],
|
||||
[filteredSuggestedPrompts, filteredHistories],
|
||||
);
|
||||
|
||||
const handleKeyDownForInput = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === DownArrow) {
|
||||
setFocusedIndex(0);
|
||||
itemRefs.current[0]?.current?.focus();
|
||||
} else if (event.key === Enter && userPrompt) {
|
||||
inputEdited.current = true;
|
||||
startGenerateQueryProcess();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDownForItem = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === UpArrow && focusedIndex > 0) {
|
||||
itemRefs.current[focusedIndex - 1].current?.focus();
|
||||
setFocusedIndex((prevIndex) => prevIndex - 1);
|
||||
} else if (event.key === DownArrow && focusedIndex < totalSuggestions.length - 1) {
|
||||
itemRefs.current[focusedIndex + 1].current?.focus();
|
||||
setFocusedIndex((prevIndex) => prevIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
itemRefs.current = totalSuggestions.map(() => React.createRef());
|
||||
}, [totalSuggestions]);
|
||||
React.useEffect(() => {
|
||||
useTabs.getState().setIsQueryErrorThrown(false);
|
||||
}, []);
|
||||
@@ -331,23 +371,14 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
||||
id="naturalLanguageInput"
|
||||
value={userPrompt}
|
||||
onChange={handleUserPromptChange}
|
||||
onClick={() => {
|
||||
inputEdited.current = true;
|
||||
setShowSamplePrompts(true);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && userPrompt) {
|
||||
inputEdited.current = true;
|
||||
startGenerateQueryProcess();
|
||||
}
|
||||
}}
|
||||
onClick={openSamplePrompts}
|
||||
onFocus={() => setShowSamplePrompts(true)}
|
||||
elementRef={searchInputRef}
|
||||
onKeyDown={handleKeyDownForInput}
|
||||
style={{ lineHeight: 30 }}
|
||||
styles={{
|
||||
root: { width: "100%" },
|
||||
suffix: {
|
||||
background: "none",
|
||||
padding: 0,
|
||||
},
|
||||
suffix: { background: "none", padding: 0 },
|
||||
fieldGroup: {
|
||||
borderRadius: 4,
|
||||
borderColor: "#D1D1D1",
|
||||
@@ -360,7 +391,8 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
||||
},
|
||||
}}
|
||||
disabled={isGeneratingQuery}
|
||||
autoComplete="off"
|
||||
autoComplete="list"
|
||||
aria-expanded={showSamplePrompts}
|
||||
placeholder="Ask a question in natural language and we’ll generate the query for you."
|
||||
aria-labelledby="copilot-textfield-label"
|
||||
onRenderSuffix={() => {
|
||||
@@ -432,6 +464,8 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
||||
setShowSamplePrompts(false);
|
||||
inputEdited.current = true;
|
||||
}}
|
||||
elementRef={itemRefs.current[i]}
|
||||
onKeyDown={handleKeyDownForItem}
|
||||
onRenderIcon={() => <Image src={RecentIcon} styles={{ root: { overflow: "unset" } }} />}
|
||||
styles={promptStyles}
|
||||
>
|
||||
@@ -454,14 +488,16 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
||||
>
|
||||
Suggested Prompts
|
||||
</Text>
|
||||
{filteredSuggestedPrompts.map((prompt) => (
|
||||
{filteredSuggestedPrompts.map((prompt, index) => (
|
||||
<DefaultButton
|
||||
key={prompt.id}
|
||||
elementRef={itemRefs.current[filteredHistories.length + index]}
|
||||
onClick={() => {
|
||||
setUserPrompt(prompt.text);
|
||||
setShowSamplePrompts(false);
|
||||
inputEdited.current = true;
|
||||
}}
|
||||
onKeyDown={handleKeyDownForItem}
|
||||
onRenderIcon={() => <Image src={HintIcon} />}
|
||||
styles={promptStyles}
|
||||
>
|
||||
@@ -514,7 +550,9 @@ export const QueryCopilotPromptbar: React.FC<QueryCopilotPromptProps> = ({
|
||||
</Link>
|
||||
{showErrorMessageBar && (
|
||||
<MessageBar messageBarType={MessageBarType.error}>
|
||||
{errorMessage ? errorMessage : "We ran into an error and were not able to execute query."}
|
||||
{errors.length > 0
|
||||
? errors[0].message
|
||||
: "We ran into an error and were not able to execute query."}
|
||||
</MessageBar>
|
||||
)}
|
||||
{showInvalidQueryMessageBar && (
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { getErrorMessage, getErrorStack, handleError } from "Common/ErrorHandlingUtils";
|
||||
import { shouldEnableCrossPartitionKey } from "Common/HeadersUtility";
|
||||
import { MinimalQueryIterator } from "Common/IteratorUtilities";
|
||||
import QueryError from "Common/QueryError";
|
||||
import { createUri } from "Common/UrlUtility";
|
||||
import { queryDocumentsPage } from "Common/dataAccess/queryDocumentsPage";
|
||||
import { configContext } from "ConfigContext";
|
||||
@@ -354,7 +355,7 @@ export const QueryDocumentsPerPage = async (
|
||||
);
|
||||
|
||||
useQueryCopilot.getState().setQueryResults(queryResults);
|
||||
useQueryCopilot.getState().setErrorMessage("");
|
||||
useQueryCopilot.getState().setErrors([]);
|
||||
useQueryCopilot.getState().setShowErrorMessageBar(false);
|
||||
traceSuccess(Action.ExecuteQueryGeneratedFromQueryCopilot, {
|
||||
correlationId: useQueryCopilot.getState().correlationId,
|
||||
@@ -366,12 +367,13 @@ export const QueryDocumentsPerPage = async (
|
||||
const errorMessage = getErrorMessage(error);
|
||||
traceFailure(Action.ExecuteQueryGeneratedFromQueryCopilot, {
|
||||
correlationId: useQueryCopilot.getState().correlationId,
|
||||
errorMessage: errorMessage,
|
||||
errorMessage,
|
||||
});
|
||||
handleError(errorMessage, "executeQueryCopilotTab");
|
||||
useTabs.getState().setIsQueryErrorThrown(true);
|
||||
if (isCopilotActive) {
|
||||
useQueryCopilot.getState().setErrorMessage(errorMessage);
|
||||
const queryErrors = QueryError.tryParse(error);
|
||||
useQueryCopilot.getState().setErrors(queryErrors);
|
||||
useQueryCopilot.getState().setShowErrorMessageBar(true);
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -8,7 +8,7 @@ export const QueryCopilotResults: React.FC = (): JSX.Element => {
|
||||
<QueryResultSection
|
||||
isMongoDB={false}
|
||||
queryEditorContent={useQueryCopilot.getState().selectedQuery || useQueryCopilot.getState().query}
|
||||
error={useQueryCopilot.getState().errorMessage}
|
||||
errors={useQueryCopilot.getState().errors}
|
||||
queryResults={useQueryCopilot.getState().queryResults}
|
||||
isExecuting={useQueryCopilot.getState().isExecuting}
|
||||
executeQueryDocumentsPage={(firstItemIndex: number) =>
|
||||
|
||||
@@ -24,7 +24,9 @@ export const QuickstartCarousel: React.FC<QuickstartCarouselProps> = ({
|
||||
>
|
||||
<Stack>
|
||||
<Stack horizontal horizontalAlign="space-between" style={{ padding: 16 }}>
|
||||
<Text variant="xLarge">{getHeaderText(page)}</Text>
|
||||
<Text role="heading" aria-level={1} variant="xLarge">
|
||||
{getHeaderText(page)}
|
||||
</Text>
|
||||
<IconButton iconProps={{ iconName: "Cancel" }} onClick={() => setPage(4)} ariaLabel="Close" />
|
||||
</Stack>
|
||||
{getContent(page)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuButtonProps,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
@@ -60,6 +61,7 @@ const useSidebarStyles = makeStyles({
|
||||
alignItems: "center",
|
||||
justifyItems: "center",
|
||||
width: "100%",
|
||||
containerType: "size", // Use this container for "@container" queries below this.
|
||||
...cosmosShorthands.borderBottom(),
|
||||
},
|
||||
loadingProgressBar: {
|
||||
@@ -83,6 +85,18 @@ const useSidebarStyles = makeStyles({
|
||||
},
|
||||
},
|
||||
},
|
||||
globalCommandsMenuButton: {
|
||||
display: "initial",
|
||||
"@container (min-width: 250px)": {
|
||||
display: "none",
|
||||
},
|
||||
},
|
||||
globalCommandsSplitButton: {
|
||||
display: "none",
|
||||
"@container (min-width: 250px)": {
|
||||
display: "flex",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
interface GlobalCommandsProps {
|
||||
@@ -171,13 +185,19 @@ const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
|
||||
<Menu positioning="below-end">
|
||||
<MenuTrigger disableButtonEnhancement>
|
||||
{(triggerProps: MenuButtonProps) => (
|
||||
<SplitButton
|
||||
menuButton={{ ...triggerProps, "aria-label": "More commands" }}
|
||||
primaryActionButton={{ onClick: onPrimaryActionClick }}
|
||||
icon={primaryAction.icon}
|
||||
>
|
||||
{primaryAction.label}
|
||||
</SplitButton>
|
||||
<>
|
||||
<SplitButton
|
||||
menuButton={{ ...triggerProps, "aria-label": "More commands" }}
|
||||
primaryActionButton={{ onClick: onPrimaryActionClick }}
|
||||
className={styles.globalCommandsSplitButton}
|
||||
icon={primaryAction.icon}
|
||||
>
|
||||
{primaryAction.label}
|
||||
</SplitButton>
|
||||
<MenuButton {...triggerProps} icon={primaryAction.icon} className={styles.globalCommandsMenuButton}>
|
||||
New...
|
||||
</MenuButton>
|
||||
</>
|
||||
)}
|
||||
</MenuTrigger>
|
||||
<MenuPopover>
|
||||
@@ -199,7 +219,7 @@ interface SidebarProps {
|
||||
explorer: Explorer;
|
||||
}
|
||||
|
||||
const CollapseThreshold = 50;
|
||||
const CollapseThreshold = 140;
|
||||
|
||||
export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
|
||||
const styles = useSidebarStyles();
|
||||
@@ -249,6 +269,12 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
|
||||
setLoading(false);
|
||||
}, [setLoading]);
|
||||
|
||||
const hasGlobalCommands = !(
|
||||
configContext.platform === Platform.Fabric ||
|
||||
userContext.apiType === "Postgres" ||
|
||||
userContext.apiType === "VCoreMongo"
|
||||
);
|
||||
|
||||
return (
|
||||
<Allotment ref={allotment} onChange={onChange} onDragEnd={onDragEnd} className="resourceTreeAndTabs">
|
||||
{/* Collections Tree - Start */}
|
||||
@@ -268,6 +294,7 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
|
||||
<div className={styles.floatingControls}>
|
||||
<button
|
||||
type="button"
|
||||
data-test="Sidebar/RefreshButton"
|
||||
className={styles.floatingControlButton}
|
||||
disabled={loading}
|
||||
title="Refresh"
|
||||
@@ -285,8 +312,11 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.expandedContent}>
|
||||
<GlobalCommands explorer={explorer} />
|
||||
<div
|
||||
className={styles.expandedContent}
|
||||
style={!hasGlobalCommands ? { gridTemplateRows: "1fr" } : undefined}
|
||||
>
|
||||
{hasGlobalCommands && <GlobalCommands explorer={explorer} />}
|
||||
<ResourceTree explorer={explorer} />
|
||||
</div>
|
||||
</>
|
||||
@@ -304,7 +334,7 @@ export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
|
||||
</CosmosFluentProvider>
|
||||
</Allotment.Pane>
|
||||
)}
|
||||
<Allotment.Pane minSize={800}>
|
||||
<Allotment.Pane minSize={200}>
|
||||
<Tabs explorer={explorer} />
|
||||
</Allotment.Pane>
|
||||
</Allotment>
|
||||
|
||||
100
src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts
Normal file
100
src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
// Definitions of State data
|
||||
|
||||
import { deleteState, loadState, saveState, saveStateDebounced } from "Shared/AppStatePersistenceUtility";
|
||||
import { userContext } from "UserContext";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
|
||||
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
|
||||
|
||||
const componentName = "DocumentsTab";
|
||||
export enum SubComponentName {
|
||||
ColumnSizes = "ColumnSizes",
|
||||
FilterHistory = "FilterHistory",
|
||||
MainTabDivider = "MainTabDivider",
|
||||
}
|
||||
|
||||
export type ColumnSizesMap = { [columnId: string]: WidthDefinition };
|
||||
export type WidthDefinition = { idealWidth?: number; minWidth?: number };
|
||||
export type TabDivider = { leftPaneWidthPercent: number };
|
||||
|
||||
/**
|
||||
*
|
||||
* @param subComponentName
|
||||
* @param collection
|
||||
* @param defaultValue Will be returned if persisted state is not found
|
||||
* @returns
|
||||
*/
|
||||
export const readSubComponentState = <T>(
|
||||
subComponentName: SubComponentName,
|
||||
collection: ViewModels.CollectionBase,
|
||||
defaultValue: T,
|
||||
): T => {
|
||||
const globalAccountName = userContext.databaseAccount?.name;
|
||||
if (!globalAccountName) {
|
||||
const message = "Database account name not found in userContext";
|
||||
console.error(message);
|
||||
TelemetryProcessor.traceFailure(Action.ReadPersistedTabState, { message, componentName });
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const state = loadState({
|
||||
componentName: componentName,
|
||||
subComponentName,
|
||||
globalAccountName,
|
||||
databaseName: collection.databaseId,
|
||||
containerName: collection.id(),
|
||||
}) as T;
|
||||
|
||||
return state || defaultValue;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param subComponentName
|
||||
* @param collection
|
||||
* @param state State to save
|
||||
* @param debounce true for high-frequency calls (e.g mouse drag events)
|
||||
*/
|
||||
export const saveSubComponentState = <T>(
|
||||
subComponentName: SubComponentName,
|
||||
collection: ViewModels.CollectionBase,
|
||||
state: T,
|
||||
debounce?: boolean,
|
||||
): void => {
|
||||
const globalAccountName = userContext.databaseAccount?.name;
|
||||
if (!globalAccountName) {
|
||||
const message = "Database account name not found in userContext";
|
||||
console.error(message);
|
||||
TelemetryProcessor.traceFailure(Action.SavePersistedTabState, { message, componentName });
|
||||
return;
|
||||
}
|
||||
|
||||
(debounce ? saveStateDebounced : saveState)(
|
||||
{
|
||||
componentName: componentName,
|
||||
subComponentName,
|
||||
globalAccountName,
|
||||
databaseName: collection.databaseId,
|
||||
containerName: collection.id(),
|
||||
},
|
||||
state,
|
||||
);
|
||||
};
|
||||
|
||||
export const deleteSubComponentState = (subComponentName: SubComponentName, collection: ViewModels.CollectionBase) => {
|
||||
const globalAccountName = userContext.databaseAccount?.name;
|
||||
if (!globalAccountName) {
|
||||
const message = "Database account name not found in userContext";
|
||||
console.error(message);
|
||||
TelemetryProcessor.traceFailure(Action.DeletePersistedTabState, { message, componentName });
|
||||
return;
|
||||
}
|
||||
|
||||
deleteState({
|
||||
componentName: componentName,
|
||||
subComponentName,
|
||||
globalAccountName,
|
||||
databaseName: collection.databaseId,
|
||||
containerName: collection.id(),
|
||||
});
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SAVE_BUTTON_ID,
|
||||
UPDATE_BUTTON_ID,
|
||||
UPLOAD_BUTTON_ID,
|
||||
addStringsNoDuplicate,
|
||||
buildQuery,
|
||||
getDiscardExistingDocumentChangesButtonState,
|
||||
getDiscardNewDocumentChangesButtonState,
|
||||
@@ -339,7 +340,10 @@ describe("Documents tab (noSql API)", () => {
|
||||
const createMockProps = (): IDocumentsTabComponentProps => ({
|
||||
isPreferredApiMongoDB: false,
|
||||
documentIds: [],
|
||||
collection: undefined,
|
||||
collection: {
|
||||
id: ko.observable<string>("collectionId"),
|
||||
databaseId: "databaseId",
|
||||
} as ViewModels.CollectionBase,
|
||||
partitionKey: { kind: "Hash", paths: ["/foo"], version: 2 },
|
||||
onLoadStartKey: 0,
|
||||
tabTitle: "",
|
||||
@@ -380,7 +384,7 @@ describe("Documents tab (noSql API)", () => {
|
||||
.findWhere((node) => node.text() === "Edit Filter")
|
||||
.at(0)
|
||||
.simulate("click");
|
||||
expect(wrapper.find("#filterInput").exists()).toBeTruthy();
|
||||
expect(wrapper.find("Input.filterInput").exists()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -474,3 +478,13 @@ describe("Documents tab (noSql API)", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Documents tab", () => {
|
||||
it("should add strings to array without duplicate", () => {
|
||||
const array1 = ["a", "b", "c"];
|
||||
const array2 = ["b", "c", "d"];
|
||||
|
||||
const array3 = addStringsNoDuplicate(array1, array2);
|
||||
expect(array3).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,12 +20,13 @@ import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
|
||||
import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities";
|
||||
import { usePrevious } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
|
||||
import {
|
||||
DocumentsTabPrefs,
|
||||
readDocumentsTabPrefs,
|
||||
saveDocumentsTabPrefsDebounced,
|
||||
} from "Explorer/Tabs/DocumentsTabV2/documentsTabPrefs";
|
||||
SubComponentName,
|
||||
TabDivider,
|
||||
readSubComponentState,
|
||||
saveSubComponentState,
|
||||
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
|
||||
import { usePrevious } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
|
||||
import { CosmosFluentProvider, LayoutConstants, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil";
|
||||
import { useSelectedNode } from "Explorer/useSelectedNode";
|
||||
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
|
||||
@@ -48,6 +49,7 @@ import * as Logger from "../../../Common/Logger";
|
||||
import * as MongoProxyClient from "../../../Common/MongoProxyClient";
|
||||
import * as DataModels from "../../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import { CollectionBase } from "../../../Contracts/ViewModels";
|
||||
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
|
||||
import * as QueryUtils from "../../../Utils/QueryUtils";
|
||||
import { defaultQueryFields, extractPartitionKeyValues } from "../../../Utils/QueryUtils";
|
||||
@@ -56,6 +58,8 @@ import ObjectId from "../../Tree/ObjectId";
|
||||
import TabsBase from "../TabsBase";
|
||||
import { ColumnDefinition, DocumentsTableComponent, DocumentsTableComponentItem } from "./DocumentsTableComponent";
|
||||
|
||||
const MAX_FILTER_HISTORY_COUNT = 100; // Datalist will become scrollable, so we can afford to keep more items than fit on the screen
|
||||
|
||||
const loadMoreHeight = LayoutConstants.rowHeight;
|
||||
export const useDocumentsTabStyles = makeStyles({
|
||||
container: {
|
||||
@@ -486,6 +490,24 @@ export const buildQuery = (
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Export to expose to unit tests
|
||||
*
|
||||
* Add array2 to array1 without duplicates
|
||||
* @param array1
|
||||
* @param array2
|
||||
* @return array1 with array2 added without duplicates
|
||||
*/
|
||||
export const addStringsNoDuplicate = (array1: string[], array2: string[]): string[] => {
|
||||
const result = [...array1];
|
||||
array2.forEach((item) => {
|
||||
if (!result.includes(item)) {
|
||||
result.push(item);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
// Export to expose to unit tests
|
||||
export interface IDocumentsTabComponentProps {
|
||||
isPreferredApiMongoDB: boolean;
|
||||
@@ -500,6 +522,11 @@ export interface IDocumentsTabComponentProps {
|
||||
isTabActive: boolean;
|
||||
}
|
||||
|
||||
const getUniqueId = (collection: ViewModels.CollectionBase): string => `${collection.databaseId}-${collection.id()}`;
|
||||
|
||||
const defaultSqlFilters = ['WHERE c.id = "foo"', "ORDER BY c._ts DESC", 'WHERE c.id = "foo" ORDER BY c._ts DESC'];
|
||||
const defaultMongoFilters = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"];
|
||||
|
||||
// Extend DocumentId to include fields displayed in the table
|
||||
type ExtendedDocumentId = DocumentId & { tableFields?: DocumentsTableComponentItem };
|
||||
|
||||
@@ -553,8 +580,12 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
ViewModels.DocumentExplorerState.noDocumentSelected,
|
||||
);
|
||||
|
||||
// Preferences
|
||||
const [prefs, setPrefs] = useState<DocumentsTabPrefs>(readDocumentsTabPrefs());
|
||||
// State
|
||||
const [tabStateData, setTabStateData] = useState<TabDivider>(() =>
|
||||
readSubComponentState(SubComponentName.MainTabDivider, _collection, {
|
||||
leftPaneWidthPercent: 35,
|
||||
}),
|
||||
);
|
||||
|
||||
const isQueryCopilotSampleContainer =
|
||||
_collection?.isSampleCollection &&
|
||||
@@ -564,6 +595,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
// For Mongo only
|
||||
const [continuationToken, setContinuationToken] = useState<string>(undefined);
|
||||
|
||||
// User's filter history
|
||||
const [lastFilterContents, setLastFilterContents] = useState<string[]>(() =>
|
||||
readSubComponentState(SubComponentName.FilterHistory, _collection, []),
|
||||
);
|
||||
|
||||
const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -589,8 +625,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
}
|
||||
}, [documentIds, clickedRowIndex, editorState]);
|
||||
|
||||
let lastFilterContents = ['WHERE c.id = "foo"', "ORDER BY c._ts DESC", 'WHERE c.id = "foo" ORDER BY c._ts DESC'];
|
||||
|
||||
const applyFilterButton = {
|
||||
enabled: true,
|
||||
visible: true,
|
||||
@@ -930,7 +964,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
/**
|
||||
* Implementation using bulk delete NoSQL API
|
||||
*/
|
||||
let _deleteDocuments = useCallback(
|
||||
const _deleteDocuments = useCallback(
|
||||
async (toDeleteDocumentIds: DocumentId[]): Promise<DocumentId[]> => {
|
||||
onExecutionErrorChange(false);
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.DeleteDocuments, {
|
||||
@@ -941,11 +975,29 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
|
||||
// TODO: Once JS SDK Bug fix for bulk deleting legacy containers (whose systemKey==1) is released:
|
||||
// Remove the check for systemKey, remove call to deleteNoSqlDocument(). deleteNoSqlDocuments() should always be called.
|
||||
return (
|
||||
partitionKey.systemKey
|
||||
? deleteNoSqlDocument(_collection, toDeleteDocumentIds[0]).then(() => [toDeleteDocumentIds[0]])
|
||||
: deleteNoSqlDocuments(_collection, toDeleteDocumentIds)
|
||||
)
|
||||
const _deleteNoSqlDocuments = async (
|
||||
collection: CollectionBase,
|
||||
toDeleteDocumentIds: DocumentId[],
|
||||
): Promise<DocumentId[]> => {
|
||||
return partitionKey.systemKey
|
||||
? deleteNoSqlDocument(collection, toDeleteDocumentIds[0]).then(() => [toDeleteDocumentIds[0]])
|
||||
: deleteNoSqlDocuments(collection, toDeleteDocumentIds);
|
||||
};
|
||||
|
||||
const deletePromise = !isPreferredApiMongoDB
|
||||
? _deleteNoSqlDocuments(_collection, toDeleteDocumentIds)
|
||||
: MongoProxyClient.deleteDocuments(
|
||||
_collection.databaseId,
|
||||
_collection as ViewModels.Collection,
|
||||
toDeleteDocumentIds,
|
||||
).then(({ deletedCount, isAcknowledged }) => {
|
||||
if (deletedCount === toDeleteDocumentIds.length && isAcknowledged) {
|
||||
return toDeleteDocumentIds;
|
||||
}
|
||||
throw new Error(`Delete failed with deletedCount: ${deletedCount} and isAcknowledged: ${isAcknowledged}`);
|
||||
});
|
||||
|
||||
return deletePromise
|
||||
.then(
|
||||
(deletedIds) => {
|
||||
TelemetryProcessor.traceSuccess(
|
||||
@@ -976,7 +1028,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
)
|
||||
.finally(() => setIsExecuting(false));
|
||||
},
|
||||
[_collection, onExecutionErrorChange, tabTitle],
|
||||
[_collection, isPreferredApiMongoDB, onExecutionErrorChange, tabTitle],
|
||||
);
|
||||
|
||||
const deleteDocuments = useCallback(
|
||||
@@ -1001,7 +1053,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
(error: Error) =>
|
||||
useDialog
|
||||
.getState()
|
||||
.showOkModalDialog("Delete documents", `Document(s) deleted failed (${JSON.stringify(error)})`),
|
||||
.showOkModalDialog("Delete documents", `Deleting document(s) failed (${error.message})`),
|
||||
)
|
||||
.finally(() => setIsExecuting(false));
|
||||
},
|
||||
@@ -1274,7 +1326,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
|
||||
const onFilterKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === "Enter") {
|
||||
refreshDocumentsGrid(true);
|
||||
onApplyFilterClick();
|
||||
|
||||
// Suppress the default behavior of the key
|
||||
e.preventDefault();
|
||||
@@ -1518,7 +1570,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
return partitionKey;
|
||||
};
|
||||
|
||||
lastFilterContents = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"];
|
||||
partitionKeyProperties = partitionKeyProperties?.map((partitionKeyProperty, i) => {
|
||||
if (partitionKeyProperty && ~partitionKeyProperty.indexOf(`"`)) {
|
||||
partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, "");
|
||||
@@ -1533,62 +1584,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
return partitionKeyProperty;
|
||||
});
|
||||
|
||||
/**
|
||||
* Mongo implementation
|
||||
* TODO: update proxy to use mongo driver deleteMany
|
||||
*/
|
||||
_deleteDocuments = (toDeleteDocumentIds: DocumentId[]): Promise<DocumentId[]> => {
|
||||
const promises = toDeleteDocumentIds.map((documentId) => _deleteDocument(documentId));
|
||||
return Promise.all(promises);
|
||||
};
|
||||
|
||||
const __deleteDocument = async (documentId: DocumentId): Promise<DocumentId> => {
|
||||
await MongoProxyClient.deleteDocument(_collection.databaseId, _collection as ViewModels.Collection, documentId);
|
||||
return documentId;
|
||||
};
|
||||
|
||||
const _deleteDocument = useCallback(
|
||||
(documentId: DocumentId): Promise<DocumentId> => {
|
||||
onExecutionErrorChange(false);
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.DeleteDocument, {
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle,
|
||||
});
|
||||
setIsExecuting(true);
|
||||
return __deleteDocument(documentId)
|
||||
.then(
|
||||
(deletedDocumentId) => {
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.DeleteDocument,
|
||||
{
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle,
|
||||
},
|
||||
startKey,
|
||||
);
|
||||
return deletedDocumentId;
|
||||
},
|
||||
(error) => {
|
||||
onExecutionErrorChange(true);
|
||||
console.error(error);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.DeleteDocument,
|
||||
{
|
||||
dataExplorerArea: Constants.Areas.Tab,
|
||||
tabTitle,
|
||||
error: getErrorMessage(error),
|
||||
errorStack: getErrorStack(error),
|
||||
},
|
||||
startKey,
|
||||
);
|
||||
return undefined;
|
||||
},
|
||||
)
|
||||
.finally(() => setIsExecuting(false));
|
||||
},
|
||||
[__deleteDocument, onExecutionErrorChange, tabTitle],
|
||||
);
|
||||
|
||||
onSaveNewDocumentClick = useCallback((): Promise<unknown> => {
|
||||
const documentContent = JSON.parse(selectedDocumentContent);
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.CreateDocument, {
|
||||
@@ -1795,6 +1790,24 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
}
|
||||
// ***************** Mongo ***************************
|
||||
|
||||
const onApplyFilterClick = (): void => {
|
||||
refreshDocumentsGrid(true);
|
||||
|
||||
// Remove duplicates, but keep order
|
||||
if (lastFilterContents.includes(filterContent)) {
|
||||
lastFilterContents.splice(lastFilterContents.indexOf(filterContent), 1);
|
||||
}
|
||||
|
||||
// Save filter content to local storage
|
||||
lastFilterContents.unshift(filterContent);
|
||||
|
||||
// Keep the list size under MAX_FILTER_HISTORY_COUNT. Drop last element if needed.
|
||||
const limitedLastFilterContents = lastFilterContents.slice(0, MAX_FILTER_HISTORY_COUNT);
|
||||
|
||||
setLastFilterContents(limitedLastFilterContents);
|
||||
saveSubComponentState(SubComponentName.FilterHistory, _collection, lastFilterContents);
|
||||
};
|
||||
|
||||
const refreshDocumentsGrid = useCallback(
|
||||
(applyFilterButtonPressed: boolean): void => {
|
||||
// clear documents grid
|
||||
@@ -1825,15 +1838,6 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
[createIterator, filterContent],
|
||||
);
|
||||
|
||||
const onTableColumnResize = (columnId: string, width: number) => {
|
||||
if (!prefs.columnWidths) {
|
||||
prefs.columnWidths = {};
|
||||
}
|
||||
prefs.columnWidths[columnId] = width;
|
||||
saveDocumentsTabPrefsDebounced(prefs);
|
||||
setPrefs({ ...prefs });
|
||||
};
|
||||
|
||||
const onColumnSelectionChange = (newSelectedColumnIds: string[]): void => {
|
||||
// Do not allow to unselecting all columns
|
||||
if (newSelectedColumnIds.length === 0) {
|
||||
@@ -1892,12 +1896,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
<div className={styles.filterRow}>
|
||||
{!isPreferredApiMongoDB && <span> SELECT * FROM c </span>}
|
||||
<Input
|
||||
id="filterInput"
|
||||
ref={filterInput}
|
||||
type="text"
|
||||
size="small"
|
||||
list="filtersList"
|
||||
className={styles.filterInput}
|
||||
list={`filtersList-${getUniqueId(_collection)}`}
|
||||
className={`filterInput ${styles.filterInput}`}
|
||||
title="Type a query predicate or choose one from the list."
|
||||
placeholder={
|
||||
isPreferredApiMongoDB
|
||||
@@ -1911,8 +1914,11 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
onBlur={() => setIsFilterFocused(false)}
|
||||
/>
|
||||
|
||||
<datalist id="filtersList">
|
||||
{lastFilterContents.map((filter) => (
|
||||
<datalist id={`filtersList-${getUniqueId(_collection)}`}>
|
||||
{addStringsNoDuplicate(
|
||||
lastFilterContents,
|
||||
isPreferredApiMongoDB ? defaultMongoFilters : defaultSqlFilters,
|
||||
).map((filter) => (
|
||||
<option key={filter} value={filter} />
|
||||
))}
|
||||
</datalist>
|
||||
@@ -1920,7 +1926,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
<Button
|
||||
appearance="primary"
|
||||
size="small"
|
||||
onClick={() => refreshDocumentsGrid(true)}
|
||||
onClick={onApplyFilterClick}
|
||||
disabled={!applyFilterButton.enabled}
|
||||
aria-label="Apply filter"
|
||||
tabIndex={0}
|
||||
@@ -1951,11 +1957,16 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* <Split> doesn't like to be a flex child */}
|
||||
<div style={{ overflow: "hidden", height: "100%" }}>
|
||||
<Allotment>
|
||||
<Allotment.Pane preferredSize="35%" minSize={175}>
|
||||
<Allotment
|
||||
onDragEnd={(sizes: number[]) => {
|
||||
tabStateData.leftPaneWidthPercent = (100 * sizes[0]) / (sizes[0] + sizes[1]);
|
||||
saveSubComponentState(SubComponentName.MainTabDivider, _collection, tabStateData);
|
||||
setTabStateData(tabStateData);
|
||||
}}
|
||||
>
|
||||
<Allotment.Pane preferredSize={`${tabStateData.leftPaneWidthPercent}%`} minSize={55}>
|
||||
<div style={{ height: "100%", width: "100%", overflow: "hidden" }} ref={tableContainerRef}>
|
||||
<div className={styles.floatingControlsContainer}>
|
||||
<div className={styles.floatingControls}>
|
||||
@@ -1993,9 +2004,9 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
isSelectionDisabled={
|
||||
configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly
|
||||
}
|
||||
onColumnResize={onTableColumnResize}
|
||||
onColumnSelectionChange={onColumnSelectionChange}
|
||||
defaultColumnSelection={getInitialColumnSelection()}
|
||||
collection={_collection}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2012,7 +2023,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
|
||||
)}
|
||||
</div>
|
||||
</Allotment.Pane>
|
||||
<Allotment.Pane preferredSize="65%" minSize={300}>
|
||||
<Allotment.Pane minSize={30}>
|
||||
<div style={{ height: "100%", width: "100%" }}>
|
||||
{isTabActive && selectedDocumentContent && selectedRows.size <= 1 && (
|
||||
<EditorReact
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { deleteDocument } from "Common/MongoProxyClient";
|
||||
import { deleteDocuments } from "Common/MongoProxyClient";
|
||||
import { Platform, updateConfigContext } from "ConfigContext";
|
||||
import { EditorReactProps } from "Explorer/Controls/Editor/EditorReact";
|
||||
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
|
||||
@@ -49,7 +49,7 @@ jest.mock("Common/MongoProxyClient", () => ({
|
||||
id: "id1",
|
||||
}),
|
||||
),
|
||||
deleteDocument: jest.fn(() => Promise.resolve()),
|
||||
deleteDocuments: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock("Explorer/Controls/Editor/EditorReact", () => ({
|
||||
@@ -179,8 +179,8 @@ describe("Documents tab (Mongo API)", () => {
|
||||
});
|
||||
|
||||
it("clicking Delete Document asks for confirmation", () => {
|
||||
const mockDeleteDocument = deleteDocument as jest.Mock;
|
||||
mockDeleteDocument.mockClear();
|
||||
const mockDeleteDocuments = deleteDocuments as jest.Mock;
|
||||
mockDeleteDocuments.mockClear();
|
||||
|
||||
act(() => {
|
||||
useCommandBar
|
||||
@@ -189,7 +189,7 @@ describe("Documents tab (Mongo API)", () => {
|
||||
.onCommandClick(undefined);
|
||||
});
|
||||
|
||||
expect(mockDeleteDocument).toHaveBeenCalled();
|
||||
expect(mockDeleteDocuments).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TableRowId } from "@fluentui/react-components";
|
||||
import { mount } from "enzyme";
|
||||
import React from "react";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import { DocumentsTableComponent, IDocumentsTableComponentProps } from "./DocumentsTableComponent";
|
||||
|
||||
const PARTITION_KEY_HEADER = "partitionKey";
|
||||
@@ -20,11 +21,19 @@ describe("DocumentsTableComponent", () => {
|
||||
height: 0,
|
||||
width: 0,
|
||||
},
|
||||
columnsDefinition: [
|
||||
{ id: ID_HEADER, label: "ID" },
|
||||
{ id: PARTITION_KEY_HEADER, label: "Partition Key" },
|
||||
columnDefinitions: [
|
||||
{ id: ID_HEADER, label: "ID", isPartitionKey: false },
|
||||
{ id: PARTITION_KEY_HEADER, label: "Partition Key", isPartitionKey: true },
|
||||
],
|
||||
isSelectionDisabled: false,
|
||||
collection: {
|
||||
databaseId: "db",
|
||||
id: ((): string => "coll") as ko.Observable<string>,
|
||||
} as ViewModels.CollectionBase,
|
||||
onRefreshTable: (): void => {
|
||||
throw new Error("Function not implemented.");
|
||||
},
|
||||
selectedColumnIds: [],
|
||||
});
|
||||
|
||||
it("should render documents and partition keys in header", () => {
|
||||
|
||||
@@ -37,6 +37,13 @@ import {
|
||||
} from "@fluentui/react-icons";
|
||||
import { NormalizedEventKey } from "Common/Constants";
|
||||
import { TableColumnSelectionPane } from "Explorer/Panes/TableColumnSelectionPane/TableColumnSelectionPane";
|
||||
import {
|
||||
ColumnSizesMap,
|
||||
readSubComponentState,
|
||||
saveSubComponentState,
|
||||
SubComponentName,
|
||||
WidthDefinition,
|
||||
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
|
||||
import { INITIAL_SELECTED_ROW_INDEX, useDocumentsTabStyles } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2";
|
||||
import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
|
||||
import { LayoutConstants } from "Explorer/Theme/ThemeUtil";
|
||||
@@ -44,6 +51,7 @@ import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/Keybo
|
||||
import { useSidePanel } from "hooks/useSidePanel";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { FixedSizeList as List, ListChildComponentProps } from "react-window";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
|
||||
export type DocumentsTableComponentItem = {
|
||||
id: string;
|
||||
@@ -53,7 +61,6 @@ export type ColumnDefinition = {
|
||||
id: string;
|
||||
label: string;
|
||||
isPartitionKey: boolean;
|
||||
defaultWidthPx?: number;
|
||||
};
|
||||
export interface IDocumentsTableComponentProps {
|
||||
onRefreshTable: () => void;
|
||||
@@ -66,7 +73,7 @@ export interface IDocumentsTableComponentProps {
|
||||
columnDefinitions: ColumnDefinition[];
|
||||
style?: React.CSSProperties;
|
||||
isSelectionDisabled?: boolean;
|
||||
onColumnResize?: (columnId: string, width: number) => void;
|
||||
collection: ViewModels.CollectionBase;
|
||||
onColumnSelectionChange?: (newSelectedColumnIds: string[]) => void;
|
||||
defaultColumnSelection?: string[];
|
||||
}
|
||||
@@ -81,8 +88,6 @@ interface ReactWindowRenderFnProps extends ListChildComponentProps {
|
||||
data: TableRowData[];
|
||||
}
|
||||
|
||||
const DEFAULT_COLUMN_WIDTH_PX = 200;
|
||||
const MIN_COLUMN_WIDTH_PX = 20;
|
||||
const COLUMNS_MENU_NAME = "columnsMenu";
|
||||
|
||||
export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> = ({
|
||||
@@ -95,21 +100,26 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
|
||||
selectedColumnIds,
|
||||
columnDefinitions,
|
||||
isSelectionDisabled,
|
||||
onColumnResize: _onColumnResize,
|
||||
collection,
|
||||
onColumnSelectionChange,
|
||||
defaultColumnSelection,
|
||||
}: IDocumentsTableComponentProps) => {
|
||||
const styles = useDocumentsTabStyles();
|
||||
|
||||
const initialSizingOptions: TableColumnSizingOptions = {};
|
||||
columnDefinitions.forEach((column) => {
|
||||
initialSizingOptions[column.id] = {
|
||||
idealWidth: column.defaultWidthPx || DEFAULT_COLUMN_WIDTH_PX, // 0 is not a valid width
|
||||
minWidth: MIN_COLUMN_WIDTH_PX,
|
||||
};
|
||||
const defaultSize: WidthDefinition = {
|
||||
idealWidth: 200,
|
||||
minWidth: 50,
|
||||
};
|
||||
|
||||
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(() => {
|
||||
const columnSizesMap: ColumnSizesMap = readSubComponentState(SubComponentName.ColumnSizes, collection, {});
|
||||
const columnSizesPx: ColumnSizesMap = {};
|
||||
selectedColumnIds.forEach((columnId) => {
|
||||
columnSizesPx[columnId] = (columnSizesMap && columnSizesMap[columnId]) || defaultSize;
|
||||
});
|
||||
return columnSizesPx;
|
||||
});
|
||||
|
||||
const [columnSizingOptions, setColumnSizingOptions] = React.useState<TableColumnSizingOptions>(initialSizingOptions);
|
||||
const [sortState, setSortState] = React.useState<{
|
||||
sortDirection: "ascending" | "descending";
|
||||
sortColumn: TableColumnId | undefined;
|
||||
@@ -118,19 +128,21 @@ export const DocumentsTableComponent: React.FC<IDocumentsTableComponentProps> =
|
||||
sortColumn: undefined,
|
||||
});
|
||||
|
||||
const onColumnResize = React.useCallback(
|
||||
(_, { columnId, width }) => {
|
||||
setColumnSizingOptions((state) => ({
|
||||
const onColumnResize = React.useCallback((_, { columnId, width }) => {
|
||||
setColumnSizingOptions((state) => {
|
||||
const newSizingOptions = {
|
||||
...state,
|
||||
[columnId]: {
|
||||
...state[columnId],
|
||||
idealWidth: width,
|
||||
},
|
||||
}));
|
||||
_onColumnResize(columnId, width);
|
||||
},
|
||||
[_onColumnResize],
|
||||
);
|
||||
};
|
||||
|
||||
saveSubComponentState(SubComponentName.ColumnSizes, collection, newSizingOptions, true);
|
||||
|
||||
return newSizingOptions;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// const restoreFocusTargetAttribute = useRestoreFocusTarget();
|
||||
|
||||
|
||||
@@ -38,9 +38,11 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
||||
}
|
||||
}
|
||||
>
|
||||
<Allotment>
|
||||
<Allotment
|
||||
onDragEnd={[Function]}
|
||||
>
|
||||
<Allotment.Pane
|
||||
minSize={175}
|
||||
minSize={55}
|
||||
preferredSize="35%"
|
||||
>
|
||||
<div
|
||||
@@ -85,6 +87,12 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
||||
}
|
||||
>
|
||||
<DocumentsTableComponent
|
||||
collection={
|
||||
{
|
||||
"databaseId": "databaseId",
|
||||
"id": [Function],
|
||||
}
|
||||
}
|
||||
columnDefinitions={
|
||||
[
|
||||
{
|
||||
@@ -94,9 +102,13 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
||||
},
|
||||
]
|
||||
}
|
||||
defaultColumnSelection={
|
||||
[
|
||||
"id",
|
||||
]
|
||||
}
|
||||
isSelectionDisabled={true}
|
||||
items={[]}
|
||||
onColumnResize={[Function]}
|
||||
onColumnSelectionChange={[Function]}
|
||||
onItemClicked={[Function]}
|
||||
onRefreshTable={[Function]}
|
||||
@@ -117,8 +129,7 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
|
||||
</div>
|
||||
</Allotment.Pane>
|
||||
<Allotment.Pane
|
||||
minSize={300}
|
||||
preferredSize="65%"
|
||||
minSize={30}
|
||||
>
|
||||
<div
|
||||
style={
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
exports[`DocumentsTableComponent should not render selection column when isSelectionDisabled is true 1`] = `
|
||||
<DocumentsTableComponent
|
||||
collection={
|
||||
{
|
||||
"databaseId": "db",
|
||||
"id": [Function],
|
||||
}
|
||||
}
|
||||
columnHeaders={
|
||||
{
|
||||
"idHeader": "id",
|
||||
@@ -1003,6 +1009,12 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
|
||||
|
||||
exports[`DocumentsTableComponent should render documents and partition keys in header 1`] = `
|
||||
<DocumentsTableComponent
|
||||
collection={
|
||||
{
|
||||
"databaseId": "db",
|
||||
"id": [Function],
|
||||
}
|
||||
}
|
||||
columnHeaders={
|
||||
{
|
||||
"idHeader": "id",
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
// Utility functions to manage DocumentsTab preferences
|
||||
|
||||
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
|
||||
|
||||
export interface DocumentsTabPrefs {
|
||||
leftPaneWidthPercent: number;
|
||||
columnWidths?: { [columnId: string]: number }; // TODO save per database/collection
|
||||
}
|
||||
|
||||
const defaultPrefs: DocumentsTabPrefs = {
|
||||
leftPaneWidthPercent: 35,
|
||||
};
|
||||
|
||||
export const readDocumentsTabPrefs = (): DocumentsTabPrefs => {
|
||||
const prefs = LocalStorageUtility.getEntryObject<DocumentsTabPrefs>(StorageKey.DocumentsTabPrefs);
|
||||
return prefs || defaultPrefs;
|
||||
};
|
||||
|
||||
export const saveDocumentsTabPrefs = (prefs: DocumentsTabPrefs): void => {
|
||||
LocalStorageUtility.setEntryObject(StorageKey.DocumentsTabPrefs, prefs);
|
||||
};
|
||||
|
||||
const DEBOUNCE_TIMEOUT_MS = 300;
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
/**
|
||||
* Wait for a short period of time before saving the preferences to avoid too many updates.
|
||||
* @param prefs
|
||||
*/
|
||||
export const saveDocumentsTabPrefsDebounced = (prefs: DocumentsTabPrefs): void => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
timeoutId = setTimeout(() => saveDocumentsTabPrefs(prefs), DEBOUNCE_TIMEOUT_MS);
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import MongoUtility from "../../../Common/MongoUtility";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import Explorer from "../../Explorer";
|
||||
import { NewQueryTab } from "../QueryTab/QueryTab";
|
||||
import QueryTabComponent, { IQueryTabComponentProps, ITabAccessor } from "../QueryTab/QueryTabComponent";
|
||||
import { IQueryTabComponentProps, ITabAccessor, QueryTabComponent } from "../QueryTab/QueryTabComponent";
|
||||
|
||||
export interface IMongoQueryTabProps {
|
||||
container: Explorer;
|
||||
|
||||
124
src/Explorer/Tabs/QueryTab/ErrorList.tsx
Normal file
124
src/Explorer/Tabs/QueryTab/ErrorList.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
Button,
|
||||
DataGrid,
|
||||
DataGridBody,
|
||||
DataGridCell,
|
||||
DataGridHeader,
|
||||
DataGridHeaderCell,
|
||||
DataGridRow,
|
||||
TableCellLayout,
|
||||
TableColumnDefinition,
|
||||
TableColumnSizingOptions,
|
||||
createTableColumn,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { ErrorCircleFilled, MoreHorizontalRegular, WarningFilled } from "@fluentui/react-icons";
|
||||
import QueryError, { QueryErrorSeverity, compareSeverity } from "Common/QueryError";
|
||||
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
|
||||
import { useNotificationConsole } from "hooks/useNotificationConsole";
|
||||
import React from "react";
|
||||
|
||||
const severityIcons = {
|
||||
[QueryErrorSeverity.Error]: <ErrorCircleFilled color={tokens.colorPaletteRedBackground3} />,
|
||||
[QueryErrorSeverity.Warning]: <WarningFilled color={tokens.colorPaletteYellowForeground1} />,
|
||||
};
|
||||
|
||||
export const ErrorList: React.FC<{ errors: QueryError[] }> = ({ errors }) => {
|
||||
const styles = useQueryTabStyles();
|
||||
const onErrorDetailsClick = (): boolean => {
|
||||
useNotificationConsole.getState().expandConsole();
|
||||
return false;
|
||||
};
|
||||
|
||||
const columns: TableColumnDefinition<QueryError>[] = [
|
||||
createTableColumn<QueryError>({
|
||||
columnId: "code",
|
||||
compare: (item1, item2) => item1.code.localeCompare(item2.code),
|
||||
renderHeaderCell: () => null,
|
||||
renderCell: (item) => item.code,
|
||||
}),
|
||||
createTableColumn<QueryError>({
|
||||
columnId: "severity",
|
||||
compare: (item1, item2) => compareSeverity(item1.severity, item2.severity),
|
||||
renderHeaderCell: () => null,
|
||||
renderCell: (item) => <TableCellLayout media={severityIcons[item.severity]}>{item.severity}</TableCellLayout>,
|
||||
}),
|
||||
createTableColumn<QueryError>({
|
||||
columnId: "location",
|
||||
compare: (item1, item2) => item1.location?.start?.offset - item2.location?.start?.offset,
|
||||
renderHeaderCell: () => "Location",
|
||||
renderCell: (item) =>
|
||||
item.location
|
||||
? item.location.start.lineNumber
|
||||
? `Line ${item.location.start.lineNumber}`
|
||||
: "<unknown>"
|
||||
: "<no location>",
|
||||
}),
|
||||
createTableColumn<QueryError>({
|
||||
columnId: "message",
|
||||
compare: (item1, item2) => item1.message.localeCompare(item2.message),
|
||||
renderHeaderCell: () => "Message",
|
||||
renderCell: (item) => (
|
||||
<div className={styles.errorListMessageCell}>
|
||||
<div className={styles.errorListMessage}>{item.message}</div>
|
||||
<div>
|
||||
<Button
|
||||
aria-label="Details"
|
||||
appearance="subtle"
|
||||
icon={<MoreHorizontalRegular />}
|
||||
onClick={onErrorDetailsClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
];
|
||||
|
||||
const columnSizingOptions: TableColumnSizingOptions = {
|
||||
code: {
|
||||
minWidth: 75,
|
||||
idealWidth: 75,
|
||||
defaultWidth: 75,
|
||||
},
|
||||
severity: {
|
||||
minWidth: 100,
|
||||
idealWidth: 100,
|
||||
defaultWidth: 100,
|
||||
},
|
||||
location: {
|
||||
minWidth: 100,
|
||||
idealWidth: 100,
|
||||
defaultWidth: 100,
|
||||
},
|
||||
message: {
|
||||
minWidth: 500,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
data-test="QueryTab/ResultsPane/ErrorList"
|
||||
items={errors}
|
||||
columns={columns}
|
||||
sortable
|
||||
resizableColumns
|
||||
columnSizingOptions={columnSizingOptions}
|
||||
focusMode="composite"
|
||||
>
|
||||
<DataGridHeader>
|
||||
<DataGridRow>
|
||||
{({ renderHeaderCell }) => <DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>}
|
||||
</DataGridRow>
|
||||
</DataGridHeader>
|
||||
<DataGridBody<QueryError>>
|
||||
{({ item, rowId }) => (
|
||||
<DataGridRow<QueryError> key={rowId} data-test={`Row:${rowId}`}>
|
||||
{({ columnId, renderCell }) => (
|
||||
<DataGridCell data-test={`Row:${rowId}/Column:${columnId}`}>{renderCell(item)}</DataGridCell>
|
||||
)}
|
||||
</DataGridRow>
|
||||
)}
|
||||
</DataGridBody>
|
||||
</DataGrid>
|
||||
);
|
||||
};
|
||||
@@ -1,544 +1,93 @@
|
||||
import {
|
||||
DetailsList,
|
||||
DetailsListLayoutMode,
|
||||
IColumn,
|
||||
Icon,
|
||||
IconButton,
|
||||
Link,
|
||||
Pivot,
|
||||
PivotItem,
|
||||
SelectionMode,
|
||||
Stack,
|
||||
Text,
|
||||
TooltipHost,
|
||||
} from "@fluentui/react";
|
||||
import { HttpHeaders, NormalizedEventKey } from "Common/Constants";
|
||||
import MongoUtility from "Common/MongoUtility";
|
||||
import { QueryMetrics } from "Contracts/DataModels";
|
||||
import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
|
||||
import { IDocument } from "Explorer/Tabs/QueryTab/QueryTabComponent";
|
||||
import { userContext } from "UserContext";
|
||||
import copy from "clipboard-copy";
|
||||
import { useNotificationConsole } from "hooks/useNotificationConsole";
|
||||
import { Link } from "@fluentui/react-components";
|
||||
import QueryError from "Common/QueryError";
|
||||
import { IndeterminateProgressBar } from "Explorer/Controls/IndeterminateProgressBar";
|
||||
import { MessageBanner } from "Explorer/Controls/MessageBanner";
|
||||
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
|
||||
import React from "react";
|
||||
import CopilotCopy from "../../../../images/CopilotCopy.svg";
|
||||
import DownloadQueryMetrics from "../../../../images/DownloadQuery.svg";
|
||||
import QueryEditorNext from "../../../../images/Query-Editor-Next.svg";
|
||||
import RunQuery from "../../../../images/RunQuery.png";
|
||||
import InfoColor from "../../../../images/info_color.svg";
|
||||
import { QueryResults } from "../../../Contracts/ViewModels";
|
||||
import { ErrorList } from "./ErrorList";
|
||||
import { ResultsView } from "./ResultsView";
|
||||
|
||||
interface QueryResultProps {
|
||||
export interface ResultsViewProps {
|
||||
isMongoDB: boolean;
|
||||
queryEditorContent: string;
|
||||
error: string;
|
||||
isExecuting: boolean;
|
||||
queryResults: QueryResults;
|
||||
executeQueryDocumentsPage: (firstItemIndex: number) => Promise<void>;
|
||||
}
|
||||
|
||||
interface QueryResultProps extends ResultsViewProps {
|
||||
queryEditorContent: string;
|
||||
errors: QueryError[];
|
||||
isExecuting: boolean;
|
||||
}
|
||||
|
||||
const ExecuteQueryCallToAction: React.FC = () => {
|
||||
const styles = useQueryTabStyles();
|
||||
return (
|
||||
<div data-test="QueryTab/ResultsPane/ExecuteCTA" className={styles.executeCallToAction}>
|
||||
<div>
|
||||
<p>
|
||||
<img src={RunQuery} aria-hidden="true" />
|
||||
</p>
|
||||
<p>Execute a query to see the results</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const QueryResultSection: React.FC<QueryResultProps> = ({
|
||||
isMongoDB,
|
||||
queryEditorContent,
|
||||
error,
|
||||
errors,
|
||||
queryResults,
|
||||
isExecuting,
|
||||
executeQueryDocumentsPage,
|
||||
isExecuting,
|
||||
}: QueryResultProps): JSX.Element => {
|
||||
const queryMetrics = React.useRef(queryResults?.headers?.[HttpHeaders.queryMetrics]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const latestQueryMetrics = queryResults?.headers?.[HttpHeaders.queryMetrics];
|
||||
if (latestQueryMetrics && Object.keys(latestQueryMetrics).length > 0) {
|
||||
queryMetrics.current = latestQueryMetrics;
|
||||
}
|
||||
}, [queryResults]);
|
||||
|
||||
const onRender = (item: IDocument): JSX.Element => (
|
||||
<>
|
||||
<Text style={{ paddingLeft: 10, margin: 0 }}>{`${item.metric}`}</Text>
|
||||
</>
|
||||
);
|
||||
const columns: IColumn[] = [
|
||||
{
|
||||
key: "column1",
|
||||
name: "Description",
|
||||
iconName: "Info",
|
||||
isIconOnly: true,
|
||||
minWidth: 10,
|
||||
maxWidth: 12,
|
||||
iconClassName: "iconheadercell",
|
||||
data: String,
|
||||
fieldName: "",
|
||||
onRender: (item: IDocument) => {
|
||||
if (item.toolTip !== "") {
|
||||
return (
|
||||
<>
|
||||
<TooltipHost content={`${item.toolTip}`}>
|
||||
<Link style={{ color: "#323130" }}>
|
||||
<Icon iconName="Info" ariaLabel={`${item.toolTip}`} className="panelInfoIcon" tabIndex={0} />
|
||||
</Link>
|
||||
</TooltipHost>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "column2",
|
||||
name: "METRIC",
|
||||
minWidth: 200,
|
||||
data: String,
|
||||
fieldName: "metric",
|
||||
onRender,
|
||||
},
|
||||
{
|
||||
key: "column3",
|
||||
name: "VALUE",
|
||||
minWidth: 200,
|
||||
data: String,
|
||||
fieldName: "value",
|
||||
},
|
||||
];
|
||||
const styles = useQueryTabStyles();
|
||||
const maybeSubQuery = queryEditorContent && /.*\(.*SELECT.*\)/i.test(queryEditorContent);
|
||||
const queryResultsString = queryResults
|
||||
? isMongoDB
|
||||
? MongoUtility.tojson(queryResults.documents, undefined, false)
|
||||
: JSON.stringify(queryResults.documents, undefined, 4)
|
||||
: "";
|
||||
|
||||
const onErrorDetailsClick = (): boolean => {
|
||||
useNotificationConsole.getState().expandConsole();
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const onErrorDetailsKeyPress = (event: React.KeyboardEvent<HTMLAnchorElement>): boolean => {
|
||||
if (event.key === NormalizedEventKey.Space || event.key === NormalizedEventKey.Enter) {
|
||||
onErrorDetailsClick();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const onDownloadQueryMetricsCsvClick = (): boolean => {
|
||||
downloadQueryMetricsCsvData();
|
||||
return false;
|
||||
};
|
||||
|
||||
const onDownloadQueryMetricsCsvKeyPress = (event: React.KeyboardEvent<HTMLAnchorElement>): boolean => {
|
||||
if (event.key === NormalizedEventKey.Space || NormalizedEventKey.Enter) {
|
||||
downloadQueryMetricsCsvData();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const downloadQueryMetricsCsvData = (): void => {
|
||||
const csvData: string = generateQueryMetricsCsvData();
|
||||
if (!csvData) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (navigator.msSaveBlob) {
|
||||
// for IE and Edge
|
||||
navigator.msSaveBlob(
|
||||
new Blob([csvData], { type: "data:text/csv;charset=utf-8" }),
|
||||
"PerPartitionQueryMetrics.csv",
|
||||
);
|
||||
} else {
|
||||
const downloadLink: HTMLAnchorElement = document.createElement("a");
|
||||
downloadLink.href = "data:text/csv;charset=utf-8," + encodeURI(csvData);
|
||||
downloadLink.target = "_self";
|
||||
downloadLink.download = "QueryMetricsPerPartition.csv";
|
||||
|
||||
// for some reason, FF displays the download prompt only when
|
||||
// the link is added to the dom so we add and remove it
|
||||
document.body.appendChild(downloadLink);
|
||||
downloadLink.click();
|
||||
downloadLink.remove();
|
||||
}
|
||||
};
|
||||
|
||||
const getAggregatedQueryMetrics = (): QueryMetrics => {
|
||||
const aggregatedQueryMetrics = {
|
||||
documentLoadTime: 0,
|
||||
documentWriteTime: 0,
|
||||
indexHitDocumentCount: 0,
|
||||
outputDocumentCount: 0,
|
||||
outputDocumentSize: 0,
|
||||
indexLookupTime: 0,
|
||||
retrievedDocumentCount: 0,
|
||||
retrievedDocumentSize: 0,
|
||||
vmExecutionTime: 0,
|
||||
runtimeExecutionTimes: {
|
||||
queryEngineExecutionTime: 0,
|
||||
systemFunctionExecutionTime: 0,
|
||||
userDefinedFunctionExecutionTime: 0,
|
||||
},
|
||||
totalQueryExecutionTime: 0,
|
||||
} as QueryMetrics;
|
||||
|
||||
if (queryMetrics.current) {
|
||||
Object.keys(queryMetrics.current).forEach((partitionKeyRangeId) => {
|
||||
const queryMetricsPerPartition = queryMetrics.current[partitionKeyRangeId];
|
||||
if (!queryMetricsPerPartition) {
|
||||
return;
|
||||
}
|
||||
aggregatedQueryMetrics.documentLoadTime += queryMetricsPerPartition.documentLoadTime?.totalMilliseconds() || 0;
|
||||
aggregatedQueryMetrics.documentWriteTime +=
|
||||
queryMetricsPerPartition.documentWriteTime?.totalMilliseconds() || 0;
|
||||
aggregatedQueryMetrics.indexHitDocumentCount += queryMetricsPerPartition.indexHitDocumentCount || 0;
|
||||
aggregatedQueryMetrics.outputDocumentCount += queryMetricsPerPartition.outputDocumentCount || 0;
|
||||
aggregatedQueryMetrics.outputDocumentSize += queryMetricsPerPartition.outputDocumentSize || 0;
|
||||
aggregatedQueryMetrics.indexLookupTime += queryMetricsPerPartition.indexLookupTime?.totalMilliseconds() || 0;
|
||||
aggregatedQueryMetrics.retrievedDocumentCount += queryMetricsPerPartition.retrievedDocumentCount || 0;
|
||||
aggregatedQueryMetrics.retrievedDocumentSize += queryMetricsPerPartition.retrievedDocumentSize || 0;
|
||||
aggregatedQueryMetrics.vmExecutionTime += queryMetricsPerPartition.vmExecutionTime?.totalMilliseconds() || 0;
|
||||
aggregatedQueryMetrics.totalQueryExecutionTime +=
|
||||
queryMetricsPerPartition.totalQueryExecutionTime?.totalMilliseconds() || 0;
|
||||
aggregatedQueryMetrics.runtimeExecutionTimes.queryEngineExecutionTime +=
|
||||
queryMetricsPerPartition.runtimeExecutionTimes?.queryEngineExecutionTime?.totalMilliseconds() || 0;
|
||||
aggregatedQueryMetrics.runtimeExecutionTimes.systemFunctionExecutionTime +=
|
||||
queryMetricsPerPartition.runtimeExecutionTimes?.systemFunctionExecutionTime?.totalMilliseconds() || 0;
|
||||
aggregatedQueryMetrics.runtimeExecutionTimes.userDefinedFunctionExecutionTime +=
|
||||
queryMetricsPerPartition.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.totalMilliseconds() || 0;
|
||||
});
|
||||
}
|
||||
|
||||
return aggregatedQueryMetrics;
|
||||
};
|
||||
|
||||
const generateQueryMetricsCsvData = (): string => {
|
||||
if (queryMetrics.current) {
|
||||
let csvData =
|
||||
[
|
||||
"Partition key range id",
|
||||
"Retrieved document count",
|
||||
"Retrieved document size (in bytes)",
|
||||
"Output document count",
|
||||
"Output document size (in bytes)",
|
||||
"Index hit document count",
|
||||
"Index lookup time (ms)",
|
||||
"Document load time (ms)",
|
||||
"Query engine execution time (ms)",
|
||||
"System function execution time (ms)",
|
||||
"User defined function execution time (ms)",
|
||||
"Document write time (ms)",
|
||||
].join(",") + "\n";
|
||||
|
||||
Object.keys(queryMetrics.current).forEach((partitionKeyRangeId) => {
|
||||
const queryMetricsPerPartition = queryMetrics.current[partitionKeyRangeId];
|
||||
csvData +=
|
||||
[
|
||||
partitionKeyRangeId,
|
||||
queryMetricsPerPartition.retrievedDocumentCount,
|
||||
queryMetricsPerPartition.retrievedDocumentSize,
|
||||
queryMetricsPerPartition.outputDocumentCount,
|
||||
queryMetricsPerPartition.outputDocumentSize,
|
||||
queryMetricsPerPartition.indexHitDocumentCount,
|
||||
queryMetricsPerPartition.indexLookupTime?.totalMilliseconds(),
|
||||
queryMetricsPerPartition.documentLoadTime?.totalMilliseconds(),
|
||||
queryMetricsPerPartition.runtimeExecutionTimes?.queryEngineExecutionTime?.totalMilliseconds(),
|
||||
queryMetricsPerPartition.runtimeExecutionTimes?.systemFunctionExecutionTime?.totalMilliseconds(),
|
||||
queryMetricsPerPartition.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.totalMilliseconds(),
|
||||
queryMetricsPerPartition.documentWriteTime?.totalMilliseconds(),
|
||||
].join(",") + "\n";
|
||||
});
|
||||
|
||||
return csvData;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const onFetchNextPageClick = async (): Promise<void> => {
|
||||
const { firstItemIndex, itemCount } = queryResults;
|
||||
await executeQueryDocumentsPage(firstItemIndex + itemCount - 1);
|
||||
};
|
||||
|
||||
const generateQueryStatsItems = (): IDocument[] => {
|
||||
const items: IDocument[] = [
|
||||
{
|
||||
metric: "Request Charge",
|
||||
value: `${queryResults.requestCharge} RUs`,
|
||||
toolTip: "Request Charge",
|
||||
},
|
||||
{
|
||||
metric: "Showing Results",
|
||||
value: queryResults.itemCount > 0 ? `${queryResults.firstItemIndex} - ${queryResults.lastItemIndex}` : `0 - 0`,
|
||||
toolTip: "Showing Results",
|
||||
},
|
||||
];
|
||||
|
||||
if (userContext.apiType === "SQL") {
|
||||
const aggregatedQueryMetrics = getAggregatedQueryMetrics();
|
||||
items.push(
|
||||
{
|
||||
metric: "Retrieved document count",
|
||||
value: aggregatedQueryMetrics.retrievedDocumentCount?.toString() || "",
|
||||
toolTip: "Total number of retrieved documents",
|
||||
},
|
||||
{
|
||||
metric: "Retrieved document size",
|
||||
value: `${aggregatedQueryMetrics.retrievedDocumentSize?.toString() || 0} bytes`,
|
||||
toolTip: "Total size of retrieved documents in bytes",
|
||||
},
|
||||
{
|
||||
metric: "Output document count",
|
||||
value: aggregatedQueryMetrics.outputDocumentCount?.toString() || "",
|
||||
toolTip: "Number of output documents",
|
||||
},
|
||||
{
|
||||
metric: "Output document size",
|
||||
value: `${aggregatedQueryMetrics.outputDocumentSize?.toString() || 0} bytes`,
|
||||
toolTip: "Total size of output documents in bytes",
|
||||
},
|
||||
{
|
||||
metric: "Index hit document count",
|
||||
value: aggregatedQueryMetrics.indexHitDocumentCount?.toString() || "",
|
||||
toolTip: "Total number of documents matched by the filter",
|
||||
},
|
||||
{
|
||||
metric: "Index lookup time",
|
||||
value: `${aggregatedQueryMetrics.indexLookupTime?.toString() || 0} ms`,
|
||||
toolTip: "Time spent in physical index layer",
|
||||
},
|
||||
{
|
||||
metric: "Document load time",
|
||||
value: `${aggregatedQueryMetrics.documentLoadTime?.toString() || 0} ms`,
|
||||
toolTip: "Time spent in loading documents",
|
||||
},
|
||||
{
|
||||
metric: "Query engine execution time",
|
||||
value: `${aggregatedQueryMetrics.runtimeExecutionTimes?.queryEngineExecutionTime?.toString() || 0} ms`,
|
||||
toolTip:
|
||||
"Time spent by the query engine to execute the query expression (excludes other execution times like load documents or write results)",
|
||||
},
|
||||
{
|
||||
metric: "System function execution time",
|
||||
value: `${aggregatedQueryMetrics.runtimeExecutionTimes?.systemFunctionExecutionTime?.toString() || 0} ms`,
|
||||
toolTip: "Total time spent executing system (built-in) functions",
|
||||
},
|
||||
{
|
||||
metric: "User defined function execution time",
|
||||
value: `${
|
||||
aggregatedQueryMetrics.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.toString() || 0
|
||||
} ms`,
|
||||
toolTip: "Total time spent executing user-defined functions",
|
||||
},
|
||||
{
|
||||
metric: "Document write time",
|
||||
value: `${aggregatedQueryMetrics.documentWriteTime.toString() || 0} ms`,
|
||||
toolTip: "Time spent to write query result set to response buffer",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (queryResults.roundTrips) {
|
||||
items.push({
|
||||
metric: "Round Trips",
|
||||
value: queryResults.roundTrips?.toString(),
|
||||
toolTip: "Number of round trips",
|
||||
});
|
||||
}
|
||||
|
||||
if (queryResults.activityId) {
|
||||
items.push({
|
||||
metric: "Activity id",
|
||||
value: queryResults.activityId,
|
||||
toolTip: "",
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const onClickCopyResults = (): void => {
|
||||
copy(queryResultsString);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack style={{ height: "100%" }}>
|
||||
{isMongoDB && queryEditorContent.length === 0 && (
|
||||
<div className="mongoQueryHelper">
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,10 +7,11 @@ import * as DataModels from "../../../Contracts/DataModels";
|
||||
import type { QueryTabOptions } from "../../../Contracts/ViewModels";
|
||||
import { useTabs } from "../../../hooks/useTabs";
|
||||
import Explorer from "../../Explorer";
|
||||
import QueryTabComponent, {
|
||||
import {
|
||||
IQueryTabComponentProps,
|
||||
ITabAccessor,
|
||||
QueryTabFunctionComponent,
|
||||
QueryTabComponent,
|
||||
QueryTabCopilotComponent,
|
||||
} from "../../Tabs/QueryTab/QueryTabComponent";
|
||||
import TabsBase from "../TabsBase";
|
||||
|
||||
@@ -49,7 +50,7 @@ export class NewQueryTab extends TabsBase {
|
||||
public render(): JSX.Element {
|
||||
return userContext.apiType === "SQL" ? (
|
||||
<CopilotProvider>
|
||||
<QueryTabFunctionComponent {...this.iQueryTabComponentProps} />
|
||||
<QueryTabCopilotComponent {...this.iQueryTabComponentProps} />
|
||||
</CopilotProvider>
|
||||
) : (
|
||||
<QueryTabComponent {...this.iQueryTabComponentProps} />
|
||||
|
||||
@@ -2,9 +2,10 @@ import { fireEvent, render } from "@testing-library/react";
|
||||
import { CollectionTabKind } from "Contracts/ViewModels";
|
||||
import { CopilotProvider } from "Explorer/QueryCopilot/QueryCopilotContext";
|
||||
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
|
||||
import QueryTabComponent, {
|
||||
import {
|
||||
IQueryTabComponentProps,
|
||||
QueryTabFunctionComponent,
|
||||
QueryTabComponent,
|
||||
QueryTabCopilotComponent,
|
||||
} from "Explorer/Tabs/QueryTab/QueryTabComponent";
|
||||
import TabsBase from "Explorer/Tabs/TabsBase";
|
||||
import { updateUserContext, userContext } from "UserContext";
|
||||
@@ -42,7 +43,7 @@ describe("QueryTabComponent", () => {
|
||||
|
||||
const { container } = render(<QueryTabComponent {...propsMock} />);
|
||||
|
||||
const launchCopilotButton = container.querySelector(".queryEditorWatermarkText");
|
||||
const launchCopilotButton = container.querySelector('[data-test="QueryTab/ResultsPane/ExecuteCTA"]');
|
||||
fireEvent.keyDown(launchCopilotButton, { key: "c", altKey: true });
|
||||
|
||||
expect(mockStore.setShowCopilotSidebar).toHaveBeenCalledWith(true);
|
||||
@@ -70,7 +71,7 @@ describe("QueryTabComponent", () => {
|
||||
|
||||
const container = mount(
|
||||
<CopilotProvider>
|
||||
<QueryTabFunctionComponent {...propsMock} />
|
||||
<QueryTabCopilotComponent {...propsMock} />
|
||||
</CopilotProvider>,
|
||||
);
|
||||
expect(container.find(QueryCopilotPromptbar).exists()).toBe(true);
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable no-console */
|
||||
import { FeedOptions, QueryOperationOptions } from "@azure/cosmos";
|
||||
import QueryError, { createMonacoErrorLocationResolver, createMonacoMarkersForQueryErrors } from "Common/QueryError";
|
||||
import { SplitterDirection } from "Common/Splitter";
|
||||
import { Platform, configContext } from "ConfigContext";
|
||||
import { useDialog } from "Explorer/Controls/Dialog";
|
||||
import { monaco } from "Explorer/LazyMonaco";
|
||||
import { QueryCopilotFeedbackModal } from "Explorer/QueryCopilot/Modal/QueryCopilotFeedbackModal";
|
||||
import { useCopilotStore } from "Explorer/QueryCopilot/QueryCopilotContext";
|
||||
import { QueryCopilotPromptbar } from "Explorer/QueryCopilot/QueryCopilotPromptbar";
|
||||
import { OnExecuteQueryClick, QueryDocumentsPerPage } from "Explorer/QueryCopilot/Shared/QueryCopilotClient";
|
||||
import { QueryCopilotSidebar } from "Explorer/QueryCopilot/V2/Sidebar/QueryCopilotSidebar";
|
||||
import { QueryResultSection } from "Explorer/Tabs/QueryTab/QueryResultSection";
|
||||
import { QueryTabStyles, useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
|
||||
import { CosmosFluentProvider } from "Explorer/Theme/ThemeUtil";
|
||||
import { useSelectedNode } from "Explorer/useSelectedNode";
|
||||
import { KeyboardAction } from "KeyboardShortcuts";
|
||||
import { QueryConstants } from "Shared/Constants";
|
||||
@@ -21,10 +25,10 @@ import {
|
||||
ruThresholdEnabled,
|
||||
} from "Shared/StorageUtility";
|
||||
import { Action } from "Shared/Telemetry/TelemetryConstants";
|
||||
import { Allotment } from "allotment";
|
||||
import { QueryCopilotState, useQueryCopilot } from "hooks/useQueryCopilot";
|
||||
import { TabsState, useTabs } from "hooks/useTabs";
|
||||
import React, { Fragment } from "react";
|
||||
import SplitterLayout from "react-splitter-layout";
|
||||
import React, { Fragment, createRef } from "react";
|
||||
import "react-splitter-layout/lib/index.css";
|
||||
import { format } from "react-string-format";
|
||||
import QueryCommandIcon from "../../../../images/CopilotCommand.svg";
|
||||
@@ -35,7 +39,6 @@ import ExecuteQueryIcon from "../../../../images/ExecuteQuery.svg";
|
||||
import CheckIcon from "../../../../images/check-1.svg";
|
||||
import SaveQueryIcon from "../../../../images/save-cosmos.svg";
|
||||
import { NormalizedEventKey } from "../../../Common/Constants";
|
||||
import { getErrorMessage } from "../../../Common/ErrorHandlingUtils";
|
||||
import * as HeadersUtility from "../../../Common/HeadersUtility";
|
||||
import { MinimalQueryIterator } from "../../../Common/IteratorUtilities";
|
||||
import { queryIterator } from "../../../Common/MongoProxyClient";
|
||||
@@ -102,8 +105,9 @@ interface IQueryTabStates {
|
||||
toggleState: ToggleState;
|
||||
sqlQueryEditorContent: string;
|
||||
selectedContent: string;
|
||||
selection?: monaco.Selection;
|
||||
executedSelection?: monaco.Selection; // We need to capture the selection that was used when executing, in case the user changes their section while the query is executing.
|
||||
queryResults: ViewModels.QueryResults;
|
||||
error: string;
|
||||
isExecutionError: boolean;
|
||||
isExecuting: boolean;
|
||||
showCopilotSidebar: boolean;
|
||||
@@ -112,9 +116,12 @@ interface IQueryTabStates {
|
||||
copilotActive: boolean;
|
||||
currentTabActive: boolean;
|
||||
queryResultsView: SplitterDirection;
|
||||
errors?: QueryError[];
|
||||
modelMarkers?: monaco.editor.IMarkerData[];
|
||||
}
|
||||
|
||||
export const QueryTabFunctionComponent = (props: IQueryTabComponentProps): any => {
|
||||
export const QueryTabCopilotComponent = (props: IQueryTabComponentProps): any => {
|
||||
const styles = useQueryTabStyles();
|
||||
const copilotStore = useCopilotStore();
|
||||
const isSampleCopilotActive = useSelectedNode.getState().isQueryCopilotCollectionSelected();
|
||||
const queryTabProps = {
|
||||
@@ -125,10 +132,20 @@ export const QueryTabFunctionComponent = (props: IQueryTabComponentProps): any =
|
||||
isSampleCopilotActive: isSampleCopilotActive,
|
||||
copilotStore: copilotStore,
|
||||
};
|
||||
return <QueryTabComponent {...queryTabProps}></QueryTabComponent>;
|
||||
return <QueryTabComponentImpl styles={styles} {...queryTabProps}></QueryTabComponentImpl>;
|
||||
};
|
||||
|
||||
export default class QueryTabComponent extends React.Component<IQueryTabComponentProps, IQueryTabStates> {
|
||||
export const QueryTabComponent = (props: IQueryTabComponentProps): any => {
|
||||
const styles = useQueryTabStyles();
|
||||
return <QueryTabComponentImpl styles={styles} {...props}></QueryTabComponentImpl>;
|
||||
};
|
||||
|
||||
type QueryTabComponentImplProps = IQueryTabComponentProps & {
|
||||
styles: QueryTabStyles;
|
||||
};
|
||||
|
||||
// Inner (legacy) class component. We only use this component via one of the two functional components above (since we need to use the `useQueryTabStyles` hook).
|
||||
class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps, IQueryTabStates> {
|
||||
public queryEditorId: string;
|
||||
public executeQueryButton: Button;
|
||||
public saveQueryButton: Button;
|
||||
@@ -139,16 +156,19 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
|
||||
public isCopilotTabActive: boolean;
|
||||
private _iterator: MinimalQueryIterator;
|
||||
private queryAbortController: AbortController;
|
||||
queryEditor: React.RefObject<EditorReact>;
|
||||
|
||||
constructor(props: IQueryTabComponentProps) {
|
||||
constructor(props: QueryTabComponentImplProps) {
|
||||
super(props);
|
||||
|
||||
this.queryEditor = createRef<EditorReact>();
|
||||
|
||||
this.state = {
|
||||
toggleState: ToggleState.Result,
|
||||
sqlQueryEditorContent: props.isPreferredApiMongoDB ? "{}" : props.queryText || "SELECT * FROM c",
|
||||
selectedContent: "",
|
||||
queryResults: undefined,
|
||||
error: "",
|
||||
errors: [],
|
||||
isExecutionError: this.props.isExecutionError,
|
||||
isExecuting: false,
|
||||
showCopilotSidebar: useQueryCopilot.getState().showCopilotSidebar,
|
||||
@@ -221,9 +241,10 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
|
||||
|
||||
public onExecuteQueryClick = async (): Promise<void> => {
|
||||
this._iterator = undefined;
|
||||
|
||||
setTimeout(async () => {
|
||||
await this._executeQueryDocumentsPage(0);
|
||||
}, 100);
|
||||
}, 100); // TODO: Revert this
|
||||
if (this.state.copilotActive) {
|
||||
const query = this.state.sqlQueryEditorContent.split("\r\n")?.pop();
|
||||
const isqueryEdited = this.props.copilotStore.generatedQuery && this.props.copilotStore.generatedQuery !== query;
|
||||
@@ -302,23 +323,22 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
|
||||
}
|
||||
|
||||
private async _executeQueryDocumentsPage(firstItemIndex: number): Promise<void> {
|
||||
// Capture the query content and the selection being executed (if any).
|
||||
const query = this.state.selectedContent || this.state.sqlQueryEditorContent;
|
||||
const selection = this.state.selection;
|
||||
this.setState({
|
||||
// Track the executed selection so that we can evaluate error positions relative to it, even if the user changes their current selection.
|
||||
executedSelection: selection,
|
||||
});
|
||||
|
||||
this.queryAbortController = new AbortController();
|
||||
if (this._iterator === undefined) {
|
||||
this._iterator = this.props.isPreferredApiMongoDB
|
||||
? queryIterator(
|
||||
this.props.collection.databaseId,
|
||||
this.props.viewModelcollection,
|
||||
this.state.selectedContent || this.state.sqlQueryEditorContent,
|
||||
)
|
||||
: queryDocuments(
|
||||
this.props.collection.databaseId,
|
||||
this.props.collection.id(),
|
||||
this.state.selectedContent || this.state.sqlQueryEditorContent,
|
||||
{
|
||||
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,34 +709,33 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
|
||||
containerId={this.props.collection.id()}
|
||||
></QueryCopilotPromptbar>
|
||||
)}
|
||||
<div className="tabPaneContentContainer">
|
||||
<SplitterLayout
|
||||
vertical={this.state.queryResultsView === SplitterDirection.Vertical}
|
||||
primaryIndex={0}
|
||||
primaryMinSize={100}
|
||||
secondaryMinSize={200}
|
||||
>
|
||||
<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,
|
||||
@@ -719,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}
|
||||
@@ -745,7 +774,7 @@ export default class QueryTabComponent extends React.Component<IQueryTabComponen
|
||||
render(): JSX.Element {
|
||||
const shouldScaleElements = this.state.showCopilotSidebar && this.isCopilotTabActive;
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "row", height: "100%" }}>
|
||||
<div data-test="QueryTab" style={{ display: "flex", flexDirection: "row", height: "100%" }}>
|
||||
<div style={{ width: shouldScaleElements ? "70%" : "100%", height: "100%" }}>
|
||||
{this.getEditorAndQueryResult()}
|
||||
</div>
|
||||
|
||||
396
src/Explorer/Tabs/QueryTab/ResultsView.tsx
Normal file
396
src/Explorer/Tabs/QueryTab/ResultsView.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
import {
|
||||
Button,
|
||||
DataGrid,
|
||||
DataGridBody,
|
||||
DataGridCell,
|
||||
DataGridHeader,
|
||||
DataGridHeaderCell,
|
||||
DataGridRow,
|
||||
SelectTabData,
|
||||
SelectTabEvent,
|
||||
Tab,
|
||||
TabList,
|
||||
TableColumnDefinition,
|
||||
createTableColumn,
|
||||
} from "@fluentui/react-components";
|
||||
import { ArrowDownloadRegular, CopyRegular } from "@fluentui/react-icons";
|
||||
import { HttpHeaders } from "Common/Constants";
|
||||
import MongoUtility from "Common/MongoUtility";
|
||||
import { QueryMetrics } from "Contracts/DataModels";
|
||||
import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
|
||||
import { IDocument } from "Explorer/Tabs/QueryTab/QueryTabComponent";
|
||||
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
|
||||
import { userContext } from "UserContext";
|
||||
import copy from "clipboard-copy";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { ResultsViewProps } from "./QueryResultSection";
|
||||
|
||||
enum ResultsTabs {
|
||||
Results = "results",
|
||||
QueryStats = "queryStats",
|
||||
}
|
||||
|
||||
const ResultsTab: React.FC<ResultsViewProps> = ({ queryResults, isMongoDB, executeQueryDocumentsPage }) => {
|
||||
const styles = useQueryTabStyles();
|
||||
const queryResultsString = queryResults
|
||||
? isMongoDB
|
||||
? MongoUtility.tojson(queryResults.documents, undefined, false)
|
||||
: JSON.stringify(queryResults.documents, undefined, 4)
|
||||
: "";
|
||||
|
||||
const onClickCopyResults = (): void => {
|
||||
copy(queryResultsString);
|
||||
};
|
||||
|
||||
const onFetchNextPageClick = async (): Promise<void> => {
|
||||
const { firstItemIndex, itemCount } = queryResults;
|
||||
await executeQueryDocumentsPage(firstItemIndex + itemCount - 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.queryResultsBar}>
|
||||
<div>
|
||||
{queryResults.itemCount > 0 ? `${queryResults.firstItemIndex} - ${queryResults.lastItemIndex}` : `0 - 0`}
|
||||
</div>
|
||||
{queryResults.hasMoreResults && (
|
||||
<a href="#" onClick={() => onFetchNextPageClick()}>
|
||||
Load more
|
||||
</a>
|
||||
)}
|
||||
<div className={styles.flexGrowSpacer} />
|
||||
<Button
|
||||
size="small"
|
||||
appearance="transparent"
|
||||
icon={<CopyRegular />}
|
||||
title="Copy to Clipboard"
|
||||
aria-label="Copy"
|
||||
onClick={onClickCopyResults}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.queryResultsViewer}>
|
||||
<EditorReact language={"json"} content={queryResultsString} isReadOnly={true} ariaLabel={"Query results"} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const QueryStatsTab: React.FC<Pick<ResultsViewProps, "queryResults">> = ({ queryResults }) => {
|
||||
const styles = useQueryTabStyles();
|
||||
const queryMetrics = React.useRef(queryResults?.headers?.[HttpHeaders.queryMetrics]);
|
||||
React.useEffect(() => {
|
||||
const latestQueryMetrics = queryResults?.headers?.[HttpHeaders.queryMetrics];
|
||||
if (latestQueryMetrics && Object.keys(latestQueryMetrics).length > 0) {
|
||||
queryMetrics.current = latestQueryMetrics;
|
||||
}
|
||||
}, [queryResults]);
|
||||
|
||||
const getAggregatedQueryMetrics = (): QueryMetrics => {
|
||||
const aggregatedQueryMetrics = {
|
||||
documentLoadTime: 0,
|
||||
documentWriteTime: 0,
|
||||
indexHitDocumentCount: 0,
|
||||
outputDocumentCount: 0,
|
||||
outputDocumentSize: 0,
|
||||
indexLookupTime: 0,
|
||||
retrievedDocumentCount: 0,
|
||||
retrievedDocumentSize: 0,
|
||||
vmExecutionTime: 0,
|
||||
runtimeExecutionTimes: {
|
||||
queryEngineExecutionTime: 0,
|
||||
systemFunctionExecutionTime: 0,
|
||||
userDefinedFunctionExecutionTime: 0,
|
||||
},
|
||||
totalQueryExecutionTime: 0,
|
||||
} as QueryMetrics;
|
||||
|
||||
if (queryMetrics.current) {
|
||||
Object.keys(queryMetrics.current).forEach((partitionKeyRangeId) => {
|
||||
const queryMetricsPerPartition = queryMetrics.current[partitionKeyRangeId];
|
||||
if (!queryMetricsPerPartition) {
|
||||
return;
|
||||
}
|
||||
aggregatedQueryMetrics.documentLoadTime += queryMetricsPerPartition.documentLoadTime?.totalMilliseconds() || 0;
|
||||
aggregatedQueryMetrics.documentWriteTime +=
|
||||
queryMetricsPerPartition.documentWriteTime?.totalMilliseconds() || 0;
|
||||
aggregatedQueryMetrics.indexHitDocumentCount += queryMetricsPerPartition.indexHitDocumentCount || 0;
|
||||
aggregatedQueryMetrics.outputDocumentCount += queryMetricsPerPartition.outputDocumentCount || 0;
|
||||
aggregatedQueryMetrics.outputDocumentSize += queryMetricsPerPartition.outputDocumentSize || 0;
|
||||
aggregatedQueryMetrics.indexLookupTime += queryMetricsPerPartition.indexLookupTime?.totalMilliseconds() || 0;
|
||||
aggregatedQueryMetrics.retrievedDocumentCount += queryMetricsPerPartition.retrievedDocumentCount || 0;
|
||||
aggregatedQueryMetrics.retrievedDocumentSize += queryMetricsPerPartition.retrievedDocumentSize || 0;
|
||||
aggregatedQueryMetrics.vmExecutionTime += queryMetricsPerPartition.vmExecutionTime?.totalMilliseconds() || 0;
|
||||
aggregatedQueryMetrics.totalQueryExecutionTime +=
|
||||
queryMetricsPerPartition.totalQueryExecutionTime?.totalMilliseconds() || 0;
|
||||
aggregatedQueryMetrics.runtimeExecutionTimes.queryEngineExecutionTime +=
|
||||
queryMetricsPerPartition.runtimeExecutionTimes?.queryEngineExecutionTime?.totalMilliseconds() || 0;
|
||||
aggregatedQueryMetrics.runtimeExecutionTimes.systemFunctionExecutionTime +=
|
||||
queryMetricsPerPartition.runtimeExecutionTimes?.systemFunctionExecutionTime?.totalMilliseconds() || 0;
|
||||
aggregatedQueryMetrics.runtimeExecutionTimes.userDefinedFunctionExecutionTime +=
|
||||
queryMetricsPerPartition.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.totalMilliseconds() || 0;
|
||||
});
|
||||
}
|
||||
|
||||
return aggregatedQueryMetrics;
|
||||
};
|
||||
|
||||
const columns: TableColumnDefinition<IDocument>[] = [
|
||||
createTableColumn<IDocument>({
|
||||
columnId: "metric",
|
||||
renderHeaderCell: () => "Metric",
|
||||
renderCell: (item) => item.metric,
|
||||
}),
|
||||
createTableColumn<IDocument>({
|
||||
columnId: "value",
|
||||
renderHeaderCell: () => "Value",
|
||||
renderCell: (item) => item.value,
|
||||
}),
|
||||
];
|
||||
|
||||
const generateQueryStatsItems = (): IDocument[] => {
|
||||
const items: IDocument[] = [
|
||||
{
|
||||
metric: "Request Charge",
|
||||
value: `${queryResults.requestCharge} RUs`,
|
||||
toolTip: "Request Charge",
|
||||
},
|
||||
{
|
||||
metric: "Showing Results",
|
||||
value: queryResults.itemCount > 0 ? `${queryResults.firstItemIndex} - ${queryResults.lastItemIndex}` : `0 - 0`,
|
||||
toolTip: "Showing Results",
|
||||
},
|
||||
];
|
||||
|
||||
if (userContext.apiType === "SQL") {
|
||||
const aggregatedQueryMetrics = getAggregatedQueryMetrics();
|
||||
items.push(
|
||||
{
|
||||
metric: "Retrieved document count",
|
||||
value: aggregatedQueryMetrics.retrievedDocumentCount?.toString() || "",
|
||||
toolTip: "Total number of retrieved documents",
|
||||
},
|
||||
{
|
||||
metric: "Retrieved document size",
|
||||
value: `${aggregatedQueryMetrics.retrievedDocumentSize?.toString() || 0} bytes`,
|
||||
toolTip: "Total size of retrieved documents in bytes",
|
||||
},
|
||||
{
|
||||
metric: "Output document count",
|
||||
value: aggregatedQueryMetrics.outputDocumentCount?.toString() || "",
|
||||
toolTip: "Number of output documents",
|
||||
},
|
||||
{
|
||||
metric: "Output document size",
|
||||
value: `${aggregatedQueryMetrics.outputDocumentSize?.toString() || 0} bytes`,
|
||||
toolTip: "Total size of output documents in bytes",
|
||||
},
|
||||
{
|
||||
metric: "Index hit document count",
|
||||
value: aggregatedQueryMetrics.indexHitDocumentCount?.toString() || "",
|
||||
toolTip: "Total number of documents matched by the filter",
|
||||
},
|
||||
{
|
||||
metric: "Index lookup time",
|
||||
value: `${aggregatedQueryMetrics.indexLookupTime?.toString() || 0} ms`,
|
||||
toolTip: "Time spent in physical index layer",
|
||||
},
|
||||
{
|
||||
metric: "Document load time",
|
||||
value: `${aggregatedQueryMetrics.documentLoadTime?.toString() || 0} ms`,
|
||||
toolTip: "Time spent in loading documents",
|
||||
},
|
||||
{
|
||||
metric: "Query engine execution time",
|
||||
value: `${aggregatedQueryMetrics.runtimeExecutionTimes?.queryEngineExecutionTime?.toString() || 0} ms`,
|
||||
toolTip:
|
||||
"Time spent by the query engine to execute the query expression (excludes other execution times like load documents or write results)",
|
||||
},
|
||||
{
|
||||
metric: "System function execution time",
|
||||
value: `${aggregatedQueryMetrics.runtimeExecutionTimes?.systemFunctionExecutionTime?.toString() || 0} ms`,
|
||||
toolTip: "Total time spent executing system (built-in) functions",
|
||||
},
|
||||
{
|
||||
metric: "User defined function execution time",
|
||||
value: `${
|
||||
aggregatedQueryMetrics.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.toString() || 0
|
||||
} ms`,
|
||||
toolTip: "Total time spent executing user-defined functions",
|
||||
},
|
||||
{
|
||||
metric: "Document write time",
|
||||
value: `${aggregatedQueryMetrics.documentWriteTime.toString() || 0} ms`,
|
||||
toolTip: "Time spent to write query result set to response buffer",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (queryResults.roundTrips) {
|
||||
items.push({
|
||||
metric: "Round Trips",
|
||||
value: queryResults.roundTrips?.toString(),
|
||||
toolTip: "Number of round trips",
|
||||
});
|
||||
}
|
||||
|
||||
if (queryResults.activityId) {
|
||||
items.push({
|
||||
metric: "Activity id",
|
||||
value: queryResults.activityId,
|
||||
toolTip: "",
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const generateQueryMetricsCsvData = (): string => {
|
||||
if (queryMetrics.current) {
|
||||
let csvData =
|
||||
[
|
||||
"Partition key range id",
|
||||
"Retrieved document count",
|
||||
"Retrieved document size (in bytes)",
|
||||
"Output document count",
|
||||
"Output document size (in bytes)",
|
||||
"Index hit document count",
|
||||
"Index lookup time (ms)",
|
||||
"Document load time (ms)",
|
||||
"Query engine execution time (ms)",
|
||||
"System function execution time (ms)",
|
||||
"User defined function execution time (ms)",
|
||||
"Document write time (ms)",
|
||||
].join(",") + "\n";
|
||||
|
||||
Object.keys(queryMetrics.current).forEach((partitionKeyRangeId) => {
|
||||
const queryMetricsPerPartition = queryMetrics.current[partitionKeyRangeId];
|
||||
csvData +=
|
||||
[
|
||||
partitionKeyRangeId,
|
||||
queryMetricsPerPartition.retrievedDocumentCount,
|
||||
queryMetricsPerPartition.retrievedDocumentSize,
|
||||
queryMetricsPerPartition.outputDocumentCount,
|
||||
queryMetricsPerPartition.outputDocumentSize,
|
||||
queryMetricsPerPartition.indexHitDocumentCount,
|
||||
queryMetricsPerPartition.indexLookupTime?.totalMilliseconds(),
|
||||
queryMetricsPerPartition.documentLoadTime?.totalMilliseconds(),
|
||||
queryMetricsPerPartition.runtimeExecutionTimes?.queryEngineExecutionTime?.totalMilliseconds(),
|
||||
queryMetricsPerPartition.runtimeExecutionTimes?.systemFunctionExecutionTime?.totalMilliseconds(),
|
||||
queryMetricsPerPartition.runtimeExecutionTimes?.userDefinedFunctionExecutionTime?.totalMilliseconds(),
|
||||
queryMetricsPerPartition.documentWriteTime?.totalMilliseconds(),
|
||||
].join(",") + "\n";
|
||||
});
|
||||
|
||||
return csvData;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const downloadQueryMetricsCsvData = (): void => {
|
||||
const csvData: string = generateQueryMetricsCsvData();
|
||||
if (!csvData) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (navigator.msSaveBlob) {
|
||||
// for IE and Edge
|
||||
navigator.msSaveBlob(
|
||||
new Blob([csvData], { type: "data:text/csv;charset=utf-8" }),
|
||||
"PerPartitionQueryMetrics.csv",
|
||||
);
|
||||
} else {
|
||||
const downloadLink: HTMLAnchorElement = document.createElement("a");
|
||||
downloadLink.href = "data:text/csv;charset=utf-8," + encodeURI(csvData);
|
||||
downloadLink.target = "_self";
|
||||
downloadLink.download = "QueryMetricsPerPartition.csv";
|
||||
|
||||
// for some reason, FF displays the download prompt only when
|
||||
// the link is added to the dom so we add and remove it
|
||||
document.body.appendChild(downloadLink);
|
||||
downloadLink.click();
|
||||
downloadLink.remove();
|
||||
}
|
||||
};
|
||||
|
||||
const onDownloadQueryMetricsCsvClick = (): boolean => {
|
||||
downloadQueryMetricsCsvData();
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.metricsGridContainer}>
|
||||
<DataGrid
|
||||
data-test="QueryTab/ResultsPane/ResultsView/QueryStatsList"
|
||||
className={styles.queryStatsGrid}
|
||||
items={generateQueryStatsItems()}
|
||||
columns={columns}
|
||||
sortable
|
||||
getRowId={(item) => item.metric}
|
||||
focusMode="composite"
|
||||
>
|
||||
<DataGridHeader>
|
||||
<DataGridRow>
|
||||
{({ renderHeaderCell }) => <DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>}
|
||||
</DataGridRow>
|
||||
</DataGridHeader>
|
||||
<DataGridBody<IDocument>>
|
||||
{({ item, rowId }) => (
|
||||
<DataGridRow<IDocument> key={rowId} data-test={`Row:${rowId}`}>
|
||||
{({ columnId, renderCell }) => (
|
||||
<DataGridCell data-test={`Row:${rowId}/Column:${columnId}`}>{renderCell(item)}</DataGridCell>
|
||||
)}
|
||||
</DataGridRow>
|
||||
)}
|
||||
</DataGridBody>
|
||||
</DataGrid>
|
||||
<div className={styles.metricsGridButtons}>
|
||||
{userContext.apiType === "SQL" && (
|
||||
<Button appearance="subtle" onClick={() => onDownloadQueryMetricsCsvClick()} icon={<ArrowDownloadRegular />}>
|
||||
Per-partition query metrics (CSV)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResults, executeQueryDocumentsPage }) => {
|
||||
const styles = useQueryTabStyles();
|
||||
const [activeTab, setActiveTab] = useState<ResultsTabs>(ResultsTabs.Results);
|
||||
|
||||
const onTabSelect = useCallback((event: SelectTabEvent, data: SelectTabData) => {
|
||||
setActiveTab(data.value as ResultsTabs);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div data-test="QueryTab/ResultsPane/ResultsView" className={styles.queryResultsTabPanel}>
|
||||
<TabList selectedValue={activeTab} onTabSelect={onTabSelect}>
|
||||
<Tab
|
||||
data-test="QueryTab/ResultsPane/ResultsView/ResultsTab"
|
||||
id={ResultsTabs.Results}
|
||||
value={ResultsTabs.Results}
|
||||
>
|
||||
Results
|
||||
</Tab>
|
||||
<Tab
|
||||
data-test="QueryTab/ResultsPane/ResultsView/QueryStatsTab"
|
||||
id={ResultsTabs.QueryStats}
|
||||
value={ResultsTabs.QueryStats}
|
||||
>
|
||||
Query Stats
|
||||
</Tab>
|
||||
</TabList>
|
||||
<div className={styles.queryResultsTabContentContainer}>
|
||||
{activeTab === ResultsTabs.Results && (
|
||||
<ResultsTab
|
||||
queryResults={queryResults}
|
||||
isMongoDB={isMongoDB}
|
||||
executeQueryDocumentsPage={executeQueryDocumentsPage}
|
||||
/>
|
||||
)}
|
||||
{activeTab === ResultsTabs.QueryStats && <QueryStatsTab queryResults={queryResults} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
84
src/Explorer/Tabs/QueryTab/Styles.ts
Normal file
84
src/Explorer/Tabs/QueryTab/Styles.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { makeStyles, shorthands } from "@fluentui/react-components";
|
||||
import { cosmosShorthands } from "Explorer/Theme/ThemeUtil";
|
||||
|
||||
export type QueryTabStyles = ReturnType<typeof useQueryTabStyles>;
|
||||
export const useQueryTabStyles = makeStyles({
|
||||
queryTab: {
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
queryEditor: {
|
||||
...shorthands.border("none"),
|
||||
paddingTop: "4px",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
},
|
||||
executeCallToAction: {
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
textAlign: "center",
|
||||
},
|
||||
queryResultsPanel: {
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
queryResultsMessage: {
|
||||
...shorthands.margin("5px"),
|
||||
},
|
||||
queryResultsBody: {
|
||||
flexGrow: 1,
|
||||
justifySelf: "stretch",
|
||||
},
|
||||
queryResultsTabPanel: {
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
rowGap: "12px",
|
||||
flexDirection: "column",
|
||||
},
|
||||
queryResultsTabContentContainer: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flexGrow: 1,
|
||||
paddingLeft: "12px",
|
||||
paddingRight: "12px",
|
||||
overflow: "auto",
|
||||
},
|
||||
queryResultsViewer: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
queryResultsBar: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
columnGap: "4px",
|
||||
paddingBottom: "4px",
|
||||
},
|
||||
flexGrowSpacer: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
queryStatsGrid: {
|
||||
flexGrow: 1,
|
||||
overflow: "auto",
|
||||
},
|
||||
metricsGridContainer: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
paddingBottom: "6px",
|
||||
maxHeight: "100%",
|
||||
},
|
||||
metricsGridButtons: {
|
||||
...cosmosShorthands.borderTop(),
|
||||
},
|
||||
errorListMessageCell: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
},
|
||||
errorListMessage: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
});
|
||||
@@ -57,7 +57,8 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
|
||||
const defaultMessageBarStyles: IMessageBarStyles = {
|
||||
root: {
|
||||
height: `${LayoutConstants.rowHeight}px`,
|
||||
overflow: "auto",
|
||||
overflow: "hidden",
|
||||
flexDirection: "row",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -298,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 => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user