import { Callout, DirectionalHint, ICalloutProps, ILinkProps, Link, Stack, Text } from "@fluentui/react"; import * as React from "react"; import shallow from "zustand/shallow"; 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, ConnectionStatusType, Notebook } 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 { useDialog } from "../Controls/Dialog"; 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( (state) => ({ isNotebookEnabled: state.isNotebookEnabled, myNotebooksContentRoot: state.myNotebooksContentRoot, galleryContentRoot: state.galleryContentRoot, gitHubNotebooksContentRoot: state.gitHubNotebooksContentRoot, updateNotebookItem: state.updateNotebookItem, }), shallow ); 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 (userContext.features.notebooksTemporarilyDown) { notebooksTree.children.push(buildNotebooksTemporarilyDownTree()); } else { if (galleryContentRoot) { notebooksTree.children.push(buildGalleryNotebooksTree()); } if (myNotebooksContentRoot && useNotebook.getState().connectionInfo.status == ConnectionStatusType.Connected) { 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(true)); } } return notebooksTree; }; const buildNotebooksTemporarilyDownTree = (): TreeNode => { return { label: Notebook.temporarilyDownMsg, className: "clickDisabled", }; }; 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 = (isConnected: boolean): TreeNode => { const gitHubNotebooksTree: TreeNode = buildNotebookDirectoryNode( gitHubNotebooksContentRoot, (item: NotebookContentItem) => { container.openNotebook(item).then((hasOpened) => { if (hasOpened) { mostRecentActivity.notebookWasItemOpened(userContext.databaseAccount?.id, item); } }); }, true ); const manageGitContextMenu: TreeNodeMenuItem[] = [ { 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(); }, }, ]; const connectGitContextMenu: TreeNodeMenuItem[] = [ { label: "Connect to GitHub", onClick: () => useSidePanel .getState() .openSidePanel( "Connect to GitHub", ), }, ]; gitHubNotebooksTree.contextMenu = isConnected ? manageGitContextMenu : connectGitContextMenu; gitHubNotebooksTree.isExpanded = true; gitHubNotebooksTree.isAlphaSorted = true; return gitHubNotebooksTree; }; const buildChildNodes = ( item: NotebookContentItem, onFileClick: (item: NotebookContentItem) => void, isGithubTree?: boolean ): TreeNode[] => { if (!item || !item.children) { return []; } else { return item.children.map((item) => { const result = item.type === NotebookContentItemType.Directory ? buildNotebookDirectoryNode(item, onFileClick, isGithubTree) : buildNotebookFileNode(item, onFileClick, isGithubTree); result.timestamp = item.timestamp; return result; }); } }; const buildNotebookFileNode = ( item: NotebookContentItem, onFileClick: (item: NotebookContentItem) => void, isGithubTree?: boolean ): 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, isGithubTree), data: item, }; }; const createFileContextMenu = ( container: Explorer, item: NotebookContentItem, isGithubTree?: boolean ): TreeNodeMenuItem[] => { let items: TreeNodeMenuItem[] = [ { label: "Rename", iconSrc: NotebookIcon, onClick: () => container.renameNotebook(item, isGithubTree), }, { label: "Delete", iconSrc: DeleteIcon, onClick: () => { useDialog .getState() .showOkCancelModalDialog( "Confirm delete", `Are you sure you want to delete "${item.name}"`, "Delete", () => container.deleteNotebookFile(item, isGithubTree), "Cancel", undefined ); }, }, { label: "Copy to ...", iconSrc: CopyIcon, onClick: () => copyNotebook(container, item), }, { label: "Download", iconSrc: NotebookIcon, onClick: () => container.downloadFile(item), }, ]; if (item.type === NotebookContentItemType.Notebook) { items.push({ label: "Publish to gallery", iconSrc: PublishIcon, onClick: async () => { TelemetryProcessor.trace(Action.NotebooksGalleryClickPublishToGallery, ActionModifiers.Mark, { source: Source.ResourceTreeMenu, }); const content = await container.readFile(item); if (content) { await container.publishNotebook(item.name, content); } }, }); } // "Copy to ..." isn't needed if github locations are not available if (!container.notebookManager?.gitHubOAuthService.isLoggedIn()) { items = items.filter((item) => item.label !== "Copy to ..."); } return items; }; const copyNotebook = async (container: Explorer, item: NotebookContentItem) => { const content = await container.readFile(item); if (content) { container.copyNotebook(item.name, content); } }; const createDirectoryContextMenu = ( container: Explorer, item: NotebookContentItem, isGithubTree?: boolean ): TreeNodeMenuItem[] => { let items: TreeNodeMenuItem[] = [ { label: "Refresh", iconSrc: RefreshIcon, onClick: () => loadSubitems(item, isGithubTree), }, { label: "Delete", iconSrc: DeleteIcon, onClick: () => { useDialog .getState() .showOkCancelModalDialog( "Confirm delete", `Are you sure you want to delete "${item.name}?"`, "Delete", () => container.deleteNotebookFile(item, isGithubTree), "Cancel", undefined ); }, }, { label: "Rename", iconSrc: NotebookIcon, onClick: () => container.renameNotebook(item, isGithubTree), }, { label: "New Directory", iconSrc: NewNotebookIcon, onClick: () => container.onCreateDirectory(item, isGithubTree), }, { label: "New Notebook", iconSrc: NewNotebookIcon, onClick: () => container.onNewNotebookClicked(item, isGithubTree), }, { 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, isGithubTree?: boolean ): TreeNode => { return { label: item.name, iconSrc: undefined, className: "notebookHeader", isAlphaSorted: true, isLeavesParentsSeparate: true, onClick: () => { if (!item.children) { loadSubitems(item, isGithubTree); } }, isSelected: () => { return ( activeTab && activeTab.tabKind === ViewModels.CollectionTabKind.NotebookV2 && /* TODO Redesign Tab interface so that resource tree doesn't need to know about NotebookV2Tab. NotebookV2Tab could be dynamically imported, but not worth it to just get this type right. */ (activeTab as any).notebookPath() === item.path ); }, contextMenu: item.path !== pseudoDirPath ? createDirectoryContextMenu(container, item, isGithubTree) : undefined, data: item, children: buildChildNodes(item, onFileClick, isGithubTree), }; }; const buildDataTree = (): TreeNode => { const databaseTreeNodes: TreeNode[] = databases.map((database: ViewModels.Database) => { const databaseNode: TreeNode = { label: database.id(), iconSrc: CosmosDBIcon, isExpanded: 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() && !userContext.features.notebooksTemporarilyDown ) { 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, isGithubTree?: boolean): Promise => { const updatedItem = await container.notebookManager?.notebookContentClient?.updateItemChildren(item); updateNotebookItem(updatedItem, isGithubTree); }; const dataRootNode = buildDataTree(); if (isNotebookEnabled) { return ( <> {buildGalleryCallout()} ); } return ; };