diff --git a/.env.example b/.env.example index 62538cbc0..ea79c9a84 100644 --- a/.env.example +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.eslintignore b/.eslintignore index 15d8762b6..8c86e0e52 100644 --- a/.eslintignore +++ b/.eslintignore @@ -191,4 +191,5 @@ src/Explorer/Notebook/NotebookRenderer/decorators/kbd-shortcuts/index.tsx src/Explorer/Notebook/temp/inputs/connected-editors/codemirror.tsx src/Explorer/Tree/ResourceTreeAdapter.tsx __mocks__/monaco-editor.ts -src/Explorer/Tree/ResourceTreeAdapterForResourceToken.test.tsx \ No newline at end of file +src/Explorer/Tree/ResourceTreeAdapterForResourceToken.test.tsx +src/Explorer/Tree/ResourceTree.tsx \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 997269344..319b7ee6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5583,6 +5583,11 @@ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz", "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": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", diff --git a/package.json b/package.json index 886b54c8f..0135c1797 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@octokit/rest": "17.9.2", "@phosphor/widgets": "1.9.3", "@testing-library/jest-dom": "5.11.9", + "@types/lodash": "4.14.171", "@types/mkdirp": "1.0.1", "@types/node-fetch": "2.5.7", "applicationinsights": "1.8.0", diff --git a/src/Common/ResourceTree.tsx b/src/Common/ResourceTreeContainer.tsx similarity index 83% rename from src/Common/ResourceTree.tsx rename to src/Common/ResourceTreeContainer.tsx index da9c1bad4..129a38115 100644 --- a/src/Common/ResourceTree.tsx +++ b/src/Common/ResourceTreeContainer.tsx @@ -2,17 +2,21 @@ import React, { FunctionComponent } from "react"; import arrowLeftImg from "../../images/imgarrowlefticon.svg"; import refreshImg from "../../images/refresh-cosmos.svg"; import { AuthType } from "../AuthType"; +import Explorer from "../Explorer/Explorer"; +import { ResourceTree } from "../Explorer/Tree/ResourceTree"; import { userContext } from "../UserContext"; -export interface ResourceTreeProps { +export interface ResourceTreeContainerProps { toggleLeftPaneExpanded: () => void; isLeftPaneExpanded: boolean; + container: Explorer; } -export const ResourceTree: FunctionComponent = ({ +export const ResourceTreeContainer: FunctionComponent = ({ toggleLeftPaneExpanded, isLeftPaneExpanded, -}: ResourceTreeProps): JSX.Element => { + container, +}: ResourceTreeContainerProps): JSX.Element => { return (
{/* Collections Window - - Start */} @@ -49,8 +53,10 @@ export const ResourceTree: FunctionComponent = ({
{userContext.authType === AuthType.ResourceToken ? (
- ) : ( + ) : userContext.features.enableKOResourceTree ? (
+ ) : ( + )}
{/* Collections Window - End */} diff --git a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap index edb2e91db..cb8ecbfaf 100644 --- a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap @@ -42,15 +42,6 @@ exports[`SettingsComponent renders 1`] = ` "resourceTree": ResourceTreeAdapter { "container": [Circular], "copyNotebook": [Function], - "gitHubOAuthService": GitHubOAuthService { - "junoClient": JunoClient { - "cachedPinnedRepos": [Function], - }, - "token": [Function], - }, - "junoClient": JunoClient { - "cachedPinnedRepos": [Function], - }, "parameters": [Function], }, "resourceTreeForResourceToken": ResourceTreeAdapterForResourceToken { @@ -122,15 +113,6 @@ exports[`SettingsComponent renders 1`] = ` "resourceTree": ResourceTreeAdapter { "container": [Circular], "copyNotebook": [Function], - "gitHubOAuthService": GitHubOAuthService { - "junoClient": JunoClient { - "cachedPinnedRepos": [Function], - }, - "token": [Function], - }, - "junoClient": JunoClient { - "cachedPinnedRepos": [Function], - }, "parameters": [Function], }, "resourceTreeForResourceToken": ResourceTreeAdapterForResourceToken { diff --git a/src/Explorer/Explorer.tsx b/src/Explorer/Explorer.tsx index 7a8d2465b..ac1e077d6 100644 --- a/src/Explorer/Explorer.tsx +++ b/src/Explorer/Explorer.tsx @@ -362,6 +362,9 @@ export default class Explorer { notebookServerEndpoint: userContext.features.notebookServerUrl || connectionInfo.notebookServerEndpoint, authToken: userContext.features.notebookServerToken || connectionInfo.authToken, }); + + useNotebook.getState().initializeNotebooksTree(this.notebookManager); + this.refreshNotebookList(); this._isInitializingNotebooks = false; @@ -842,6 +845,8 @@ export default class Explorer { } await this.resourceTree.initialize(); + await useNotebook.getState().initializeNotebooksTree(this.notebookManager); + this.notebookManager?.refreshPinnedRepos(); if (this.notebookToImport) { this.importAndOpenContent(this.notebookToImport.name, this.notebookToImport.content); @@ -932,14 +937,15 @@ export default class Explorer { .finally(clearInProgressMessage); } - public refreshContentItem(item: NotebookContentItem): Promise { + // TODO: Delete this function when ResourceTreeAdapter is removed. + public async refreshContentItem(item: NotebookContentItem): Promise { if (!useNotebook.getState().isNotebookEnabled || !this.notebookManager?.notebookContentClient) { const error = "Attempt to refresh notebook list, but notebook is not enabled"; handleError(error, "Explorer/refreshContentItem"); return Promise.reject(new Error(error)); } - return this.notebookManager?.notebookContentClient.updateItemChildren(item); + await this.notebookManager?.notebookContentClient.updateItemChildrenInPlace(item); } public openNotebookTerminal(kind: ViewModels.TerminalKind) { diff --git a/src/Explorer/Notebook/NotebookContentClient.ts b/src/Explorer/Notebook/NotebookContentClient.ts index a4a9958d0..3599c009c 100644 --- a/src/Explorer/Notebook/NotebookContentClient.ts +++ b/src/Explorer/Notebook/NotebookContentClient.ts @@ -1,5 +1,6 @@ import { stringifyNotebook } from "@nteract/commutable"; import { FileType, IContent, IContentProvider, IEmptyContent, ServerConfig } from "@nteract/core"; +import { cloneDeep } from "lodash"; import { AjaxResponse } from "rxjs/ajax"; import * as StringUtils from "../../Utils/StringUtils"; 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 * @param item */ - public updateItemChildren(item: NotebookContentItem): Promise { + public async updateItemChildren(item: NotebookContentItem): Promise { + 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 { return this.fetchNotebookFiles(item.path).then((subItems) => { item.children = subItems; subItems.forEach((subItem) => (subItem.parent = item)); @@ -55,18 +66,20 @@ export class NotebookContentClient { }); } - public deleteContentItem(item: NotebookContentItem): Promise { - return this.deleteNotebookFile(item.path).then((path: string) => { - if (!path || path !== item.path) { - throw new Error("No path provided"); - } + public async deleteContentItem(item: NotebookContentItem): Promise { + const path = await this.deleteNotebookFile(item.path); + useNotebook.getState().deleteNotebookItem(item); - if (item.parent && item.parent.children) { - // Remove deleted child - const newChildren = item.parent.children.filter((child) => child.path !== path); - item.parent.children = newChildren; - } - }); + // TODO: Delete once old resource tree is removed + if (!path || path !== item.path) { + throw new Error("No path provided"); + } + + if (item.parent && item.parent.children) { + // Remove deleted child + const newChildren = item.parent.children.filter((child) => child.path !== path); + item.parent.children = newChildren; + } } /** diff --git a/src/Explorer/Notebook/useNotebook.ts b/src/Explorer/Notebook/useNotebook.ts index ecf927565..75b368fc4 100644 --- a/src/Explorer/Notebook/useNotebook.ts +++ b/src/Explorer/Notebook/useNotebook.ts @@ -1,3 +1,4 @@ +import { cloneDeep } from "lodash"; import create, { UseStore } from "zustand"; import { AuthType } from "../../AuthType"; import * as Constants from "../../Common/Constants"; @@ -5,8 +6,12 @@ import { getErrorMessage } from "../../Common/ErrorHandlingUtils"; import * as Logger from "../../Common/Logger"; import { configContext } from "../../ConfigContext"; 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 { getAuthorizationHeader } from "../../Utils/AuthorizationUtils"; +import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem"; +import NotebookManager from "./NotebookManager"; interface NotebookState { isNotebookEnabled: boolean; @@ -18,6 +23,9 @@ interface NotebookState { isShellEnabled: boolean; notebookBasePath: string; isInitializingNotebooks: boolean; + myNotebooksContentRoot: NotebookContentItem; + gitHubNotebooksContentRoot: NotebookContentItem; + galleryContentRoot: NotebookContentItem; setIsNotebookEnabled: (isNotebookEnabled: boolean) => void; setIsNotebooksEnabledForAccount: (isNotebooksEnabledForAccount: boolean) => void; setNotebookServerInfo: (notebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo) => void; @@ -27,9 +35,13 @@ interface NotebookState { setIsShellEnabled: (isShellEnabled: boolean) => void; setNotebookBasePath: (notebookBasePath: string) => void; refreshNotebooksEnabledStateForAccount: () => Promise; + findItem: (root: NotebookContentItem, item: NotebookContentItem) => NotebookContentItem; + updateNotebookItem: (item: NotebookContentItem) => void; + deleteNotebookItem: (item: NotebookContentItem) => void; + initializeNotebooksTree: (notebookManager: NotebookManager) => Promise; } -export const useNotebook: UseStore = create((set) => ({ +export const useNotebook: UseStore = create((set, get) => ({ isNotebookEnabled: false, isNotebooksEnabledForAccount: false, notebookServerInfo: { @@ -46,6 +58,9 @@ export const useNotebook: UseStore = create((set) => ({ isShellEnabled: false, notebookBasePath: Constants.Notebook.defaultBasePath, isInitializingNotebooks: false, + myNotebooksContentRoot: undefined, + gitHubNotebooksContentRoot: undefined, + galleryContentRoot: undefined, setIsNotebookEnabled: (isNotebookEnabled: boolean) => set({ isNotebookEnabled }), setIsNotebooksEnabledForAccount: (isNotebooksEnabledForAccount: boolean) => set({ isNotebooksEnabledForAccount }), setNotebookServerInfo: (notebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo) => @@ -103,4 +118,92 @@ export const useNotebook: UseStore = create((set) => ({ 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 => { + 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 }); + } + } + }, })); diff --git a/src/Explorer/Panes/GitHubReposPanel/__snapshots__/GitHubReposPanel.test.tsx.snap b/src/Explorer/Panes/GitHubReposPanel/__snapshots__/GitHubReposPanel.test.tsx.snap index f1c03499b..9d1b8cc0c 100644 --- a/src/Explorer/Panes/GitHubReposPanel/__snapshots__/GitHubReposPanel.test.tsx.snap +++ b/src/Explorer/Panes/GitHubReposPanel/__snapshots__/GitHubReposPanel.test.tsx.snap @@ -31,15 +31,6 @@ exports[`GitHub Repos Panel should render Default properly 1`] = ` "resourceTree": ResourceTreeAdapter { "container": [Circular], "copyNotebook": [Function], - "gitHubOAuthService": GitHubOAuthService { - "junoClient": JunoClient { - "cachedPinnedRepos": [Function], - }, - "token": [Function], - }, - "junoClient": JunoClient { - "cachedPinnedRepos": [Function], - }, "parameters": [Function], }, "resourceTreeForResourceToken": ResourceTreeAdapterForResourceToken { diff --git a/src/Explorer/Panes/StringInputPane/__snapshots__/StringInputPane.test.tsx.snap b/src/Explorer/Panes/StringInputPane/__snapshots__/StringInputPane.test.tsx.snap index e159e0052..27f1f941d 100644 --- a/src/Explorer/Panes/StringInputPane/__snapshots__/StringInputPane.test.tsx.snap +++ b/src/Explorer/Panes/StringInputPane/__snapshots__/StringInputPane.test.tsx.snap @@ -21,15 +21,6 @@ exports[`StringInput Pane should render Create new directory properly 1`] = ` "resourceTree": ResourceTreeAdapter { "container": [Circular], "copyNotebook": [Function], - "gitHubOAuthService": GitHubOAuthService { - "junoClient": JunoClient { - "cachedPinnedRepos": [Function], - }, - "token": [Function], - }, - "junoClient": JunoClient { - "cachedPinnedRepos": [Function], - }, "parameters": [Function], }, "resourceTreeForResourceToken": ResourceTreeAdapterForResourceToken { diff --git a/src/Explorer/Tree/Collection.ts b/src/Explorer/Tree/Collection.ts index c486da03e..cefa6bbeb 100644 --- a/src/Explorer/Tree/Collection.ts +++ b/src/Explorer/Tree/Collection.ts @@ -571,8 +571,8 @@ export default class Collection implements ViewModels.Collection { }; public onSettingsClick = async (): Promise => { - await this.loadOffer(); useSelectedNode.getState().setSelectedNode(this); + await this.loadOffer(); this.selectedSubnodeKind(ViewModels.CollectionTabKind.Settings); TelemetryProcessor.trace(Action.SelectItem, ActionModifiers.Mark, { description: "Settings node", diff --git a/src/Explorer/Tree/Database.tsx b/src/Explorer/Tree/Database.tsx index 5d4f83fc7..5dc01343f 100644 --- a/src/Explorer/Tree/Database.tsx +++ b/src/Explorer/Tree/Database.tsx @@ -57,7 +57,7 @@ export default class Database implements ViewModels.Database { this.isOfferRead = false; } - public onSettingsClick = () => { + public onSettingsClick = (): void => { useSelectedNode.getState().setSelectedNode(this); this.selectedSubnodeKind(ViewModels.CollectionTabKind.DatabaseSettings); TelemetryProcessor.trace(Action.SelectItem, ActionModifiers.Mark, { @@ -193,6 +193,8 @@ export default class Database implements ViewModels.Database { //merge collections this.addCollectionsToList(collectionVMs); this.deleteCollectionsFromList(deltaCollections.toDelete); + + useDatabases.getState().updateDatabase(this); } public async openAddCollection(database: Database): Promise { diff --git a/src/Explorer/Tree/ResourceTree.tsx b/src/Explorer/Tree/ResourceTree.tsx new file mode 100644 index 000000000..b5b6dfe92 --- /dev/null +++ b/src/Explorer/Tree/ResourceTree.tsx @@ -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 = ({ 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 ( + + + + New gallery + + + Sample notebooks are now combined in gallery. View and try out samples provided by Microsoft and other + contributors. + + Open gallery + + + ); + }; + + 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", + + ), + }, + { + 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 => { + const updatedItem = await container.notebookManager?.notebookContentClient?.updateItemChildren(item); + updateNotebookItem(updatedItem); + }; + + const dataRootNode = buildDataTree(); + + if (isNotebookEnabled) { + return ( + <> + + + + + + + + + + {buildGalleryCallout()} + + ); + } + + return ; +}; diff --git a/src/Explorer/Tree/ResourceTreeAdapter.tsx b/src/Explorer/Tree/ResourceTreeAdapter.tsx index 85b6f4a53..6a00e92e9 100644 --- a/src/Explorer/Tree/ResourceTreeAdapter.tsx +++ b/src/Explorer/Tree/ResourceTreeAdapter.tsx @@ -16,10 +16,9 @@ import { Areas } from "../../Common/Constants"; import { isPublicInternetAccessAllowed } from "../../Common/DatabaseAccountUtility"; import * as DataModels from "../../Contracts/DataModels"; import * as ViewModels from "../../Contracts/ViewModels"; -import { GitHubOAuthService } from "../../GitHub/GitHubOAuthService"; import { useSidePanel } from "../../hooks/useSidePanel"; import { useTabs } from "../../hooks/useTabs"; -import { IPinnedRepo, JunoClient } from "../../Juno/JunoClient"; +import { IPinnedRepo } from "../../Juno/JunoClient"; import { LocalStorageUtility, StorageKey } from "../../Shared/StorageUtility"; import { Action, ActionModifiers, Source } from "../../Shared/Telemetry/TelemetryConstants"; import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; @@ -56,8 +55,6 @@ export class ResourceTreeAdapter implements ReactAdapter { public galleryContentRoot: NotebookContentItem; public myNotebooksContentRoot: NotebookContentItem; public gitHubNotebooksContentRoot: NotebookContentItem; - public junoClient: JunoClient; - public gitHubOAuthService: GitHubOAuthService; public constructor(private container: Explorer) { this.parameters = ko.observable(Date.now()); @@ -74,8 +71,6 @@ export class ResourceTreeAdapter implements ReactAdapter { useDatabases.subscribe(() => this.triggerRender()); this.triggerRender(); - this.junoClient = new JunoClient(); - this.gitHubOAuthService = new GitHubOAuthService(this.junoClient); } private traceMyNotebookTreeInfo() { @@ -639,7 +634,7 @@ export class ResourceTreeAdapter implements ReactAdapter { ), }, diff --git a/src/Main.tsx b/src/Main.tsx index c85a39f10..80d008b17 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -26,7 +26,7 @@ import "../less/TableStyles/fulldatatables.less"; import "../less/TableStyles/queryBuilder.less"; import "../less/tree.less"; import { CollapsedResourceTree } from "./Common/CollapsedResourceTree"; -import { ResourceTree } from "./Common/ResourceTree"; +import { ResourceTreeContainer } from "./Common/ResourceTreeContainer"; import "./Explorer/Controls/Accordion/AccordionComponent.less"; import "./Explorer/Controls/CollapsiblePanel/CollapsiblePanelComponent.less"; import { Dialog } from "./Explorer/Controls/Dialog"; @@ -84,7 +84,11 @@ const App: React.FunctionComponent = () => {
{/* Collections Tree Expanded - Start */} - + {/* Collections Tree Expanded - End */} {/* Collections Tree Collapsed - Start */} { await explorer.click('[aria-label="addCollection-tableId"]'); await explorer.fill('[aria-label="addCollection-tableId"]', tableId); await explorer.click("#sidePanelOkButton"); - await safeClick(explorer, `.nodeItem >> text=${keyspaceId}`); - await safeClick(explorer, `[data-test="${tableId}"] [aria-label="More"]`); - await safeClick(explorer, 'button[role="menuitem"]:has-text("Delete Table")'); + await explorer.click(`.nodeItem >> text=${keyspaceId}`, { timeout: 50000 }); + await explorer.click(`[data-test="${tableId}"] [aria-label="More"]`); + 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.click('[aria-label="OK"]'); await explorer.click(`[data-test="${keyspaceId}"] [aria-label="More"]`); diff --git a/test/graph/container.spec.ts b/test/graph/container.spec.ts index cf52b5bb4..8f7015ed3 100644 --- a/test/graph/container.spec.ts +++ b/test/graph/container.spec.ts @@ -1,6 +1,5 @@ import { jest } from "@jest/globals"; import "expect-playwright"; -import { safeClick } from "../utils/safeClick"; import { generateDatabaseNameWithTimestamp, generateUniqueName } from "../utils/shared"; jest.setTimeout(240000); @@ -20,11 +19,11 @@ test("Graph CRUD", async () => { await explorer.fill('[aria-label="Graph id"]', containerId); await explorer.fill('[aria-label="Partition key"]', "/pk"); await explorer.click("#sidePanelOkButton"); - await safeClick(explorer, `.nodeItem >> text=${databaseId}`); - await safeClick(explorer, `.nodeItem >> text=${containerId}`); + await explorer.click(`.nodeItem >> text=${databaseId}`, { timeout: 50000 }); + await explorer.click(`.nodeItem >> text=${containerId}`); // Delete database and graph - await safeClick(explorer, `[data-test="${containerId}"] [aria-label="More"]`); - await safeClick(explorer, 'button[role="menuitem"]:has-text("Delete Graph")'); + await explorer.click(`[data-test="${containerId}"] [aria-label="More"]`); + 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.click('[aria-label="OK"]'); await explorer.click(`[data-test="${databaseId}"] [aria-label="More"]`); diff --git a/test/mongo/container.spec.ts b/test/mongo/container.spec.ts index 64ccbba42..01d5a64f5 100644 --- a/test/mongo/container.spec.ts +++ b/test/mongo/container.spec.ts @@ -1,6 +1,5 @@ import { jest } from "@jest/globals"; import "expect-playwright"; -import { safeClick } from "../utils/safeClick"; import { generateDatabaseNameWithTimestamp, generateUniqueName } from "../utils/shared"; jest.setTimeout(240000); @@ -20,10 +19,10 @@ test("Mongo CRUD", async () => { await explorer.fill('[aria-label="Collection id"]', containerId); await explorer.fill('[aria-label="Shard key"]', "/pk"); await explorer.click("#sidePanelOkButton"); - await safeClick(explorer, `.nodeItem >> text=${databaseId}`); - await safeClick(explorer, `.nodeItem >> text=${containerId}`); + await explorer.click(`.nodeItem >> text=${databaseId}`, { timeout: 50000 }); + await explorer.click(`.nodeItem >> text=${containerId}`); // 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('[aria-label="Index Field Name 0"]'); 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('[data-test="Save"]'); // Delete database and collection - await safeClick(explorer, `[data-test="${containerId}"] [aria-label="More"]`); - await safeClick(explorer, 'button[role="menuitem"]:has-text("Delete Collection")'); + await explorer.click(`[data-test="${containerId}"] [aria-label="More"]`); + 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.click('[aria-label="OK"]'); await explorer.click(`[data-test="${databaseId}"] [aria-label="More"]`); diff --git a/test/mongo/container32.spec.ts b/test/mongo/container32.spec.ts index 30fdfd5fe..239ef31c2 100644 --- a/test/mongo/container32.spec.ts +++ b/test/mongo/container32.spec.ts @@ -1,6 +1,5 @@ import { jest } from "@jest/globals"; import "expect-playwright"; -import { safeClick } from "../utils/safeClick"; import { generateDatabaseNameWithTimestamp, generateUniqueName } from "../utils/shared"; jest.setTimeout(240000); @@ -20,11 +19,11 @@ test("Mongo CRUD", async () => { await explorer.fill('[aria-label="Collection id"]', containerId); await explorer.fill('[aria-label="Shard key"]', "pk"); await explorer.click("#sidePanelOkButton"); - await safeClick(explorer, `.nodeItem >> text=${databaseId}`); - await safeClick(explorer, `.nodeItem >> text=${containerId}`); + explorer.click(`.nodeItem >> text=${databaseId}`, { timeout: 50000 }); + explorer.click(`.nodeItem >> text=${containerId}`); // Delete database and collection - await safeClick(explorer, `[data-test="${containerId}"] [aria-label="More"]`); - await safeClick(explorer, 'button[role="menuitem"]:has-text("Delete Collection")'); + explorer.click(`[data-test="${containerId}"] [aria-label="More"]`); + 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.click('[aria-label="OK"]'); await explorer.click(`[data-test="${databaseId}"] [aria-label="More"]`); diff --git a/test/sql/container.spec.ts b/test/sql/container.spec.ts index e68930083..91af5ce9b 100644 --- a/test/sql/container.spec.ts +++ b/test/sql/container.spec.ts @@ -1,6 +1,5 @@ import { jest } from "@jest/globals"; import "expect-playwright"; -import { safeClick } from "../utils/safeClick"; import { generateUniqueName } from "../utils/shared"; jest.setTimeout(120000); @@ -19,9 +18,9 @@ test("SQL CRUD", async () => { await explorer.fill('[aria-label="Container id"]', containerId); await explorer.fill('[aria-label="Partition key"]', "/pk"); await explorer.click("#sidePanelOkButton"); - await safeClick(explorer, `.nodeItem >> text=${databaseId}`); - await safeClick(explorer, `[data-test="${containerId}"] [aria-label="More"]`); - await safeClick(explorer, 'button[role="menuitem"]:has-text("Delete Container")'); + await explorer.click(`.nodeItem >> text=${databaseId}`, { timeout: 50000 }); + await explorer.click(`[data-test="${containerId}"] [aria-label="More"]`); + 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.click('[aria-label="OK"]'); await explorer.click(`[data-test="${databaseId}"] [aria-label="More"]`); diff --git a/test/tables/container.spec.ts b/test/tables/container.spec.ts index b2337baa9..92ac6e41e 100644 --- a/test/tables/container.spec.ts +++ b/test/tables/container.spec.ts @@ -1,6 +1,5 @@ import { jest } from "@jest/globals"; import "expect-playwright"; -import { safeClick } from "../utils/safeClick"; import { generateUniqueName } from "../utils/shared"; jest.setTimeout(120000); @@ -17,9 +16,9 @@ test("Tables CRUD", async () => { await explorer.click('[data-test="New Table"]'); await explorer.fill('[aria-label="Table id"]', tableId); await explorer.click("#sidePanelOkButton"); - await safeClick(explorer, `[data-test="TablesDB"]`); - await safeClick(explorer, `[data-test="${tableId}"] [aria-label="More"]`); - await safeClick(explorer, 'button[role="menuitem"]:has-text("Delete Table")'); + await explorer.click(`[data-test="TablesDB"]`, { timeout: 50000 }); + await explorer.click(`[data-test="${tableId}"] [aria-label="More"]`); + 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.click('[aria-label="OK"]'); await expect(explorer).not.toHaveText(".dataResourceTree", tableId); diff --git a/test/utils/safeClick.ts b/test/utils/safeClick.ts deleted file mode 100644 index d0c307bd0..000000000 --- a/test/utils/safeClick.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Frame } from "playwright"; - -export async function safeClick(page: Frame, selector: string): Promise { - // 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); -} diff --git a/tsconfig.strict.json b/tsconfig.strict.json index 6a9a6e78a..6b80784b5 100644 --- a/tsconfig.strict.json +++ b/tsconfig.strict.json @@ -26,7 +26,6 @@ "./src/Common/ObjectCache.ts", "./src/Common/OfferUtility.test.ts", "./src/Common/OfferUtility.ts", - "./src/Common/ResourceTree.tsx", "./src/Common/Splitter.ts", "./src/Common/ThemeUtility.ts", "./src/Common/UrlUtility.ts", @@ -142,7 +141,7 @@ "./src/userContext.test.ts", "src/Common/EntityValue.tsx", "./src/Platform/Hosted/Components/SwitchAccount.tsx", - "./src/Platform/Hosted/Components/SwitchSubscription.tsx", + "./src/Platform/Hosted/Components/SwitchSubscription.tsx" ], "include": [ "src/CellOutputViewer/transforms/**/*", @@ -168,4 +167,4 @@ "src/Terminal/**/*", "src/Utils/arm/**/*" ] -} \ No newline at end of file +}