mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-20 01:11:25 +00:00
Use new Fluent-based Resource Tree for all environments (#1841)
Co-authored-by: Laurent Nguyen <laurent.nguyen@microsoft.com>
This commit is contained in:
committed by
GitHub
parent
cebf044803
commit
98c5fe65e6
@@ -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()} />;
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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." }}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@ exports[`Resource tree for schema should render 1`] = `
|
||||
className="treeComponent dataResourceTree"
|
||||
role="tree"
|
||||
>
|
||||
<TreeNodeComponent
|
||||
<LegacyTreeNodeComponent
|
||||
generation={0}
|
||||
node={
|
||||
Object {
|
||||
|
||||
2074
src/Explorer/Tree/__snapshots__/treeNodeUtil.test.ts.snap
Normal file
2074
src/Explorer/Tree/__snapshots__/treeNodeUtil.test.ts.snap
Normal file
File diff suppressed because it is too large
Load Diff
602
src/Explorer/Tree/treeNodeUtil.test.ts
Normal file
602
src/Explorer/Tree/treeNodeUtil.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
522
src/Explorer/Tree/treeNodeUtil.ts
Normal file
522
src/Explorer/Tree/treeNodeUtil.ts
Normal 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);
|
||||
};
|
||||
Reference in New Issue
Block a user