Refactor ResourceTree to use FluentUI 9 Tree component

This commit is contained in:
Laurent Nguyen 2023-08-31 15:52:07 +02:00
parent f8ff0626d9
commit fa865f99c8
7 changed files with 1740 additions and 31676 deletions

33018
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,6 +13,7 @@
"@babel/plugin-proposal-class-properties": "7.12.1",
"@babel/plugin-proposal-decorators": "7.12.12",
"@fluentui/react": "8.14.3",
"@fluentui/react-components": "9.30.1",
"@jupyterlab/services": "6.0.2",
"@jupyterlab/terminal": "3.0.3",
"@microsoft/applicationinsights-web": "2.6.1",

View File

@ -1,6 +1,6 @@
import React from "react";
import { shallow } from "enzyme";
import { TreeComponent, TreeNode, TreeNodeComponent } from "./TreeComponent";
import React from "react";
import { TreeComponent, TreeNode, TreeNodeComponent_old } from "./TreeComponent";
const buildChildren = (): TreeNode[] => {
const grandChild11: TreeNode = {
@ -98,7 +98,7 @@ describe("TreeNodeComponent", () => {
generation: 12,
paddingLeft: 23,
};
const wrapper = shallow(<TreeNodeComponent {...props} />);
const wrapper = shallow(<TreeNodeComponent_old {...props} />);
expect(wrapper).toMatchSnapshot();
});
@ -113,7 +113,7 @@ describe("TreeNodeComponent", () => {
generation: 2,
paddingLeft: 9,
};
const wrapper = shallow(<TreeNodeComponent {...props} />);
const wrapper = shallow(<TreeNodeComponent_old {...props} />);
expect(wrapper).toMatchSnapshot();
});
@ -128,7 +128,7 @@ describe("TreeNodeComponent", () => {
generation: 2,
paddingLeft: 9,
};
const wrapper = shallow(<TreeNodeComponent {...props} />);
const wrapper = shallow(<TreeNodeComponent_old {...props} />);
expect(wrapper).toMatchSnapshot();
});
@ -156,7 +156,7 @@ describe("TreeNodeComponent", () => {
generation: 12,
paddingLeft: 23,
};
const wrapper = shallow(<TreeNodeComponent {...props} />);
const wrapper = shallow(<TreeNodeComponent_old {...props} />);
expect(wrapper).toMatchSnapshot();
});
@ -172,7 +172,7 @@ describe("TreeNodeComponent", () => {
generation: 2,
paddingLeft: 9,
};
const wrapper = shallow(<TreeNodeComponent {...props} />);
const wrapper = shallow(<TreeNodeComponent_old {...props} />);
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -12,6 +12,8 @@ import {
IContextualMenuItemProps,
IContextualMenuProps,
} from "@fluentui/react";
import { Button, Menu, MenuItem, MenuList, MenuPopover, MenuTrigger, Spinner, Tree, TreeItem, TreeItemLayout } from "@fluentui/react-components";
import { MoreHorizontal20Regular } from "@fluentui/react-icons";
import * as React from "react";
import AnimateHeight from "react-animate-height";
import LoadingIndicator_3Squares from "../../../../images/LoadingIndicator_3Squares.gif";
@ -20,7 +22,6 @@ import TriangleRightIcon from "../../../../images/Triangle-right.svg";
import * as Constants from "../../../Common/Constants";
import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
export interface TreeNodeMenuItem {
label: string;
onClick: () => void;
@ -58,13 +59,90 @@ export interface TreeComponentProps {
export class TreeComponent extends React.Component<TreeComponentProps> {
public render(): JSX.Element {
return (
<div style={this.props.style} className={`treeComponent ${this.props.className}`} role="tree">
<TreeNodeComponent paddingLeft={0} node={this.props.rootNode} generation={0} />
</div>
<Tree>
<TreeNodeComponent node={this.props.rootNode} />
</Tree>
);
}
}
export const TreeNodeComponent: React.FC<{ node: TreeNode, className?: string }> = ({ node }): JSX.Element => {
const { children } = node;
const defaultOpenItems = node.isExpanded ? children?.map((child: TreeNode) => child.label) : undefined;
const getSortedChildren = (treeNode: TreeNode): TreeNode[] => {
if (!treeNode || !treeNode.children) {
return undefined;
}
const compareFct = (a: TreeNode, b: TreeNode) => a.label.localeCompare(b.label);
let unsortedChildren;
if (treeNode.isLeavesParentsSeparate) {
// Separate parents and leave
const parents: TreeNode[] = treeNode.children.filter((node) => node.children);
const leaves: TreeNode[] = treeNode.children.filter((node) => !node.children);
if (treeNode.isAlphaSorted) {
parents.sort(compareFct);
leaves.sort(compareFct);
}
unsortedChildren = parents.concat(leaves);
} else {
unsortedChildren = treeNode.isAlphaSorted ? treeNode.children.sort(compareFct) : treeNode.children;
}
return unsortedChildren;
};
return (
<TreeItem key={node.label} value={node.label} itemType={node.children?.length > 0 || node.isLoading ? "branch" : "leaf"}>
<TreeItemLayout
className={node.className}
onClick={() => node.onClick && node.onClick(false)}
actions={node.contextMenu &&
<Menu>
<MenuTrigger disableButtonEnhancement>
<Button aria-label="More options" appearance="subtle" icon={<MoreHorizontal20Regular />} />
</MenuTrigger>
<MenuPopover>
<MenuList>{node.contextMenu.map(menuItem => (
<MenuItem disabled={menuItem.isDisabled} key={menuItem.label} onClick={menuItem.onClick}>{menuItem.label}</MenuItem>
))}</MenuList>
</MenuPopover>
</Menu>
}
expandIcon={node.isLoading ? <Spinner size="tiny" /> : undefined}
>{node.label}</TreeItemLayout>
{!node.isLoading && node.children?.length > 0 && <Tree defaultOpenItems={defaultOpenItems}>
{getSortedChildren(node).map((childNode: TreeNode) => (
<TreeNodeComponent key={childNode.id} node={childNode} />
))}
</Tree>}
</TreeItem>
);
};
// ------------------------------------------------------------
/* Tree node is a react component */
interface TreeNodeComponentProps {
node: TreeNode;
@ -76,7 +154,7 @@ interface TreeNodeComponentState {
isExpanded: boolean;
isMenuShowing: boolean;
}
export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, TreeNodeComponentState> {
export class TreeNodeComponent_old extends React.Component<TreeNodeComponentProps, TreeNodeComponentState> {
private static readonly paddingPerGenerationPx = 16;
private static readonly iconOffset = 22;
private static readonly transitionDurationMS = 200;
@ -97,9 +175,9 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
// Only call when expand has actually changed
if (this.state.isExpanded !== prevState.isExpanded) {
if (this.state.isExpanded) {
this.props.node.onExpanded && setTimeout(this.props.node.onExpanded, TreeNodeComponent.callbackDelayMS);
this.props.node.onExpanded && setTimeout(this.props.node.onExpanded, TreeNodeComponent_old.callbackDelayMS);
} else {
this.props.node.onCollapsed && setTimeout(this.props.node.onCollapsed, TreeNodeComponent.callbackDelayMS);
this.props.node.onCollapsed && setTimeout(this.props.node.onCollapsed, TreeNodeComponent_old.callbackDelayMS);
}
}
if (this.props.node.isExpanded !== this.isExpanded) {
@ -145,13 +223,13 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
}
private renderNode(node: TreeNode, generation: number): JSX.Element {
let paddingLeft = generation * TreeNodeComponent.paddingPerGenerationPx;
let paddingLeft = generation * TreeNodeComponent_old.paddingPerGenerationPx;
let additionalOffsetPx = 15;
if (node.children) {
const childrenWithSubChildren = node.children.filter((child: TreeNode) => !!child.children);
if (childrenWithSubChildren.length > 0) {
additionalOffsetPx = TreeNodeComponent.iconOffset;
additionalOffsetPx = TreeNodeComponent_old.iconOffset;
}
}
@ -159,10 +237,10 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
const showSelected =
this.props.node.isSelected &&
this.props.node.isSelected() &&
!TreeNodeComponent.isAnyDescendantSelected(this.props.node);
!TreeNodeComponent_old.isAnyDescendantSelected(this.props.node);
const headerStyle: React.CSSProperties = { paddingLeft: this.props.paddingLeft };
if (TreeNodeComponent.isNodeHeaderBlank(node)) {
if (TreeNodeComponent_old.isNodeHeaderBlank(node)) {
headerStyle.height = 0;
headerStyle.padding = 0;
}
@ -194,10 +272,10 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
<img className="loadingIcon" src={LoadingIndicator_3Squares} hidden={!this.props.node.isLoading} />
</div>
{node.children && (
<AnimateHeight duration={TreeNodeComponent.transitionDurationMS} height={this.state.isExpanded ? "auto" : 0}>
<AnimateHeight duration={TreeNodeComponent_old.transitionDurationMS} height={this.state.isExpanded ? "auto" : 0}>
<div className="nodeChildren" data-test={node.label} role="group">
{TreeNodeComponent.getSortedChildren(node).map((childNode: TreeNode) => (
<TreeNodeComponent
{TreeNodeComponent_old.getSortedChildren(node).map((childNode: TreeNode) => (
<TreeNodeComponent_old
key={`${childNode.label}-${generation + 1}-${childNode.timestamp}`}
node={childNode}
generation={generation + 1}
@ -220,7 +298,7 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
node.children &&
node.children.reduce(
(previous: boolean, child: TreeNode) =>
previous || (child.isSelected && child.isSelected()) || TreeNodeComponent.isAnyDescendantSelected(child),
previous || (child.isSelected && child.isSelected()) || TreeNodeComponent_old.isAnyDescendantSelected(child),
false
)
);
@ -231,7 +309,7 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
}
private onRightClick = (): void => {
this.contextMenuRef.current.firstChild.dispatchEvent(TreeNodeComponent.createClickEvent());
this.contextMenuRef.current.firstChild.dispatchEvent(TreeNodeComponent_old.createClickEvent());
};
private renderContextMenuButton(node: TreeNode): JSX.Element {
@ -264,7 +342,7 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
<div
data-test={`treeComponentMenuItemContainer`}
className="treeComponentMenuItemContainer"
onContextMenu={(e) => e.target.dispatchEvent(TreeNodeComponent.createClickEvent())}
onContextMenu={(e) => e.target.dispatchEvent(TreeNodeComponent_old.createClickEvent())}
>
{props.item.onRenderIcon()}
<span
@ -318,7 +396,7 @@ export class TreeNodeComponent extends React.Component<TreeNodeComponentProps, T
if (node.children) {
const isExpanded = !this.state.isExpanded;
// Prevent collapsing if node header is blank
if (!(TreeNodeComponent.isNodeHeaderBlank(node) && !isExpanded)) {
if (!(TreeNodeComponent_old.isNodeHeaderBlank(node) && !isExpanded)) {
this.setState({ isExpanded });
}
}

View File

@ -1,5 +1,6 @@
import { Callout, DirectionalHint, ICalloutProps, ILinkProps, Link, Stack, Text } from "@fluentui/react";
import { SampleDataTree } from "Explorer/Tree/SampleDataTree";
import { BrandVariants, FluentProvider, Theme, Tree, createLightTheme } from "@fluentui/react-components";
import { buildSampleDataTree } from "Explorer/Tree/SampleDataTree";
import { getItemName } from "Utils/APITypeUtils";
import * as React from "react";
import shallow from "zustand/shallow";
@ -26,9 +27,8 @@ import * as GitHubUtils from "../../Utils/GitHubUtils";
import { useSidePanel } from "../../hooks/useSidePanel";
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 { TreeNode, TreeNodeComponent, TreeNodeMenuItem } from "../Controls/TreeComponent/TreeComponent";
import Explorer from "../Explorer";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter";
import { mostRecentActivity } from "../MostRecentActivity/MostRecentActivity";
@ -50,6 +50,30 @@ 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 ResourceTree: React.FC<ResourceTreeProps> = ({ container }: ResourceTreeProps): JSX.Element => {
const databases = useDatabases((state) => state.databases);
const {
@ -118,7 +142,8 @@ export const ResourceTree: React.FC<ResourceTreeProps> = ({ container }: Resourc
const buildNotebooksTree = (): TreeNode => {
const notebooksTree: TreeNode = {
label: undefined,
id: "notebooks",
label: "NOTEBOOKS",
isExpanded: true,
children: [],
};
@ -502,7 +527,8 @@ export const ResourceTree: React.FC<ResourceTreeProps> = ({ container }: Resourc
});
return {
label: undefined,
id: "data",
label: "DATA",
isExpanded: true,
children: databaseTreeNodes,
};
@ -768,56 +794,30 @@ export const ResourceTree: React.FC<ResourceTreeProps> = ({ container }: Resourc
const isSampleDataEnabled = userContext.sampleDataConnectionInfo && userContext.apiType === "SQL";
const sampleDataResourceTokenCollection = useDatabases((state) => state.sampleDataResourceTokenCollection);
return (
<>
{!isNotebookEnabled && !isSampleDataEnabled && (
<TreeComponent className="dataResourceTree" rootNode={dataRootNode} />
)}
{isNotebookEnabled && !isSampleDataEnabled && (
<>
<AccordionComponent>
<AccordionItemComponent title={"DATA"} isExpanded={!gitHubNotebooksContentRoot}>
<TreeComponent className="dataResourceTree" rootNode={dataRootNode} />
</AccordionItemComponent>
<AccordionItemComponent title={"NOTEBOOKS"}>
<TreeComponent className="notebookResourceTree" rootNode={buildNotebooksTree()} />
</AccordionItemComponent>
</AccordionComponent>
const treeNodes = React.useMemo(() => {
if (!isNotebookEnabled && !isSampleDataEnabled) {
return dataRootNode.children;
}
{buildGalleryCallout()}
</>
)}
{!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>
const nodes: TreeNode[] = [dataRootNode];
if (isSampleDataEnabled) {
nodes.push(buildSampleDataTree(sampleDataResourceTokenCollection));
}
{buildGalleryCallout()}
</>
)}
{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>
<AccordionItemComponent title={"NOTEBOOKS"}>
<TreeComponent className="notebookResourceTree" rootNode={buildNotebooksTree()} />
</AccordionItemComponent>
</AccordionComponent>
if (isNotebookEnabled) {
nodes.push(buildNotebooksTree());
}
{buildGalleryCallout()}
</>
)}
</>
);
return nodes;
}, [isNotebookEnabled, isSampleDataEnabled]);
return (<>
<FluentProvider theme={lightTheme}>
<Tree openItems={treeNodes.map(node => node.label)}>
{treeNodes.map(node => <TreeNodeComponent key={node.id} className="dataResourceTree" node={node} />)}
</Tree>
</FluentProvider>
{(isNotebookEnabled || isSampleDataEnabled) && buildGalleryCallout()}
</>);
};

View File

@ -0,0 +1,83 @@
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import TabsBase from "Explorer/Tabs/TabsBase";
import { useSelectedNode } from "Explorer/useSelectedNode";
import { useTabs } from "hooks/useTabs";
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 { TreeNode } from "../Controls/TreeComponent/TreeComponent";
export const buildSampleDataTree = (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]
),
},
],
},
],
};
// TODO handle case non-initialized
return updatedSampleTree;
// export const SampleDataTree = ({
// sampleDataResourceTokenCollection,
// }: {
// sampleDataResourceTokenCollection: ViewModels.CollectionBase;
// }): JSX.Element => {
// return {
// id: "sampleData",
// label: "SAMPLE DATA",
// isExpanded: true,
// children: [updatedSampleTree],
// };
// };
// return (
// <TreeNodeComponent
// className="dataResourceTree"
// node={sampleDataResourceTokenCollection ? buildSampleDataTree() : { label: "Sample data not initialized." }}
// />
// );
};

View File

@ -1,78 +0,0 @@
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import TabsBase from "Explorer/Tabs/TabsBase";
import { useSelectedNode } from "Explorer/useSelectedNode";
import { useTabs } from "hooks/useTabs";
import React from "react";
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";
export const SampleDataTree = ({
sampleDataResourceTokenCollection,
}: {
sampleDataResourceTokenCollection: ViewModels.CollectionBase;
}): JSX.Element => {
const buildSampleDataTree = (): 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 {
label: undefined,
isExpanded: true,
children: [updatedSampleTree],
};
};
return (
<TreeComponent
className="dataResourceTree"
rootNode={sampleDataResourceTokenCollection ? buildSampleDataTree() : { label: "Sample data not initialized." }}
/>
);
};