Use new Fluent-based Resource Tree for all environments (#1841)

Co-authored-by: Laurent Nguyen <laurent.nguyen@microsoft.com>
This commit is contained in:
Ashley Stanton-Nurse 2024-05-29 09:56:27 -07:00 committed by GitHub
parent cebf044803
commit 98c5fe65e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 5866 additions and 1333 deletions

View File

@ -125,7 +125,7 @@ src/Explorer/Controls/InputTypeahead/InputTypeaheadComponent.tsx
src/Explorer/Controls/Notebook/NotebookTerminalComponent.test.tsx src/Explorer/Controls/Notebook/NotebookTerminalComponent.test.tsx
src/Explorer/Controls/Notebook/NotebookTerminalComponent.tsx src/Explorer/Controls/Notebook/NotebookTerminalComponent.tsx
src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.tsx src/Explorer/Controls/NotebookViewer/NotebookViewerComponent.tsx
src/Explorer/Controls/TreeComponent/TreeComponent.tsx src/Explorer/Controls/TreeComponent/LegacyTreeComponent.tsx
src/Explorer/Graph/GraphExplorerComponent/GraphExplorer.test.tsx src/Explorer/Graph/GraphExplorerComponent/GraphExplorer.test.tsx
src/Explorer/Graph/GraphExplorerComponent/NodePropertiesComponent.tsx src/Explorer/Graph/GraphExplorerComponent/NodePropertiesComponent.tsx
src/Explorer/Graph/GraphExplorerComponent/ReadOnlyNodePropertiesComponent.test.tsx src/Explorer/Graph/GraphExplorerComponent/ReadOnlyNodePropertiesComponent.test.tsx

View File

@ -1,14 +1,10 @@
import { ResourceTree } from "Explorer/Tree/ResourceTree";
import React, { FunctionComponent, MutableRefObject, useEffect, useRef } from "react"; import React, { FunctionComponent, MutableRefObject, useEffect, useRef } from "react";
import arrowLeftImg from "../../images/imgarrowlefticon.svg"; import arrowLeftImg from "../../images/imgarrowlefticon.svg";
import refreshImg from "../../images/refresh-cosmos.svg"; import refreshImg from "../../images/refresh-cosmos.svg";
import { AuthType } from "../AuthType";
import Explorer from "../Explorer/Explorer"; import Explorer from "../Explorer/Explorer";
import { ResourceTokenTree } from "../Explorer/Tree/ResourceTokenTree"; import { ResourceTree } from "../Explorer/Tree/ResourceTree";
import { ResourceTree2 } from "../Explorer/Tree2/ResourceTree";
import { userContext } from "../UserContext"; import { userContext } from "../UserContext";
import { getApiShortDisplayName } from "../Utils/APITypeUtils"; import { getApiShortDisplayName } from "../Utils/APITypeUtils";
import { Platform, configContext } from "./../ConfigContext";
import { NormalizedEventKey } from "./Constants"; import { NormalizedEventKey } from "./Constants";
export interface ResourceTreeContainerProps { export interface ResourceTreeContainerProps {
@ -74,12 +70,8 @@ export const ResourceTreeContainer: FunctionComponent<ResourceTreeContainerProps
</div> </div>
</div> </div>
</div> </div>
{userContext.authType === AuthType.ResourceToken ? ( {userContext.features.enableKoResourceTree ? (
<ResourceTokenTree />
) : userContext.features.enableKoResourceTree ? (
<div style={{ overflowY: "auto" }} data-bind="react:resourceTree" /> <div style={{ overflowY: "auto" }} data-bind="react:resourceTree" />
) : configContext.platform === Platform.Fabric ? (
<ResourceTree2 container={container} />
) : ( ) : (
<ResourceTree container={container} /> <ResourceTree container={container} />
)} )}

View File

@ -176,6 +176,11 @@ export interface Collection extends CollectionBase {
loadTriggers(): Promise<any>; loadTriggers(): Promise<any>;
loadOffer(): Promise<void>; loadOffer(): Promise<void>;
showStoredProcedures: ko.Observable<boolean>;
showTriggers: ko.Observable<boolean>;
showUserDefinedFunctions: ko.Observable<boolean>;
showConflicts: ko.Observable<boolean>;
createStoredProcedureNode(data: StoredProcedureDefinition & Resource): StoredProcedure; createStoredProcedureNode(data: StoredProcedureDefinition & Resource): StoredProcedure;
createUserDefinedFunctionNode(data: UserDefinedFunctionDefinition & Resource): UserDefinedFunction; createUserDefinedFunctionNode(data: UserDefinedFunctionDefinition & Resource): UserDefinedFunction;
createTriggerNode(data: TriggerDefinition | SqlTriggerResource): Trigger; createTriggerNode(data: TriggerDefinition | SqlTriggerResource): Trigger;

View File

@ -1,3 +1,4 @@
import { TreeNodeMenuItem } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import { useDatabases } from "Explorer/useDatabases"; import { useDatabases } from "Explorer/useDatabases";
import { Action } from "Shared/Telemetry/TelemetryConstants"; import { Action } from "Shared/Telemetry/TelemetryConstants";
import { traceOpen } from "Shared/Telemetry/TelemetryProcessor"; import { traceOpen } from "Shared/Telemetry/TelemetryProcessor";
@ -19,7 +20,6 @@ import { userContext } from "../UserContext";
import { getCollectionName, getDatabaseName } from "../Utils/APITypeUtils"; import { getCollectionName, getDatabaseName } from "../Utils/APITypeUtils";
import { useSidePanel } from "../hooks/useSidePanel"; import { useSidePanel } from "../hooks/useSidePanel";
import { Platform, configContext } from "./../ConfigContext"; import { Platform, configContext } from "./../ConfigContext";
import { TreeNodeMenuItem } from "./Controls/TreeComponent/TreeComponent";
import Explorer from "./Explorer"; import Explorer from "./Explorer";
import { useNotebook } from "./Notebook/useNotebook"; import { useNotebook } from "./Notebook/useNotebook";
import { DeleteCollectionConfirmationPane } from "./Panes/DeleteCollectionConfirmationPane/DeleteCollectionConfirmationPane"; import { DeleteCollectionConfirmationPane } from "./Panes/DeleteCollectionConfirmationPane/DeleteCollectionConfirmationPane";

View File

@ -1,48 +1,48 @@
import React from "react";
import { shallow } from "enzyme"; import { shallow } from "enzyme";
import { TreeComponent, TreeNode, TreeNodeComponent } from "./TreeComponent"; import React from "react";
import { LegacyTreeComponent, LegacyTreeNode, LegacyTreeNodeComponent } from "./LegacyTreeComponent";
const buildChildren = (): TreeNode[] => { const buildChildren = (): LegacyTreeNode[] => {
const grandChild11: TreeNode = { const grandChild11: LegacyTreeNode = {
label: "ZgrandChild11", label: "ZgrandChild11",
}; };
const grandChild12: TreeNode = { const grandChild12: LegacyTreeNode = {
label: "AgrandChild12", label: "AgrandChild12",
}; };
const child1: TreeNode = { const child1: LegacyTreeNode = {
label: "Bchild1", label: "Bchild1",
children: [grandChild11, grandChild12], children: [grandChild11, grandChild12],
}; };
const child2: TreeNode = { const child2: LegacyTreeNode = {
label: "2child2", label: "2child2",
}; };
return [child1, child2]; return [child1, child2];
}; };
const buildChildren2 = (): TreeNode[] => { const buildChildren2 = (): LegacyTreeNode[] => {
const grandChild11: TreeNode = { const grandChild11: LegacyTreeNode = {
label: "ZgrandChild11", label: "ZgrandChild11",
}; };
const grandChild12: TreeNode = { const grandChild12: LegacyTreeNode = {
label: "AgrandChild12", label: "AgrandChild12",
}; };
const child1: TreeNode = { const child1: LegacyTreeNode = {
label: "aChild", label: "aChild",
}; };
const child2: TreeNode = { const child2: LegacyTreeNode = {
label: "bchild", label: "bchild",
children: [grandChild11, grandChild12], children: [grandChild11, grandChild12],
}; };
const child3: TreeNode = { const child3: LegacyTreeNode = {
label: "cchild", label: "cchild",
}; };
const child4: TreeNode = { const child4: LegacyTreeNode = {
label: "dchild", label: "dchild",
children: [grandChild11, grandChild12], children: [grandChild11, grandChild12],
}; };
@ -50,7 +50,7 @@ const buildChildren2 = (): TreeNode[] => {
return [child1, child2, child3, child4]; return [child1, child2, child3, child4];
}; };
describe("TreeComponent", () => { describe("LegacyTreeComponent", () => {
it("renders a simple tree", () => { it("renders a simple tree", () => {
const root = { const root = {
label: "root", label: "root",
@ -62,14 +62,14 @@ describe("TreeComponent", () => {
className: "tree", className: "tree",
}; };
const wrapper = shallow(<TreeComponent {...props} />); const wrapper = shallow(<LegacyTreeComponent {...props} />);
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
}); });
describe("TreeNodeComponent", () => { describe("LegacyTreeNodeComponent", () => {
it("renders a simple node (sorted children, expanded)", () => { it("renders a simple node (sorted children, expanded)", () => {
const node: TreeNode = { const node: LegacyTreeNode = {
label: "label", label: "label",
id: "id", id: "id",
children: buildChildren(), children: buildChildren(),
@ -98,12 +98,12 @@ describe("TreeNodeComponent", () => {
generation: 12, generation: 12,
paddingLeft: 23, paddingLeft: 23,
}; };
const wrapper = shallow(<TreeNodeComponent {...props} />); const wrapper = shallow(<LegacyTreeNodeComponent {...props} />);
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
it("renders unsorted children by default", () => { it("renders unsorted children by default", () => {
const node: TreeNode = { const node: LegacyTreeNode = {
label: "label", label: "label",
children: buildChildren(), children: buildChildren(),
isExpanded: true, isExpanded: true,
@ -113,12 +113,12 @@ describe("TreeNodeComponent", () => {
generation: 2, generation: 2,
paddingLeft: 9, paddingLeft: 9,
}; };
const wrapper = shallow(<TreeNodeComponent {...props} />); const wrapper = shallow(<LegacyTreeNodeComponent {...props} />);
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
it("does not render children by default", () => { it("does not render children by default", () => {
const node: TreeNode = { const node: LegacyTreeNode = {
label: "label", label: "label",
children: buildChildren(), children: buildChildren(),
isAlphaSorted: false, isAlphaSorted: false,
@ -128,12 +128,12 @@ describe("TreeNodeComponent", () => {
generation: 2, generation: 2,
paddingLeft: 9, paddingLeft: 9,
}; };
const wrapper = shallow(<TreeNodeComponent {...props} />); const wrapper = shallow(<LegacyTreeNodeComponent {...props} />);
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
it("renders sorted children, expanded, leaves and parents separated", () => { it("renders sorted children, expanded, leaves and parents separated", () => {
const node: TreeNode = { const node: LegacyTreeNode = {
label: "label", label: "label",
id: "id", id: "id",
children: buildChildren2(), children: buildChildren2(),
@ -156,12 +156,12 @@ describe("TreeNodeComponent", () => {
generation: 12, generation: 12,
paddingLeft: 23, paddingLeft: 23,
}; };
const wrapper = shallow(<TreeNodeComponent {...props} />); const wrapper = shallow(<LegacyTreeNodeComponent {...props} />);
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
it("renders loading icon", () => { it("renders loading icon", () => {
const node: TreeNode = { const node: LegacyTreeNode = {
label: "label", label: "label",
children: [], children: [],
isExpanded: true, isExpanded: true,
@ -172,7 +172,7 @@ describe("TreeNodeComponent", () => {
generation: 2, generation: 2,
paddingLeft: 9, paddingLeft: 9,
}; };
const wrapper = shallow(<TreeNodeComponent {...props} />); const wrapper = shallow(<LegacyTreeNodeComponent {...props} />);
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
}); });

View File

@ -12,6 +12,7 @@ import {
IContextualMenuItemProps, IContextualMenuItemProps,
IContextualMenuProps, IContextualMenuProps,
} from "@fluentui/react"; } from "@fluentui/react";
import { TreeNodeMenuItem } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import * as React from "react"; import * as React from "react";
import AnimateHeight from "react-animate-height"; import AnimateHeight from "react-animate-height";
import LoadingIndicator_3Squares from "../../../../images/LoadingIndicator_3Squares.gif"; import LoadingIndicator_3Squares from "../../../../images/LoadingIndicator_3Squares.gif";
@ -22,18 +23,10 @@ import { StyleConstants } from "../../../Common/StyleConstants";
import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants"; import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor"; import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
export interface TreeNodeMenuItem { export interface LegacyTreeNode {
label: string;
onClick: () => void;
iconSrc?: string;
isDisabled?: boolean;
styleClass?: string;
}
export interface TreeNode {
label: string; label: string;
id?: string; id?: string;
children?: TreeNode[]; children?: LegacyTreeNode[];
contextMenu?: TreeNodeMenuItem[]; contextMenu?: TreeNodeMenuItem[];
iconSrc?: string; iconSrc?: string;
isExpanded?: boolean; isExpanded?: boolean;
@ -50,34 +43,37 @@ export interface TreeNode {
onContextMenuOpen?: () => void; onContextMenuOpen?: () => void;
} }
export interface TreeComponentProps { export interface LegacyTreeComponentProps {
rootNode: TreeNode; rootNode: LegacyTreeNode;
style?: any; style?: any;
className?: string; className?: string;
} }
export class TreeComponent extends React.Component<TreeComponentProps> { export class LegacyTreeComponent extends React.Component<LegacyTreeComponentProps> {
public render(): JSX.Element { public render(): JSX.Element {
return ( return (
<div style={this.props.style} className={`treeComponent ${this.props.className}`} role="tree"> <div style={this.props.style} className={`treeComponent ${this.props.className}`} role="tree">
<TreeNodeComponent paddingLeft={0} node={this.props.rootNode} generation={0} /> <LegacyTreeNodeComponent paddingLeft={0} node={this.props.rootNode} generation={0} />
</div> </div>
); );
} }
} }
/* Tree node is a react component */ /* Tree node is a react component */
interface TreeNodeComponentProps { interface LegacyTreeNodeComponentProps {
node: TreeNode; node: LegacyTreeNode;
generation: number; generation: number;
paddingLeft: number; paddingLeft: number;
} }
interface TreeNodeComponentState { interface LegacyTreeNodeComponentState {
isExpanded: boolean; isExpanded: boolean;
isMenuShowing: boolean; isMenuShowing: boolean;
} }
export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, TreeNodeComponentState> { export class LegacyTreeNodeComponent extends React.Component<
LegacyTreeNodeComponentProps,
LegacyTreeNodeComponentState
> {
private static readonly paddingPerGenerationPx = 16; private static readonly paddingPerGenerationPx = 16;
private static readonly iconOffset = 22; private static readonly iconOffset = 22;
private static readonly transitionDurationMS = 200; private static readonly transitionDurationMS = 200;
@ -85,7 +81,7 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
private contextMenuRef = React.createRef<HTMLDivElement>(); private contextMenuRef = React.createRef<HTMLDivElement>();
private isExpanded: boolean; private isExpanded: boolean;
constructor(props: TreeNodeComponentProps) { constructor(props: LegacyTreeNodeComponentProps) {
super(props); super(props);
this.isExpanded = props.node.isExpanded; this.isExpanded = props.node.isExpanded;
this.state = { this.state = {
@ -94,13 +90,13 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
}; };
} }
componentDidUpdate(prevProps: TreeNodeComponentProps, prevState: TreeNodeComponentState) { componentDidUpdate(prevProps: LegacyTreeNodeComponentProps, prevState: LegacyTreeNodeComponentState) {
// Only call when expand has actually changed // Only call when expand has actually changed
if (this.state.isExpanded !== prevState.isExpanded) { if (this.state.isExpanded !== prevState.isExpanded) {
if (this.state.isExpanded) { if (this.state.isExpanded) {
this.props.node.onExpanded && setTimeout(this.props.node.onExpanded, TreeNodeComponent.callbackDelayMS); this.props.node.onExpanded && setTimeout(this.props.node.onExpanded, LegacyTreeNodeComponent.callbackDelayMS);
} else { } else {
this.props.node.onCollapsed && setTimeout(this.props.node.onCollapsed, TreeNodeComponent.callbackDelayMS); this.props.node.onCollapsed && setTimeout(this.props.node.onCollapsed, LegacyTreeNodeComponent.callbackDelayMS);
} }
} }
if (this.props.node.isExpanded !== this.isExpanded) { if (this.props.node.isExpanded !== this.isExpanded) {
@ -115,18 +111,18 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
return this.renderNode(this.props.node, this.props.generation); return this.renderNode(this.props.node, this.props.generation);
} }
private static getSortedChildren(treeNode: TreeNode): TreeNode[] { private static getSortedChildren(treeNode: LegacyTreeNode): LegacyTreeNode[] {
if (!treeNode || !treeNode.children) { if (!treeNode || !treeNode.children) {
return undefined; return undefined;
} }
const compareFct = (a: TreeNode, b: TreeNode) => a.label.localeCompare(b.label); const compareFct = (a: LegacyTreeNode, b: LegacyTreeNode) => a.label.localeCompare(b.label);
let unsortedChildren; let unsortedChildren;
if (treeNode.isLeavesParentsSeparate) { if (treeNode.isLeavesParentsSeparate) {
// Separate parents and leave // Separate parents and leave
const parents: TreeNode[] = treeNode.children.filter((node) => node.children); const parents: LegacyTreeNode[] = treeNode.children.filter((node) => node.children);
const leaves: TreeNode[] = treeNode.children.filter((node) => !node.children); const leaves: LegacyTreeNode[] = treeNode.children.filter((node) => !node.children);
if (treeNode.isAlphaSorted) { if (treeNode.isAlphaSorted) {
parents.sort(compareFct); parents.sort(compareFct);
@ -141,18 +137,18 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
return unsortedChildren; return unsortedChildren;
} }
private static isNodeHeaderBlank(node: TreeNode): boolean { private static isNodeHeaderBlank(node: LegacyTreeNode): boolean {
return (node.label === undefined || node.label === null) && !node.contextMenu; return (node.label === undefined || node.label === null) && !node.contextMenu;
} }
private renderNode(node: TreeNode, generation: number): JSX.Element { private renderNode(node: LegacyTreeNode, generation: number): JSX.Element {
let paddingLeft = generation * TreeNodeComponent.paddingPerGenerationPx; const paddingLeft = generation * LegacyTreeNodeComponent.paddingPerGenerationPx;
let additionalOffsetPx = 15; let additionalOffsetPx = 15;
if (node.children) { if (node.children) {
const childrenWithSubChildren = node.children.filter((child: TreeNode) => !!child.children); const childrenWithSubChildren = node.children.filter((child: LegacyTreeNode) => !!child.children);
if (childrenWithSubChildren.length > 0) { if (childrenWithSubChildren.length > 0) {
additionalOffsetPx = TreeNodeComponent.iconOffset; additionalOffsetPx = LegacyTreeNodeComponent.iconOffset;
} }
} }
@ -160,10 +156,10 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
const showSelected = const showSelected =
this.props.node.isSelected && this.props.node.isSelected &&
this.props.node.isSelected() && this.props.node.isSelected() &&
!TreeNodeComponent.isAnyDescendantSelected(this.props.node); !LegacyTreeNodeComponent.isAnyDescendantSelected(this.props.node);
const headerStyle: React.CSSProperties = { paddingLeft: this.props.paddingLeft }; const headerStyle: React.CSSProperties = { paddingLeft: this.props.paddingLeft };
if (TreeNodeComponent.isNodeHeaderBlank(node)) { if (LegacyTreeNodeComponent.isNodeHeaderBlank(node)) {
headerStyle.height = 0; headerStyle.height = 0;
headerStyle.padding = 0; headerStyle.padding = 0;
} }
@ -195,10 +191,13 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
<img className="loadingIcon" src={LoadingIndicator_3Squares} hidden={!this.props.node.isLoading} /> <img className="loadingIcon" src={LoadingIndicator_3Squares} hidden={!this.props.node.isLoading} />
</div> </div>
{node.children && ( {node.children && (
<AnimateHeight duration={TreeNodeComponent.transitionDurationMS} height={this.state.isExpanded ? "auto" : 0}> <AnimateHeight
duration={LegacyTreeNodeComponent.transitionDurationMS}
height={this.state.isExpanded ? "auto" : 0}
>
<div className="nodeChildren" data-test={node.label} role="group"> <div className="nodeChildren" data-test={node.label} role="group">
{TreeNodeComponent.getSortedChildren(node).map((childNode: TreeNode) => ( {LegacyTreeNodeComponent.getSortedChildren(node).map((childNode: LegacyTreeNode) => (
<TreeNodeComponent <LegacyTreeNodeComponent
key={`${childNode.label}-${generation + 1}-${childNode.timestamp}`} key={`${childNode.label}-${generation + 1}-${childNode.timestamp}`}
node={childNode} node={childNode}
generation={generation + 1} generation={generation + 1}
@ -216,12 +215,14 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
* Recursive: is the node or any descendant selected * Recursive: is the node or any descendant selected
* @param node * @param node
*/ */
private static isAnyDescendantSelected(node: TreeNode): boolean { private static isAnyDescendantSelected(node: LegacyTreeNode): boolean {
return ( return (
node.children && node.children &&
node.children.reduce( node.children.reduce(
(previous: boolean, child: TreeNode) => (previous: boolean, child: LegacyTreeNode) =>
previous || (child.isSelected && child.isSelected()) || TreeNodeComponent.isAnyDescendantSelected(child), previous ||
(child.isSelected && child.isSelected()) ||
LegacyTreeNodeComponent.isAnyDescendantSelected(child),
false, false,
) )
); );
@ -232,10 +233,10 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
} }
private onRightClick = (): void => { private onRightClick = (): void => {
this.contextMenuRef.current.firstChild.dispatchEvent(TreeNodeComponent.createClickEvent()); this.contextMenuRef.current.firstChild.dispatchEvent(LegacyTreeNodeComponent.createClickEvent());
}; };
private renderContextMenuButton(node: TreeNode): JSX.Element { private renderContextMenuButton(node: LegacyTreeNode): JSX.Element {
const menuItemLabel = "More"; const menuItemLabel = "More";
const buttonStyles: Partial<IButtonStyles> = { const buttonStyles: Partial<IButtonStyles> = {
rootFocused: { outline: `1px dashed ${StyleConstants.FocusColor}` }, rootFocused: { outline: `1px dashed ${StyleConstants.FocusColor}` },
@ -265,7 +266,7 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
<div <div
data-test={`treeComponentMenuItemContainer`} data-test={`treeComponentMenuItemContainer`}
className="treeComponentMenuItemContainer" className="treeComponentMenuItemContainer"
onContextMenu={(e) => e.target.dispatchEvent(TreeNodeComponent.createClickEvent())} onContextMenu={(e) => e.target.dispatchEvent(LegacyTreeNodeComponent.createClickEvent())}
> >
{props.item.onRenderIcon()} {props.item.onRenderIcon()}
<span <span
@ -297,7 +298,7 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
); );
} }
private renderCollapseExpandIcon(node: TreeNode): JSX.Element { private renderCollapseExpandIcon(node: LegacyTreeNode): JSX.Element {
if (!node.children || !node.label) { if (!node.children || !node.label) {
return <></>; return <></>;
} }
@ -314,12 +315,12 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
); );
} }
private onNodeClick = (event: React.MouseEvent<HTMLDivElement>, node: TreeNode): void => { private onNodeClick = (event: React.MouseEvent<HTMLDivElement>, node: LegacyTreeNode): void => {
event.stopPropagation(); event.stopPropagation();
if (node.children) { if (node.children) {
const isExpanded = !this.state.isExpanded; const isExpanded = !this.state.isExpanded;
// Prevent collapsing if node header is blank // Prevent collapsing if node header is blank
if (!(TreeNodeComponent.isNodeHeaderBlank(node) && !isExpanded)) { if (!(LegacyTreeNodeComponent.isNodeHeaderBlank(node) && !isExpanded)) {
this.setState({ isExpanded }); this.setState({ isExpanded });
} }
} }
@ -327,14 +328,14 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
this.props.node.onClick && this.props.node.onClick(this.state.isExpanded); this.props.node.onClick && this.props.node.onClick(this.state.isExpanded);
}; };
private onNodeKeyPress = (event: React.KeyboardEvent<HTMLDivElement>, node: TreeNode): void => { private onNodeKeyPress = (event: React.KeyboardEvent<HTMLDivElement>, node: LegacyTreeNode): void => {
if (event.charCode === Constants.KeyCodes.Space || event.charCode === Constants.KeyCodes.Enter) { if (event.charCode === Constants.KeyCodes.Space || event.charCode === Constants.KeyCodes.Enter) {
event.stopPropagation(); event.stopPropagation();
this.props.node.onClick && this.props.node.onClick(this.state.isExpanded); this.props.node.onClick && this.props.node.onClick(this.state.isExpanded);
} }
}; };
private onCollapseExpandIconKeyPress = (event: React.KeyboardEvent<HTMLDivElement>, node: TreeNode): void => { private onCollapseExpandIconKeyPress = (event: React.KeyboardEvent<HTMLDivElement>, node: LegacyTreeNode): void => {
if (event.charCode === Constants.KeyCodes.Space || event.charCode === Constants.KeyCodes.Enter) { if (event.charCode === Constants.KeyCodes.Space || event.charCode === Constants.KeyCodes.Enter) {
event.stopPropagation(); event.stopPropagation();
if (node.children) { if (node.children) {

View File

@ -0,0 +1,180 @@
import { TreeItem, TreeItemLayout, tokens } from "@fluentui/react-components";
import PromiseSource from "Utils/PromiseSource";
import { mount, shallow } from "enzyme";
import React from "react";
import { act } from "react-dom/test-utils";
import { TreeNode, TreeNodeComponent } from "./TreeNodeComponent";
function generateTestNode(id: string, additionalProps?: Partial<TreeNode>): TreeNode {
const node: TreeNode = {
id,
label: `${id}Label`,
className: `${id}Class`,
iconSrc: `${id}Icon`,
onClick: jest.fn().mockName(`${id}Click`),
...additionalProps,
};
return node;
}
describe("TreeNodeComponent", () => {
it("renders a single node", () => {
const node = generateTestNode("root");
const component = shallow(<TreeNodeComponent node={node} treeNodeId={node.id} />);
expect(component).toMatchSnapshot();
// The "click" handler is actually attached to onOpenChange, with a type of "Click".
component
.find(TreeItem)
.props()
.onOpenChange(null!, { open: true, value: "borp", target: null!, event: null!, type: "Click" });
expect(node.onClick).toHaveBeenCalled();
});
it("renders a node with a menu", () => {
const node = generateTestNode("root", {
contextMenu: [
{
label: "enabledItem",
onClick: jest.fn().mockName("enabledItemClick"),
},
{
label: "disabledItem",
onClick: jest.fn().mockName("disabledItemClick"),
isDisabled: true,
},
],
});
const component = shallow(<TreeNodeComponent node={node} treeNodeId={node.id} />);
expect(component).toMatchSnapshot();
});
it("renders a loading spinner if the node is loading", async () => {
const loading = new PromiseSource();
const node = generateTestNode("root", {
onExpanded: () => loading.promise,
});
const component = shallow(<TreeNodeComponent node={node} treeNodeId={node.id} />);
act(() => {
component
.find(TreeItem)
.props()
.onOpenChange(null!, { open: true, value: "borp", target: null!, event: null!, type: "ExpandIconClick" });
});
expect(component).toMatchSnapshot("loading");
await loading.resolveAndWait();
expect(component).toMatchSnapshot("loaded");
});
it("renders single selected leaf node as selected", () => {
const node = generateTestNode("root", {
isSelected: () => true,
});
const component = shallow(<TreeNodeComponent node={node} treeNodeId={node.id} />);
expect(component.find(TreeItemLayout).props().style?.backgroundColor).toStrictEqual(
tokens.colorNeutralBackground1Selected,
);
expect(component).toMatchSnapshot();
});
it("renders selected parent node as selected if no descendant nodes are selected", () => {
const node = generateTestNode("root", {
isSelected: () => true,
children: [
generateTestNode("child1", {
children: [generateTestNode("grandchild1"), generateTestNode("grandchild2")],
}),
generateTestNode("child2"),
],
});
const component = shallow(<TreeNodeComponent node={node} treeNodeId={node.id} />);
expect(component.find(TreeItemLayout).props().style?.backgroundColor).toStrictEqual(
tokens.colorNeutralBackground1Selected,
);
expect(component).toMatchSnapshot();
});
it("renders selected parent node as unselected if any descendant node is selected", () => {
const node = generateTestNode("root", {
isSelected: () => true,
children: [
generateTestNode("child1", {
children: [
generateTestNode("grandchild1", {
isSelected: () => true,
}),
generateTestNode("grandchild2"),
],
}),
generateTestNode("child2"),
],
});
const component = shallow(<TreeNodeComponent node={node} treeNodeId={node.id} />);
expect(component.find(TreeItemLayout).props().style?.backgroundColor).toBeUndefined();
expect(component).toMatchSnapshot();
});
it("renders an icon if the node has one", () => {
const node = generateTestNode("root", {
iconSrc: "the-icon.svg",
});
const component = shallow(<TreeNodeComponent node={node} treeNodeId={node.id} />);
expect(component).toMatchSnapshot();
});
it("does not render children if the node is loading", () => {
const node = generateTestNode("root", {
isLoading: true,
children: [
generateTestNode("child1", {
children: [generateTestNode("grandchild1"), generateTestNode("grandchild2")],
}),
generateTestNode("child2"),
],
});
const component = shallow(<TreeNodeComponent node={node} treeNodeId={node.id} />);
expect(component).toMatchSnapshot();
});
it("fully renders a tree", () => {
const child3Loading = new PromiseSource();
const node = generateTestNode("root", {
isSelected: () => true,
children: [
generateTestNode("child1", {
children: [
generateTestNode("grandchild1", {
iconSrc: "grandchild1Icon.svg",
isSelected: () => true,
}),
generateTestNode("grandchild2"),
],
}),
generateTestNode("child2Loading", {
isLoading: true,
children: [generateTestNode("grandchild3NotRendered")],
}),
generateTestNode("child3Expanding", {
onExpanded: () => child3Loading.promise,
}),
],
});
const component = mount(<TreeNodeComponent node={node} treeNodeId={node.id} />);
// Find and expand the child3Expanding node
const expandingChild = component.find(TreeItem).filterWhere((n) => n.props().value === "root/child3ExpandingLabel");
act(() => {
expandingChild.props().onOpenChange(null!, {
open: true,
value: "root/child3ExpandingLabel",
target: null!,
event: null!,
type: "Click",
});
});
expect(component).toMatchSnapshot();
});
});

View File

@ -0,0 +1,206 @@
import {
Button,
Menu,
MenuItem,
MenuList,
MenuOpenChangeData,
MenuOpenEvent,
MenuPopover,
MenuTrigger,
Spinner,
Tree,
TreeItem,
TreeItemLayout,
TreeOpenChangeData,
TreeOpenChangeEvent,
} from "@fluentui/react-components";
import { MoreHorizontal20Regular } from "@fluentui/react-icons";
import { tokens } from "@fluentui/react-theme";
import * as React from "react";
import { useCallback } from "react";
export interface TreeNodeMenuItem {
label: string;
onClick: () => void;
iconSrc?: string;
isDisabled?: boolean;
styleClass?: string;
}
export interface TreeNode {
label: string;
id?: string;
children?: TreeNode[];
contextMenu?: TreeNodeMenuItem[];
iconSrc?: string;
isExpanded?: boolean;
className?: string;
isAlphaSorted?: boolean;
// data?: any; // Piece of data corresponding to this node
timestamp?: number;
isLeavesParentsSeparate?: boolean; // Display parents together first, then leaves
isLoading?: boolean;
isScrollable?: boolean;
isSelected?: () => boolean;
onClick?: () => void; // Only if a leaf, other click will expand/collapse
onExpanded?: () => Promise<void>;
onCollapsed?: () => void;
onContextMenuOpen?: () => void;
}
export interface TreeNodeComponentProps {
node: TreeNode;
className?: string;
treeNodeId: string;
}
/** Function that returns true if any descendant (at any depth) of this node is selected. */
function isAnyDescendantSelected(node: TreeNode): boolean {
return (
node.children &&
node.children.reduce(
(previous: boolean, child: TreeNode) =>
previous || (child.isSelected && child.isSelected()) || isAnyDescendantSelected(child),
false,
)
);
}
const getTreeIcon = (iconSrc: string): JSX.Element => <img src={iconSrc} alt="" style={{ width: 16, height: 16 }} />;
export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
node,
treeNodeId,
}: TreeNodeComponentProps): JSX.Element => {
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const getSortedChildren = (treeNode: TreeNode): TreeNode[] => {
if (!treeNode || !treeNode.children) {
return undefined;
}
const compareFct = (a: TreeNode, b: TreeNode) => a.label.localeCompare(b.label);
let unsortedChildren;
if (treeNode.isLeavesParentsSeparate) {
// Separate parents and leave
const parents: TreeNode[] = treeNode.children.filter((node) => node.children);
const leaves: TreeNode[] = treeNode.children.filter((node) => !node.children);
if (treeNode.isAlphaSorted) {
parents.sort(compareFct);
leaves.sort(compareFct);
}
unsortedChildren = parents.concat(leaves);
} else {
unsortedChildren = treeNode.isAlphaSorted ? treeNode.children.sort(compareFct) : treeNode.children;
}
return unsortedChildren;
};
const isBranch = node.children?.length > 0;
const onOpenChange = useCallback(
(_: TreeOpenChangeEvent, data: TreeOpenChangeData) => {
if (data.type === "Click" && !isBranch && node.onClick) {
node.onClick();
}
if (!node.isExpanded && data.open && node.onExpanded) {
// Catch the transition non-expanded to expanded
setIsLoading(true);
node.onExpanded?.().then(() => setIsLoading(false));
} else if (node.isExpanded && !data.open && node.onCollapsed) {
// Catch the transition expanded to non-expanded
node.onCollapsed?.();
}
},
[isBranch, node, setIsLoading],
);
const onMenuOpenChange = useCallback(
(e: MenuOpenEvent, data: MenuOpenChangeData) => {
if (data.open) {
node.onContextMenuOpen?.();
}
},
[node],
);
// We show a node as selected if it is selected AND no descendant is selected.
// We want to show only the deepest selected node as selected.
const isCurrentNodeSelected = node.isSelected && node.isSelected();
const shouldShowAsSelected = isCurrentNodeSelected && !isAnyDescendantSelected(node);
const contextMenuItems = (node.contextMenu ?? []).map((menuItem) => (
<MenuItem
data-test={`TreeNode/ContextMenuItem:${menuItem.label}`}
disabled={menuItem.isDisabled}
key={menuItem.label}
onClick={menuItem.onClick}
>
{menuItem.label}
</MenuItem>
));
const treeItem = (
<TreeItem
value={treeNodeId}
itemType={isBranch ? "branch" : "leaf"}
style={{ height: "100%" }}
onOpenChange={onOpenChange}
>
<TreeItemLayout
className={node.className}
data-test={`TreeNode:${treeNodeId}`}
actions={
contextMenuItems.length > 0 && (
<Menu onOpenChange={onMenuOpenChange}>
<MenuTrigger disableButtonEnhancement>
<Button
aria-label="More options"
data-test="TreeNode/ContextMenuTrigger"
appearance="subtle"
icon={<MoreHorizontal20Regular />}
/>
</MenuTrigger>
<MenuPopover data-test={`TreeNode/ContextMenu:${treeNodeId}`}>
<MenuList>{contextMenuItems}</MenuList>
</MenuPopover>
</Menu>
)
}
expandIcon={isLoading ? <Spinner size="extra-tiny" /> : undefined}
iconBefore={node.iconSrc && getTreeIcon(node.iconSrc)}
style={{
backgroundColor: shouldShowAsSelected ? tokens.colorNeutralBackground1Selected : undefined,
}}
>
{node.label}
</TreeItemLayout>
{!node.isLoading && node.children?.length > 0 && (
<Tree style={{ overflow: node.isScrollable ? "auto" : undefined }}>
{getSortedChildren(node).map((childNode: TreeNode) => (
<TreeNodeComponent key={childNode.label} node={childNode} treeNodeId={`${treeNodeId}/${childNode.label}`} />
))}
</Tree>
)}
</TreeItem>
);
if (contextMenuItems.length === 0) {
return treeItem;
}
// For accessibility, it's highly recommended that any 'actions' also be available in the context menu.
// See https://react.fluentui.dev/?path=/docs/components-tree--default#actions
return (
<Menu positioning="below-end" openOnContext onOpenChange={onMenuOpenChange}>
<MenuTrigger disableButtonEnhancement>{treeItem}</MenuTrigger>
<MenuPopover>
<MenuList>{contextMenuItems}</MenuList>
</MenuPopover>
</Menu>
);
};

View File

@ -1,11 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TreeComponent renders a simple tree 1`] = ` exports[`LegacyTreeComponent renders a simple tree 1`] = `
<div <div
className="treeComponent tree" className="treeComponent tree"
role="tree" role="tree"
> >
<TreeNodeComponent <LegacyTreeNodeComponent
generation={0} generation={0}
node={ node={
Object { Object {
@ -33,7 +33,7 @@ exports[`TreeComponent renders a simple tree 1`] = `
</div> </div>
`; `;
exports[`TreeNodeComponent does not render children by default 1`] = ` exports[`LegacyTreeNodeComponent does not render children by default 1`] = `
<div <div
className=" main2 nodeItem " className=" main2 nodeItem "
onClick={[Function]} onClick={[Function]}
@ -102,7 +102,7 @@ exports[`TreeNodeComponent does not render children by default 1`] = `
data-test="label" data-test="label"
role="group" role="group"
> >
<TreeNodeComponent <LegacyTreeNodeComponent
generation={3} generation={3}
key="Bchild1-3-undefined" key="Bchild1-3-undefined"
node={ node={
@ -120,7 +120,7 @@ exports[`TreeNodeComponent does not render children by default 1`] = `
} }
paddingLeft={32} paddingLeft={32}
/> />
<TreeNodeComponent <LegacyTreeNodeComponent
generation={3} generation={3}
key="2child2-3-undefined" key="2child2-3-undefined"
node={ node={
@ -135,7 +135,7 @@ exports[`TreeNodeComponent does not render children by default 1`] = `
</div> </div>
`; `;
exports[`TreeNodeComponent renders a simple node (sorted children, expanded) 1`] = ` exports[`LegacyTreeNodeComponent renders a simple node (sorted children, expanded) 1`] = `
<div <div
className="nodeClassname main12 nodeItem " className="nodeClassname main12 nodeItem "
id="id" id="id"
@ -254,7 +254,7 @@ exports[`TreeNodeComponent renders a simple node (sorted children, expanded) 1`]
data-test="label" data-test="label"
role="group" role="group"
> >
<TreeNodeComponent <LegacyTreeNodeComponent
generation={13} generation={13}
key="2child2-13-undefined" key="2child2-13-undefined"
node={ node={
@ -264,7 +264,7 @@ exports[`TreeNodeComponent renders a simple node (sorted children, expanded) 1`]
} }
paddingLeft={214} paddingLeft={214}
/> />
<TreeNodeComponent <LegacyTreeNodeComponent
generation={13} generation={13}
key="Bchild1-13-undefined" key="Bchild1-13-undefined"
node={ node={
@ -287,7 +287,7 @@ exports[`TreeNodeComponent renders a simple node (sorted children, expanded) 1`]
</div> </div>
`; `;
exports[`TreeNodeComponent renders loading icon 1`] = ` exports[`LegacyTreeNodeComponent renders loading icon 1`] = `
<div <div
className=" main2 nodeItem " className=" main2 nodeItem "
onClick={[Function]} onClick={[Function]}
@ -360,7 +360,7 @@ exports[`TreeNodeComponent renders loading icon 1`] = `
</div> </div>
`; `;
exports[`TreeNodeComponent renders sorted children, expanded, leaves and parents separated 1`] = ` exports[`LegacyTreeNodeComponent renders sorted children, expanded, leaves and parents separated 1`] = `
<div <div
className="nodeClassname main12 nodeItem " className="nodeClassname main12 nodeItem "
id="id" id="id"
@ -470,7 +470,7 @@ exports[`TreeNodeComponent renders sorted children, expanded, leaves and parents
data-test="label" data-test="label"
role="group" role="group"
> >
<TreeNodeComponent <LegacyTreeNodeComponent
generation={13} generation={13}
key="bchild-13-undefined" key="bchild-13-undefined"
node={ node={
@ -488,7 +488,7 @@ exports[`TreeNodeComponent renders sorted children, expanded, leaves and parents
} }
paddingLeft={192} paddingLeft={192}
/> />
<TreeNodeComponent <LegacyTreeNodeComponent
generation={13} generation={13}
key="dchild-13-undefined" key="dchild-13-undefined"
node={ node={
@ -506,7 +506,7 @@ exports[`TreeNodeComponent renders sorted children, expanded, leaves and parents
} }
paddingLeft={192} paddingLeft={192}
/> />
<TreeNodeComponent <LegacyTreeNodeComponent
generation={13} generation={13}
key="aChild-13-undefined" key="aChild-13-undefined"
node={ node={
@ -516,7 +516,7 @@ exports[`TreeNodeComponent renders sorted children, expanded, leaves and parents
} }
paddingLeft={214} paddingLeft={214}
/> />
<TreeNodeComponent <LegacyTreeNodeComponent
generation={13} generation={13}
key="cchild-13-undefined" key="cchild-13-undefined"
node={ node={
@ -531,7 +531,7 @@ exports[`TreeNodeComponent renders sorted children, expanded, leaves and parents
</div> </div>
`; `;
exports[`TreeNodeComponent renders unsorted children by default 1`] = ` exports[`LegacyTreeNodeComponent renders unsorted children by default 1`] = `
<div <div
className=" main2 nodeItem " className=" main2 nodeItem "
onClick={[Function]} onClick={[Function]}
@ -600,7 +600,7 @@ exports[`TreeNodeComponent renders unsorted children by default 1`] = `
data-test="label" data-test="label"
role="group" role="group"
> >
<TreeNodeComponent <LegacyTreeNodeComponent
generation={3} generation={3}
key="Bchild1-3-undefined" key="Bchild1-3-undefined"
node={ node={
@ -618,7 +618,7 @@ exports[`TreeNodeComponent renders unsorted children by default 1`] = `
} }
paddingLeft={32} paddingLeft={32}
/> />
<TreeNodeComponent <LegacyTreeNodeComponent
generation={3} generation={3}
key="2child2-3-undefined" key="2child2-3-undefined"
node={ node={

File diff suppressed because it is too large Load Diff

View File

@ -1,147 +0,0 @@
import {
Button,
Menu,
MenuItem,
MenuList,
MenuPopover,
MenuTrigger,
Spinner,
Tree,
TreeItem,
TreeItemLayout,
TreeOpenChangeData,
TreeOpenChangeEvent,
} from "@fluentui/react-components";
import { MoreHorizontal20Regular } from "@fluentui/react-icons";
import { tokens } from "@fluentui/react-theme";
import * as React from "react";
export interface TreeNode2MenuItem {
label: string;
onClick: () => void;
iconSrc?: string;
isDisabled?: boolean;
styleClass?: string;
}
export interface TreeNode2 {
label: string;
id?: string;
children?: TreeNode2[];
contextMenu?: TreeNode2MenuItem[];
iconSrc?: string;
isExpanded?: boolean;
className?: string;
isAlphaSorted?: boolean;
// data?: any; // Piece of data corresponding to this node
timestamp?: number;
isLeavesParentsSeparate?: boolean; // Display parents together first, then leaves
isLoading?: boolean;
isScrollable?: boolean;
isSelected?: () => boolean;
onClick?: () => void; // Only if a leaf, other click will expand/collapse
onExpanded?: () => Promise<void>;
onCollapsed?: () => void;
onContextMenuOpen?: () => void;
}
export interface TreeNode2ComponentProps {
node: TreeNode2;
className?: string;
treeNodeId: string;
}
const getTreeIcon = (iconSrc: string): JSX.Element => <img src={iconSrc} alt="" style={{ width: 20, height: 20 }} />;
export const TreeNode2Component: React.FC<TreeNode2ComponentProps> = ({
node,
treeNodeId,
}: TreeNode2ComponentProps): JSX.Element => {
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const getSortedChildren = (treeNode: TreeNode2): TreeNode2[] => {
if (!treeNode || !treeNode.children) {
return undefined;
}
const compareFct = (a: TreeNode2, b: TreeNode2) => a.label.localeCompare(b.label);
let unsortedChildren;
if (treeNode.isLeavesParentsSeparate) {
// Separate parents and leave
const parents: TreeNode2[] = treeNode.children.filter((node) => node.children);
const leaves: TreeNode2[] = treeNode.children.filter((node) => !node.children);
if (treeNode.isAlphaSorted) {
parents.sort(compareFct);
leaves.sort(compareFct);
}
unsortedChildren = parents.concat(leaves);
} else {
unsortedChildren = treeNode.isAlphaSorted ? treeNode.children.sort(compareFct) : treeNode.children;
}
return unsortedChildren;
};
const onOpenChange = (_: TreeOpenChangeEvent, data: TreeOpenChangeData) => {
if (!node.isExpanded && data.open && node.onExpanded) {
// Catch the transition non-expanded to expanded
setIsLoading(true);
node.onExpanded?.().then(() => setIsLoading(false));
} else if (node.isExpanded && !data.open && node.onCollapsed) {
// Catch the transition expanded to non-expanded
node.onCollapsed?.();
}
};
return (
<TreeItem
value={treeNodeId}
itemType={node.children !== undefined ? "branch" : "leaf"}
style={{ height: "100%" }}
onOpenChange={onOpenChange}
>
<TreeItemLayout
className={node.className}
actions={
node.contextMenu && (
<Menu>
<MenuTrigger disableButtonEnhancement>
<Button aria-label="More options" appearance="subtle" icon={<MoreHorizontal20Regular />} />
</MenuTrigger>
<MenuPopover>
<MenuList>
{node.contextMenu.map((menuItem) => (
<MenuItem disabled={menuItem.isDisabled} key={menuItem.label} onClick={menuItem.onClick}>
{menuItem.label}
</MenuItem>
))}
</MenuList>
</MenuPopover>
</Menu>
)
}
expandIcon={isLoading ? <Spinner size="extra-tiny" /> : undefined}
iconBefore={node.iconSrc && getTreeIcon(node.iconSrc)}
style={{
backgroundColor: node.isSelected && node.isSelected() ? tokens.colorNeutralBackground1Selected : undefined,
}}
>
<span onClick={() => node.onClick?.()}>{node.label}</span>
</TreeItemLayout>
{!node.isLoading && node.children?.length > 0 && (
<Tree style={{ overflow: node.isScrollable ? "auto" : undefined }}>
{getSortedChildren(node).map((childNode: TreeNode2) => (
<TreeNode2Component
key={childNode.label}
node={childNode}
treeNodeId={`${treeNodeId}/${childNode.label}`}
/>
))}
</Tree>
)}
</TreeItem>
);
};

View File

@ -136,14 +136,6 @@ export default class Explorer {
this.isTabsContentExpanded = ko.observable(false); this.isTabsContentExpanded = ko.observable(false);
document.addEventListener(
"contextmenu",
(e) => {
e.preventDefault();
},
false,
);
$(() => { $(() => {
$(document.body).click(() => $(".commandDropdownContainer").hide()); $(document.body).click(() => $(".commandDropdownContainer").hide());
}); });

View File

@ -832,8 +832,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
iconName="Info" iconName="Info"
className="panelInfoIcon" className="panelInfoIcon"
tabIndex={0} tabIndex={0}
ariaLabel="Enable analytical store capability to perform near real-time analytics on your operational data, without ariaLabel="Enable analytical store capability to perform near real-time analytics on your operational data, without impacting the performance of transactional workloads."
impacting the performance of transactional workloads."
/> />
</TooltipHost> </TooltipHost>
</Stack> </Stack>

View File

@ -53,6 +53,7 @@ export class PanelContainerComponent extends React.Component<PanelContainerProps
return ( return (
<Panel <Panel
data-test={`Panel:${this.props.headerText}`}
headerText={this.props.headerText} headerText={this.props.headerText}
isOpen={this.props.isOpen} isOpen={this.props.isOpen}
onDismiss={this.onDissmiss} onDismiss={this.onDissmiss}

View File

@ -4,6 +4,7 @@ exports[`PaneContainerComponent test should be resize if notification console is
<StyledPanelBase <StyledPanelBase
closeButtonAriaLabel="Close test" closeButtonAriaLabel="Close test"
customWidth="440px" customWidth="440px"
data-test="Panel:test"
headerClassName="panelHeader" headerClassName="panelHeader"
headerText="test" headerText="test"
isFooterAtBottom={true} isFooterAtBottom={true}
@ -44,6 +45,7 @@ exports[`PaneContainerComponent test should render with panel content and header
<StyledPanelBase <StyledPanelBase
closeButtonAriaLabel="Close test" closeButtonAriaLabel="Close test"
customWidth="440px" customWidth="440px"
data-test="Panel:test"
headerClassName="panelHeader" headerClassName="panelHeader"
headerText="test" headerText="test"
isFooterAtBottom={true} isFooterAtBottom={true}

View File

@ -1,65 +0,0 @@
import React from "react";
import CollectionIcon from "../../../images/tree-collection.svg";
import * as ViewModels from "../../Contracts/ViewModels";
import { useTabs } from "../../hooks/useTabs";
import { userContext } from "../../UserContext";
import { TreeComponent, TreeNode } from "../Controls/TreeComponent/TreeComponent";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import { mostRecentActivity } from "../MostRecentActivity/MostRecentActivity";
import { useDatabases } from "../useDatabases";
import { useSelectedNode } from "../useSelectedNode";
export const ResourceTokenTree: React.FC = (): JSX.Element => {
const collection = useDatabases((state) => state.resourceTokenCollection);
const buildCollectionNode = (): TreeNode => {
if (!collection) {
return {
label: undefined,
isExpanded: true,
children: [],
};
}
const children: TreeNode[] = [];
children.push({
label: "Items",
onClick: () => {
collection.onDocumentDBDocumentsClick();
// push to most recent
mostRecentActivity.collectionWasOpened(userContext.databaseAccount?.id, collection);
},
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [ViewModels.CollectionTabKind.Documents]),
});
const collectionNode: TreeNode = {
label: collection.id(),
iconSrc: CollectionIcon,
isExpanded: true,
children,
className: "collectionHeader",
onClick: () => {
// Rewritten version of expandCollapseCollection
useSelectedNode.getState().setSelectedNode(collection);
useCommandBar.getState().setContextButtons([]);
useTabs
.getState()
.refreshActiveTab(
(tab) => tab.collection?.id() === collection.id() && tab.collection.databaseId === collection.databaseId,
);
},
isSelected: () => useSelectedNode.getState().isDataNodeSelected(collection.databaseId, collection.id()),
};
return {
label: undefined,
isExpanded: true,
children: [collectionNode],
};
};
return <TreeComponent className="dataResourceTree" rootNode={buildCollectionNode()} />;
};

View File

@ -1,43 +1,29 @@
import { SampleDataTree } from "Explorer/Tree/SampleDataTree"; import {
import { getItemName } from "Utils/APITypeUtils"; BrandVariants,
FluentProvider,
Theme,
Tree,
TreeItemValue,
TreeOpenChangeData,
TreeOpenChangeEvent,
createLightTheme,
} from "@fluentui/react-components";
import { AuthType } from "AuthType";
import { TreeNode, TreeNodeComponent } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import {
createDatabaseTreeNodes,
createResourceTokenTreeNodes,
createSampleDataTreeNodes,
} from "Explorer/Tree/treeNodeUtil";
import { useDatabases } from "Explorer/useDatabases";
import { userContext } from "UserContext";
import { useQueryCopilot } from "hooks/useQueryCopilot"; import { useQueryCopilot } from "hooks/useQueryCopilot";
import { useTabs } from "hooks/useTabs";
import * as React from "react"; import * as React from "react";
import { useEffect, useMemo } from "react";
import shallow from "zustand/shallow"; import shallow from "zustand/shallow";
import CosmosDBIcon from "../../../images/Azure-Cosmos-DB.svg";
import DeleteIcon from "../../../images/delete.svg";
import CopyIcon from "../../../images/notebook/Notebook-copy.svg";
import NewNotebookIcon from "../../../images/notebook/Notebook-new.svg";
import NotebookIcon from "../../../images/notebook/Notebook-resource.svg";
import FileIcon from "../../../images/notebook/file-cosmos.svg";
import PublishIcon from "../../../images/notebook/publish_content.svg";
import RefreshIcon from "../../../images/refresh-cosmos.svg";
import CollectionIcon from "../../../images/tree-collection.svg";
import { isPublicInternetAccessAllowed } from "../../Common/DatabaseAccountUtility";
import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels";
import { Action, ActionModifiers, Source } from "../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { userContext } from "../../UserContext";
import { isServerlessAccount } from "../../Utils/CapabilityUtils";
import * as GitHubUtils from "../../Utils/GitHubUtils";
import { useTabs } from "../../hooks/useTabs";
import * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFactory";
import { AccordionComponent, AccordionItemComponent } from "../Controls/Accordion/AccordionComponent";
import { useDialog } from "../Controls/Dialog";
import { TreeComponent, TreeNode, TreeNodeMenuItem } from "../Controls/TreeComponent/TreeComponent";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import { mostRecentActivity } from "../MostRecentActivity/MostRecentActivity";
import { NotebookContentItem, NotebookContentItemType } from "../Notebook/NotebookContentItem";
import { NotebookUtil } from "../Notebook/NotebookUtil";
import { useNotebook } from "../Notebook/useNotebook"; import { useNotebook } from "../Notebook/useNotebook";
import TabsBase from "../Tabs/TabsBase";
import { useDatabases } from "../useDatabases";
import { useSelectedNode } from "../useSelectedNode";
import { Platform, configContext } from "./../../ConfigContext";
import StoredProcedure from "./StoredProcedure";
import Trigger from "./Trigger";
import UserDefinedFunction from "./UserDefinedFunction";
export const MyNotebooksTitle = "My Notebooks"; export const MyNotebooksTitle = "My Notebooks";
export const GitHubReposTitle = "GitHub repos"; export const GitHubReposTitle = "GitHub repos";
@ -46,611 +32,153 @@ interface ResourceTreeProps {
container: Explorer; container: Explorer;
} }
const cosmosdb: BrandVariants = {
10: "#020305",
20: "#111723",
30: "#16263D",
40: "#193253",
50: "#1B3F6A",
60: "#1B4C82",
70: "#18599B",
80: "#1267B4",
90: "#3174C2",
100: "#4F82C8",
110: "#6790CF",
120: "#7D9ED5",
130: "#92ACDC",
140: "#A6BAE2",
150: "#BAC9E9",
160: "#CDD8EF",
};
const lightTheme: Theme = {
...createLightTheme(cosmosdb),
};
export const DATA_TREE_LABEL = "DATA";
export const MY_DATA_TREE_LABEL = "MY DATA";
export const SAMPLE_DATA_TREE_LABEL = "SAMPLE DATA";
/**
* Top-level tree that has no label, but contains all subtrees
*/
export const ResourceTree: React.FC<ResourceTreeProps> = ({ container }: ResourceTreeProps): JSX.Element => { export const ResourceTree: React.FC<ResourceTreeProps> = ({ container }: ResourceTreeProps): JSX.Element => {
const databases = useDatabases((state) => state.databases); const [openItems, setOpenItems] = React.useState<Iterable<TreeItemValue>>([DATA_TREE_LABEL]);
const {
isNotebookEnabled, const { isNotebookEnabled } = useNotebook(
myNotebooksContentRoot,
galleryContentRoot,
gitHubNotebooksContentRoot,
updateNotebookItem,
} = useNotebook(
(state) => ({ (state) => ({
isNotebookEnabled: state.isNotebookEnabled, isNotebookEnabled: state.isNotebookEnabled,
myNotebooksContentRoot: state.myNotebooksContentRoot,
galleryContentRoot: state.galleryContentRoot,
gitHubNotebooksContentRoot: state.gitHubNotebooksContentRoot,
updateNotebookItem: state.updateNotebookItem,
}), }),
shallow, shallow,
); );
const { activeTab, refreshActiveTab } = useTabs();
const showScriptNodes =
configContext.platform !== Platform.Fabric && (userContext.apiType === "SQL" || userContext.apiType === "Gremlin");
const pseudoDirPath = "PsuedoDir";
const buildChildNodes = ( // We intentionally avoid using a state selector here because we want to re-render the tree if the active tab changes.
item: NotebookContentItem, const { refreshActiveTab } = useTabs();
onFileClick: (item: NotebookContentItem) => void,
isGithubTree?: boolean,
): TreeNode[] => {
if (!item || !item.children) {
return [];
} else {
return item.children.map((item) => {
const result =
item.type === NotebookContentItemType.Directory
? buildNotebookDirectoryNode(item, onFileClick, isGithubTree)
: buildNotebookFileNode(item, onFileClick, isGithubTree);
result.timestamp = item.timestamp;
return result;
});
}
};
const buildNotebookFileNode = ( const { databases, resourceTokenCollection, sampleDataResourceTokenCollection } = useDatabases((state) => ({
item: NotebookContentItem, databases: state.databases,
onFileClick: (item: NotebookContentItem) => void, resourceTokenCollection: state.resourceTokenCollection,
isGithubTree?: boolean, sampleDataResourceTokenCollection: state.sampleDataResourceTokenCollection,
): TreeNode => { }));
return { const { isCopilotEnabled, isCopilotSampleDBEnabled } = useQueryCopilot((state) => ({
label: item.name, isCopilotEnabled: state.copilotEnabled,
iconSrc: NotebookUtil.isNotebookFile(item.path) ? NotebookIcon : FileIcon, isCopilotSampleDBEnabled: state.copilotSampleDBEnabled,
className: "notebookHeader", }));
onClick: () => onFileClick(item),
isSelected: () => {
return (
activeTab &&
activeTab.tabKind === ViewModels.CollectionTabKind.NotebookV2 &&
/* TODO Redesign Tab interface so that resource tree doesn't need to know about NotebookV2Tab.
NotebookV2Tab could be dynamically imported, but not worth it to just get this type right.
*/
(activeTab as any).notebookPath() === item.path
);
},
contextMenu: createFileContextMenu(container, item, isGithubTree),
data: item,
};
};
const createFileContextMenu = ( const databaseTreeNodes =
container: Explorer, userContext.authType === AuthType.ResourceToken
item: NotebookContentItem, ? createResourceTokenTreeNodes(resourceTokenCollection)
isGithubTree?: boolean, : createDatabaseTreeNodes(container, isNotebookEnabled, databases, refreshActiveTab);
): TreeNodeMenuItem[] => {
let items: TreeNodeMenuItem[] = [
{
label: "Rename",
iconSrc: NotebookIcon,
onClick: () => container.renameNotebook(item, isGithubTree),
},
{
label: "Delete",
iconSrc: DeleteIcon,
onClick: () => {
useDialog
.getState()
.showOkCancelModalDialog(
"Confirm delete",
`Are you sure you want to delete "${item.name}"`,
"Delete",
() => container.deleteNotebookFile(item, isGithubTree),
"Cancel",
undefined,
);
},
},
{
label: "Copy to ...",
iconSrc: CopyIcon,
onClick: () => copyNotebook(container, item),
},
{
label: "Download",
iconSrc: NotebookIcon,
onClick: () => container.downloadFile(item),
},
];
if (item.type === NotebookContentItemType.Notebook && userContext.features.publicGallery) {
items.push({
label: "Publish to gallery",
iconSrc: PublishIcon,
onClick: async () => {
TelemetryProcessor.trace(Action.NotebooksGalleryClickPublishToGallery, ActionModifiers.Mark, {
source: Source.ResourceTreeMenu,
});
const content = await container.readFile(item);
if (content) {
await container.publishNotebook(item.name, content);
}
},
});
}
// "Copy to ..." isn't needed if github locations are not available
if (!container.notebookManager?.gitHubOAuthService.isLoggedIn()) {
items = items.filter((item) => item.label !== "Copy to ...");
}
return items;
};
const copyNotebook = async (container: Explorer, item: NotebookContentItem) => {
const content = await container.readFile(item);
if (content) {
container.copyNotebook(item.name, content);
}
};
const createDirectoryContextMenu = (
container: Explorer,
item: NotebookContentItem,
isGithubTree?: boolean,
): TreeNodeMenuItem[] => {
let items: TreeNodeMenuItem[] = [
{
label: "Refresh",
iconSrc: RefreshIcon,
onClick: () => loadSubitems(item, isGithubTree),
},
{
label: "Delete",
iconSrc: DeleteIcon,
onClick: () => {
useDialog
.getState()
.showOkCancelModalDialog(
"Confirm delete",
`Are you sure you want to delete "${item.name}?"`,
"Delete",
() => container.deleteNotebookFile(item, isGithubTree),
"Cancel",
undefined,
);
},
},
{
label: "Rename",
iconSrc: NotebookIcon,
onClick: () => container.renameNotebook(item, isGithubTree),
},
{
label: "New Directory",
iconSrc: NewNotebookIcon,
onClick: () => container.onCreateDirectory(item, isGithubTree),
},
];
//disallow renaming of temporary notebook workspace
if (item?.path === useNotebook.getState().notebookBasePath) {
items = items.filter((item) => item.label !== "Rename");
}
// For GitHub paths remove "Delete", "Rename", "New Directory", "Upload File"
if (GitHubUtils.fromContentUri(item.path)) {
items = items.filter(
(item) =>
item.label !== "Delete" &&
item.label !== "Rename" &&
item.label !== "New Directory" &&
item.label !== "Upload File",
);
}
return items;
};
const buildNotebookDirectoryNode = (
item: NotebookContentItem,
onFileClick: (item: NotebookContentItem) => void,
isGithubTree?: boolean,
): TreeNode => {
return {
label: item.name,
iconSrc: undefined,
className: "notebookHeader",
isAlphaSorted: true,
isLeavesParentsSeparate: true,
onClick: () => {
if (!item.children) {
loadSubitems(item, isGithubTree);
}
},
isSelected: () => {
return (
activeTab &&
activeTab.tabKind === ViewModels.CollectionTabKind.NotebookV2 &&
/* TODO Redesign Tab interface so that resource tree doesn't need to know about NotebookV2Tab.
NotebookV2Tab could be dynamically imported, but not worth it to just get this type right.
*/
(activeTab as any).notebookPath() === item.path
);
},
contextMenu: item.path !== pseudoDirPath ? createDirectoryContextMenu(container, item, isGithubTree) : undefined,
data: item,
children: buildChildNodes(item, onFileClick, isGithubTree),
};
};
const buildDataTree = (): TreeNode => {
const databaseTreeNodes: TreeNode[] = databases.map((database: ViewModels.Database) => {
const databaseNode: TreeNode = {
label: database.id(),
iconSrc: CosmosDBIcon,
isExpanded: database.isDatabaseExpanded(),
className: "databaseHeader",
children: [],
isSelected: () => useSelectedNode.getState().isDataNodeSelected(database.id()),
contextMenu: ResourceTreeContextMenuButtonFactory.createDatabaseContextMenu(container, database.id()),
onClick: async (isExpanded) => {
useSelectedNode.getState().setSelectedNode(database);
// Rewritten version of expandCollapseDatabase():
if (isExpanded) {
database.collapseDatabase();
} else {
if (databaseNode.children?.length === 0) {
databaseNode.isLoading = true;
}
await database.expandDatabase();
}
databaseNode.isLoading = false;
useCommandBar.getState().setContextButtons([]);
refreshActiveTab((tab: TabsBase) => tab.collection?.databaseId === database.id());
},
onContextMenuOpen: () => useSelectedNode.getState().setSelectedNode(database),
};
if (database.isDatabaseShared()) {
databaseNode.children.push({
id: database.isSampleDB ? "sampleScaleSettings" : "",
label: "Scale",
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(database.id(), undefined, [ViewModels.CollectionTabKind.DatabaseSettingsV2]),
onClick: database.onSettingsClick.bind(database),
});
}
// Find collections
database
.collections()
.forEach((collection: ViewModels.Collection) =>
databaseNode.children.push(buildCollectionNode(database, collection)),
);
if (database.collectionsContinuationToken) {
const loadMoreNode: TreeNode = {
label: "load more",
className: "loadMoreHeader",
onClick: async () => {
await database.loadCollections();
useDatabases.getState().updateDatabase(database);
},
};
databaseNode.children.push(loadMoreNode);
}
database.collections.subscribe((collections: ViewModels.Collection[]) => {
collections.forEach((collection: ViewModels.Collection) =>
databaseNode.children.push(buildCollectionNode(database, collection)),
);
});
return databaseNode;
});
return {
label: undefined,
isExpanded: true,
children: databaseTreeNodes,
};
};
const buildCollectionNode = (database: ViewModels.Database, collection: ViewModels.Collection): TreeNode => {
const children: TreeNode[] = [];
children.push({
label: getItemName(),
id: collection.isSampleCollection ? "sampleItems" : "",
onClick: () => {
collection.openTab();
// push to most recent
mostRecentActivity.collectionWasOpened(userContext.databaseAccount?.id, collection);
},
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [
ViewModels.CollectionTabKind.Documents,
ViewModels.CollectionTabKind.Graph,
]),
contextMenu: ResourceTreeContextMenuButtonFactory.createCollectionContextMenuButton(container, collection),
});
if (
isNotebookEnabled &&
userContext.apiType === "Mongo" &&
isPublicInternetAccessAllowed() &&
useNotebook.getState().isPhoenixFeatures
) {
children.push({
label: "Schema (Preview)",
onClick: collection.onSchemaAnalyzerClick.bind(collection),
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [ViewModels.CollectionTabKind.SchemaAnalyzer]),
});
}
if (userContext.apiType !== "Cassandra" || !isServerlessAccount()) {
let id = "";
if (collection.isSampleCollection) {
id = database.isDatabaseShared() ? "sampleSettings" : "sampleScaleSettings";
}
children.push({
id,
label: database.isDatabaseShared() || isServerlessAccount() ? "Settings" : "Scale & Settings",
onClick: collection.onSettingsClick.bind(collection),
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [
ViewModels.CollectionTabKind.CollectionSettingsV2,
]),
});
}
const schemaNode: TreeNode = buildSchemaNode(collection);
if (schemaNode) {
children.push(schemaNode);
}
if (showScriptNodes) {
children.push(buildStoredProcedureNode(collection));
children.push(buildUserDefinedFunctionsNode(collection));
children.push(buildTriggerNode(collection));
}
// This is a rewrite of showConflicts
const showConflicts =
userContext?.databaseAccount?.properties.enableMultipleWriteLocations &&
collection.rawDataModel &&
!!collection.rawDataModel.conflictResolutionPolicy;
if (showConflicts) {
children.push({
label: "Conflicts",
onClick: collection.onConflictsClick.bind(collection),
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [ViewModels.CollectionTabKind.Conflicts]),
});
}
return {
label: collection.id(),
iconSrc: CollectionIcon,
isExpanded: collection.isCollectionExpanded(),
children: children,
className: "collectionHeader",
contextMenu: ResourceTreeContextMenuButtonFactory.createCollectionContextMenuButton(container, collection),
onClick: () => {
// Rewritten version of expandCollapseCollection
useSelectedNode.getState().setSelectedNode(collection);
useCommandBar.getState().setContextButtons([]);
refreshActiveTab(
(tab: TabsBase) =>
tab.collection?.id() === collection.id() && tab.collection.databaseId === collection.databaseId,
);
},
onExpanded: () => {
if (showScriptNodes) {
collection.loadStoredProcedures();
collection.loadUserDefinedFunctions();
collection.loadTriggers();
}
},
isSelected: () => useSelectedNode.getState().isDataNodeSelected(collection.databaseId, collection.id()),
onContextMenuOpen: () => useSelectedNode.getState().setSelectedNode(collection),
};
};
const buildStoredProcedureNode = (collection: ViewModels.Collection): TreeNode => {
return {
label: "Stored Procedures",
children: collection.storedProcedures().map((sp: StoredProcedure) => ({
label: sp.id(),
onClick: sp.open.bind(sp),
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [
ViewModels.CollectionTabKind.StoredProcedures,
]),
contextMenu: ResourceTreeContextMenuButtonFactory.createStoreProcedureContextMenuItems(container, sp),
})),
onClick: async () => {
await collection.loadStoredProcedures();
collection.selectedSubnodeKind(ViewModels.CollectionTabKind.StoredProcedures);
refreshActiveTab(
(tab: TabsBase) =>
tab.collection?.id() === collection.id() && tab.collection.databaseId === collection.databaseId,
);
},
};
};
const buildUserDefinedFunctionsNode = (collection: ViewModels.Collection): TreeNode => {
return {
label: "User Defined Functions",
children: collection.userDefinedFunctions().map((udf: UserDefinedFunction) => ({
label: udf.id(),
onClick: udf.open.bind(udf),
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [
ViewModels.CollectionTabKind.UserDefinedFunctions,
]),
contextMenu: ResourceTreeContextMenuButtonFactory.createUserDefinedFunctionContextMenuItems(container, udf),
})),
onClick: async () => {
await collection.loadUserDefinedFunctions();
collection.selectedSubnodeKind(ViewModels.CollectionTabKind.UserDefinedFunctions);
refreshActiveTab(
(tab: TabsBase) =>
tab.collection?.id() === collection.id() && tab.collection.databaseId === collection.databaseId,
);
},
};
};
const buildTriggerNode = (collection: ViewModels.Collection): TreeNode => {
return {
label: "Triggers",
children: collection.triggers().map((trigger: Trigger) => ({
label: trigger.id(),
onClick: trigger.open.bind(trigger),
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [ViewModels.CollectionTabKind.Triggers]),
contextMenu: ResourceTreeContextMenuButtonFactory.createTriggerContextMenuItems(container, trigger),
})),
onClick: async () => {
await collection.loadTriggers();
collection.selectedSubnodeKind(ViewModels.CollectionTabKind.Triggers);
refreshActiveTab(
(tab: TabsBase) =>
tab.collection?.id() === collection.id() && tab.collection.databaseId === collection.databaseId,
);
},
};
};
const buildSchemaNode = (collection: ViewModels.Collection): TreeNode => {
if (collection.analyticalStorageTtl() === undefined) {
return undefined;
}
if (!collection.schema || !collection.schema.fields) {
return undefined;
}
return {
label: "Schema",
children: getSchemaNodes(collection.schema.fields),
onClick: () => {
collection.selectedSubnodeKind(ViewModels.CollectionTabKind.Schema);
refreshActiveTab((tab: TabsBase) => tab.collection && tab.collection.rid === collection.rid);
},
};
};
const getSchemaNodes = (fields: DataModels.IDataField[]): TreeNode[] => {
const schema: any = {};
//unflatten
fields.forEach((field: DataModels.IDataField) => {
const path: string[] = field.path.split(".");
const fieldProperties = [field.dataType.name, `HasNulls: ${field.hasNulls}`];
let current: any = {};
path.forEach((name: string, pathIndex: number) => {
if (pathIndex === 0) {
if (schema[name] === undefined) {
if (pathIndex === path.length - 1) {
schema[name] = fieldProperties;
} else {
schema[name] = {};
}
}
current = schema[name];
} else {
if (current[name] === undefined) {
if (pathIndex === path.length - 1) {
current[name] = fieldProperties;
} else {
current[name] = {};
}
}
current = current[name];
}
});
});
const traverse = (obj: any): TreeNode[] => {
const children: TreeNode[] = [];
if (obj !== undefined && !Array.isArray(obj) && typeof obj === "object") {
Object.entries(obj).forEach(([key, value]) => {
children.push({ label: key, children: traverse(value) });
});
} else if (Array.isArray(obj)) {
return [{ label: obj[0] }, { label: obj[1] }];
}
return children;
};
return traverse(schema);
};
const loadSubitems = async (item: NotebookContentItem, isGithubTree?: boolean): Promise<void> => {
const updatedItem = await container.notebookManager?.notebookContentClient?.updateItemChildren(item);
updateNotebookItem(updatedItem, isGithubTree);
};
const dataRootNode = buildDataTree();
const isSampleDataEnabled = const isSampleDataEnabled =
useQueryCopilot().copilotEnabled && isCopilotEnabled &&
useQueryCopilot().copilotSampleDBEnabled && isCopilotSampleDBEnabled &&
userContext.sampleDataConnectionInfo && userContext.sampleDataConnectionInfo &&
userContext.apiType === "SQL"; userContext.apiType === "SQL";
const sampleDataResourceTokenCollection = useDatabases((state) => state.sampleDataResourceTokenCollection);
const sampleDataNodes = useMemo<TreeNode[]>(() => {
return isSampleDataEnabled && sampleDataResourceTokenCollection
? createSampleDataTreeNodes(sampleDataResourceTokenCollection)
: [];
}, [isSampleDataEnabled, sampleDataResourceTokenCollection]);
const rootNodes: TreeNode[] = useMemo(() => {
if (sampleDataNodes.length > 0) {
return [
{
id: "data",
label: MY_DATA_TREE_LABEL,
className: "accordionItemHeader",
children: databaseTreeNodes,
isScrollable: true,
},
{
id: "sampleData",
label: SAMPLE_DATA_TREE_LABEL,
className: "accordionItemHeader",
children: sampleDataNodes,
},
];
} else {
return [
{
id: "data",
label: DATA_TREE_LABEL,
className: "accordionItemHeader",
children: databaseTreeNodes,
isScrollable: true,
},
];
}
}, [databaseTreeNodes, sampleDataNodes]);
useEffect(() => {
// Compute open items based on node.isExpanded
const updateOpenItems = (node: TreeNode, parentNodeId: string): void => {
// This will look for ANY expanded node, event if its parent node isn't expanded
// and add it to the openItems list
const globalId = parentNodeId === undefined ? node.label : `${parentNodeId}/${node.label}`;
if (node.isExpanded) {
let found = false;
for (const id of openItems) {
if (id === globalId) {
found = true;
break;
}
}
if (!found) {
setOpenItems((prevOpenItems) => [...prevOpenItems, globalId]);
}
}
if (node.children) {
for (const child of node.children) {
updateOpenItems(child, globalId);
}
}
};
rootNodes.forEach((n) => updateOpenItems(n, undefined));
}, [rootNodes, openItems, setOpenItems]);
const handleOpenChange = (event: TreeOpenChangeEvent, data: TreeOpenChangeData) => setOpenItems(data.openItems);
return ( return (
<> <>
{!isNotebookEnabled && !isSampleDataEnabled && ( <FluentProvider theme={lightTheme} style={{ overflow: "hidden" }}>
<TreeComponent className="dataResourceTree" rootNode={dataRootNode} /> <Tree
)} aria-label="CosmosDB resources"
{isNotebookEnabled && !isSampleDataEnabled && ( openItems={openItems}
<> onOpenChange={handleOpenChange}
<AccordionComponent> size="small"
<AccordionItemComponent title={"DATA"} isExpanded={!gitHubNotebooksContentRoot}> style={{ height: "100%", minWidth: "290px" }}
<TreeComponent className="dataResourceTree" rootNode={dataRootNode} /> >
</AccordionItemComponent> {rootNodes.map((node) => (
</AccordionComponent> <TreeNodeComponent key={node.label} className="dataResourceTree" node={node} treeNodeId={node.label} />
</> ))}
)} </Tree>
{!isNotebookEnabled && isSampleDataEnabled && ( </FluentProvider>
<>
<AccordionComponent>
<AccordionItemComponent title={"MY DATA"} isExpanded={!gitHubNotebooksContentRoot}>
<TreeComponent className="dataResourceTree" rootNode={dataRootNode} />
</AccordionItemComponent>
<AccordionItemComponent title={"SAMPLE DATA"} containerStyles={{ display: "table" }}>
<SampleDataTree sampleDataResourceTokenCollection={sampleDataResourceTokenCollection} />
</AccordionItemComponent>
</AccordionComponent>
</>
)}
{isNotebookEnabled && isSampleDataEnabled && (
<>
<AccordionComponent>
<AccordionItemComponent title={"MY DATA"} isExpanded={!gitHubNotebooksContentRoot}>
<TreeComponent className="dataResourceTree" rootNode={dataRootNode} />
</AccordionItemComponent>
<AccordionItemComponent title={"SAMPLE DATA"} containerStyles={{ display: "table" }}>
<SampleDataTree sampleDataResourceTokenCollection={sampleDataResourceTokenCollection} />
</AccordionItemComponent>
</AccordionComponent>
</>
)}
</> </>
); );
}; };

View File

@ -2,7 +2,11 @@ import { shallow } from "enzyme";
import React from "react"; import React from "react";
import * as DataModels from "../../Contracts/DataModels"; import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels"; import * as ViewModels from "../../Contracts/ViewModels";
import { TreeComponent, TreeComponentProps, TreeNode } from "../Controls/TreeComponent/TreeComponent"; import {
LegacyTreeComponent,
LegacyTreeComponentProps,
LegacyTreeNode,
} from "../Controls/TreeComponent/LegacyTreeComponent";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
import Collection from "./Collection"; import Collection from "./Collection";
import { ResourceTreeAdapter } from "./ResourceTreeAdapter"; import { ResourceTreeAdapter } from "./ResourceTreeAdapter";
@ -225,12 +229,12 @@ describe("Resource tree for schema", () => {
const resourceTree = new ResourceTreeAdapter(mockContainer); const resourceTree = new ResourceTreeAdapter(mockContainer);
it("should render", () => { it("should render", () => {
const rootNode: TreeNode = resourceTree.buildSchemaNode(createMockCollection()); const rootNode: LegacyTreeNode = resourceTree.buildSchemaNode(createMockCollection());
const props: TreeComponentProps = { const props: LegacyTreeComponentProps = {
rootNode, rootNode,
className: "dataResourceTree", className: "dataResourceTree",
}; };
const wrapper = shallow(<TreeComponent {...props} />); const wrapper = shallow(<LegacyTreeComponent {...props} />);
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
}); });

View File

@ -1,3 +1,5 @@
import { TreeNodeMenuItem } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import { shouldShowScriptNodes } from "Explorer/Tree/treeNodeUtil";
import { getItemName } from "Utils/APITypeUtils"; import { getItemName } from "Utils/APITypeUtils";
import * as ko from "knockout"; import * as ko from "knockout";
import * as React from "react"; import * as React from "react";
@ -23,7 +25,7 @@ import * as GitHubUtils from "../../Utils/GitHubUtils";
import { useTabs } from "../../hooks/useTabs"; import { useTabs } from "../../hooks/useTabs";
import * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFactory"; import * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFactory";
import { useDialog } from "../Controls/Dialog"; import { useDialog } from "../Controls/Dialog";
import { TreeComponent, TreeNode, TreeNodeMenuItem } from "../Controls/TreeComponent/TreeComponent"; import { LegacyTreeComponent, LegacyTreeNode } from "../Controls/TreeComponent/LegacyTreeComponent";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter"; import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import { mostRecentActivity } from "../MostRecentActivity/MostRecentActivity"; import { mostRecentActivity } from "../MostRecentActivity/MostRecentActivity";
@ -33,7 +35,6 @@ import { useNotebook } from "../Notebook/useNotebook";
import TabsBase from "../Tabs/TabsBase"; import TabsBase from "../Tabs/TabsBase";
import { useDatabases } from "../useDatabases"; import { useDatabases } from "../useDatabases";
import { useSelectedNode } from "../useSelectedNode"; import { useSelectedNode } from "../useSelectedNode";
import { Platform, configContext } from "./../../ConfigContext";
import StoredProcedure from "./StoredProcedure"; import StoredProcedure from "./StoredProcedure";
import Trigger from "./Trigger"; import Trigger from "./Trigger";
import UserDefinedFunction from "./UserDefinedFunction"; import UserDefinedFunction from "./UserDefinedFunction";
@ -95,7 +96,7 @@ export class ResourceTreeAdapter implements ReactAdapter {
public renderComponent(): JSX.Element { public renderComponent(): JSX.Element {
const dataRootNode = this.buildDataTree(); const dataRootNode = this.buildDataTree();
return <TreeComponent className="dataResourceTree" rootNode={dataRootNode} />; return <LegacyTreeComponent className="dataResourceTree" rootNode={dataRootNode} />;
} }
public async initialize(): Promise<void[]> { public async initialize(): Promise<void[]> {
@ -157,9 +158,11 @@ export class ResourceTreeAdapter implements ReactAdapter {
} }
} }
private buildDataTree(): TreeNode { private buildDataTree(): LegacyTreeNode {
const databaseTreeNodes: TreeNode[] = useDatabases.getState().databases.map((database: ViewModels.Database) => { const databaseTreeNodes: LegacyTreeNode[] = useDatabases
const databaseNode: TreeNode = { .getState()
.databases.map((database: ViewModels.Database) => {
const databaseNode: LegacyTreeNode = {
label: database.id(), label: database.id(),
iconSrc: CosmosDBIcon, iconSrc: CosmosDBIcon,
isExpanded: false, isExpanded: false,
@ -219,18 +222,8 @@ export class ResourceTreeAdapter implements ReactAdapter {
}; };
} }
/** private buildCollectionNode(database: ViewModels.Database, collection: ViewModels.Collection): LegacyTreeNode {
* This is a rewrite of Collection.ts : showScriptsMenu, showStoredProcedures, showTriggers, showUserDefinedFunctions const children: LegacyTreeNode[] = [];
* @param container
*/
private static showScriptNodes(container: Explorer): boolean {
return (
configContext.platform !== Platform.Fabric && (userContext.apiType === "SQL" || userContext.apiType === "Gremlin")
);
}
private buildCollectionNode(database: ViewModels.Database, collection: ViewModels.Collection): TreeNode {
const children: TreeNode[] = [];
children.push({ children.push({
label: getItemName(), label: getItemName(),
onClick: () => { onClick: () => {
@ -274,12 +267,12 @@ export class ResourceTreeAdapter implements ReactAdapter {
}); });
} }
const schemaNode: TreeNode = this.buildSchemaNode(collection); const schemaNode: LegacyTreeNode = this.buildSchemaNode(collection);
if (schemaNode) { if (schemaNode) {
children.push(schemaNode); children.push(schemaNode);
} }
if (ResourceTreeAdapter.showScriptNodes(this.container)) { if (shouldShowScriptNodes()) {
children.push(this.buildStoredProcedureNode(collection)); children.push(this.buildStoredProcedureNode(collection));
children.push(this.buildUserDefinedFunctionsNode(collection)); children.push(this.buildUserDefinedFunctionsNode(collection));
children.push(this.buildTriggerNode(collection)); children.push(this.buildTriggerNode(collection));
@ -321,7 +314,7 @@ export class ResourceTreeAdapter implements ReactAdapter {
); );
}, },
onExpanded: () => { onExpanded: () => {
if (ResourceTreeAdapter.showScriptNodes(this.container)) { if (shouldShowScriptNodes()) {
collection.loadStoredProcedures(); collection.loadStoredProcedures();
collection.loadUserDefinedFunctions(); collection.loadUserDefinedFunctions();
collection.loadTriggers(); collection.loadTriggers();
@ -332,7 +325,7 @@ export class ResourceTreeAdapter implements ReactAdapter {
}; };
} }
private buildStoredProcedureNode(collection: ViewModels.Collection): TreeNode { private buildStoredProcedureNode(collection: ViewModels.Collection): LegacyTreeNode {
return { return {
label: "Stored Procedures", label: "Stored Procedures",
children: collection.storedProcedures().map((sp: StoredProcedure) => ({ children: collection.storedProcedures().map((sp: StoredProcedure) => ({
@ -358,7 +351,7 @@ export class ResourceTreeAdapter implements ReactAdapter {
}; };
} }
private buildUserDefinedFunctionsNode(collection: ViewModels.Collection): TreeNode { private buildUserDefinedFunctionsNode(collection: ViewModels.Collection): LegacyTreeNode {
return { return {
label: "User Defined Functions", label: "User Defined Functions",
children: collection.userDefinedFunctions().map((udf: UserDefinedFunction) => ({ children: collection.userDefinedFunctions().map((udf: UserDefinedFunction) => ({
@ -387,7 +380,7 @@ export class ResourceTreeAdapter implements ReactAdapter {
}; };
} }
private buildTriggerNode(collection: ViewModels.Collection): TreeNode { private buildTriggerNode(collection: ViewModels.Collection): LegacyTreeNode {
return { return {
label: "Triggers", label: "Triggers",
children: collection.triggers().map((trigger: Trigger) => ({ children: collection.triggers().map((trigger: Trigger) => ({
@ -411,7 +404,7 @@ export class ResourceTreeAdapter implements ReactAdapter {
}; };
} }
public buildSchemaNode(collection: ViewModels.Collection): TreeNode { public buildSchemaNode(collection: ViewModels.Collection): LegacyTreeNode {
if (collection.analyticalStorageTtl() == undefined) { if (collection.analyticalStorageTtl() == undefined) {
return undefined; return undefined;
} }
@ -430,7 +423,7 @@ export class ResourceTreeAdapter implements ReactAdapter {
}; };
} }
private getSchemaNodes(fields: DataModels.IDataField[]): TreeNode[] { private getSchemaNodes(fields: DataModels.IDataField[]): LegacyTreeNode[] {
const schema: any = {}; const schema: any = {};
//unflatten //unflatten
@ -461,8 +454,8 @@ export class ResourceTreeAdapter implements ReactAdapter {
}); });
}); });
const traverse = (obj: any): TreeNode[] => { const traverse = (obj: any): LegacyTreeNode[] => {
const children: TreeNode[] = []; const children: LegacyTreeNode[] = [];
if (obj !== null && !Array.isArray(obj) && typeof obj === "object") { if (obj !== null && !Array.isArray(obj) && typeof obj === "object") {
Object.entries(obj).forEach(([key, value]) => { Object.entries(obj).forEach(([key, value]) => {
@ -483,7 +476,7 @@ export class ResourceTreeAdapter implements ReactAdapter {
onFileClick: (item: NotebookContentItem) => void, onFileClick: (item: NotebookContentItem) => void,
createDirectoryContextMenu: boolean, createDirectoryContextMenu: boolean,
createFileContextMenu: boolean, createFileContextMenu: boolean,
): TreeNode[] { ): LegacyTreeNode[] {
if (!item || !item.children) { if (!item || !item.children) {
return []; return [];
} else { } else {
@ -502,7 +495,7 @@ export class ResourceTreeAdapter implements ReactAdapter {
item: NotebookContentItem, item: NotebookContentItem,
onFileClick: (item: NotebookContentItem) => void, onFileClick: (item: NotebookContentItem) => void,
createFileContextMenu: boolean, createFileContextMenu: boolean,
): TreeNode { ): LegacyTreeNode {
return { return {
label: item.name, label: item.name,
iconSrc: NotebookUtil.isNotebookFile(item.path) ? NotebookIcon : FileIcon, iconSrc: NotebookUtil.isNotebookFile(item.path) ? NotebookIcon : FileIcon,
@ -650,7 +643,7 @@ export class ResourceTreeAdapter implements ReactAdapter {
onFileClick: (item: NotebookContentItem) => void, onFileClick: (item: NotebookContentItem) => void,
createDirectoryContextMenu: boolean, createDirectoryContextMenu: boolean,
createFileContextMenu: boolean, createFileContextMenu: boolean,
): TreeNode { ): LegacyTreeNode {
return { return {
label: item.name, label: item.name,
iconSrc: undefined, iconSrc: undefined,

View File

@ -7,15 +7,15 @@ import CosmosDBIcon from "../../../images/Azure-Cosmos-DB.svg";
import CollectionIcon from "../../../images/tree-collection.svg"; import CollectionIcon from "../../../images/tree-collection.svg";
import * as ViewModels from "../../Contracts/ViewModels"; import * as ViewModels from "../../Contracts/ViewModels";
import * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFactory"; import * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFactory";
import { TreeComponent, TreeNode } from "../Controls/TreeComponent/TreeComponent"; import { LegacyTreeComponent, LegacyTreeNode } from "../Controls/TreeComponent/LegacyTreeComponent";
export const SampleDataTree = ({ export const SampleDataTree = ({
sampleDataResourceTokenCollection, sampleDataResourceTokenCollection,
}: { }: {
sampleDataResourceTokenCollection: ViewModels.CollectionBase; sampleDataResourceTokenCollection: ViewModels.CollectionBase;
}): JSX.Element => { }): JSX.Element => {
const buildSampleDataTree = (): TreeNode => { const buildSampleDataTree = (): LegacyTreeNode => {
const updatedSampleTree: TreeNode = { const updatedSampleTree: LegacyTreeNode = {
label: sampleDataResourceTokenCollection.databaseId, label: sampleDataResourceTokenCollection.databaseId,
isExpanded: false, isExpanded: false,
iconSrc: CosmosDBIcon, iconSrc: CosmosDBIcon,
@ -70,7 +70,7 @@ export const SampleDataTree = ({
}; };
return ( return (
<TreeComponent <LegacyTreeComponent
className="dataResourceTree" className="dataResourceTree"
rootNode={sampleDataResourceTokenCollection ? buildSampleDataTree() : { label: "Sample data not initialized." }} rootNode={sampleDataResourceTokenCollection ? buildSampleDataTree() : { label: "Sample data not initialized." }}
/> />

View File

@ -5,7 +5,7 @@ exports[`Resource tree for schema should render 1`] = `
className="treeComponent dataResourceTree" className="treeComponent dataResourceTree"
role="tree" role="tree"
> >
<TreeNodeComponent <LegacyTreeNodeComponent
generation={0} generation={0}
node={ node={
Object { Object {

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,602 @@
import { CapabilityNames } from "Common/Constants";
import { Platform, updateConfigContext } from "ConfigContext";
import { TreeNode } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import Explorer from "Explorer/Explorer";
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { useNotebook } from "Explorer/Notebook/useNotebook";
import { DeleteDatabaseConfirmationPanel } from "Explorer/Panes/DeleteDatabaseConfirmationPanel";
import TabsBase from "Explorer/Tabs/TabsBase";
import StoredProcedure from "Explorer/Tree/StoredProcedure";
import Trigger from "Explorer/Tree/Trigger";
import UserDefinedFunction from "Explorer/Tree/UserDefinedFunction";
import {
createDatabaseTreeNodes,
createResourceTokenTreeNodes,
createSampleDataTreeNodes,
} from "Explorer/Tree/treeNodeUtil";
import { useDatabases } from "Explorer/useDatabases";
import { useSelectedNode } from "Explorer/useSelectedNode";
import { updateUserContext } from "UserContext";
import PromiseSource from "Utils/PromiseSource";
import { useSidePanel } from "hooks/useSidePanel";
import { useTabs } from "hooks/useTabs";
import ko from "knockout";
import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels";
jest.mock("Explorer/Explorer", () => {
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
class MockExplorer {
onNewCollectionClicked = jest.fn();
}
return MockExplorer;
});
jest.mock("Explorer/Tree/StoredProcedure", () => {
let counter = 0;
class MockStoredProcedure {
id: () => string;
open = jest.fn();
delete = jest.fn();
constructor() {
this.id = () => `mockSproc${counter}`;
counter++;
}
}
return MockStoredProcedure;
});
jest.mock("Explorer/Tree/UserDefinedFunction", () => {
let counter = 0;
class MockUserDefinedFunction {
id: () => string;
open = jest.fn();
delete = jest.fn();
constructor() {
this.id = () => `mockUdf${counter}`;
counter++;
}
}
return MockUserDefinedFunction;
});
jest.mock("Explorer/Tree/Trigger", () => {
let counter = 0;
class MockTrigger {
id: () => string;
open = jest.fn();
delete = jest.fn();
constructor() {
this.id = () => `mockTrigger${counter}`;
counter++;
}
}
return MockTrigger;
});
jest.mock("Common/DatabaseAccountUtility", () => {
return {
isPublicInternetAccessAllowed: () => true,
};
});
// Defining this value outside the mock, AND prefixing the name with 'mock' is required by Jest's mocking logic.
let nextTabIndex = 1;
class MockTab extends TabsBase {
constructor(tabOptions: Pick<ViewModels.TabOptions, "tabKind"> & Partial<ViewModels.TabOptions>) {
super({
title: `Mock Tab ${nextTabIndex}`,
tabPath: `mockTabs/tab${nextTabIndex}`,
...tabOptions,
} as ViewModels.TabOptions);
nextTabIndex++;
}
onActivate = jest.fn();
}
/** A basic test collection that can be expanded on in tests. */
const baseCollection = {
container: new Explorer(),
databaseId: "testDatabase",
id: ko.observable<string>("testCollection"),
defaultTtl: ko.observable<number>(5),
analyticalStorageTtl: ko.observable<number>(undefined),
selectedSubnodeKind: ko.observable<ViewModels.CollectionTabKind>(),
indexingPolicy: ko.observable<DataModels.IndexingPolicy>({
automatic: true,
indexingMode: "consistent",
includedPaths: [],
excludedPaths: [],
}),
uniqueKeyPolicy: {} as DataModels.UniqueKeyPolicy,
usageSizeInKB: ko.observable(100),
offer: ko.observable<DataModels.Offer>({
autoscaleMaxThroughput: undefined,
manualThroughput: 10000,
minimumThroughput: 6000,
id: "offer",
offerReplacePending: false,
}),
conflictResolutionPolicy: ko.observable<DataModels.ConflictResolutionPolicy>(
{} as DataModels.ConflictResolutionPolicy,
),
changeFeedPolicy: ko.observable<DataModels.ChangeFeedPolicy>({} as DataModels.ChangeFeedPolicy),
geospatialConfig: ko.observable<DataModels.GeospatialConfig>({} as DataModels.GeospatialConfig),
getDatabase: () => {},
partitionKey: {
paths: [],
kind: "hash",
version: 2,
},
storedProcedures: ko.observableArray([]),
userDefinedFunctions: ko.observableArray([]),
triggers: ko.observableArray([]),
partitionKeyProperties: ["testPartitionKey"],
readSettings: () => {},
isCollectionExpanded: ko.observable(true),
onSettingsClick: jest.fn(),
onDocumentDBDocumentsClick: jest.fn(),
onNewQueryClick: jest.fn(),
onConflictsClick: jest.fn(),
onSchemaAnalyzerClick: jest.fn(),
} as unknown as ViewModels.Collection;
/** A basic test database that can be expanded on in tests */
const baseDatabase = {
container: new Explorer(),
id: ko.observable<string>("testDatabase"),
collections: ko.observableArray<ViewModels.Collection>([]),
isDatabaseShared: ko.pureComputed(() => false),
isDatabaseExpanded: ko.observable(true),
selectedSubnodeKind: ko.observable<ViewModels.CollectionTabKind>(),
expandDatabase: jest.fn().mockResolvedValue({}),
collapseDatabase: jest.fn(),
onSettingsClick: jest.fn(),
} as unknown as ViewModels.Database;
/** Configures app state so that useSelectedNode.getState().isDataNodeSelected() returns true for the provided arguments. */
function selectDataNode(
node: ViewModels.Database | ViewModels.CollectionBase,
subnodeKind?: ViewModels.CollectionTabKind,
) {
useSelectedNode.getState().setSelectedNode(node);
if (subnodeKind !== undefined) {
node.selectedSubnodeKind(subnodeKind);
useTabs.getState().activateNewTab(new MockTab({ tabKind: subnodeKind, node }));
}
}
describe("createSampleDataTreeNodes", () => {
let sampleDataResourceTokenCollection: ViewModels.Collection;
let nodes: TreeNode[];
beforeEach(() => {
jest.resetAllMocks();
const collection = { ...baseCollection };
useDatabases.setState({ sampleDataResourceTokenCollection: collection });
useSelectedNode.setState({ selectedNode: undefined });
sampleDataResourceTokenCollection = collection;
nodes = createSampleDataTreeNodes(sampleDataResourceTokenCollection);
});
it("creates the expected tree nodes", () => {
expect(nodes).toMatchSnapshot();
});
});
describe("createResourceTokenTreeNodes", () => {
let resourceTokenCollection: ViewModels.Collection;
let nodes: TreeNode[];
beforeEach(() => {
jest.resetAllMocks();
const collection = { ...baseCollection };
useDatabases.setState({ resourceTokenCollection: collection });
useSelectedNode.setState({ selectedNode: undefined });
resourceTokenCollection = collection;
nodes = createResourceTokenTreeNodes(resourceTokenCollection);
});
it("returns an empty node when collection is undefined or null", () => {
const snapshot = `
Array [
Object {
"children": Array [],
"isExpanded": true,
"label": "",
},
]
`;
expect(createResourceTokenTreeNodes(undefined)).toMatchInlineSnapshot(snapshot);
expect(createResourceTokenTreeNodes(null)).toMatchInlineSnapshot(snapshot);
});
it("creates the expected tree nodes", () => {
expect(nodes).toMatchSnapshot();
});
});
describe("createDatabaseTreeNodes", () => {
let explorer: Explorer;
let standardDb: ViewModels.Database;
let sharedDb: ViewModels.Database;
let giganticDb: ViewModels.Database;
let standardCollection: ViewModels.Collection;
let sampleItemsCollection: ViewModels.Collection;
let schemaCollection: ViewModels.Collection;
let conflictsCollection: ViewModels.Collection;
let sproc: StoredProcedure;
let udf: UserDefinedFunction;
let trigger: Trigger;
let refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void;
beforeEach(() => {
jest.resetAllMocks();
explorer = new Explorer();
standardDb = {
...baseDatabase,
id: ko.observable("standardDb"),
container: explorer,
} as ViewModels.Database;
sharedDb = {
...baseDatabase,
id: ko.observable("sharedDatabase"),
container: explorer,
isDatabaseShared: ko.pureComputed(() => true),
} as ViewModels.Database;
giganticDb = {
...baseDatabase,
id: ko.observable("giganticDatabase"),
container: explorer,
collectionsContinuationToken: "continuationToken",
} as ViewModels.Database;
standardCollection = {
...baseCollection,
id: ko.observable("standardCollection"),
container: explorer,
databaseId: standardDb.id(),
} as ViewModels.Collection;
// These classes are mocked, so the constructor args don't matter
sproc = new StoredProcedure(explorer, standardCollection, {} as never);
standardCollection.storedProcedures = ko.pureComputed(() => [sproc]);
udf = new UserDefinedFunction(explorer, standardCollection, {} as never);
standardCollection.userDefinedFunctions = ko.pureComputed(() => [udf]);
trigger = new Trigger(explorer, standardCollection, {} as never);
standardCollection.triggers = ko.pureComputed(() => [trigger]);
sampleItemsCollection = {
...baseCollection,
id: ko.observable("sampleItemsCollection"),
container: explorer,
databaseId: sharedDb.id(),
isSampleCollection: true,
} as ViewModels.Collection;
schemaCollection = {
...baseCollection,
id: ko.observable("schemaCollection"),
container: explorer,
databaseId: sharedDb.id(),
analyticalStorageTtl: ko.observable<number>(5),
schema: {
fields: [
{
path: "address.street",
dataType: { name: "string" },
hasNulls: false,
},
{
path: "address.line2",
dataType: { name: "string" },
hasNulls: true,
},
{
path: "address.zip",
dataType: { name: "number" },
hasNulls: false,
},
{
path: "orderId",
dataType: { name: "string" },
hasNulls: false,
},
],
} as unknown,
} as ViewModels.Collection;
conflictsCollection = {
...baseCollection,
id: ko.observable("conflictsCollection"),
rawDataModel: {
conflictResolutionPolicy: {
mode: "Custom",
conflictResolutionPath: "path",
conflictResolutionProcedure: "proc",
},
},
} as ViewModels.Collection;
standardDb.collections = ko.observableArray([standardCollection, conflictsCollection]);
sharedDb.collections = ko.observableArray([sampleItemsCollection]);
giganticDb.collections = ko.observableArray([schemaCollection]);
useDatabases.setState({
databases: [standardDb, sharedDb, giganticDb],
updateDatabase: jest.fn(),
});
useSelectedNode.setState({ selectedNode: undefined });
refreshActiveTab = jest.fn();
});
describe("using NoSQL API on Hosted Platform", () => {
let nodes: TreeNode[];
beforeEach(() => {
updateConfigContext({
platform: Platform.Hosted,
});
updateUserContext({
databaseAccount: {
properties: {
capabilities: [],
},
} as never,
});
nodes = createDatabaseTreeNodes(explorer, false, useDatabases.getState().databases, refreshActiveTab);
});
it("creates expected tree", () => {
expect(nodes).toMatchSnapshot();
});
});
it.each<[string, Platform, boolean, Partial<DataModels.DatabaseAccountExtendedProperties>]>([
["the SQL API, on Fabric", Platform.Fabric, false, { capabilities: [], enableMultipleWriteLocations: true }],
["the SQL API, on Portal", Platform.Portal, false, { capabilities: [], enableMultipleWriteLocations: true }],
[
"the Cassandra API, serverless, on Hosted",
Platform.Hosted,
false,
{
capabilities: [
{ name: CapabilityNames.EnableCassandra, description: "" },
{ name: CapabilityNames.EnableServerless, description: "" },
],
},
],
[
"the Mongo API, with Notebooks and Phoenix features, on Emulator",
Platform.Emulator,
true,
{
capabilities: [{ name: CapabilityNames.EnableMongo, description: "" }],
},
],
])("generates the correct tree structure for %s", (_, platform, isNotebookEnabled, dbAccountProperties) => {
useNotebook.setState({ isPhoenixFeatures: isNotebookEnabled });
updateConfigContext({ platform });
updateUserContext({
databaseAccount: {
properties: {
enableMultipleWriteLocations: true,
...dbAccountProperties,
},
} as unknown as DataModels.DatabaseAccount,
});
const nodes = createDatabaseTreeNodes(
explorer,
isNotebookEnabled,
useDatabases.getState().databases,
refreshActiveTab,
);
expect(nodes).toMatchSnapshot();
});
// The above tests focused on the tree structure. The below tests focus on some core behaviors of the nodes.
// They are not exhaustive, because exhaustive tests here require a lot of mocking and can become very brittle.
// The goal is to cover some key behaviors like loading child nodes, opening tabs/side panels, etc.
it("adds new collections to database as they appear", () => {
const nodes = createDatabaseTreeNodes(explorer, false, useDatabases.getState().databases, refreshActiveTab);
const giganticDbNode = nodes.find((node) => node.label === giganticDb.id());
expect(giganticDbNode).toBeDefined();
expect(giganticDbNode.children.map((node) => node.label)).toStrictEqual(["schemaCollection", "load more"]);
giganticDb.collections.push({
...baseCollection,
id: ko.observable("addedCollection"),
});
expect(giganticDbNode.children.map((node) => node.label)).toStrictEqual([
"schemaCollection",
"addedCollection",
"load more",
]);
});
describe("the database node", () => {
let nodes: TreeNode[];
let standardDbNode: TreeNode;
let sharedDbNode: TreeNode;
let giganticDbNode: TreeNode;
beforeEach(() => {
updateConfigContext({ platform: Platform.Hosted });
updateUserContext({
databaseAccount: {
properties: {
capabilities: [],
},
} as unknown as DataModels.DatabaseAccount,
});
nodes = createDatabaseTreeNodes(explorer, false, useDatabases.getState().databases, refreshActiveTab);
standardDbNode = nodes.find((node) => node.label === standardDb.id());
sharedDbNode = nodes.find((node) => node.label === sharedDb.id());
giganticDbNode = nodes.find((node) => node.label === giganticDb.id());
});
it("loads child nodes when expanded", async () => {
// Temporarily clear the child nodes to trigger the loading behavior
standardDbNode.children = [];
const expanding = new PromiseSource();
let expandCalled = false;
standardDb.expandDatabase = () => {
expandCalled = true;
return expanding.promise;
};
standardDbNode.onExpanded();
expect(useSelectedNode.getState().selectedNode).toBe(standardDb);
expect(standardDbNode.isLoading).toStrictEqual(true);
expect(expandCalled).toStrictEqual(true);
await expanding.resolveAndWait();
expect(standardDbNode.isLoading).toStrictEqual(false);
expect(useCommandBar.getState().contextButtons).toStrictEqual([]);
expect(refreshActiveTab).toHaveBeenCalled();
expect(useDatabases.getState().updateDatabase).toHaveBeenCalledWith(standardDb);
});
it("opens a New Container panel when 'New Container' option in context menu is clicked", () => {
const newContainerMenuItem = standardDbNode.contextMenu.find((item) => item.label === "New Container");
newContainerMenuItem.onClick();
expect(explorer.onNewCollectionClicked).toHaveBeenCalled();
});
it("opens a Delete Database panel when 'Delete Database' option in context menu is clicked", () => {
const deleteDatabaseMenuItem = standardDbNode.contextMenu.find((item) => item.label === "Delete Database");
deleteDatabaseMenuItem.onClick();
expect(useSidePanel.getState().headerText).toStrictEqual("Delete Database");
expect(useSidePanel.getState().panelContent.type).toStrictEqual(DeleteDatabaseConfirmationPanel);
});
describe("the Scale subnode", () => {
let scaleNode: TreeNode;
beforeEach(() => {
scaleNode = sharedDbNode.children.find((node) => node.label === "Scale");
});
it("is selected when Scale tab is open", () => {
expect(scaleNode.isSelected()).toStrictEqual(false);
selectDataNode(sharedDb, ViewModels.CollectionTabKind.DatabaseSettingsV2);
expect(scaleNode.isSelected()).toStrictEqual(true);
});
it("opens settings tab when clicked", () => {
expect(sharedDb.onSettingsClick).not.toHaveBeenCalled();
scaleNode.onClick();
expect(sharedDb.onSettingsClick).toHaveBeenCalled();
});
});
describe("the load more node", () => {
it("loads more collections when clicked", async () => {
const loadCollections = new PromiseSource();
let loadCalled = false;
giganticDb.loadCollections = () => {
loadCalled = true;
return loadCollections.promise;
};
const loadMoreNode = giganticDbNode.children.find((node) => node.label === "load more");
loadMoreNode.onClick();
expect(loadCalled).toStrictEqual(true);
await loadCollections.resolveAndWait();
expect(useDatabases.getState().updateDatabase).toHaveBeenCalledWith(giganticDb);
});
});
describe("the Collection subnode", () => {
let standardCollectionNode: TreeNode;
beforeEach(() => {
standardCollectionNode = standardDbNode.children.find((node) => node.label === standardCollection.id());
});
it.each([
[
"for SQL API",
() => updateUserContext({ databaseAccount: { properties: {} } as unknown as DataModels.DatabaseAccount }),
],
[
"for Gremlin API",
() =>
updateUserContext({
databaseAccount: {
properties: { capabilities: [{ name: CapabilityNames.EnableGremlin, description: "" }] },
} as unknown as DataModels.DatabaseAccount,
}),
],
])("loads sprocs/udfs/triggers when expanded, %s", async () => {
standardCollection.loadStoredProcedures = jest.fn(() => Promise.resolve());
standardCollection.loadUserDefinedFunctions = jest.fn(() => Promise.resolve());
standardCollection.loadTriggers = jest.fn(() => Promise.resolve());
await standardCollectionNode.onExpanded();
expect(standardCollection.loadStoredProcedures).toHaveBeenCalled();
expect(standardCollection.loadUserDefinedFunctions).toHaveBeenCalled();
expect(standardCollection.loadTriggers).toHaveBeenCalled();
});
it.each([
["in Fabric", () => updateConfigContext({ platform: Platform.Fabric })],
[
"for Cassandra API",
() =>
updateUserContext({
databaseAccount: {
properties: { capabilities: [{ name: CapabilityNames.EnableCassandra, description: "" }] },
} as unknown as DataModels.DatabaseAccount,
}),
],
[
"for Mongo API",
() =>
updateUserContext({
databaseAccount: {
properties: { capabilities: [{ name: CapabilityNames.EnableMongo, description: "" }] },
} as unknown as DataModels.DatabaseAccount,
}),
],
[
"for Tables API",
() =>
updateUserContext({
databaseAccount: {
properties: { capabilities: [{ name: CapabilityNames.EnableTable, description: "" }] },
} as unknown as DataModels.DatabaseAccount,
}),
],
])("does not load sprocs/udfs/triggers when expanded, %s", async (_, setup) => {
setup();
// Rebuild the nodes after changing the user/config context.
nodes = createDatabaseTreeNodes(explorer, false, useDatabases.getState().databases, refreshActiveTab);
standardDbNode = nodes.find((node) => node.label === standardDb.id());
standardCollectionNode = standardDbNode.children.find((node) => node.label === standardCollection.id());
standardCollection.loadStoredProcedures = jest.fn(() => Promise.resolve());
standardCollection.loadUserDefinedFunctions = jest.fn(() => Promise.resolve());
standardCollection.loadTriggers = jest.fn(() => Promise.resolve());
await standardCollectionNode.onExpanded();
expect(standardCollection.loadStoredProcedures).not.toHaveBeenCalled();
expect(standardCollection.loadUserDefinedFunctions).not.toHaveBeenCalled();
expect(standardCollection.loadTriggers).not.toHaveBeenCalled();
});
});
});
});

View File

@ -1,4 +1,4 @@
import { TreeNode2 } from "Explorer/Controls/TreeComponent2/TreeNode2Component"; import { TreeNode } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import TabsBase from "Explorer/Tabs/TabsBase"; import TabsBase from "Explorer/Tabs/TabsBase";
import StoredProcedure from "Explorer/Tree/StoredProcedure"; import StoredProcedure from "Explorer/Tree/StoredProcedure";
import Trigger from "Explorer/Tree/Trigger"; import Trigger from "Explorer/Tree/Trigger";
@ -6,8 +6,11 @@ import UserDefinedFunction from "Explorer/Tree/UserDefinedFunction";
import { useDatabases } from "Explorer/useDatabases"; import { useDatabases } from "Explorer/useDatabases";
import { getItemName } from "Utils/APITypeUtils"; import { getItemName } from "Utils/APITypeUtils";
import { isServerlessAccount } from "Utils/CapabilityUtils"; import { isServerlessAccount } from "Utils/CapabilityUtils";
import { useTabs } from "hooks/useTabs";
import CosmosDBIcon from "../../../images/Azure-Cosmos-DB.svg";
import CollectionIcon from "../../../images/tree-collection.svg"; import CollectionIcon from "../../../images/tree-collection.svg";
import { isPublicInternetAccessAllowed } from "../../Common/DatabaseAccountUtility"; import { isPublicInternetAccessAllowed } from "../../Common/DatabaseAccountUtility";
import { Platform, configContext } from "../../ConfigContext";
import * as DataModels from "../../Contracts/DataModels"; import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels"; import * as ViewModels from "../../Contracts/ViewModels";
import { userContext } from "../../UserContext"; import { userContext } from "../../UserContext";
@ -17,7 +20,192 @@ import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import { mostRecentActivity } from "../MostRecentActivity/MostRecentActivity"; import { mostRecentActivity } from "../MostRecentActivity/MostRecentActivity";
import { useNotebook } from "../Notebook/useNotebook"; import { useNotebook } from "../Notebook/useNotebook";
import { useSelectedNode } from "../useSelectedNode"; import { useSelectedNode } from "../useSelectedNode";
import { Platform, configContext } from "./../../ConfigContext";
export const shouldShowScriptNodes = (): boolean => {
return (
configContext.platform !== Platform.Fabric && (userContext.apiType === "SQL" || userContext.apiType === "Gremlin")
);
};
export const createSampleDataTreeNodes = (sampleDataResourceTokenCollection: ViewModels.CollectionBase): TreeNode[] => {
const updatedSampleTree: TreeNode = {
label: sampleDataResourceTokenCollection.databaseId,
isExpanded: false,
iconSrc: CosmosDBIcon,
className: "databaseHeader",
children: [
{
label: sampleDataResourceTokenCollection.id(),
iconSrc: CollectionIcon,
isExpanded: false,
className: "collectionHeader",
contextMenu: ResourceTreeContextMenuButtonFactory.createSampleCollectionContextMenuButton(),
onClick: () => {
useSelectedNode.getState().setSelectedNode(sampleDataResourceTokenCollection);
useCommandBar.getState().setContextButtons([]);
useTabs
.getState()
.refreshActiveTab(
(tab: TabsBase) =>
tab.collection?.id() === sampleDataResourceTokenCollection.id() &&
tab.collection.databaseId === sampleDataResourceTokenCollection.databaseId,
);
},
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(sampleDataResourceTokenCollection.databaseId, sampleDataResourceTokenCollection.id()),
onContextMenuOpen: () => useSelectedNode.getState().setSelectedNode(sampleDataResourceTokenCollection),
children: [
{
label: "Items",
onClick: () => sampleDataResourceTokenCollection.onDocumentDBDocumentsClick(),
contextMenu: ResourceTreeContextMenuButtonFactory.createSampleCollectionContextMenuButton(),
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(
sampleDataResourceTokenCollection.databaseId,
sampleDataResourceTokenCollection.id(),
[ViewModels.CollectionTabKind.Documents],
),
},
],
},
],
};
return [updatedSampleTree];
};
export const createResourceTokenTreeNodes = (collection: ViewModels.CollectionBase): TreeNode[] => {
if (!collection) {
return [
{
label: "",
isExpanded: true,
children: [],
},
];
}
const children: TreeNode[] = [];
children.push({
label: "Items",
onClick: () => {
collection.onDocumentDBDocumentsClick();
// push to most recent
mostRecentActivity.collectionWasOpened(userContext.databaseAccount?.id, collection);
},
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [ViewModels.CollectionTabKind.Documents]),
});
const collectionNode: TreeNode = {
label: collection.id(),
iconSrc: CollectionIcon,
isExpanded: true,
children,
className: "collectionHeader",
onClick: () => {
// Rewritten version of expandCollapseCollection
useSelectedNode.getState().setSelectedNode(collection);
useCommandBar.getState().setContextButtons([]);
useTabs
.getState()
.refreshActiveTab(
(tab) => tab.collection?.id() === collection.id() && tab.collection.databaseId === collection.databaseId,
);
},
isSelected: () => useSelectedNode.getState().isDataNodeSelected(collection.databaseId, collection.id()),
};
return [collectionNode];
};
export const createDatabaseTreeNodes = (
container: Explorer,
isNotebookEnabled: boolean,
databases: ViewModels.Database[],
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void,
): TreeNode[] => {
const databaseTreeNodes: TreeNode[] = databases.map((database: ViewModels.Database) => {
const buildDatabaseChildNodes = (databaseNode: TreeNode) => {
databaseNode.children = [];
if (database.isDatabaseShared() && configContext.platform !== Platform.Fabric) {
databaseNode.children.push({
id: database.isSampleDB ? "sampleScaleSettings" : "",
label: "Scale",
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(database.id(), undefined, [ViewModels.CollectionTabKind.DatabaseSettingsV2]),
onClick: database.onSettingsClick.bind(database),
});
}
// Find collections
database
.collections()
.forEach((collection: ViewModels.Collection) =>
databaseNode.children.push(
buildCollectionNode(database, collection, isNotebookEnabled, container, refreshActiveTab),
),
);
if (database.collectionsContinuationToken) {
const loadMoreNode: TreeNode = {
label: "load more",
className: "loadMoreHeader",
onClick: async () => {
await database.loadCollections();
useDatabases.getState().updateDatabase(database);
},
};
databaseNode.children.push(loadMoreNode);
}
};
const databaseNode: TreeNode = {
label: database.id(),
iconSrc: CosmosDBIcon,
className: "databaseHeader",
children: [],
isSelected: () => useSelectedNode.getState().isDataNodeSelected(database.id()),
contextMenu: ResourceTreeContextMenuButtonFactory.createDatabaseContextMenu(container, database.id()),
onExpanded: async () => {
useSelectedNode.getState().setSelectedNode(database);
if (!databaseNode.children || databaseNode.children?.length === 0) {
databaseNode.isLoading = true;
}
await database.expandDatabase();
databaseNode.isLoading = false;
useCommandBar.getState().setContextButtons([]);
refreshActiveTab((tab: TabsBase) => tab.collection?.databaseId === database.id());
useDatabases.getState().updateDatabase(database);
},
onContextMenuOpen: () => useSelectedNode.getState().setSelectedNode(database),
isExpanded: database.isDatabaseExpanded(),
onCollapsed: () => {
database.collapseDatabase();
// useCommandBar.getState().setContextButtons([]);
useDatabases.getState().updateDatabase(database);
},
};
buildDatabaseChildNodes(databaseNode);
database.collections.subscribe(() => {
buildDatabaseChildNodes(databaseNode);
});
return databaseNode;
});
return databaseTreeNodes;
};
export const buildCollectionNode = ( export const buildCollectionNode = (
database: ViewModels.Database, database: ViewModels.Database,
@ -25,15 +213,14 @@ export const buildCollectionNode = (
isNotebookEnabled: boolean, isNotebookEnabled: boolean,
container: Explorer, container: Explorer,
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void, refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void,
): TreeNode2 => { ): TreeNode => {
let children: TreeNode2[]; let children: TreeNode[];
// Flat Tree for Fabric // Flat Tree for Fabric
if (configContext.platform !== Platform.Fabric) { if (configContext.platform !== Platform.Fabric) {
children = buildCollectionNodeChildren(database, collection, isNotebookEnabled, container, refreshActiveTab); children = buildCollectionNodeChildren(database, collection, isNotebookEnabled, container, refreshActiveTab);
} }
return { const collectionNode: TreeNode = {
label: collection.id(), label: collection.id(),
iconSrc: CollectionIcon, iconSrc: CollectionIcon,
children: children, children: children,
@ -54,6 +241,15 @@ export const buildCollectionNode = (
tab.collection?.id() === collection.id() && tab.collection.databaseId === collection.databaseId, tab.collection?.id() === collection.id() && tab.collection.databaseId === collection.databaseId,
); );
useDatabases.getState().updateDatabase(database); useDatabases.getState().updateDatabase(database);
// If we're showing script nodes, start loading them.
if (shouldShowScriptNodes()) {
await collection.loadStoredProcedures();
await collection.loadUserDefinedFunctions();
await collection.loadTriggers();
}
useDatabases.getState().updateDatabase(database);
}, },
isSelected: () => useSelectedNode.getState().isDataNodeSelected(collection.databaseId, collection.id()), isSelected: () => useSelectedNode.getState().isDataNodeSelected(collection.databaseId, collection.id()),
onContextMenuOpen: () => useSelectedNode.getState().setSelectedNode(collection), onContextMenuOpen: () => useSelectedNode.getState().setSelectedNode(collection),
@ -64,6 +260,8 @@ export const buildCollectionNode = (
}, },
isExpanded: collection.isCollectionExpanded(), isExpanded: collection.isCollectionExpanded(),
}; };
return collectionNode;
}; };
const buildCollectionNodeChildren = ( const buildCollectionNodeChildren = (
@ -72,9 +270,8 @@ const buildCollectionNodeChildren = (
isNotebookEnabled: boolean, isNotebookEnabled: boolean,
container: Explorer, container: Explorer,
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void, refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void,
): TreeNode2[] => { ): TreeNode[] => {
const showScriptNodes = userContext.apiType === "SQL" || userContext.apiType === "Gremlin"; const children: TreeNode[] = [];
const children: TreeNode2[] = [];
children.push({ children.push({
label: getItemName(), label: getItemName(),
id: collection.isSampleCollection ? "sampleItems" : "", id: collection.isSampleCollection ? "sampleItems" : "",
@ -128,14 +325,14 @@ const buildCollectionNodeChildren = (
}); });
} }
const schemaNode: TreeNode2 = buildSchemaNode(collection, container, refreshActiveTab); const schemaNode: TreeNode = buildSchemaNode(collection, container, refreshActiveTab);
if (schemaNode) { if (schemaNode) {
children.push(schemaNode); children.push(schemaNode);
} }
const onUpdateDatabase = () => useDatabases.getState().updateDatabase(database); const onUpdateDatabase = () => useDatabases.getState().updateDatabase(database);
if (showScriptNodes) { if (shouldShowScriptNodes()) {
children.push(buildStoredProcedureNode(collection, container, refreshActiveTab, onUpdateDatabase)); children.push(buildStoredProcedureNode(collection, container, refreshActiveTab, onUpdateDatabase));
children.push(buildUserDefinedFunctionsNode(collection, container, refreshActiveTab, onUpdateDatabase)); children.push(buildUserDefinedFunctionsNode(collection, container, refreshActiveTab, onUpdateDatabase));
children.push(buildTriggerNode(collection, container, refreshActiveTab, onUpdateDatabase)); children.push(buildTriggerNode(collection, container, refreshActiveTab, onUpdateDatabase));
@ -166,7 +363,7 @@ const buildStoredProcedureNode = (
container: Explorer, container: Explorer,
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void, refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void,
onUpdateDatabase: () => void, onUpdateDatabase: () => void,
): TreeNode2 => { ): TreeNode => {
return { return {
label: "Stored Procedures", label: "Stored Procedures",
children: collection.storedProcedures().map((sp: StoredProcedure) => ({ children: collection.storedProcedures().map((sp: StoredProcedure) => ({
@ -195,7 +392,7 @@ const buildUserDefinedFunctionsNode = (
container: Explorer, container: Explorer,
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void, refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void,
onUpdateDatabase: () => void, onUpdateDatabase: () => void,
): TreeNode2 => { ): TreeNode => {
return { return {
label: "User Defined Functions", label: "User Defined Functions",
children: collection.userDefinedFunctions().map((udf: UserDefinedFunction) => ({ children: collection.userDefinedFunctions().map((udf: UserDefinedFunction) => ({
@ -226,7 +423,7 @@ const buildTriggerNode = (
container: Explorer, container: Explorer,
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void, refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void,
onUpdateDatabase: () => void, onUpdateDatabase: () => void,
): TreeNode2 => { ): TreeNode => {
return { return {
label: "Triggers", label: "Triggers",
children: collection.triggers().map((trigger: Trigger) => ({ children: collection.triggers().map((trigger: Trigger) => ({
@ -254,7 +451,7 @@ const buildSchemaNode = (
collection: ViewModels.Collection, collection: ViewModels.Collection,
container: Explorer, container: Explorer,
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void, refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void,
): TreeNode2 => { ): TreeNode => {
if (collection.analyticalStorageTtl() === undefined) { if (collection.analyticalStorageTtl() === undefined) {
return undefined; return undefined;
} }
@ -273,7 +470,7 @@ const buildSchemaNode = (
}; };
}; };
const getSchemaNodes = (fields: DataModels.IDataField[]): TreeNode2[] => { const getSchemaNodes = (fields: DataModels.IDataField[]): TreeNode[] => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const schema: any = {}; const schema: any = {};
@ -307,8 +504,8 @@ const getSchemaNodes = (fields: DataModels.IDataField[]): TreeNode2[] => {
}); });
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const traverse = (obj: any): TreeNode2[] => { const traverse = (obj: any): TreeNode[] => {
const children: TreeNode2[] = []; const children: TreeNode[] = [];
if (obj !== undefined && !Array.isArray(obj) && typeof obj === "object") { if (obj !== undefined && !Array.isArray(obj) && typeof obj === "object") {
Object.entries(obj).forEach(([key, value]) => { Object.entries(obj).forEach(([key, value]) => {

View File

@ -1,107 +0,0 @@
import {
FluentProvider,
Tree,
TreeItemValue,
TreeOpenChangeData,
TreeOpenChangeEvent,
} from "@fluentui/react-components";
import { configContext } from "ConfigContext";
import { TreeNode2, TreeNode2Component } from "Explorer/Controls/TreeComponent2/TreeNode2Component";
import { getPlatformTheme } from "Explorer/Theme/ThemeUtil";
import { useDatabaseTreeNodes } from "Explorer/Tree2/useDatabaseTreeNodes";
import * as React from "react";
import shallow from "zustand/shallow";
import Explorer from "../Explorer";
import { useNotebook } from "../Notebook/useNotebook";
export const MyNotebooksTitle = "My Notebooks";
export const GitHubReposTitle = "GitHub repos";
interface ResourceTreeProps {
container: Explorer;
}
export const DATA_TREE_LABEL = "DATA";
/**
* Top-level tree that has no label, but contains all subtrees
*/
export const ResourceTree2: React.FC<ResourceTreeProps> = ({ container }: ResourceTreeProps): JSX.Element => {
const {
isNotebookEnabled,
// myNotebooksContentRoot,
// galleryContentRoot,
// gitHubNotebooksContentRoot,
// updateNotebookItem,
} = useNotebook(
(state) => ({
isNotebookEnabled: state.isNotebookEnabled,
myNotebooksContentRoot: state.myNotebooksContentRoot,
galleryContentRoot: state.galleryContentRoot,
gitHubNotebooksContentRoot: state.gitHubNotebooksContentRoot,
updateNotebookItem: state.updateNotebookItem,
}),
shallow,
);
// const { activeTab } = useTabs();
const databaseTreeNodes = useDatabaseTreeNodes(container, isNotebookEnabled);
const [openItems, setOpenItems] = React.useState<Iterable<TreeItemValue>>([DATA_TREE_LABEL]);
const dataNodeTree: TreeNode2 = {
id: "data",
label: DATA_TREE_LABEL,
className: "accordionItemHeader",
children: databaseTreeNodes,
isScrollable: true,
};
React.useEffect(() => {
// Compute open items based on node.isExpanded
const updateOpenItems = (node: TreeNode2, parentNodeId: string): void => {
// This will look for ANY expanded node, event if its parent node isn't expanded
// and add it to the openItems list
const globalId = parentNodeId === undefined ? node.label : `${parentNodeId}/${node.label}`;
if (node.isExpanded) {
let found = false;
for (const id of openItems) {
if (id === globalId) {
found = true;
break;
}
}
if (!found) {
setOpenItems((prevOpenItems) => [...prevOpenItems, globalId]);
}
}
if (node.children) {
for (const child of node.children) {
updateOpenItems(child, globalId);
}
}
};
updateOpenItems(dataNodeTree, undefined);
}, [databaseTreeNodes]);
const handleOpenChange = (event: TreeOpenChangeEvent, data: TreeOpenChangeData) => setOpenItems(data.openItems);
return (
<>
<FluentProvider theme={getPlatformTheme(configContext.platform)} style={{ overflow: "hidden" }}>
<Tree
aria-label="CosmosDB resources"
openItems={openItems}
onOpenChange={handleOpenChange}
size="medium"
style={{ height: "100%", width: "290px" }}
>
{[dataNodeTree].map((node) => (
<TreeNode2Component key={node.label} className="dataResourceTree" node={node} treeNodeId={node.label} />
))}
</Tree>
</FluentProvider>
</>
);
};

View File

@ -1,90 +0,0 @@
import { TreeNode2 } from "Explorer/Controls/TreeComponent2/TreeNode2Component";
import TabsBase from "Explorer/Tabs/TabsBase";
import { buildCollectionNode } from "Explorer/Tree2/containerTreeNodeUtil";
import { useDatabases } from "Explorer/useDatabases";
import { useTabs } from "hooks/useTabs";
import CosmosDBIcon from "../../../images/Azure-Cosmos-DB.svg";
import * as ViewModels from "../../Contracts/ViewModels";
import Explorer from "../Explorer";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import { useSelectedNode } from "../useSelectedNode";
import { Platform, configContext } from "./../../ConfigContext";
export const useDatabaseTreeNodes = (container: Explorer, isNotebookEnabled: boolean): TreeNode2[] => {
const databases = useDatabases((state) => state.databases);
const { refreshActiveTab } = useTabs();
const databaseTreeNodes: TreeNode2[] = databases.map((database: ViewModels.Database) => {
const databaseNode: TreeNode2 = {
label: database.id(),
iconSrc: CosmosDBIcon,
className: "databaseHeader",
children: [],
isSelected: () => useSelectedNode.getState().isDataNodeSelected(database.id()),
contextMenu: undefined, // TODO Disable this for now as the actions don't work. ResourceTreeContextMenuButtonFactory.createDatabaseContextMenu(container, database.id()),
onExpanded: async () => {
useSelectedNode.getState().setSelectedNode(database);
if (!databaseNode.children || databaseNode.children?.length === 0) {
databaseNode.isLoading = true;
}
await database.expandDatabase();
databaseNode.isLoading = false;
useCommandBar.getState().setContextButtons([]);
refreshActiveTab((tab: TabsBase) => tab.collection?.databaseId === database.id());
useDatabases.getState().updateDatabase(database);
},
onContextMenuOpen: () => useSelectedNode.getState().setSelectedNode(database),
isExpanded: database.isDatabaseExpanded(),
onCollapsed: () => {
database.collapseDatabase();
// useCommandBar.getState().setContextButtons([]);
useDatabases.getState().updateDatabase(database);
},
};
if (database.isDatabaseShared() && configContext.platform !== Platform.Fabric) {
databaseNode.children.push({
id: database.isSampleDB ? "sampleScaleSettings" : "",
label: "Scale",
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(database.id(), undefined, [ViewModels.CollectionTabKind.DatabaseSettingsV2]),
onClick: database.onSettingsClick.bind(database),
});
}
// Find collections
database
.collections()
.forEach((collection: ViewModels.Collection) =>
databaseNode.children.push(
buildCollectionNode(database, collection, isNotebookEnabled, container, refreshActiveTab),
),
);
if (database.collectionsContinuationToken) {
const loadMoreNode: TreeNode2 = {
label: "load more",
className: "loadMoreHeader",
onClick: async () => {
await database.loadCollections();
useDatabases.getState().updateDatabase(database);
},
};
databaseNode.children.push(loadMoreNode);
}
database.collections.subscribe((collections: ViewModels.Collection[]) => {
collections.forEach((collection: ViewModels.Collection) =>
databaseNode.children.push(
buildCollectionNode(database, collection, isNotebookEnabled, container, refreshActiveTab),
),
);
});
return databaseNode;
});
return databaseTreeNodes;
};

View File

@ -69,7 +69,7 @@ export type AdminFeedbackPolicySettings = {
[key in AdminFeedbackControlPolicy]: boolean; [key in AdminFeedbackControlPolicy]: boolean;
}; };
interface UserContext { export interface UserContext {
readonly fabricContext?: FabricContext; readonly fabricContext?: FabricContext;
readonly authType?: AuthType; readonly authType?: AuthType;
readonly masterKey?: string; readonly masterKey?: string;

View File

@ -0,0 +1,18 @@
export default class PromiseSource<T = void> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
constructor() {
this.promise = new Promise((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});
}
/** Resolves the promise, then gets off the thread and waits until the currently-registered 'then' callback run. */
async resolveAndWait(value: T): Promise<T> {
this.resolve(value);
return await this.promise;
}
}

View File

@ -1,6 +1,14 @@
import { jest } from "@jest/globals"; import { jest } from "@jest/globals";
import "expect-playwright"; import "expect-playwright";
import { generateUniqueName, getAzureCLICredentialsToken } from "../utils/shared"; import {
AccountType,
generateUniqueName,
getPanelSelector,
getTestExplorerUrl,
getTreeMenuItemSelector,
getTreeNodeSelector,
openContextMenu,
} from "../utils/shared";
import { waitForExplorer } from "../utils/waitForExplorer"; import { waitForExplorer } from "../utils/waitForExplorer";
jest.setTimeout(120000); jest.setTimeout(120000);
@ -8,30 +16,42 @@ test("Cassandra keyspace and table CRUD", async () => {
const keyspaceId = generateUniqueName("keyspace"); const keyspaceId = generateUniqueName("keyspace");
const tableId = generateUniqueName("table"); const tableId = generateUniqueName("table");
// We can't retrieve AZ CLI credentials from the browser so we get them here.
const token = await getAzureCLICredentialsToken();
page.setDefaultTimeout(50000); page.setDefaultTimeout(50000);
await page.goto(`https://localhost:1234/testExplorer.html?accountName=portal-cassandra-runner&token=${token}`); const url = await getTestExplorerUrl(AccountType.Cassandra);
await page.goto(url);
await page.waitForSelector("iframe"); await page.waitForSelector("iframe");
const explorer = await waitForExplorer(); const explorer = await waitForExplorer();
await explorer.click('[data-test="New Table"]'); await explorer.click('[data-test="New Table"]');
await explorer.waitForSelector(getPanelSelector("Add Table"));
await explorer.click('[aria-label="Keyspace id"]'); await explorer.click('[aria-label="Keyspace id"]');
await explorer.fill('[aria-label="Keyspace id"]', keyspaceId); await explorer.fill('[aria-label="Keyspace id"]', keyspaceId);
await explorer.click('[aria-label="addCollection-table Id Create table"]'); await explorer.click('[aria-label="addCollection-table Id Create table"]');
await explorer.fill('[aria-label="addCollection-table Id Create table"]', tableId); await explorer.fill('[aria-label="addCollection-table Id Create table"]', tableId);
await explorer.fill('[aria-label="Table max RU/s"]', "1000");
await explorer.click("#sidePanelOkButton"); await explorer.click("#sidePanelOkButton");
await explorer.click(`.nodeItem >> text=${keyspaceId}`); await explorer.waitForSelector(getPanelSelector("Add Table"), { state: "detached" });
await explorer.click(`[data-test="${tableId}"] [aria-label="More options"]`);
await explorer.click('button[role="menuitem"]:has-text("Delete Table")'); await explorer.click(getTreeNodeSelector(`DATA/${keyspaceId}`));
await openContextMenu(explorer, `DATA/${keyspaceId}/${tableId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${keyspaceId}/${tableId}`, "Delete Table"));
await explorer.waitForSelector(getPanelSelector("Delete Table"));
await explorer.fill('text=* Confirm by typing the table id >> input[type="text"]', tableId); await explorer.fill('text=* Confirm by typing the table id >> input[type="text"]', tableId);
await explorer.click('[aria-label="OK"]'); await explorer.click('[aria-label="OK"]');
await explorer.click(`[data-test="${keyspaceId}"] [aria-label="More options"]`); await explorer.waitForSelector(getPanelSelector("Delete Table"), { state: "detached" });
await explorer.click('button[role="menuitem"]:has-text("Delete Keyspace")');
await openContextMenu(explorer, `DATA/${keyspaceId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${keyspaceId}`, "Delete Keyspace"));
await explorer.waitForSelector(getPanelSelector("Delete Keyspace"));
await explorer.click('text=* Confirm by typing the database id >> input[type="text"]'); await explorer.click('text=* Confirm by typing the database id >> input[type="text"]');
await explorer.fill('text=* Confirm by typing the database id >> input[type="text"]', keyspaceId); await explorer.fill('text=* Confirm by typing the database id >> input[type="text"]', keyspaceId);
await explorer.click("#sidePanelOkButton"); await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("Delete Keyspace"), { state: "detached" });
await expect(explorer).not.toHaveText(".dataResourceTree", keyspaceId); await expect(explorer).not.toHaveText(".dataResourceTree", keyspaceId);
await expect(explorer).not.toHaveText(".dataResourceTree", tableId); await expect(explorer).not.toHaveText(".dataResourceTree", tableId);
}); });

View File

@ -1,6 +1,14 @@
import { jest } from "@jest/globals"; import { jest } from "@jest/globals";
import "expect-playwright"; import "expect-playwright";
import { generateDatabaseNameWithTimestamp, generateUniqueName, getAzureCLICredentialsToken } from "../utils/shared"; import {
generateDatabaseNameWithTimestamp,
generateUniqueName,
getAzureCLICredentialsToken,
getPanelSelector,
getTreeMenuItemSelector,
getTreeNodeSelector,
openContextMenu,
} from "../utils/shared";
import { waitForExplorer } from "../utils/waitForExplorer"; import { waitForExplorer } from "../utils/waitForExplorer";
jest.setTimeout(240000); jest.setTimeout(240000);
@ -17,22 +25,34 @@ test("Graph CRUD", async () => {
// Create new database and graph // Create new database and graph
await explorer.click('[data-test="New Graph"]'); await explorer.click('[data-test="New Graph"]');
await explorer.waitForSelector(getPanelSelector("New Graph"));
await explorer.fill('[aria-label="New database id, Type a new database id"]', databaseId); await explorer.fill('[aria-label="New database id, Type a new database id"]', databaseId);
await explorer.fill('[aria-label="Graph id, Example Graph1"]', containerId); await explorer.fill('[aria-label="Graph id, Example Graph1"]', containerId);
await explorer.fill('[aria-label="Partition key"]', "/pk"); await explorer.fill('[aria-label="Partition key"]', "/pk");
await explorer.click("#sidePanelOkButton"); await explorer.click("#sidePanelOkButton");
await explorer.click(`.nodeItem >> text=${databaseId}`); await explorer.waitForSelector(getPanelSelector("New Graph"), { state: "detached" });
await explorer.click(`.nodeItem >> text=${containerId}`);
// Delete database and graph // Delete database and graph
await explorer.click(`[data-test="${containerId}"] [aria-label="More options"]`);
await explorer.click('button[role="menuitem"]:has-text("Delete Graph")'); await explorer.click(getTreeNodeSelector(`DATA/${databaseId}`));
await openContextMenu(explorer, `DATA/${databaseId}/${containerId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${databaseId}/${containerId}`, "Delete Graph"));
await explorer.waitForSelector(getPanelSelector("Delete Graph"));
await explorer.fill('text=* Confirm by typing the graph id >> input[type="text"]', containerId); await explorer.fill('text=* Confirm by typing the graph id >> input[type="text"]', containerId);
await explorer.click('[aria-label="OK"]'); await explorer.click('[aria-label="OK"]');
await explorer.click(`[data-test="${databaseId}"] [aria-label="More options"]`); await explorer.waitForSelector(getPanelSelector("Delete Graph"), { state: "detached" });
await explorer.click('button[role="menuitem"]:has-text("Delete Database")');
await openContextMenu(explorer, `DATA/${databaseId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${databaseId}`, "Delete Database"));
await explorer.waitForSelector(getPanelSelector("Delete Database"));
await explorer.click('text=* Confirm by typing the database id >> input[type="text"]'); await explorer.click('text=* Confirm by typing the database id >> input[type="text"]');
await explorer.fill('text=* Confirm by typing the database id >> input[type="text"]', databaseId); await explorer.fill('text=* Confirm by typing the database id >> input[type="text"]', databaseId);
await explorer.click("#sidePanelOkButton"); await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("Delete Database"), { state: "detached" });
await expect(explorer).not.toHaveText(".dataResourceTree", databaseId); await expect(explorer).not.toHaveText(".dataResourceTree", databaseId);
await expect(explorer).not.toHaveText(".dataResourceTree", containerId); await expect(explorer).not.toHaveText(".dataResourceTree", containerId);
}); });

View File

@ -1,6 +1,15 @@
import { jest } from "@jest/globals"; import { jest } from "@jest/globals";
import "expect-playwright"; import "expect-playwright";
import { generateDatabaseNameWithTimestamp, generateUniqueName, getAzureCLICredentialsToken } from "../utils/shared"; import {
AccountType,
generateDatabaseNameWithTimestamp,
generateUniqueName,
getPanelSelector,
getTestExplorerUrl,
getTreeMenuItemSelector,
getTreeNodeSelector,
openContextMenu,
} from "../utils/shared";
import { waitForExplorer } from "../utils/waitForExplorer"; import { waitForExplorer } from "../utils/waitForExplorer";
jest.setTimeout(240000); jest.setTimeout(240000);
@ -8,42 +17,56 @@ test("Mongo CRUD", async () => {
const databaseId = generateDatabaseNameWithTimestamp(); const databaseId = generateDatabaseNameWithTimestamp();
const containerId = generateUniqueName("container"); const containerId = generateUniqueName("container");
// We can't retrieve AZ CLI credentials from the browser so we get them here.
const token = await getAzureCLICredentialsToken();
page.setDefaultTimeout(50000); page.setDefaultTimeout(50000);
await page.goto(`https://localhost:1234/testExplorer.html?accountName=portal-mongo-runner&token=${token}`); const url = await getTestExplorerUrl(AccountType.Mongo);
await page.goto(url);
const explorer = await waitForExplorer(); const explorer = await waitForExplorer();
// Create new database and collection // Create new database and collection
await explorer.click('[data-test="New Collection"]'); await explorer.click('[data-test="New Collection"]');
await explorer.waitForSelector(getPanelSelector("New Collection"));
await explorer.fill('[aria-label="New database id, Type a new database id"]', databaseId); await explorer.fill('[aria-label="New database id, Type a new database id"]', databaseId);
await explorer.fill('[aria-label="Collection id, Example Collection1"]', containerId); await explorer.fill('[aria-label="Collection id, Example Collection1"]', containerId);
await explorer.fill('[aria-label="Shard key"]', "pk"); await explorer.fill('[aria-label="Shard key"]', "pk");
await explorer.click("#sidePanelOkButton"); await explorer.click("#sidePanelOkButton");
await explorer.click(`.nodeItem >> text=${databaseId}`); await explorer.waitForSelector(getPanelSelector("New Collection"), { state: "detached" });
await explorer.click(`.nodeItem >> text=${containerId}`);
await explorer.click(getTreeNodeSelector(`DATA/${databaseId}`));
await explorer.click(getTreeNodeSelector(`DATA/${databaseId}/${containerId}`));
// Create indexing policy // Create indexing policy
await explorer.click(".nodeItem >> text=Settings"); await explorer.click(getTreeNodeSelector(`DATA/${databaseId}/${containerId}/Settings`));
await explorer.click('button[role="tab"]:has-text("Indexing Policy")'); await explorer.click('button[role="tab"]:has-text("Indexing Policy")');
await explorer.click('[aria-label="Index Field Name 0"]'); await explorer.click('[aria-label="Index Field Name 0"]');
await explorer.fill('[aria-label="Index Field Name 0"]', "foo"); await explorer.fill('[aria-label="Index Field Name 0"]', "foo");
await explorer.click("text=Select an index type"); await explorer.click("text=Select an index type");
await explorer.click('button[role="option"]:has-text("Single Field")'); await explorer.click('button[role="option"]:has-text("Single Field")');
await explorer.click('[data-test="Save"]'); await explorer.click('[data-test="Save"]');
// Remove indexing policy // Remove indexing policy
await explorer.click('[aria-label="Delete index Button"]'); await explorer.click('[aria-label="Delete index Button"]');
await explorer.click('[data-test="Save"]'); await explorer.click('[data-test="Save"]');
// Delete database and collection // Delete database and collection
await explorer.click(`[data-test="${containerId}"] [aria-label="More options"]`); await openContextMenu(explorer, `DATA/${databaseId}/${containerId}`);
await explorer.click('button[role="menuitem"]:has-text("Delete Collection")'); await explorer.click(getTreeMenuItemSelector(`DATA/${databaseId}/${containerId}`, "Delete Collection"));
await explorer.waitForSelector(getPanelSelector("Delete Collection"));
await explorer.fill('text=* Confirm by typing the collection id >> input[type="text"]', containerId); await explorer.fill('text=* Confirm by typing the collection id >> input[type="text"]', containerId);
await explorer.click('[aria-label="OK"]'); await explorer.click('[aria-label="OK"]');
await explorer.click(`[data-test="${databaseId}"] [aria-label="More options"]`); await explorer.waitForSelector(getPanelSelector("Delete Collection"), { state: "detached" });
await explorer.click('button[role="menuitem"]:has-text("Delete Database")');
await openContextMenu(explorer, `DATA/${databaseId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${databaseId}`, "Delete Database"));
await explorer.waitForSelector(getPanelSelector("Delete Database"));
await explorer.click('text=* Confirm by typing the database id >> input[type="text"]'); await explorer.click('text=* Confirm by typing the database id >> input[type="text"]');
await explorer.fill('text=* Confirm by typing the database id >> input[type="text"]', databaseId); await explorer.fill('text=* Confirm by typing the database id >> input[type="text"]', databaseId);
await explorer.click("#sidePanelOkButton"); await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("Delete Database"), { state: "detached" });
await expect(explorer).not.toHaveText(".dataResourceTree", databaseId); await expect(explorer).not.toHaveText(".dataResourceTree", databaseId);
await expect(explorer).not.toHaveText(".dataResourceTree", containerId); await expect(explorer).not.toHaveText(".dataResourceTree", containerId);
}); });

View File

@ -1,6 +1,15 @@
import { jest } from "@jest/globals"; import { jest } from "@jest/globals";
import "expect-playwright"; import "expect-playwright";
import { generateDatabaseNameWithTimestamp, generateUniqueName, getAzureCLICredentialsToken } from "../utils/shared"; import {
AccountType,
generateDatabaseNameWithTimestamp,
generateUniqueName,
getPanelSelector,
getTestExplorerUrl,
getTreeMenuItemSelector,
getTreeNodeSelector,
openContextMenu,
} from "../utils/shared";
import { waitForExplorer } from "../utils/waitForExplorer"; import { waitForExplorer } from "../utils/waitForExplorer";
jest.setTimeout(240000); jest.setTimeout(240000);
@ -8,31 +17,43 @@ test("Mongo CRUD", async () => {
const databaseId = generateDatabaseNameWithTimestamp(); const databaseId = generateDatabaseNameWithTimestamp();
const containerId = generateUniqueName("container"); const containerId = generateUniqueName("container");
// We can't retrieve AZ CLI credentials from the browser so we get them here.
const token = await getAzureCLICredentialsToken();
page.setDefaultTimeout(50000); page.setDefaultTimeout(50000);
await page.goto(`https://localhost:1234/testExplorer.html?accountName=portal-mongo32-runner&token=${token}`); const url = await getTestExplorerUrl(AccountType.Mongo32);
await page.goto(url);
const explorer = await waitForExplorer(); const explorer = await waitForExplorer();
// Create new database and collection // Create new database and collection
await explorer.click('[data-test="New Collection"]'); await explorer.click('[data-test="New Collection"]');
await explorer.waitForSelector(getPanelSelector("New Collection"));
await explorer.fill('[aria-label="New database id, Type a new database id"]', databaseId); await explorer.fill('[aria-label="New database id, Type a new database id"]', databaseId);
await explorer.fill('[aria-label="Collection id, Example Collection1"]', containerId); await explorer.fill('[aria-label="Collection id, Example Collection1"]', containerId);
await explorer.fill('[aria-label="Shard key"]', "pk"); await explorer.fill('[aria-label="Shard key"]', "pk");
await explorer.click("#sidePanelOkButton"); await explorer.click("#sidePanelOkButton");
explorer.click(`.nodeItem >> text=${databaseId}`); await explorer.waitForSelector(getPanelSelector("New Collection"), { state: "detached" });
explorer.click(`.nodeItem >> text=${containerId}`);
await explorer.click(getTreeNodeSelector(`DATA/${databaseId}`));
await explorer.click(getTreeNodeSelector(`DATA/${databaseId}/${containerId}`));
// Delete database and collection // Delete database and collection
explorer.click(`[data-test="${containerId}"] [aria-label="More options"]`); await openContextMenu(explorer, `DATA/${databaseId}/${containerId}`);
explorer.click('button[role="menuitem"]:has-text("Delete Collection")'); await explorer.click(getTreeMenuItemSelector(`DATA/${databaseId}/${containerId}`, "Delete Collection"));
await explorer.waitForSelector(getPanelSelector("Delete Collection"));
await explorer.fill('text=* Confirm by typing the collection id >> input[type="text"]', containerId); await explorer.fill('text=* Confirm by typing the collection id >> input[type="text"]', containerId);
await explorer.click('[aria-label="OK"]'); await explorer.click('[aria-label="OK"]');
await explorer.click(`[data-test="${databaseId}"] [aria-label="More options"]`); await explorer.waitForSelector(getPanelSelector("Delete Collection"), { state: "detached" });
await explorer.click('button[role="menuitem"]:has-text("Delete Database")');
await openContextMenu(explorer, `DATA/${databaseId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${databaseId}`, "Delete Database"));
await explorer.waitForSelector(getPanelSelector("Delete Database"));
await explorer.click('text=* Confirm by typing the database id >> input[type="text"]'); await explorer.click('text=* Confirm by typing the database id >> input[type="text"]');
await explorer.fill('text=* Confirm by typing the database id >> input[type="text"]', databaseId); await explorer.fill('text=* Confirm by typing the database id >> input[type="text"]', databaseId);
await explorer.click("#sidePanelOkButton"); await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("Delete Database"), { state: "detached" });
await expect(explorer).not.toHaveText(".dataResourceTree", databaseId); await expect(explorer).not.toHaveText(".dataResourceTree", databaseId);
await expect(explorer).not.toHaveText(".dataResourceTree", containerId); await expect(explorer).not.toHaveText(".dataResourceTree", containerId);
}); });

View File

@ -1,6 +1,14 @@
import { jest } from "@jest/globals"; import { jest } from "@jest/globals";
import "expect-playwright"; import "expect-playwright";
import { generateUniqueName, getAzureCLICredentialsToken } from "../utils/shared"; import {
AccountType,
generateUniqueName,
getPanelSelector,
getTestExplorerUrl,
getTreeMenuItemSelector,
getTreeNodeSelector,
openContextMenu,
} from "../utils/shared";
import { waitForExplorer } from "../utils/waitForExplorer"; import { waitForExplorer } from "../utils/waitForExplorer";
jest.setTimeout(120000); jest.setTimeout(120000);
@ -8,28 +16,40 @@ test("SQL CRUD", async () => {
const databaseId = generateUniqueName("db"); const databaseId = generateUniqueName("db");
const containerId = generateUniqueName("container"); const containerId = generateUniqueName("container");
// We can't retrieve AZ CLI credentials from the browser so we get them here.
const token = await getAzureCLICredentialsToken();
page.setDefaultTimeout(50000); page.setDefaultTimeout(50000);
await page.goto(`https://localhost:1234/testExplorer.html?accountName=portal-sql-runner-west-us&token=${token}`); const url = await getTestExplorerUrl(AccountType.SQL);
await page.goto(url);
const explorer = await waitForExplorer(); const explorer = await waitForExplorer();
await explorer.click('[data-test="New Container"]'); await explorer.click('[data-test="New Container"]');
await explorer.waitForSelector(getPanelSelector("New Container"));
await explorer.fill('[aria-label="New database id, Type a new database id"]', databaseId); await explorer.fill('[aria-label="New database id, Type a new database id"]', databaseId);
await explorer.fill('[aria-label="Container id, Example Container1"]', containerId); await explorer.fill('[aria-label="Container id, Example Container1"]', containerId);
await explorer.fill('[aria-label="Partition key"]', "/pk"); await explorer.fill('[aria-label="Partition key"]', "/pk");
await explorer.click("#sidePanelOkButton"); await explorer.click("#sidePanelOkButton");
await explorer.click(`.nodeItem >> text=${databaseId}`); await explorer.waitForSelector(getPanelSelector("New Container"), { state: "detached" });
await explorer.click(`[data-test="${containerId}"] [aria-label="More options"]`);
await explorer.click('button[role="menuitem"]:has-text("Delete Container")'); await explorer.click(getTreeNodeSelector(`DATA/${databaseId}`));
await explorer.hover(getTreeNodeSelector(`DATA/${databaseId}/${containerId}`));
await openContextMenu(explorer, `DATA/${databaseId}/${containerId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${databaseId}/${containerId}`, "Delete Container"));
await explorer.waitForSelector(getPanelSelector("Delete Container"));
await explorer.fill('text=* Confirm by typing the container id >> input[type="text"]', containerId); await explorer.fill('text=* Confirm by typing the container id >> input[type="text"]', containerId);
await explorer.click('[aria-label="OK"]'); await explorer.click('[aria-label="OK"]');
await explorer.click(`[data-test="${databaseId}"] [aria-label="More options"]`); await explorer.waitForSelector(getPanelSelector("Delete Container"), { state: "detached" });
await explorer.click('button[role="menuitem"]:has-text("Delete Database")');
await openContextMenu(explorer, `DATA/${databaseId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${databaseId}`, "Delete Database"));
await explorer.waitForSelector(getPanelSelector("Delete Database"));
await explorer.click('text=* Confirm by typing the database id >> input[type="text"]'); await explorer.click('text=* Confirm by typing the database id >> input[type="text"]');
await explorer.fill('text=* Confirm by typing the database id >> input[type="text"]', databaseId); await explorer.fill('text=* Confirm by typing the database id >> input[type="text"]', databaseId);
await explorer.click("#sidePanelOkButton"); await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("Delete Database"), { state: "detached" });
await expect(explorer).not.toHaveText(".dataResourceTree", databaseId); await expect(explorer).not.toHaveText(".dataResourceTree", databaseId);
await expect(explorer).not.toHaveText(".dataResourceTree", containerId); await expect(explorer).not.toHaveText(".dataResourceTree", containerId);
}); });

View File

@ -2,7 +2,8 @@ import { CosmosDBManagementClient } from "@azure/arm-cosmosdb";
import { CosmosClient, PermissionMode } from "@azure/cosmos"; import { CosmosClient, PermissionMode } from "@azure/cosmos";
import { jest } from "@jest/globals"; import { jest } from "@jest/globals";
import "expect-playwright"; import "expect-playwright";
import { generateUniqueName, getAzureCLICredentials } from "../utils/shared"; import { generateUniqueName, getAzureCLICredentials, getTreeNodeSelector } from "../utils/shared";
import { waitForExplorer } from "../utils/waitForExplorer";
jest.setTimeout(120000); jest.setTimeout(120000);
const subscriptionId = process.env["AZURE_SUBSCRIPTION_ID"] ?? ""; const subscriptionId = process.env["AZURE_SUBSCRIPTION_ID"] ?? "";
@ -16,7 +17,7 @@ test("Resource token", async () => {
const dbId = generateUniqueName("db"); const dbId = generateUniqueName("db");
const collectionId = generateUniqueName("col"); const collectionId = generateUniqueName("col");
const client = new CosmosClient({ const client = new CosmosClient({
endpoint: account.documentEndpoint, endpoint: account.documentEndpoint!,
key: keys.primaryMasterKey, key: keys.primaryMasterKey,
}); });
const { database } = await client.databases.createIfNotExists({ id: dbId }); const { database } = await client.databases.createIfNotExists({ id: dbId });
@ -32,11 +33,10 @@ test("Resource token", async () => {
await page.goto("https://localhost:1234/hostedExplorer.html"); await page.goto("https://localhost:1234/hostedExplorer.html");
await page.waitForSelector("div > p.switchConnectTypeText"); await page.waitForSelector("div > p.switchConnectTypeText");
await page.click("div > p.switchConnectTypeText"); await page.click("div > p.switchConnectTypeText");
await page.type("input[class='inputToken']", resourceTokenConnectionString); await page.fill("input[class='inputToken']", resourceTokenConnectionString);
await page.click("input[value='Connect']"); await page.click("input[value='Connect']");
await page.waitForSelector("iframe"); const explorer = await waitForExplorer();
const explorer = await page.frame({
name: "explorer", const collectionNodeLabel = await explorer.textContent(getTreeNodeSelector(`DATA/${collectionId}`));
}); expect(collectionNodeLabel).toBe(collectionId);
await explorer.textContent(`css=.dataResourceTree >> "${collectionId}"`);
}); });

View File

@ -1,27 +1,40 @@
import { jest } from "@jest/globals"; import { jest } from "@jest/globals";
import "expect-playwright"; import "expect-playwright";
import { generateUniqueName, getAzureCLICredentialsToken } from "../utils/shared"; import {
AccountType,
generateUniqueName,
getPanelSelector,
getTestExplorerUrl,
getTreeMenuItemSelector,
openContextMenu,
} from "../utils/shared";
import { waitForExplorer } from "../utils/waitForExplorer"; import { waitForExplorer } from "../utils/waitForExplorer";
jest.setTimeout(120000); jest.setTimeout(120000);
test("Tables CRUD", async () => { test("Tables CRUD", async () => {
const tableId = generateUniqueName("table"); const tableId = generateUniqueName("table");
// We can't retrieve AZ CLI credentials from the browser so we get them here.
const token = await getAzureCLICredentialsToken();
page.setDefaultTimeout(50000); page.setDefaultTimeout(50000);
await page.goto(`https://localhost:1234/testExplorer.html?accountName=portal-tables-runner&token=${token}`); const url = await getTestExplorerUrl(AccountType.Tables);
await page.goto(url);
const explorer = await waitForExplorer(); const explorer = await waitForExplorer();
await page.waitForSelector('text="Querying databases"', { state: "detached" }); await page.waitForSelector('text="Querying databases"', { state: "detached" });
await explorer.click('[data-test="New Table"]'); await explorer.click('[data-test="New Table"]');
await explorer.waitForSelector(getPanelSelector("New Table"));
await explorer.fill('[aria-label="Table id, Example Table1"]', tableId); await explorer.fill('[aria-label="Table id, Example Table1"]', tableId);
await explorer.click("#sidePanelOkButton"); await explorer.click("#sidePanelOkButton");
await explorer.click(`[data-test="TablesDB"]`); await explorer.waitForSelector(getPanelSelector("New Table"), { state: "detached" });
await explorer.click(`[data-test="${tableId}"] [aria-label="More options"]`);
await explorer.click('button[role="menuitem"]:has-text("Delete Table")'); await openContextMenu(explorer, `DATA/TablesDB/${tableId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/TablesDB/${tableId}`, "Delete Table"));
await explorer.waitForSelector(getPanelSelector("Delete Table"));
await explorer.fill('text=* Confirm by typing the table id >> input[type="text"]', tableId); await explorer.fill('text=* Confirm by typing the table id >> input[type="text"]', tableId);
await explorer.click('[aria-label="OK"]'); await explorer.click('[aria-label="OK"]');
await explorer.waitForSelector(getPanelSelector("Delete Table"), { state: "detached" });
await expect(explorer).not.toHaveText(".dataResourceTree", tableId); await expect(explorer).not.toHaveText(".dataResourceTree", tableId);
}); });

View File

@ -4,9 +4,9 @@ import { DataExplorerInputsFrame } from "../../src/Contracts/ViewModels";
import { updateUserContext } from "../../src/UserContext"; import { updateUserContext } from "../../src/UserContext";
import { get, listKeys } from "../../src/Utils/arm/generatedClients/cosmos/databaseAccounts"; import { get, listKeys } from "../../src/Utils/arm/generatedClients/cosmos/databaseAccounts";
const resourceGroup = process.env.RESOURCE_GROUP || "";
const subscriptionId = process.env.SUBSCRIPTION_ID || "";
const urlSearchParams = new URLSearchParams(window.location.search); const urlSearchParams = new URLSearchParams(window.location.search);
const resourceGroup = urlSearchParams.get("resourceGroup") || process.env.RESOURCE_GROUP || "";
const subscriptionId = urlSearchParams.get("subscriptionId") || process.env.SUBSCRIPTION_ID || "";
const accountName = urlSearchParams.get("accountName") || "portal-sql-runner-west-us"; const accountName = urlSearchParams.get("accountName") || "portal-sql-runner-west-us";
const selfServeType = urlSearchParams.get("selfServeType") || "example"; const selfServeType = urlSearchParams.get("selfServeType") || "example";
const iframeSrc = urlSearchParams.get("iframeSrc") || "explorer.html?platform=Portal&disablePortalInitCache"; const iframeSrc = urlSearchParams.get("iframeSrc") || "explorer.html?platform=Portal&disablePortalInitCache";

View File

@ -1,5 +1,35 @@
import { AzureCliCredentials } from "@azure/ms-rest-nodeauth"; import { AzureCliCredentials } from "@azure/ms-rest-nodeauth";
import crypto from "crypto"; import crypto from "crypto";
import { Frame } from "playwright";
export enum AccountType {
Tables = "Tables",
Cassandra = "Cassandra",
Gremlin = "Gremlin",
Mongo = "Mongo",
Mongo32 = "Mongo32",
SQL = "SQL",
}
export const defaultAccounts: Record<AccountType, string> = {
[AccountType.Tables]: "portal-tables-runner",
[AccountType.Cassandra]: "portal-cassandra-runner",
[AccountType.Gremlin]: "portal-gremlin-runner",
[AccountType.Mongo]: "portal-mongo-runner",
[AccountType.Mongo32]: "portal-mongo32-runner",
[AccountType.SQL]: "portal-sql-runner-west-us",
};
const resourceGroup = process.env.DE_TEST_RESOURCE_GROUP ?? "runners";
const subscriptionId = process.env.DE_TEST_SUBSCRIPTION_ID ?? "69e02f2d-f059-4409-9eac-97e8a276ae2c";
export async function getTestExplorerUrl(accountType: AccountType) {
// We can't retrieve AZ CLI credentials from the browser so we get them here.
const token = await getAzureCLICredentialsToken();
const accountName =
process.env[`DE_TEST_ACCOUNT_NAME_${accountType.toLocaleUpperCase()}`] ?? defaultAccounts[accountType];
return `https://localhost:1234/testExplorer.html?accountName=${accountName}&resourceGroup=${resourceGroup}&subscriptionId=${subscriptionId}&token=${token}`;
}
export function generateUniqueName(baseName = "", length = 4): string { export function generateUniqueName(baseName = "", length = 4): string {
return `${baseName}${crypto.randomBytes(length).toString("hex")}`; return `${baseName}${crypto.randomBytes(length).toString("hex")}`;
@ -18,3 +48,22 @@ export async function getAzureCLICredentialsToken(): Promise<string> {
const token = (await credentials.getToken()).accessToken; const token = (await credentials.getToken()).accessToken;
return token; return token;
} }
export function getPanelSelector(title: string) {
return `[data-test="Panel:${title}"]`;
}
export function getTreeNodeSelector(id: string) {
return `[data-test="TreeNode:${id}"]`;
}
export function getTreeMenuItemSelector(nodeId: string, itemLabel: string) {
return `[data-test="TreeNode/ContextMenu:${nodeId}"] [data-test="TreeNode/ContextMenuItem:${itemLabel}"]`;
}
export async function openContextMenu(explorer: Frame, nodeIdentifier: string) {
const nodeSelector = getTreeNodeSelector(nodeIdentifier);
await explorer.hover(nodeSelector);
await explorer.click(`${nodeSelector} [data-test="TreeNode/ContextMenuTrigger"]`);
await explorer.waitForSelector(`[data-test="TreeNode/ContextMenu:${nodeIdentifier}"]`);
}

View File

@ -1,24 +1,79 @@
const { CosmosClient } = require("@azure/cosmos"); const { CosmosClient } = require("@azure/cosmos");
async function main() { let endpoint = process.env.ENDPOINT;
const endpoint = process.env.ENDPOINT; let key = process.env.KEY;
if (!endpoint) { let databaseId = process.env.DATABASE;
throw new Error("Expected env var ENDPOINT"); let containerId = process.env.CONTAINER;
} let partitionKey = process.env.partitionKey;
const key = process.env.KEY;
if (!key) {
throw new Error("Expected env var KEY");
}
const databaseId = process.env.DATABASE;
if (!databaseId) {
throw new Error("Expected env var DATABASE");
}
const containerId = process.env.CONTAINER;
if (!containerId) {
throw new Error("Expected env var CONTAINER");
}
const partitionKey = process.env.partitionKey;
// Super rudimentary, but dependency-free arg parsing
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case "--help":
console.log("Usage: node generateResourceToken.js --endpoint <endpoint> --key <key> --database <database> --container <container> --partitionKey <partitionKey>");
console.log("Options");
console.log(" --endpoint: Cosmos DB endpoint (can also be set via ENDPOINT env var)");
console.log(" --key: Cosmos DB key (can also be set via KEY env var)");
console.log(" --database: Cosmos DB database ID (can also be set via DATABASE env var)");
console.log(" --container: Cosmos DB container ID (can also be set via CONTAINER env var)");
console.log(" --partitionKey: Cosmos DB container partition key (can also be set via partitionKey env var; Optional)");
process.exit(0);
break;
case "--endpoint":
i++;
if (i >= args.length) {
throw new Error("--endpoint requires an argument");
}
endpoint = args[i];
break;
case "--key":
i++;
if (i >= args.length) {
throw new Error("--key requires an argument");
}
key = args[i];
break;
case "--database":
i++;
if (i >= args.length) {
throw new Error("--database requires an argument");
}
databaseId = args[i];
break;
case "--container":
i++;
if (i >= args.length) {
throw new Error("--container requires an argument");
}
containerId = args[i];
break;
case "--partitionKey":
i++;
if (i >= args.length) {
throw new Error("--partitionKey requires an argument");
}
partitionKey = args[i];
break;
default:
throw new Error(`Unknown argument: ${args[i]}`);
}
}
if (!endpoint) {
throw new Error("Endpoint is required, either via --endpoint or ENDPOINT env var");
}
if (!key) {
throw new Error("Key is required, either via --key or KEY env var");
}
if (!databaseId) {
throw new Error("Database is required, either via --database or DATABASE env var");
}
if (!containerId) {
throw new Error("Container is required, either via --container or CONTAINER env var");
}
async function main() {
const client = new CosmosClient({ const client = new CosmosClient({
endpoint, endpoint,
key key
@ -53,4 +108,4 @@ async function main() {
main().catch(error => { main().catch(error => {
console.log("Error!"); console.log("Error!");
throw error; throw error;
}); }).then(() => process.exit(0));