mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-07-21 21:07:23 +01:00
Filtering DBs and option for pin(fav)(#2301)
* implemented search bar * formatting corrected * added pin(fav) and sorting in local in sidebar tree of DE * reverted changes * fixed lint and formatting issues * fixed lint and formatting issues * theme toggle button is disabled if in portal * fixed lint error * added link on disabled theme toggle button * updated the variable for pin icon * removed en-us from url --------- Co-authored-by: nishthaAhujaa <nishtha17354@iiittd.ac.in> Co-authored-by: sakshigupta12feb <sakshigupta12feb1@gmail.com> Co-authored-by: Sakshi Gupta <sakshig@microsoft.com>
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
import { Tree, TreeItemValue, TreeOpenChangeData, TreeOpenChangeEvent } from "@fluentui/react-components";
|
||||
import { Home16Regular } from "@fluentui/react-icons";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Tree,
|
||||
TreeItemValue,
|
||||
TreeOpenChangeData,
|
||||
TreeOpenChangeEvent,
|
||||
} from "@fluentui/react-components";
|
||||
import { ArrowSortDown20Regular, ArrowSortUp20Regular, Home16Regular, Search20Regular } from "@fluentui/react-icons";
|
||||
import { AuthType } from "AuthType";
|
||||
import { useTreeStyles } from "Explorer/Controls/TreeComponent/Styles";
|
||||
import { TreeNode, TreeNodeComponent } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
|
||||
@@ -55,6 +62,11 @@ export const ResourceTree: React.FC<ResourceTreeProps> = ({ explorer }: Resource
|
||||
sampleDataResourceTokenCollection: state.sampleDataResourceTokenCollection,
|
||||
}));
|
||||
const databasesFetchedSuccessfully = useDatabases((state) => state.databasesFetchedSuccessfully);
|
||||
const searchText = useDatabases((state) => state.searchText);
|
||||
const setSearchText = useDatabases((state) => state.setSearchText);
|
||||
const sortOrder = useDatabases((state) => state.sortOrder);
|
||||
const setSortOrder = useDatabases((state) => state.setSortOrder);
|
||||
const pinnedDatabaseIds = useDatabases((state) => state.pinnedDatabaseIds);
|
||||
const { isCopilotEnabled, isCopilotSampleDBEnabled } = useQueryCopilot((state) => ({
|
||||
isCopilotEnabled: state.copilotEnabled,
|
||||
isCopilotSampleDBEnabled: state.copilotSampleDBEnabled,
|
||||
@@ -63,8 +75,24 @@ export const ResourceTree: React.FC<ResourceTreeProps> = ({ explorer }: Resource
|
||||
const databaseTreeNodes = useMemo(() => {
|
||||
return userContext.authType === AuthType.ResourceToken
|
||||
? createResourceTokenTreeNodes(resourceTokenCollection)
|
||||
: createDatabaseTreeNodes(explorer, isNotebookEnabled, databases, refreshActiveTab);
|
||||
}, [resourceTokenCollection, databases, isNotebookEnabled, refreshActiveTab]);
|
||||
: createDatabaseTreeNodes(
|
||||
explorer,
|
||||
isNotebookEnabled,
|
||||
databases,
|
||||
refreshActiveTab,
|
||||
searchText,
|
||||
sortOrder,
|
||||
pinnedDatabaseIds,
|
||||
);
|
||||
}, [
|
||||
resourceTokenCollection,
|
||||
databases,
|
||||
isNotebookEnabled,
|
||||
refreshActiveTab,
|
||||
searchText,
|
||||
sortOrder,
|
||||
pinnedDatabaseIds,
|
||||
]);
|
||||
|
||||
const isSampleDataEnabled =
|
||||
isCopilotEnabled &&
|
||||
@@ -114,46 +142,65 @@ export const ResourceTree: React.FC<ResourceTreeProps> = ({ explorer }: Resource
|
||||
} else {
|
||||
return [...headerNodes, ...databaseTreeNodes];
|
||||
}
|
||||
// headerNodes is intentionally excluded — it depends only on isFabricMirrored() which is stable.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [databaseTreeNodes, sampleDataNodes]);
|
||||
|
||||
// Track complete DatabaseLoad scenario (start, tree rendered, interactive)
|
||||
useDatabaseLoadScenario(databaseTreeNodes, databasesFetchedSuccessfully);
|
||||
|
||||
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 expandedIds: TreeItemValue[] = [];
|
||||
const collectExpandedIds = (node: TreeNode, parentNodeId: string | undefined): void => {
|
||||
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]);
|
||||
}
|
||||
expandedIds.push(globalId);
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
updateOpenItems(child, globalId);
|
||||
collectExpandedIds(child, globalId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
rootNodes.forEach((n) => updateOpenItems(n, undefined));
|
||||
}, [rootNodes, openItems, setOpenItems]);
|
||||
rootNodes.forEach((n) => collectExpandedIds(n, undefined));
|
||||
|
||||
if (expandedIds.length > 0) {
|
||||
setOpenItems((prevOpenItems) => {
|
||||
const prevSet = new Set(prevOpenItems);
|
||||
const newIds = expandedIds.filter((id) => !prevSet.has(id));
|
||||
return newIds.length > 0 ? [...prevOpenItems, ...newIds] : prevOpenItems;
|
||||
});
|
||||
}
|
||||
}, [rootNodes]);
|
||||
|
||||
const handleOpenChange = (event: TreeOpenChangeEvent, data: TreeOpenChangeData) =>
|
||||
setOpenItems(Array.from(data.openItems));
|
||||
|
||||
const toggleSortOrder = () => {
|
||||
setSortOrder(sortOrder === "az" ? "za" : "az");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={treeStyles.treeContainer}>
|
||||
{userContext.authType !== AuthType.ResourceToken && databases.length > 0 && (
|
||||
<div style={{ padding: "8px", display: "flex", gap: "4px", alignItems: "center" }}>
|
||||
<Input
|
||||
placeholder="Search databases only"
|
||||
value={searchText}
|
||||
onChange={(_, data) => setSearchText(data?.value || "")}
|
||||
size="small"
|
||||
contentBefore={<Search20Regular />}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
appearance="subtle"
|
||||
size="small"
|
||||
icon={sortOrder === "az" ? <ArrowSortDown20Regular /> : <ArrowSortUp20Regular />}
|
||||
onClick={toggleSortOrder}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Tree
|
||||
aria-label="CosmosDB resources"
|
||||
openItems={openItems}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -363,7 +363,7 @@ describe("createDatabaseTreeNodes", () => {
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
nodes = createDatabaseTreeNodes(explorer, false, useDatabases.getState().databases, refreshActiveTab);
|
||||
nodes = createDatabaseTreeNodes(explorer, false, useDatabases.getState().databases, refreshActiveTab, "");
|
||||
});
|
||||
|
||||
it("creates expected tree", () => {
|
||||
@@ -445,6 +445,7 @@ describe("createDatabaseTreeNodes", () => {
|
||||
isNotebookEnabled,
|
||||
useDatabases.getState().databases,
|
||||
refreshActiveTab,
|
||||
"",
|
||||
);
|
||||
expect(nodes).toMatchSnapshot();
|
||||
},
|
||||
@@ -455,7 +456,7 @@ describe("createDatabaseTreeNodes", () => {
|
||||
// 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 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"]);
|
||||
@@ -487,7 +488,7 @@ describe("createDatabaseTreeNodes", () => {
|
||||
},
|
||||
} as unknown as DataModels.DatabaseAccount,
|
||||
});
|
||||
nodes = createDatabaseTreeNodes(explorer, false, useDatabases.getState().databases, refreshActiveTab);
|
||||
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());
|
||||
@@ -642,7 +643,7 @@ describe("createDatabaseTreeNodes", () => {
|
||||
setup();
|
||||
|
||||
// Rebuild the nodes after changing the user/config context.
|
||||
nodes = createDatabaseTreeNodes(explorer, false, useDatabases.getState().databases, refreshActiveTab);
|
||||
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());
|
||||
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { DatabaseRegular, DocumentMultipleRegular, EyeRegular, SettingsRegular } from "@fluentui/react-icons";
|
||||
import {
|
||||
DatabaseRegular,
|
||||
DocumentMultipleRegular,
|
||||
EyeRegular,
|
||||
Pin16Filled,
|
||||
SettingsRegular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { TreeNode } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
|
||||
import { collectionWasOpened } from "Explorer/MostRecentActivity/MostRecentActivity";
|
||||
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 { DatabaseSortOrder, useDatabases } from "Explorer/useDatabases";
|
||||
import { isFabric, isFabricMirrored, isFabricNative, isFabricNativeReadOnly } from "Platform/Fabric/FabricUtil";
|
||||
import { getItemName } from "Utils/APITypeUtils";
|
||||
import { isServerlessAccount } from "Utils/CapabilityUtils";
|
||||
@@ -27,7 +33,10 @@ export const shouldShowScriptNodes = (): boolean => {
|
||||
const TreeDatabaseIcon = <DatabaseRegular fontSize={16} />;
|
||||
const TreeSettingsIcon = <SettingsRegular fontSize={16} />;
|
||||
const TreeCollectionIcon = <DocumentMultipleRegular fontSize={16} />;
|
||||
const GlobalSecondaryIndexCollectionIcon = <EyeRegular fontSize={16} />; //check icon
|
||||
const GlobalSecondaryIndexCollectionIcon = <EyeRegular fontSize={16} />;
|
||||
|
||||
const pinnedIconStyle: React.CSSProperties = { display: "inline-flex", alignItems: "center", gap: "2px" };
|
||||
const pinnedBadgeStyle: React.CSSProperties = { color: "var(--colorBrandForeground1)" };
|
||||
|
||||
export const createSampleDataTreeNodes = (sampleDataResourceTokenCollection: ViewModels.CollectionBase): TreeNode[] => {
|
||||
const updatedSampleTree: TreeNode = {
|
||||
@@ -131,8 +140,31 @@ export const createDatabaseTreeNodes = (
|
||||
isNotebookEnabled: boolean,
|
||||
databases: ViewModels.Database[],
|
||||
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void,
|
||||
searchText = "",
|
||||
sortOrder: DatabaseSortOrder = "az",
|
||||
pinnedDatabaseIds: Set<string> = new Set(),
|
||||
): TreeNode[] => {
|
||||
const databaseTreeNodes: TreeNode[] = databases.map((database: ViewModels.Database) => {
|
||||
// Filter databases based on search text (cache lowercase to avoid repeated conversion)
|
||||
const lowerSearch = searchText.toLowerCase();
|
||||
const filteredDatabases = searchText
|
||||
? databases.filter((db) => db.id().toLowerCase().includes(lowerSearch))
|
||||
: databases;
|
||||
|
||||
// Sort: pinned first, then by name (A-Z or Z-A) within each group
|
||||
const orderedDatabases = [...filteredDatabases].sort((first, second) => {
|
||||
const isFirstPinned = pinnedDatabaseIds.has(first.id());
|
||||
const isSecondPinned = pinnedDatabaseIds.has(second.id());
|
||||
if (isFirstPinned !== isSecondPinned) {
|
||||
return isFirstPinned ? -1 : 1;
|
||||
}
|
||||
const firstName = first.id();
|
||||
const secondName = second.id();
|
||||
return sortOrder === "az"
|
||||
? firstName.localeCompare(secondName, undefined, { sensitivity: "base" })
|
||||
: secondName.localeCompare(firstName, undefined, { sensitivity: "base" });
|
||||
});
|
||||
|
||||
const databaseTreeNodes: TreeNode[] = orderedDatabases.map((database: ViewModels.Database) => {
|
||||
const buildDatabaseChildNodes = (databaseNode: TreeNode) => {
|
||||
databaseNode.children = [];
|
||||
if (database.isDatabaseShared() && configContext.platform !== Platform.Fabric) {
|
||||
@@ -170,13 +202,24 @@ export const createDatabaseTreeNodes = (
|
||||
}
|
||||
};
|
||||
|
||||
const isPinned = pinnedDatabaseIds.has(database.id());
|
||||
|
||||
const databaseIcon = isPinned ? (
|
||||
<span style={pinnedIconStyle}>
|
||||
<DatabaseRegular fontSize={16} />
|
||||
<Pin16Filled fontSize={10} style={pinnedBadgeStyle} />
|
||||
</span>
|
||||
) : (
|
||||
TreeDatabaseIcon
|
||||
);
|
||||
|
||||
const databaseNode: TreeNode = {
|
||||
label: database.id(),
|
||||
className: "databaseNode",
|
||||
children: [],
|
||||
isSelected: () => useSelectedNode.getState().isDataNodeSelected(database.id()),
|
||||
contextMenu: ResourceTreeContextMenuButtonFactory.createDatabaseContextMenu(container, database.id()),
|
||||
iconSrc: TreeDatabaseIcon,
|
||||
iconSrc: databaseIcon,
|
||||
onExpanded: async () => {
|
||||
useSelectedNode.getState().setSelectedNode(database);
|
||||
if (!databaseNode.children || databaseNode.children?.length === 0) {
|
||||
@@ -192,7 +235,6 @@ export const createDatabaseTreeNodes = (
|
||||
isExpanded: database.isDatabaseExpanded(),
|
||||
onCollapsed: () => {
|
||||
database.collapseDatabase();
|
||||
// useCommandBar.getState().setContextButtons([]);
|
||||
useDatabases.getState().updateDatabase(database);
|
||||
},
|
||||
};
|
||||
@@ -242,13 +284,13 @@ export const buildCollectionNode = (
|
||||
(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 we're showing script nodes, start loading them in parallel.
|
||||
if (shouldShowScriptNodes()) {
|
||||
await collection.loadStoredProcedures();
|
||||
await collection.loadUserDefinedFunctions();
|
||||
await collection.loadTriggers();
|
||||
await Promise.all([
|
||||
collection.loadStoredProcedures(),
|
||||
collection.loadUserDefinedFunctions(),
|
||||
collection.loadTriggers(),
|
||||
]);
|
||||
}
|
||||
|
||||
useDatabases.getState().updateDatabase(database);
|
||||
@@ -257,7 +299,6 @@ export const buildCollectionNode = (
|
||||
onContextMenuOpen: () => useSelectedNode.getState().setSelectedNode(collection),
|
||||
onCollapsed: () => {
|
||||
collection.collapseCollection();
|
||||
// useCommandBar.getState().setContextButtons([]);
|
||||
useDatabases.getState().updateDatabase(database);
|
||||
},
|
||||
isExpanded: collection.isCollectionExpanded(),
|
||||
|
||||
Reference in New Issue
Block a user