Migrate resource tree to react (#941)

This commit is contained in:
victor-meng 2021-07-20 11:40:04 -07:00 committed by GitHub
parent afacde4041
commit 6d46e48490
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 915 additions and 124 deletions

View File

@ -1,16 +1 @@
PORTAL_RUNNER_USERNAME=
PORTAL_RUNNER_PASSWORD=
PORTAL_RUNNER_SUBSCRIPTION=
PORTAL_RUNNER_RESOURCE_GROUP=
PORTAL_RUNNER_DATABASE_ACCOUNT=
PORTAL_RUNNER_DATABASE_ACCOUNT_KEY=
PORTAL_RUNNER_MONGO_DATABASE_ACCOUNT=
PORTAL_RUNNER_MONGO_DATABASE_ACCOUNT_KEY=
PORTAL_RUNNER_CONNECTION_STRING=
NOTEBOOKS_TEST_RUNNER_TENANT_ID=
NOTEBOOKS_TEST_RUNNER_CLIENT_ID=
NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET=
CASSANDRA_CONNECTION_STRING=
MONGO_CONNECTION_STRING=
TABLES_CONNECTION_STRING=
DATA_EXPLORER_ENDPOINT=https://localhost:1234/hostedExplorer.html DATA_EXPLORER_ENDPOINT=https://localhost:1234/hostedExplorer.html

View File

@ -191,4 +191,5 @@ src/Explorer/Notebook/NotebookRenderer/decorators/kbd-shortcuts/index.tsx
src/Explorer/Notebook/temp/inputs/connected-editors/codemirror.tsx src/Explorer/Notebook/temp/inputs/connected-editors/codemirror.tsx
src/Explorer/Tree/ResourceTreeAdapter.tsx src/Explorer/Tree/ResourceTreeAdapter.tsx
__mocks__/monaco-editor.ts __mocks__/monaco-editor.ts
src/Explorer/Tree/ResourceTreeAdapterForResourceToken.test.tsx src/Explorer/Tree/ResourceTreeAdapterForResourceToken.test.tsx
src/Explorer/Tree/ResourceTree.tsx

5
package-lock.json generated
View File

@ -5583,6 +5583,11 @@
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz",
"integrity": "sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==" "integrity": "sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA=="
}, },
"@types/lodash": {
"version": "4.14.171",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.171.tgz",
"integrity": "sha512-7eQ2xYLLI/LsicL2nejW9Wyko3lcpN6O/z0ZLHrEQsg280zIdCv1t/0m6UtBjUHokCGBQ3gYTbHzDkZ1xOBwwg=="
},
"@types/minimatch": { "@types/minimatch": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",

View File

@ -42,6 +42,7 @@
"@octokit/rest": "17.9.2", "@octokit/rest": "17.9.2",
"@phosphor/widgets": "1.9.3", "@phosphor/widgets": "1.9.3",
"@testing-library/jest-dom": "5.11.9", "@testing-library/jest-dom": "5.11.9",
"@types/lodash": "4.14.171",
"@types/mkdirp": "1.0.1", "@types/mkdirp": "1.0.1",
"@types/node-fetch": "2.5.7", "@types/node-fetch": "2.5.7",
"applicationinsights": "1.8.0", "applicationinsights": "1.8.0",

View File

@ -2,17 +2,21 @@ import React, { FunctionComponent } 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 { AuthType } from "../AuthType";
import Explorer from "../Explorer/Explorer";
import { ResourceTree } from "../Explorer/Tree/ResourceTree";
import { userContext } from "../UserContext"; import { userContext } from "../UserContext";
export interface ResourceTreeProps { export interface ResourceTreeContainerProps {
toggleLeftPaneExpanded: () => void; toggleLeftPaneExpanded: () => void;
isLeftPaneExpanded: boolean; isLeftPaneExpanded: boolean;
container: Explorer;
} }
export const ResourceTree: FunctionComponent<ResourceTreeProps> = ({ export const ResourceTreeContainer: FunctionComponent<ResourceTreeContainerProps> = ({
toggleLeftPaneExpanded, toggleLeftPaneExpanded,
isLeftPaneExpanded, isLeftPaneExpanded,
}: ResourceTreeProps): JSX.Element => { container,
}: ResourceTreeContainerProps): JSX.Element => {
return ( return (
<div id="main" className={isLeftPaneExpanded ? "main" : "hiddenMain"}> <div id="main" className={isLeftPaneExpanded ? "main" : "hiddenMain"}>
{/* Collections Window - - Start */} {/* Collections Window - - Start */}
@ -49,8 +53,10 @@ export const ResourceTree: FunctionComponent<ResourceTreeProps> = ({
</div> </div>
{userContext.authType === AuthType.ResourceToken ? ( {userContext.authType === AuthType.ResourceToken ? (
<div style={{ overflowY: "auto" }} data-bind="react:resourceTreeForResourceToken" /> <div style={{ overflowY: "auto" }} data-bind="react:resourceTreeForResourceToken" />
) : ( ) : userContext.features.enableKOResourceTree ? (
<div style={{ overflowY: "auto" }} data-bind="react:resourceTree" /> <div style={{ overflowY: "auto" }} data-bind="react:resourceTree" />
) : (
<ResourceTree container={container} />
)} )}
</div> </div>
{/* Collections Window - End */} {/* Collections Window - End */}

View File

@ -42,15 +42,6 @@ exports[`SettingsComponent renders 1`] = `
"resourceTree": ResourceTreeAdapter { "resourceTree": ResourceTreeAdapter {
"container": [Circular], "container": [Circular],
"copyNotebook": [Function], "copyNotebook": [Function],
"gitHubOAuthService": GitHubOAuthService {
"junoClient": JunoClient {
"cachedPinnedRepos": [Function],
},
"token": [Function],
},
"junoClient": JunoClient {
"cachedPinnedRepos": [Function],
},
"parameters": [Function], "parameters": [Function],
}, },
"resourceTreeForResourceToken": ResourceTreeAdapterForResourceToken { "resourceTreeForResourceToken": ResourceTreeAdapterForResourceToken {
@ -122,15 +113,6 @@ exports[`SettingsComponent renders 1`] = `
"resourceTree": ResourceTreeAdapter { "resourceTree": ResourceTreeAdapter {
"container": [Circular], "container": [Circular],
"copyNotebook": [Function], "copyNotebook": [Function],
"gitHubOAuthService": GitHubOAuthService {
"junoClient": JunoClient {
"cachedPinnedRepos": [Function],
},
"token": [Function],
},
"junoClient": JunoClient {
"cachedPinnedRepos": [Function],
},
"parameters": [Function], "parameters": [Function],
}, },
"resourceTreeForResourceToken": ResourceTreeAdapterForResourceToken { "resourceTreeForResourceToken": ResourceTreeAdapterForResourceToken {

View File

@ -362,6 +362,9 @@ export default class Explorer {
notebookServerEndpoint: userContext.features.notebookServerUrl || connectionInfo.notebookServerEndpoint, notebookServerEndpoint: userContext.features.notebookServerUrl || connectionInfo.notebookServerEndpoint,
authToken: userContext.features.notebookServerToken || connectionInfo.authToken, authToken: userContext.features.notebookServerToken || connectionInfo.authToken,
}); });
useNotebook.getState().initializeNotebooksTree(this.notebookManager);
this.refreshNotebookList(); this.refreshNotebookList();
this._isInitializingNotebooks = false; this._isInitializingNotebooks = false;
@ -842,6 +845,8 @@ export default class Explorer {
} }
await this.resourceTree.initialize(); await this.resourceTree.initialize();
await useNotebook.getState().initializeNotebooksTree(this.notebookManager);
this.notebookManager?.refreshPinnedRepos(); this.notebookManager?.refreshPinnedRepos();
if (this.notebookToImport) { if (this.notebookToImport) {
this.importAndOpenContent(this.notebookToImport.name, this.notebookToImport.content); this.importAndOpenContent(this.notebookToImport.name, this.notebookToImport.content);
@ -932,14 +937,15 @@ export default class Explorer {
.finally(clearInProgressMessage); .finally(clearInProgressMessage);
} }
public refreshContentItem(item: NotebookContentItem): Promise<void> { // TODO: Delete this function when ResourceTreeAdapter is removed.
public async refreshContentItem(item: NotebookContentItem): Promise<void> {
if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookContentClient) { if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookContentClient) {
const error = "Attempt to refresh notebook list, but notebook is not enabled"; const error = "Attempt to refresh notebook list, but notebook is not enabled";
handleError(error, "Explorer/refreshContentItem"); handleError(error, "Explorer/refreshContentItem");
return Promise.reject(new Error(error)); return Promise.reject(new Error(error));
} }
return this.notebookManager?.notebookContentClient.updateItemChildren(item); await this.notebookManager?.notebookContentClient.updateItemChildrenInPlace(item);
} }
public openNotebookTerminal(kind: ViewModels.TerminalKind) { public openNotebookTerminal(kind: ViewModels.TerminalKind) {

View File

@ -1,5 +1,6 @@
import { stringifyNotebook } from "@nteract/commutable"; import { stringifyNotebook } from "@nteract/commutable";
import { FileType, IContent, IContentProvider, IEmptyContent, ServerConfig } from "@nteract/core"; import { FileType, IContent, IContentProvider, IEmptyContent, ServerConfig } from "@nteract/core";
import { cloneDeep } from "lodash";
import { AjaxResponse } from "rxjs/ajax"; import { AjaxResponse } from "rxjs/ajax";
import * as StringUtils from "../../Utils/StringUtils"; import * as StringUtils from "../../Utils/StringUtils";
import * as FileSystemUtil from "./FileSystemUtil"; import * as FileSystemUtil from "./FileSystemUtil";
@ -14,7 +15,17 @@ export class NotebookContentClient {
* This updates the item and points all the children's parent to this item * This updates the item and points all the children's parent to this item
* @param item * @param item
*/ */
public updateItemChildren(item: NotebookContentItem): Promise<void> { public async updateItemChildren(item: NotebookContentItem): Promise<NotebookContentItem> {
const subItems = await this.fetchNotebookFiles(item.path);
const clonedItem = cloneDeep(item);
subItems.forEach((subItem) => (subItem.parent = clonedItem));
clonedItem.children = subItems;
return clonedItem;
}
// TODO: Delete this function when ResourceTreeAdapter is removed.
public async updateItemChildrenInPlace(item: NotebookContentItem): Promise<void> {
return this.fetchNotebookFiles(item.path).then((subItems) => { return this.fetchNotebookFiles(item.path).then((subItems) => {
item.children = subItems; item.children = subItems;
subItems.forEach((subItem) => (subItem.parent = item)); subItems.forEach((subItem) => (subItem.parent = item));
@ -55,18 +66,20 @@ export class NotebookContentClient {
}); });
} }
public deleteContentItem(item: NotebookContentItem): Promise<void> { public async deleteContentItem(item: NotebookContentItem): Promise<void> {
return this.deleteNotebookFile(item.path).then((path: string) => { const path = await this.deleteNotebookFile(item.path);
if (!path || path !== item.path) { useNotebook.getState().deleteNotebookItem(item);
throw new Error("No path provided");
}
if (item.parent && item.parent.children) { // TODO: Delete once old resource tree is removed
// Remove deleted child if (!path || path !== item.path) {
const newChildren = item.parent.children.filter((child) => child.path !== path); throw new Error("No path provided");
item.parent.children = newChildren; }
}
}); if (item.parent && item.parent.children) {
// Remove deleted child
const newChildren = item.parent.children.filter((child) => child.path !== path);
item.parent.children = newChildren;
}
} }
/** /**

View File

@ -1,3 +1,4 @@
import { cloneDeep } from "lodash";
import create, { UseStore } from "zustand"; import create, { UseStore } from "zustand";
import { AuthType } from "../../AuthType"; import { AuthType } from "../../AuthType";
import * as Constants from "../../Common/Constants"; import * as Constants from "../../Common/Constants";
@ -5,8 +6,12 @@ import { getErrorMessage } from "../../Common/ErrorHandlingUtils";
import * as Logger from "../../Common/Logger"; import * as Logger from "../../Common/Logger";
import { configContext } from "../../ConfigContext"; import { configContext } from "../../ConfigContext";
import * as DataModels from "../../Contracts/DataModels"; import * as DataModels from "../../Contracts/DataModels";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { userContext } from "../../UserContext"; import { userContext } from "../../UserContext";
import { getAuthorizationHeader } from "../../Utils/AuthorizationUtils"; import { getAuthorizationHeader } from "../../Utils/AuthorizationUtils";
import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem";
import NotebookManager from "./NotebookManager";
interface NotebookState { interface NotebookState {
isNotebookEnabled: boolean; isNotebookEnabled: boolean;
@ -18,6 +23,9 @@ interface NotebookState {
isShellEnabled: boolean; isShellEnabled: boolean;
notebookBasePath: string; notebookBasePath: string;
isInitializingNotebooks: boolean; isInitializingNotebooks: boolean;
myNotebooksContentRoot: NotebookContentItem;
gitHubNotebooksContentRoot: NotebookContentItem;
galleryContentRoot: NotebookContentItem;
setIsNotebookEnabled: (isNotebookEnabled: boolean) => void; setIsNotebookEnabled: (isNotebookEnabled: boolean) => void;
setIsNotebooksEnabledForAccount: (isNotebooksEnabledForAccount: boolean) => void; setIsNotebooksEnabledForAccount: (isNotebooksEnabledForAccount: boolean) => void;
setNotebookServerInfo: (notebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo) => void; setNotebookServerInfo: (notebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo) => void;
@ -27,9 +35,13 @@ interface NotebookState {
setIsShellEnabled: (isShellEnabled: boolean) => void; setIsShellEnabled: (isShellEnabled: boolean) => void;
setNotebookBasePath: (notebookBasePath: string) => void; setNotebookBasePath: (notebookBasePath: string) => void;
refreshNotebooksEnabledStateForAccount: () => Promise<void>; refreshNotebooksEnabledStateForAccount: () => Promise<void>;
findItem: (root: NotebookContentItem, item: NotebookContentItem) => NotebookContentItem;
updateNotebookItem: (item: NotebookContentItem) => void;
deleteNotebookItem: (item: NotebookContentItem) => void;
initializeNotebooksTree: (notebookManager: NotebookManager) => Promise<void>;
} }
export const useNotebook: UseStore<NotebookState> = create((set) => ({ export const useNotebook: UseStore<NotebookState> = create((set, get) => ({
isNotebookEnabled: false, isNotebookEnabled: false,
isNotebooksEnabledForAccount: false, isNotebooksEnabledForAccount: false,
notebookServerInfo: { notebookServerInfo: {
@ -46,6 +58,9 @@ export const useNotebook: UseStore<NotebookState> = create((set) => ({
isShellEnabled: false, isShellEnabled: false,
notebookBasePath: Constants.Notebook.defaultBasePath, notebookBasePath: Constants.Notebook.defaultBasePath,
isInitializingNotebooks: false, isInitializingNotebooks: false,
myNotebooksContentRoot: undefined,
gitHubNotebooksContentRoot: undefined,
galleryContentRoot: undefined,
setIsNotebookEnabled: (isNotebookEnabled: boolean) => set({ isNotebookEnabled }), setIsNotebookEnabled: (isNotebookEnabled: boolean) => set({ isNotebookEnabled }),
setIsNotebooksEnabledForAccount: (isNotebooksEnabledForAccount: boolean) => set({ isNotebooksEnabledForAccount }), setIsNotebooksEnabledForAccount: (isNotebooksEnabledForAccount: boolean) => set({ isNotebooksEnabledForAccount }),
setNotebookServerInfo: (notebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo) => setNotebookServerInfo: (notebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo) =>
@ -103,4 +118,92 @@ export const useNotebook: UseStore<NotebookState> = create((set) => ({
set({ isNotebooksEnabledForAccount: false }); set({ isNotebooksEnabledForAccount: false });
} }
}, },
findItem: (root: NotebookContentItem, item: NotebookContentItem): NotebookContentItem => {
const currentItem = root || get().myNotebooksContentRoot;
if (currentItem) {
if (currentItem.path === item.path && currentItem.name === item.name) {
return currentItem;
}
if (currentItem.children) {
for (const childItem of currentItem.children) {
const result = get().findItem(childItem, item);
if (result) {
return result;
}
}
}
}
return undefined;
},
updateNotebookItem: (item: NotebookContentItem): void => {
const root = cloneDeep(get().myNotebooksContentRoot);
const parentItem = get().findItem(root, item.parent);
parentItem.children = parentItem.children.filter((child) => child.path !== item.path);
parentItem.children.push(item);
item.parent = parentItem;
set({ myNotebooksContentRoot: root });
},
deleteNotebookItem: (item: NotebookContentItem): void => {
const root = cloneDeep(get().myNotebooksContentRoot);
const parentItem = get().findItem(root, item.parent);
parentItem.children = parentItem.children.filter((child) => child.path !== item.path);
set({ myNotebooksContentRoot: root });
},
initializeNotebooksTree: async (notebookManager: NotebookManager): Promise<void> => {
set({
myNotebooksContentRoot: {
name: "My Notebooks",
path: get().notebookBasePath,
type: NotebookContentItemType.Directory,
},
galleryContentRoot: {
name: "Gallery",
path: "Gallery",
type: NotebookContentItemType.File,
},
});
if (notebookManager?.gitHubOAuthService?.isLoggedIn()) {
set({
gitHubNotebooksContentRoot: {
name: "GitHub repos",
path: "PsuedoDir",
type: NotebookContentItemType.Directory,
},
});
}
if (get().notebookServerInfo?.notebookServerEndpoint) {
const updatedRoot = await notebookManager?.notebookContentClient?.updateItemChildren({
name: "My Notebooks",
path: get().notebookBasePath,
type: NotebookContentItemType.Directory,
});
set({ myNotebooksContentRoot: updatedRoot });
if (updatedRoot?.children) {
// Count 1st generation children (tree is lazy-loaded)
const nodeCounts = { files: 0, notebooks: 0, directories: 0 };
updatedRoot.children.forEach((notebookItem) => {
switch (notebookItem.type) {
case NotebookContentItemType.File:
nodeCounts.files++;
break;
case NotebookContentItemType.Directory:
nodeCounts.directories++;
break;
case NotebookContentItemType.Notebook:
nodeCounts.notebooks++;
break;
default:
break;
}
});
TelemetryProcessor.trace(Action.RefreshResourceTreeMyNotebooks, ActionModifiers.Mark, { ...nodeCounts });
}
}
},
})); }));

View File

@ -31,15 +31,6 @@ exports[`GitHub Repos Panel should render Default properly 1`] = `
"resourceTree": ResourceTreeAdapter { "resourceTree": ResourceTreeAdapter {
"container": [Circular], "container": [Circular],
"copyNotebook": [Function], "copyNotebook": [Function],
"gitHubOAuthService": GitHubOAuthService {
"junoClient": JunoClient {
"cachedPinnedRepos": [Function],
},
"token": [Function],
},
"junoClient": JunoClient {
"cachedPinnedRepos": [Function],
},
"parameters": [Function], "parameters": [Function],
}, },
"resourceTreeForResourceToken": ResourceTreeAdapterForResourceToken { "resourceTreeForResourceToken": ResourceTreeAdapterForResourceToken {

View File

@ -21,15 +21,6 @@ exports[`StringInput Pane should render Create new directory properly 1`] = `
"resourceTree": ResourceTreeAdapter { "resourceTree": ResourceTreeAdapter {
"container": [Circular], "container": [Circular],
"copyNotebook": [Function], "copyNotebook": [Function],
"gitHubOAuthService": GitHubOAuthService {
"junoClient": JunoClient {
"cachedPinnedRepos": [Function],
},
"token": [Function],
},
"junoClient": JunoClient {
"cachedPinnedRepos": [Function],
},
"parameters": [Function], "parameters": [Function],
}, },
"resourceTreeForResourceToken": ResourceTreeAdapterForResourceToken { "resourceTreeForResourceToken": ResourceTreeAdapterForResourceToken {

View File

@ -571,8 +571,8 @@ export default class Collection implements ViewModels.Collection {
}; };
public onSettingsClick = async (): Promise<void> => { public onSettingsClick = async (): Promise<void> => {
await this.loadOffer();
useSelectedNode.getState().setSelectedNode(this); useSelectedNode.getState().setSelectedNode(this);
await this.loadOffer();
this.selectedSubnodeKind(ViewModels.CollectionTabKind.Settings); this.selectedSubnodeKind(ViewModels.CollectionTabKind.Settings);
TelemetryProcessor.trace(Action.SelectItem, ActionModifiers.Mark, { TelemetryProcessor.trace(Action.SelectItem, ActionModifiers.Mark, {
description: "Settings node", description: "Settings node",

View File

@ -57,7 +57,7 @@ export default class Database implements ViewModels.Database {
this.isOfferRead = false; this.isOfferRead = false;
} }
public onSettingsClick = () => { public onSettingsClick = (): void => {
useSelectedNode.getState().setSelectedNode(this); useSelectedNode.getState().setSelectedNode(this);
this.selectedSubnodeKind(ViewModels.CollectionTabKind.DatabaseSettings); this.selectedSubnodeKind(ViewModels.CollectionTabKind.DatabaseSettings);
TelemetryProcessor.trace(Action.SelectItem, ActionModifiers.Mark, { TelemetryProcessor.trace(Action.SelectItem, ActionModifiers.Mark, {
@ -193,6 +193,8 @@ export default class Database implements ViewModels.Database {
//merge collections //merge collections
this.addCollectionsToList(collectionVMs); this.addCollectionsToList(collectionVMs);
this.deleteCollectionsFromList(deltaCollections.toDelete); this.deleteCollectionsFromList(deltaCollections.toDelete);
useDatabases.getState().updateDatabase(this);
} }
public async openAddCollection(database: Database): Promise<void> { public async openAddCollection(database: Database): Promise<void> {

View File

@ -0,0 +1,722 @@
import { Callout, DirectionalHint, ICalloutProps, ILinkProps, Link, Stack, Text } from "@fluentui/react";
import * as React from "react";
import CosmosDBIcon from "../../../images/Azure-Cosmos-DB.svg";
import DeleteIcon from "../../../images/delete.svg";
import GalleryIcon from "../../../images/GalleryIcon.svg";
import FileIcon from "../../../images/notebook/file-cosmos.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 PublishIcon from "../../../images/notebook/publish_content.svg";
import RefreshIcon from "../../../images/refresh-cosmos.svg";
import CollectionIcon from "../../../images/tree-collection.svg";
import { Areas } from "../../Common/Constants";
import { isPublicInternetAccessAllowed } from "../../Common/DatabaseAccountUtility";
import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels";
import { useSidePanel } from "../../hooks/useSidePanel";
import { useTabs } from "../../hooks/useTabs";
import { LocalStorageUtility, StorageKey } from "../../Shared/StorageUtility";
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 * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFactory";
import { AccordionComponent, AccordionItemComponent } from "../Controls/Accordion/AccordionComponent";
import { TreeComponent, TreeNode, TreeNodeMenuItem } from "../Controls/TreeComponent/TreeComponent";
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 { GitHubReposPanel } from "../Panes/GitHubReposPanel/GitHubReposPanel";
import TabsBase from "../Tabs/TabsBase";
import { useDatabases } from "../useDatabases";
import { useSelectedNode } from "../useSelectedNode";
import StoredProcedure from "./StoredProcedure";
import Trigger from "./Trigger";
import UserDefinedFunction from "./UserDefinedFunction";
export const MyNotebooksTitle = "My Notebooks";
export const GitHubReposTitle = "GitHub repos";
interface ResourceTreeProps {
container: Explorer;
}
export const ResourceTree: React.FC<ResourceTreeProps> = ({ container }: ResourceTreeProps): JSX.Element => {
const databases = useDatabases((state) => state.databases);
const {
isNotebookEnabled,
myNotebooksContentRoot,
galleryContentRoot,
gitHubNotebooksContentRoot,
updateNotebookItem,
} = useNotebook();
const { activeTab, refreshActiveTab } = useTabs();
const showScriptNodes = userContext.apiType === "SQL" || userContext.apiType === "Gremlin";
const pseudoDirPath = "PsuedoDir";
const buildGalleryCallout = (): JSX.Element => {
if (
LocalStorageUtility.hasItem(StorageKey.GalleryCalloutDismissed) &&
LocalStorageUtility.getEntryBoolean(StorageKey.GalleryCalloutDismissed)
) {
return undefined;
}
const calloutProps: ICalloutProps = {
calloutMaxWidth: 350,
ariaLabel: "New gallery",
role: "alertdialog",
gapSpace: 0,
target: ".galleryHeader",
directionalHint: DirectionalHint.leftTopEdge,
onDismiss: () => {
LocalStorageUtility.setEntryBoolean(StorageKey.GalleryCalloutDismissed, true);
},
setInitialFocus: true,
};
const openGalleryProps: ILinkProps = {
onClick: () => {
LocalStorageUtility.setEntryBoolean(StorageKey.GalleryCalloutDismissed, true);
container.openGallery();
},
};
return (
<Callout {...calloutProps}>
<Stack tokens={{ childrenGap: 10, padding: 20 }}>
<Text variant="xLarge" block>
New gallery
</Text>
<Text block>
Sample notebooks are now combined in gallery. View and try out samples provided by Microsoft and other
contributors.
</Text>
<Link {...openGalleryProps}>Open gallery</Link>
</Stack>
</Callout>
);
};
const buildNotebooksTree = (): TreeNode => {
const notebooksTree: TreeNode = {
label: undefined,
isExpanded: true,
children: [],
};
if (galleryContentRoot) {
notebooksTree.children.push(buildGalleryNotebooksTree());
}
if (myNotebooksContentRoot) {
notebooksTree.children.push(buildMyNotebooksTree());
}
if (container.notebookManager?.gitHubOAuthService.isLoggedIn()) {
// collapse all other notebook nodes
notebooksTree.children.forEach((node) => (node.isExpanded = false));
notebooksTree.children.push(buildGitHubNotebooksTree());
}
return notebooksTree;
};
const buildGalleryNotebooksTree = (): TreeNode => {
return {
label: "Gallery",
iconSrc: GalleryIcon,
className: "notebookHeader galleryHeader",
onClick: () => container.openGallery(),
isSelected: () => activeTab?.tabKind === ViewModels.CollectionTabKind.Gallery,
};
};
const buildMyNotebooksTree = (): TreeNode => {
const myNotebooksTree: TreeNode = buildNotebookDirectoryNode(
myNotebooksContentRoot,
(item: NotebookContentItem) => {
container.openNotebook(item).then((hasOpened) => {
if (hasOpened) {
mostRecentActivity.notebookWasItemOpened(userContext.databaseAccount?.id, item);
}
});
}
);
myNotebooksTree.isExpanded = true;
myNotebooksTree.isAlphaSorted = true;
// Remove "Delete" menu item from context menu
myNotebooksTree.contextMenu = myNotebooksTree.contextMenu.filter((menuItem) => menuItem.label !== "Delete");
return myNotebooksTree;
};
const buildGitHubNotebooksTree = (): TreeNode => {
const gitHubNotebooksTree: TreeNode = buildNotebookDirectoryNode(
gitHubNotebooksContentRoot,
(item: NotebookContentItem) => {
container.openNotebook(item).then((hasOpened) => {
if (hasOpened) {
mostRecentActivity.notebookWasItemOpened(userContext.databaseAccount?.id, item);
}
});
}
);
gitHubNotebooksTree.contextMenu = [
{
label: "Manage GitHub settings",
onClick: () =>
useSidePanel
.getState()
.openSidePanel(
"Manage GitHub settings",
<GitHubReposPanel
explorer={container}
gitHubClientProp={container.notebookManager.gitHubClient}
junoClientProp={container.notebookManager.junoClient}
/>
),
},
{
label: "Disconnect from GitHub",
onClick: () => {
TelemetryProcessor.trace(Action.NotebooksGitHubDisconnect, ActionModifiers.Mark, {
dataExplorerArea: Areas.Notebook,
});
container.notebookManager?.gitHubOAuthService.logout();
},
},
];
gitHubNotebooksTree.isExpanded = true;
gitHubNotebooksTree.isAlphaSorted = true;
return gitHubNotebooksTree;
};
const buildChildNodes = (
container: Explorer,
item: NotebookContentItem,
onFileClick: (item: NotebookContentItem) => void
): TreeNode[] => {
if (!item || !item.children) {
return [];
} else {
return item.children.map((item) => {
const result =
item.type === NotebookContentItemType.Directory
? buildNotebookDirectoryNode(item, onFileClick)
: buildNotebookFileNode(item, onFileClick);
result.timestamp = item.timestamp;
return result;
});
}
};
const buildNotebookFileNode = (
item: NotebookContentItem,
onFileClick: (item: NotebookContentItem) => void
): TreeNode => {
return {
label: item.name,
iconSrc: NotebookUtil.isNotebookFile(item.path) ? NotebookIcon : FileIcon,
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),
data: item,
};
};
const createFileContextMenu = (container: Explorer, item: NotebookContentItem): TreeNodeMenuItem[] => {
let items: TreeNodeMenuItem[] = [
{
label: "Rename",
iconSrc: NotebookIcon,
onClick: () => container.renameNotebook(item),
},
{
label: "Delete",
iconSrc: DeleteIcon,
onClick: () => {
container.showOkCancelModalDialog(
"Confirm delete",
`Are you sure you want to delete "${item.name}"`,
"Delete",
() => container.deleteNotebookFile(item),
"Cancel",
undefined
);
},
},
{
label: "Copy to ...",
iconSrc: CopyIcon,
onClick: () => copyNotebook(container, item),
},
{
label: "Download",
iconSrc: NotebookIcon,
onClick: () => container.downloadFile(item),
},
];
if (item.type === NotebookContentItemType.Notebook) {
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): TreeNodeMenuItem[] => {
let items: TreeNodeMenuItem[] = [
{
label: "Refresh",
iconSrc: RefreshIcon,
onClick: () => loadSubitems(item),
},
{
label: "Delete",
iconSrc: DeleteIcon,
onClick: () => {
container.showOkCancelModalDialog(
"Confirm delete",
`Are you sure you want to delete "${item.name}?"`,
"Delete",
() => container.deleteNotebookFile(item),
"Cancel",
undefined
);
},
},
{
label: "Rename",
iconSrc: NotebookIcon,
onClick: () => container.renameNotebook(item),
},
{
label: "New Directory",
iconSrc: NewNotebookIcon,
onClick: () => container.onCreateDirectory(item),
},
{
label: "New Notebook",
iconSrc: NewNotebookIcon,
onClick: () => container.onNewNotebookClicked(item),
},
{
label: "Upload File",
iconSrc: NewNotebookIcon,
onClick: () => container.openUploadFilePanel(item),
},
];
// 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
): TreeNode => {
return {
label: item.name,
iconSrc: undefined,
className: "notebookHeader",
isAlphaSorted: true,
isLeavesParentsSeparate: true,
onClick: () => {
if (!item.children) {
loadSubitems(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: item.path !== pseudoDirPath ? createDirectoryContextMenu(container, item) : undefined,
data: item,
children: buildChildNodes(container, item, onFileClick),
};
};
const buildDataTree = (): TreeNode => {
const databaseTreeNodes: TreeNode[] = databases.map((database: ViewModels.Database) => {
const databaseNode: TreeNode = {
label: database.id(),
iconSrc: CosmosDBIcon,
isExpanded: false,
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({
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))
);
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: collection.getLabel(),
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()) {
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()) {
children.push({
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: false,
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: () => {
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: () => {
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: () => {
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): Promise<void> => {
const updatedItem = await container.notebookManager?.notebookContentClient?.updateItemChildren(item);
updateNotebookItem(updatedItem);
};
const dataRootNode = buildDataTree();
if (isNotebookEnabled) {
return (
<>
<AccordionComponent>
<AccordionItemComponent title={"DATA"} isExpanded={!gitHubNotebooksContentRoot}>
<TreeComponent className="dataResourceTree" rootNode={dataRootNode} />
</AccordionItemComponent>
<AccordionItemComponent title={"NOTEBOOKS"}>
<TreeComponent className="notebookResourceTree" rootNode={buildNotebooksTree()} />
</AccordionItemComponent>
</AccordionComponent>
{buildGalleryCallout()}
</>
);
}
return <TreeComponent className="dataResourceTree" rootNode={dataRootNode} />;
};

View File

@ -16,10 +16,9 @@ import { Areas } from "../../Common/Constants";
import { isPublicInternetAccessAllowed } from "../../Common/DatabaseAccountUtility"; import { isPublicInternetAccessAllowed } from "../../Common/DatabaseAccountUtility";
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 { GitHubOAuthService } from "../../GitHub/GitHubOAuthService";
import { useSidePanel } from "../../hooks/useSidePanel"; import { useSidePanel } from "../../hooks/useSidePanel";
import { useTabs } from "../../hooks/useTabs"; import { useTabs } from "../../hooks/useTabs";
import { IPinnedRepo, JunoClient } from "../../Juno/JunoClient"; import { IPinnedRepo } from "../../Juno/JunoClient";
import { LocalStorageUtility, StorageKey } from "../../Shared/StorageUtility"; import { LocalStorageUtility, StorageKey } from "../../Shared/StorageUtility";
import { Action, ActionModifiers, Source } from "../../Shared/Telemetry/TelemetryConstants"; import { Action, ActionModifiers, Source } from "../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
@ -56,8 +55,6 @@ export class ResourceTreeAdapter implements ReactAdapter {
public galleryContentRoot: NotebookContentItem; public galleryContentRoot: NotebookContentItem;
public myNotebooksContentRoot: NotebookContentItem; public myNotebooksContentRoot: NotebookContentItem;
public gitHubNotebooksContentRoot: NotebookContentItem; public gitHubNotebooksContentRoot: NotebookContentItem;
public junoClient: JunoClient;
public gitHubOAuthService: GitHubOAuthService;
public constructor(private container: Explorer) { public constructor(private container: Explorer) {
this.parameters = ko.observable(Date.now()); this.parameters = ko.observable(Date.now());
@ -74,8 +71,6 @@ export class ResourceTreeAdapter implements ReactAdapter {
useDatabases.subscribe(() => this.triggerRender()); useDatabases.subscribe(() => this.triggerRender());
this.triggerRender(); this.triggerRender();
this.junoClient = new JunoClient();
this.gitHubOAuthService = new GitHubOAuthService(this.junoClient);
} }
private traceMyNotebookTreeInfo() { private traceMyNotebookTreeInfo() {
@ -639,7 +634,7 @@ export class ResourceTreeAdapter implements ReactAdapter {
<GitHubReposPanel <GitHubReposPanel
explorer={this.container} explorer={this.container}
gitHubClientProp={this.container.notebookManager.gitHubClient} gitHubClientProp={this.container.notebookManager.gitHubClient}
junoClientProp={this.junoClient} junoClientProp={this.container.notebookManager.junoClient}
/> />
), ),
}, },

View File

@ -26,7 +26,7 @@ import "../less/TableStyles/fulldatatables.less";
import "../less/TableStyles/queryBuilder.less"; import "../less/TableStyles/queryBuilder.less";
import "../less/tree.less"; import "../less/tree.less";
import { CollapsedResourceTree } from "./Common/CollapsedResourceTree"; import { CollapsedResourceTree } from "./Common/CollapsedResourceTree";
import { ResourceTree } from "./Common/ResourceTree"; import { ResourceTreeContainer } from "./Common/ResourceTreeContainer";
import "./Explorer/Controls/Accordion/AccordionComponent.less"; import "./Explorer/Controls/Accordion/AccordionComponent.less";
import "./Explorer/Controls/CollapsiblePanel/CollapsiblePanelComponent.less"; import "./Explorer/Controls/CollapsiblePanel/CollapsiblePanelComponent.less";
import { Dialog } from "./Explorer/Controls/Dialog"; import { Dialog } from "./Explorer/Controls/Dialog";
@ -84,7 +84,11 @@ const App: React.FunctionComponent = () => {
<div id="resourcetree" data-test="resourceTreeId" className="resourceTree"> <div id="resourcetree" data-test="resourceTreeId" className="resourceTree">
<div className="collectionsTreeWithSplitter"> <div className="collectionsTreeWithSplitter">
{/* Collections Tree Expanded - Start */} {/* Collections Tree Expanded - Start */}
<ResourceTree toggleLeftPaneExpanded={toggleLeftPaneExpanded} isLeftPaneExpanded={isLeftPaneExpanded} /> <ResourceTreeContainer
container={explorer}
toggleLeftPaneExpanded={toggleLeftPaneExpanded}
isLeftPaneExpanded={isLeftPaneExpanded}
/>
{/* Collections Tree Expanded - End */} {/* Collections Tree Expanded - End */}
{/* Collections Tree Collapsed - Start */} {/* Collections Tree Collapsed - Start */}
<CollapsedResourceTree <CollapsedResourceTree

View File

@ -15,6 +15,7 @@ export type Features = {
readonly enableTtl: boolean; readonly enableTtl: boolean;
readonly executeSproc: boolean; readonly executeSproc: boolean;
readonly enableAadDataPlane: boolean; readonly enableAadDataPlane: boolean;
readonly enableKOResourceTree: boolean;
readonly hostedDataExplorer: boolean; readonly hostedDataExplorer: boolean;
readonly junoEndpoint?: string; readonly junoEndpoint?: string;
readonly livyEndpoint?: string; readonly livyEndpoint?: string;
@ -56,6 +57,7 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear
enableSDKoperations: "true" === get("enablesdkoperations"), enableSDKoperations: "true" === get("enablesdkoperations"),
enableSpark: "true" === get("enablespark"), enableSpark: "true" === get("enablespark"),
enableTtl: "true" === get("enablettl"), enableTtl: "true" === get("enablettl"),
enableKOResourceTree: "true" === get("enablekoresourcetree"),
executeSproc: "true" === get("dataexplorerexecutesproc"), executeSproc: "true" === get("dataexplorerexecutesproc"),
hostedDataExplorer: "true" === get("hosteddataexplorerenabled"), hostedDataExplorer: "true" === get("hosteddataexplorerenabled"),
junoEndpoint: get("junoendpoint"), junoEndpoint: get("junoendpoint"),

View File

@ -1,6 +1,5 @@
import { jest } from "@jest/globals"; import { jest } from "@jest/globals";
import "expect-playwright"; import "expect-playwright";
import { safeClick } from "../utils/safeClick";
import { generateUniqueName } from "../utils/shared"; import { generateUniqueName } from "../utils/shared";
jest.setTimeout(120000); jest.setTimeout(120000);
@ -20,9 +19,9 @@ test("Cassandra keyspace and table CRUD", async () => {
await explorer.click('[aria-label="addCollection-tableId"]'); await explorer.click('[aria-label="addCollection-tableId"]');
await explorer.fill('[aria-label="addCollection-tableId"]', tableId); await explorer.fill('[aria-label="addCollection-tableId"]', tableId);
await explorer.click("#sidePanelOkButton"); await explorer.click("#sidePanelOkButton");
await safeClick(explorer, `.nodeItem >> text=${keyspaceId}`); await explorer.click(`.nodeItem >> text=${keyspaceId}`, { timeout: 50000 });
await safeClick(explorer, `[data-test="${tableId}"] [aria-label="More"]`); await explorer.click(`[data-test="${tableId}"] [aria-label="More"]`);
await safeClick(explorer, 'button[role="menuitem"]:has-text("Delete Table")'); await explorer.click('button[role="menuitem"]:has-text("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"]`); await explorer.click(`[data-test="${keyspaceId}"] [aria-label="More"]`);

View File

@ -1,6 +1,5 @@
import { jest } from "@jest/globals"; import { jest } from "@jest/globals";
import "expect-playwright"; import "expect-playwright";
import { safeClick } from "../utils/safeClick";
import { generateDatabaseNameWithTimestamp, generateUniqueName } from "../utils/shared"; import { generateDatabaseNameWithTimestamp, generateUniqueName } from "../utils/shared";
jest.setTimeout(240000); jest.setTimeout(240000);
@ -20,11 +19,11 @@ test("Graph CRUD", async () => {
await explorer.fill('[aria-label="Graph id"]', containerId); await explorer.fill('[aria-label="Graph id"]', 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 safeClick(explorer, `.nodeItem >> text=${databaseId}`); await explorer.click(`.nodeItem >> text=${databaseId}`, { timeout: 50000 });
await safeClick(explorer, `.nodeItem >> text=${containerId}`); await explorer.click(`.nodeItem >> text=${containerId}`);
// Delete database and graph // Delete database and graph
await safeClick(explorer, `[data-test="${containerId}"] [aria-label="More"]`); await explorer.click(`[data-test="${containerId}"] [aria-label="More"]`);
await safeClick(explorer, 'button[role="menuitem"]:has-text("Delete Graph")'); await explorer.click('button[role="menuitem"]:has-text("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"]`); await explorer.click(`[data-test="${databaseId}"] [aria-label="More"]`);

View File

@ -1,6 +1,5 @@
import { jest } from "@jest/globals"; import { jest } from "@jest/globals";
import "expect-playwright"; import "expect-playwright";
import { safeClick } from "../utils/safeClick";
import { generateDatabaseNameWithTimestamp, generateUniqueName } from "../utils/shared"; import { generateDatabaseNameWithTimestamp, generateUniqueName } from "../utils/shared";
jest.setTimeout(240000); jest.setTimeout(240000);
@ -20,10 +19,10 @@ test("Mongo CRUD", async () => {
await explorer.fill('[aria-label="Collection id"]', containerId); await explorer.fill('[aria-label="Collection id"]', 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 safeClick(explorer, `.nodeItem >> text=${databaseId}`); await explorer.click(`.nodeItem >> text=${databaseId}`, { timeout: 50000 });
await safeClick(explorer, `.nodeItem >> text=${containerId}`); await explorer.click(`.nodeItem >> text=${containerId}`);
// Create indexing policy // Create indexing policy
await safeClick(explorer, ".nodeItem >> text=Settings"); await explorer.click(".nodeItem >> text=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");
@ -34,8 +33,8 @@ test("Mongo CRUD", async () => {
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 safeClick(explorer, `[data-test="${containerId}"] [aria-label="More"]`); await explorer.click(`[data-test="${containerId}"] [aria-label="More"]`);
await safeClick(explorer, 'button[role="menuitem"]:has-text("Delete Collection")'); await explorer.click('button[role="menuitem"]:has-text("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"]`); await explorer.click(`[data-test="${databaseId}"] [aria-label="More"]`);

View File

@ -1,6 +1,5 @@
import { jest } from "@jest/globals"; import { jest } from "@jest/globals";
import "expect-playwright"; import "expect-playwright";
import { safeClick } from "../utils/safeClick";
import { generateDatabaseNameWithTimestamp, generateUniqueName } from "../utils/shared"; import { generateDatabaseNameWithTimestamp, generateUniqueName } from "../utils/shared";
jest.setTimeout(240000); jest.setTimeout(240000);
@ -20,11 +19,11 @@ test("Mongo CRUD", async () => {
await explorer.fill('[aria-label="Collection id"]', containerId); await explorer.fill('[aria-label="Collection id"]', 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 safeClick(explorer, `.nodeItem >> text=${databaseId}`); explorer.click(`.nodeItem >> text=${databaseId}`, { timeout: 50000 });
await safeClick(explorer, `.nodeItem >> text=${containerId}`); explorer.click(`.nodeItem >> text=${containerId}`);
// Delete database and collection // Delete database and collection
await safeClick(explorer, `[data-test="${containerId}"] [aria-label="More"]`); explorer.click(`[data-test="${containerId}"] [aria-label="More"]`);
await safeClick(explorer, 'button[role="menuitem"]:has-text("Delete Collection")'); explorer.click('button[role="menuitem"]:has-text("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"]`); await explorer.click(`[data-test="${databaseId}"] [aria-label="More"]`);

View File

@ -1,6 +1,5 @@
import { jest } from "@jest/globals"; import { jest } from "@jest/globals";
import "expect-playwright"; import "expect-playwright";
import { safeClick } from "../utils/safeClick";
import { generateUniqueName } from "../utils/shared"; import { generateUniqueName } from "../utils/shared";
jest.setTimeout(120000); jest.setTimeout(120000);
@ -19,9 +18,9 @@ test("SQL CRUD", async () => {
await explorer.fill('[aria-label="Container id"]', containerId); await explorer.fill('[aria-label="Container id"]', 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 safeClick(explorer, `.nodeItem >> text=${databaseId}`); await explorer.click(`.nodeItem >> text=${databaseId}`, { timeout: 50000 });
await safeClick(explorer, `[data-test="${containerId}"] [aria-label="More"]`); await explorer.click(`[data-test="${containerId}"] [aria-label="More"]`);
await safeClick(explorer, 'button[role="menuitem"]:has-text("Delete Container")'); await explorer.click('button[role="menuitem"]:has-text("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"]`); await explorer.click(`[data-test="${databaseId}"] [aria-label="More"]`);

View File

@ -1,6 +1,5 @@
import { jest } from "@jest/globals"; import { jest } from "@jest/globals";
import "expect-playwright"; import "expect-playwright";
import { safeClick } from "../utils/safeClick";
import { generateUniqueName } from "../utils/shared"; import { generateUniqueName } from "../utils/shared";
jest.setTimeout(120000); jest.setTimeout(120000);
@ -17,9 +16,9 @@ test("Tables CRUD", async () => {
await explorer.click('[data-test="New Table"]'); await explorer.click('[data-test="New Table"]');
await explorer.fill('[aria-label="Table id"]', tableId); await explorer.fill('[aria-label="Table id"]', tableId);
await explorer.click("#sidePanelOkButton"); await explorer.click("#sidePanelOkButton");
await safeClick(explorer, `[data-test="TablesDB"]`); await explorer.click(`[data-test="TablesDB"]`, { timeout: 50000 });
await safeClick(explorer, `[data-test="${tableId}"] [aria-label="More"]`); await explorer.click(`[data-test="${tableId}"] [aria-label="More"]`);
await safeClick(explorer, 'button[role="menuitem"]:has-text("Delete Table")'); await explorer.click('button[role="menuitem"]:has-text("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 expect(explorer).not.toHaveText(".dataResourceTree", tableId); await expect(explorer).not.toHaveText(".dataResourceTree", tableId);

View File

@ -1,11 +0,0 @@
import { Frame } from "playwright";
export async function safeClick(page: Frame, selector: string): Promise<void> {
// TODO: Remove. Playwright does this for you... mostly.
// But our knockout+react setup sometimes leaves dom nodes detached and even playwright can't recover.
// Resource tree is particually bad.
// Ideally this should only be added as a last resort
await page.waitForSelector(selector);
await page.waitForTimeout(5000);
await page.click(selector);
}

View File

@ -26,7 +26,6 @@
"./src/Common/ObjectCache.ts", "./src/Common/ObjectCache.ts",
"./src/Common/OfferUtility.test.ts", "./src/Common/OfferUtility.test.ts",
"./src/Common/OfferUtility.ts", "./src/Common/OfferUtility.ts",
"./src/Common/ResourceTree.tsx",
"./src/Common/Splitter.ts", "./src/Common/Splitter.ts",
"./src/Common/ThemeUtility.ts", "./src/Common/ThemeUtility.ts",
"./src/Common/UrlUtility.ts", "./src/Common/UrlUtility.ts",
@ -142,7 +141,7 @@
"./src/userContext.test.ts", "./src/userContext.test.ts",
"src/Common/EntityValue.tsx", "src/Common/EntityValue.tsx",
"./src/Platform/Hosted/Components/SwitchAccount.tsx", "./src/Platform/Hosted/Components/SwitchAccount.tsx",
"./src/Platform/Hosted/Components/SwitchSubscription.tsx", "./src/Platform/Hosted/Components/SwitchSubscription.tsx"
], ],
"include": [ "include": [
"src/CellOutputViewer/transforms/**/*", "src/CellOutputViewer/transforms/**/*",
@ -168,4 +167,4 @@
"src/Terminal/**/*", "src/Terminal/**/*",
"src/Utils/arm/**/*" "src/Utils/arm/**/*"
] ]
} }