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

Co-authored-by: Laurent Nguyen <laurent.nguyen@microsoft.com>
This commit is contained in:
Ashley Stanton-Nurse
2024-05-29 09:56:27 -07:00
committed by GitHub
parent cebf044803
commit 98c5fe65e6
38 changed files with 5866 additions and 1333 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,522 @@
import { TreeNode } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import TabsBase from "Explorer/Tabs/TabsBase";
import StoredProcedure from "Explorer/Tree/StoredProcedure";
import Trigger from "Explorer/Tree/Trigger";
import UserDefinedFunction from "Explorer/Tree/UserDefinedFunction";
import { useDatabases } from "Explorer/useDatabases";
import { getItemName } from "Utils/APITypeUtils";
import { isServerlessAccount } from "Utils/CapabilityUtils";
import { useTabs } from "hooks/useTabs";
import CosmosDBIcon from "../../../images/Azure-Cosmos-DB.svg";
import CollectionIcon from "../../../images/tree-collection.svg";
import { isPublicInternetAccessAllowed } from "../../Common/DatabaseAccountUtility";
import { Platform, configContext } from "../../ConfigContext";
import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels";
import { userContext } from "../../UserContext";
import * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFactory";
import Explorer from "../Explorer";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import { mostRecentActivity } from "../MostRecentActivity/MostRecentActivity";
import { useNotebook } from "../Notebook/useNotebook";
import { useSelectedNode } from "../useSelectedNode";
export const shouldShowScriptNodes = (): boolean => {
return (
configContext.platform !== Platform.Fabric && (userContext.apiType === "SQL" || userContext.apiType === "Gremlin")
);
};
export const createSampleDataTreeNodes = (sampleDataResourceTokenCollection: ViewModels.CollectionBase): TreeNode[] => {
const updatedSampleTree: TreeNode = {
label: sampleDataResourceTokenCollection.databaseId,
isExpanded: false,
iconSrc: CosmosDBIcon,
className: "databaseHeader",
children: [
{
label: sampleDataResourceTokenCollection.id(),
iconSrc: CollectionIcon,
isExpanded: false,
className: "collectionHeader",
contextMenu: ResourceTreeContextMenuButtonFactory.createSampleCollectionContextMenuButton(),
onClick: () => {
useSelectedNode.getState().setSelectedNode(sampleDataResourceTokenCollection);
useCommandBar.getState().setContextButtons([]);
useTabs
.getState()
.refreshActiveTab(
(tab: TabsBase) =>
tab.collection?.id() === sampleDataResourceTokenCollection.id() &&
tab.collection.databaseId === sampleDataResourceTokenCollection.databaseId,
);
},
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(sampleDataResourceTokenCollection.databaseId, sampleDataResourceTokenCollection.id()),
onContextMenuOpen: () => useSelectedNode.getState().setSelectedNode(sampleDataResourceTokenCollection),
children: [
{
label: "Items",
onClick: () => sampleDataResourceTokenCollection.onDocumentDBDocumentsClick(),
contextMenu: ResourceTreeContextMenuButtonFactory.createSampleCollectionContextMenuButton(),
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(
sampleDataResourceTokenCollection.databaseId,
sampleDataResourceTokenCollection.id(),
[ViewModels.CollectionTabKind.Documents],
),
},
],
},
],
};
return [updatedSampleTree];
};
export const createResourceTokenTreeNodes = (collection: ViewModels.CollectionBase): TreeNode[] => {
if (!collection) {
return [
{
label: "",
isExpanded: true,
children: [],
},
];
}
const children: TreeNode[] = [];
children.push({
label: "Items",
onClick: () => {
collection.onDocumentDBDocumentsClick();
// push to most recent
mostRecentActivity.collectionWasOpened(userContext.databaseAccount?.id, collection);
},
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [ViewModels.CollectionTabKind.Documents]),
});
const collectionNode: TreeNode = {
label: collection.id(),
iconSrc: CollectionIcon,
isExpanded: true,
children,
className: "collectionHeader",
onClick: () => {
// Rewritten version of expandCollapseCollection
useSelectedNode.getState().setSelectedNode(collection);
useCommandBar.getState().setContextButtons([]);
useTabs
.getState()
.refreshActiveTab(
(tab) => tab.collection?.id() === collection.id() && tab.collection.databaseId === collection.databaseId,
);
},
isSelected: () => useSelectedNode.getState().isDataNodeSelected(collection.databaseId, collection.id()),
};
return [collectionNode];
};
export const createDatabaseTreeNodes = (
container: Explorer,
isNotebookEnabled: boolean,
databases: ViewModels.Database[],
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void,
): TreeNode[] => {
const databaseTreeNodes: TreeNode[] = databases.map((database: ViewModels.Database) => {
const buildDatabaseChildNodes = (databaseNode: TreeNode) => {
databaseNode.children = [];
if (database.isDatabaseShared() && configContext.platform !== Platform.Fabric) {
databaseNode.children.push({
id: database.isSampleDB ? "sampleScaleSettings" : "",
label: "Scale",
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(database.id(), undefined, [ViewModels.CollectionTabKind.DatabaseSettingsV2]),
onClick: database.onSettingsClick.bind(database),
});
}
// Find collections
database
.collections()
.forEach((collection: ViewModels.Collection) =>
databaseNode.children.push(
buildCollectionNode(database, collection, isNotebookEnabled, container, refreshActiveTab),
),
);
if (database.collectionsContinuationToken) {
const loadMoreNode: TreeNode = {
label: "load more",
className: "loadMoreHeader",
onClick: async () => {
await database.loadCollections();
useDatabases.getState().updateDatabase(database);
},
};
databaseNode.children.push(loadMoreNode);
}
};
const databaseNode: TreeNode = {
label: database.id(),
iconSrc: CosmosDBIcon,
className: "databaseHeader",
children: [],
isSelected: () => useSelectedNode.getState().isDataNodeSelected(database.id()),
contextMenu: ResourceTreeContextMenuButtonFactory.createDatabaseContextMenu(container, database.id()),
onExpanded: async () => {
useSelectedNode.getState().setSelectedNode(database);
if (!databaseNode.children || databaseNode.children?.length === 0) {
databaseNode.isLoading = true;
}
await database.expandDatabase();
databaseNode.isLoading = false;
useCommandBar.getState().setContextButtons([]);
refreshActiveTab((tab: TabsBase) => tab.collection?.databaseId === database.id());
useDatabases.getState().updateDatabase(database);
},
onContextMenuOpen: () => useSelectedNode.getState().setSelectedNode(database),
isExpanded: database.isDatabaseExpanded(),
onCollapsed: () => {
database.collapseDatabase();
// useCommandBar.getState().setContextButtons([]);
useDatabases.getState().updateDatabase(database);
},
};
buildDatabaseChildNodes(databaseNode);
database.collections.subscribe(() => {
buildDatabaseChildNodes(databaseNode);
});
return databaseNode;
});
return databaseTreeNodes;
};
export const buildCollectionNode = (
database: ViewModels.Database,
collection: ViewModels.Collection,
isNotebookEnabled: boolean,
container: Explorer,
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void,
): TreeNode => {
let children: TreeNode[];
// Flat Tree for Fabric
if (configContext.platform !== Platform.Fabric) {
children = buildCollectionNodeChildren(database, collection, isNotebookEnabled, container, refreshActiveTab);
}
const collectionNode: TreeNode = {
label: collection.id(),
iconSrc: CollectionIcon,
children: children,
className: "collectionHeader",
contextMenu: ResourceTreeContextMenuButtonFactory.createCollectionContextMenuButton(container, collection),
onClick: () => {
useSelectedNode.getState().setSelectedNode(collection);
collection.openTab();
// push to most recent
mostRecentActivity.collectionWasOpened(userContext.databaseAccount?.id, collection);
},
onExpanded: async () => {
// Rewritten version of expandCollapseCollection
useSelectedNode.getState().setSelectedNode(collection);
useCommandBar.getState().setContextButtons([]);
refreshActiveTab(
(tab: TabsBase) =>
tab.collection?.id() === collection.id() && tab.collection.databaseId === collection.databaseId,
);
useDatabases.getState().updateDatabase(database);
// If we're showing script nodes, start loading them.
if (shouldShowScriptNodes()) {
await collection.loadStoredProcedures();
await collection.loadUserDefinedFunctions();
await collection.loadTriggers();
}
useDatabases.getState().updateDatabase(database);
},
isSelected: () => useSelectedNode.getState().isDataNodeSelected(collection.databaseId, collection.id()),
onContextMenuOpen: () => useSelectedNode.getState().setSelectedNode(collection),
onCollapsed: () => {
collection.collapseCollection();
// useCommandBar.getState().setContextButtons([]);
useDatabases.getState().updateDatabase(database);
},
isExpanded: collection.isCollectionExpanded(),
};
return collectionNode;
};
const buildCollectionNodeChildren = (
database: ViewModels.Database,
collection: ViewModels.Collection,
isNotebookEnabled: boolean,
container: Explorer,
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void,
): TreeNode[] => {
const children: TreeNode[] = [];
children.push({
label: getItemName(),
id: collection.isSampleCollection ? "sampleItems" : "",
onClick: () => {
collection.openTab();
// push to most recent
mostRecentActivity.collectionWasOpened(userContext.databaseAccount?.id, collection);
},
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [
ViewModels.CollectionTabKind.Documents,
ViewModels.CollectionTabKind.Graph,
]),
contextMenu: ResourceTreeContextMenuButtonFactory.createCollectionContextMenuButton(container, collection),
});
if (
isNotebookEnabled &&
userContext.apiType === "Mongo" &&
isPublicInternetAccessAllowed() &&
useNotebook.getState().isPhoenixFeatures
) {
children.push({
label: "Schema (Preview)",
onClick: collection.onSchemaAnalyzerClick.bind(collection),
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [ViewModels.CollectionTabKind.SchemaAnalyzer]),
});
}
if (userContext.apiType !== "Cassandra" || !isServerlessAccount()) {
let id = "";
if (collection.isSampleCollection) {
id = database.isDatabaseShared() ? "sampleSettings" : "sampleScaleSettings";
}
children.push({
id,
label: database.isDatabaseShared() || isServerlessAccount() ? "Settings" : "Scale & Settings",
onClick: collection.onSettingsClick.bind(collection),
isSelected: () =>
useSelectedNode
.getState()
.isDataNodeSelected(collection.databaseId, collection.id(), [
ViewModels.CollectionTabKind.CollectionSettingsV2,
]),
});
}
const schemaNode: TreeNode = buildSchemaNode(collection, container, refreshActiveTab);
if (schemaNode) {
children.push(schemaNode);
}
const onUpdateDatabase = () => useDatabases.getState().updateDatabase(database);
if (shouldShowScriptNodes()) {
children.push(buildStoredProcedureNode(collection, container, refreshActiveTab, onUpdateDatabase));
children.push(buildUserDefinedFunctionsNode(collection, container, refreshActiveTab, onUpdateDatabase));
children.push(buildTriggerNode(collection, container, refreshActiveTab, onUpdateDatabase));
}
// 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 children;
};
const buildStoredProcedureNode = (
collection: ViewModels.Collection,
container: Explorer,
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void,
onUpdateDatabase: () => void,
): 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),
})),
onExpanded: async () => {
await collection.loadStoredProcedures();
collection.selectedSubnodeKind(ViewModels.CollectionTabKind.StoredProcedures);
refreshActiveTab(
(tab: TabsBase) =>
tab.collection?.id() === collection.id() && tab.collection.databaseId === collection.databaseId,
);
onUpdateDatabase();
},
};
};
const buildUserDefinedFunctionsNode = (
collection: ViewModels.Collection,
container: Explorer,
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void,
onUpdateDatabase: () => void,
): 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),
})),
onExpanded: async () => {
await collection.loadUserDefinedFunctions();
collection.selectedSubnodeKind(ViewModels.CollectionTabKind.UserDefinedFunctions);
refreshActiveTab(
(tab: TabsBase) =>
tab.collection?.id() === collection.id() && tab.collection.databaseId === collection.databaseId,
);
onUpdateDatabase();
},
};
};
const buildTriggerNode = (
collection: ViewModels.Collection,
container: Explorer,
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void,
onUpdateDatabase: () => void,
): 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),
})),
onExpanded: async () => {
await collection.loadTriggers();
collection.selectedSubnodeKind(ViewModels.CollectionTabKind.Triggers);
refreshActiveTab(
(tab: TabsBase) =>
tab.collection?.id() === collection.id() && tab.collection.databaseId === collection.databaseId,
);
onUpdateDatabase();
},
};
};
const buildSchemaNode = (
collection: ViewModels.Collection,
container: Explorer,
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void,
): 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[] => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const schema: any = {};
//unflatten
fields.forEach((field: DataModels.IDataField) => {
const path: string[] = field.path.split(".");
const fieldProperties = [field.dataType.name, `HasNulls: ${field.hasNulls}`];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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];
}
});
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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);
};