mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-03-27 03:59:10 +00:00
* Implement fabric native path * Fix default values to work with current fabric clients * Fix Fabric native mode * Fix unit test * export Fabric context * Dynamically close Home tab for Mirrored databases in Fabric rather than conditional init (which doesn't work for Native) * For Fabric native, don't show "Delete Database" in context menu and reading databases should return the database from the context. * Update to V3 messaging * For data plane operations, skip ARM for Fabric native. Refine the tests for fabric to make the distinction between mirrored key, mirrored AAD and native. Fix FabricUtil to strict compile. * Add support for refreshing access tokens * Buf fix: don't wait for refresh is async * Fix format * Fix strict compile issue --------- Co-authored-by: Laurent Nguyen <languye@microsoft.com>
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
import {
|
|
Button,
|
|
makeStyles,
|
|
Menu,
|
|
MenuButton,
|
|
MenuButtonProps,
|
|
MenuItem,
|
|
MenuList,
|
|
MenuPopover,
|
|
MenuTrigger,
|
|
mergeClasses,
|
|
shorthands,
|
|
SplitButton,
|
|
} from "@fluentui/react-components";
|
|
import { Add16Regular, ArrowSync12Regular, ChevronLeft12Regular, ChevronRight12Regular } from "@fluentui/react-icons";
|
|
import { configContext, Platform } from "ConfigContext";
|
|
import Explorer from "Explorer/Explorer";
|
|
import { AddDatabasePanel } from "Explorer/Panes/AddDatabasePanel/AddDatabasePanel";
|
|
import { Tabs } from "Explorer/Tabs/Tabs";
|
|
import { CosmosFluentProvider, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil";
|
|
import { ResourceTree } from "Explorer/Tree/ResourceTree";
|
|
import { useDatabases } from "Explorer/useDatabases";
|
|
import { KeyboardAction, KeyboardActionGroup, KeyboardActionHandler, useKeyboardActionGroup } from "KeyboardShortcuts";
|
|
import { isFabric, isFabricMirrored, isFabricNative } from "Platform/Fabric/FabricUtil";
|
|
import { userContext } from "UserContext";
|
|
import { getCollectionName, getDatabaseName } from "Utils/APITypeUtils";
|
|
import { Allotment, AllotmentHandle } from "allotment";
|
|
import { useSidePanel } from "hooks/useSidePanel";
|
|
import { debounce } from "lodash";
|
|
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
|
|
const useSidebarStyles = makeStyles({
|
|
sidebar: {
|
|
height: "100%",
|
|
},
|
|
sidebarContainer: {
|
|
height: "100%",
|
|
backgroundColor: tokens.colorNeutralBackground1,
|
|
},
|
|
expandedContent: {
|
|
display: "grid",
|
|
height: "100%",
|
|
gridTemplateRows: `calc(${tokens.layoutRowHeight} * 2) 1fr`,
|
|
},
|
|
floatingControlsContainer: {
|
|
position: "relative",
|
|
zIndex: 1000,
|
|
width: "100%",
|
|
},
|
|
floatingControls: {
|
|
display: "flex",
|
|
flexDirection: "row",
|
|
position: "absolute",
|
|
right: 0,
|
|
},
|
|
floatingControlButton: {
|
|
...shorthands.border("none"),
|
|
backgroundColor: "transparent",
|
|
},
|
|
globalCommandsContainer: {
|
|
display: "grid",
|
|
alignItems: "center",
|
|
justifyItems: "center",
|
|
width: "100%",
|
|
containerType: "size", // Use this container for "@container" queries below this.
|
|
...cosmosShorthands.borderBottom(),
|
|
},
|
|
loadingProgressBar: {
|
|
// Float above the content
|
|
position: "absolute",
|
|
width: "100%",
|
|
height: "2px",
|
|
zIndex: 2000,
|
|
backgroundColor: tokens.colorCompoundBrandBackground,
|
|
animationIterationCount: "infinite",
|
|
animationDuration: "3s",
|
|
animationName: {
|
|
"0%": {
|
|
opacity: ".2", // matches indeterminate bar width
|
|
},
|
|
"50%": {
|
|
opacity: "1",
|
|
},
|
|
"100%": {
|
|
opacity: ".2",
|
|
},
|
|
},
|
|
},
|
|
globalCommandsMenuButton: {
|
|
display: "inline-flex",
|
|
"@container (min-width: 250px)": {
|
|
display: "none",
|
|
},
|
|
},
|
|
globalCommandsSplitButton: {
|
|
display: "none",
|
|
"@container (min-width: 250px)": {
|
|
display: "flex",
|
|
},
|
|
},
|
|
});
|
|
|
|
interface GlobalCommandsProps {
|
|
explorer: Explorer;
|
|
}
|
|
|
|
interface GlobalCommand {
|
|
id: string;
|
|
label: string;
|
|
icon: JSX.Element;
|
|
onClick: () => void;
|
|
keyboardAction?: KeyboardAction;
|
|
ref?: React.RefObject<HTMLButtonElement>;
|
|
}
|
|
|
|
const GlobalCommands: React.FC<GlobalCommandsProps> = ({ explorer }) => {
|
|
const styles = useSidebarStyles();
|
|
|
|
// Since we have two buttons in the DOM (one for small screens and one for larger screens), we wrap the entire thing in a div.
|
|
// However, that messes with the Menu positioning, so we need to get a reference to the 'div' to pass to the Menu.
|
|
// We can't use a ref though, because it would be set after the Menu is rendered, so we use a state value to force a re-render.
|
|
const [globalCommandButton, setGlobalCommandButton] = useState<HTMLElement | null>(null);
|
|
const primaryFocusableRef = useRef<HTMLButtonElement>(null);
|
|
|
|
const actions = useMemo<GlobalCommand[]>(() => {
|
|
if (
|
|
(isFabric() && userContext.fabricContext?.isReadOnly) ||
|
|
userContext.apiType === "Postgres" ||
|
|
userContext.apiType === "VCoreMongo"
|
|
) {
|
|
// No Global Commands for these API types.
|
|
// In fact, no sidebar for Postgres or VCoreMongo at all, but just in case, we check here anyway.
|
|
return [];
|
|
}
|
|
|
|
const actions: GlobalCommand[] = [
|
|
{
|
|
id: "new_collection",
|
|
label: `New ${getCollectionName()}`,
|
|
icon: <Add16Regular />,
|
|
onClick: () => {
|
|
const databaseId = isFabricNative() ? userContext.fabricContext?.databaseName : undefined;
|
|
explorer.onNewCollectionClicked({ databaseId });
|
|
},
|
|
keyboardAction: KeyboardAction.NEW_COLLECTION,
|
|
},
|
|
];
|
|
|
|
if (configContext.platform !== Platform.Fabric && userContext.apiType !== "Tables") {
|
|
actions.push({
|
|
id: "new_database",
|
|
label: `New ${getDatabaseName()}`,
|
|
icon: <Add16Regular />,
|
|
onClick: async () => {
|
|
const throughputCap = userContext.databaseAccount?.properties.capacity?.totalThroughputLimit;
|
|
if (throughputCap && throughputCap !== -1) {
|
|
await useDatabases.getState().loadAllOffers();
|
|
}
|
|
useSidePanel.getState().openSidePanel("New " + getDatabaseName(), <AddDatabasePanel explorer={explorer} />);
|
|
},
|
|
keyboardAction: KeyboardAction.NEW_DATABASE,
|
|
});
|
|
}
|
|
|
|
return actions;
|
|
}, [explorer]);
|
|
|
|
const primaryAction = useMemo(() => (actions.length > 0 ? actions[0] : undefined), [actions]);
|
|
const onPrimaryActionClick = useCallback(() => primaryAction?.onClick(), [primaryAction]);
|
|
const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.GLOBAL_COMMANDS);
|
|
|
|
useEffect(() => {
|
|
setKeyboardActions(
|
|
actions.reduce(
|
|
(acc, action) => {
|
|
if (action.keyboardAction) {
|
|
acc[action.keyboardAction] = action.onClick;
|
|
}
|
|
return acc;
|
|
},
|
|
{} as Record<KeyboardAction, KeyboardActionHandler>,
|
|
),
|
|
);
|
|
}, [actions, setKeyboardActions]);
|
|
|
|
useLayoutEffect(() => {
|
|
if (primaryFocusableRef.current) {
|
|
const timer = setTimeout(() => {
|
|
primaryFocusableRef.current.focus();
|
|
}, 0);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
return undefined;
|
|
}, []);
|
|
|
|
if (!primaryAction) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className={styles.globalCommandsContainer} data-test="GlobalCommands">
|
|
{actions.length === 1 ? (
|
|
<Button icon={primaryAction.icon} onClick={onPrimaryActionClick} ref={primaryFocusableRef}>
|
|
{primaryAction.label}
|
|
</Button>
|
|
) : (
|
|
<Menu positioning={{ target: globalCommandButton, position: "below", align: "end" }}>
|
|
<MenuTrigger disableButtonEnhancement>
|
|
{(triggerProps: MenuButtonProps) => (
|
|
<div ref={setGlobalCommandButton}>
|
|
<SplitButton
|
|
menuButton={{ ...triggerProps, "aria-label": "More commands" }}
|
|
primaryActionButton={{ onClick: onPrimaryActionClick, ref: primaryFocusableRef }}
|
|
className={styles.globalCommandsSplitButton}
|
|
icon={primaryAction.icon}
|
|
>
|
|
{primaryAction.label}
|
|
</SplitButton>
|
|
<MenuButton {...triggerProps} icon={primaryAction.icon} className={styles.globalCommandsMenuButton}>
|
|
New...
|
|
</MenuButton>
|
|
</div>
|
|
)}
|
|
</MenuTrigger>
|
|
<MenuPopover>
|
|
<MenuList>
|
|
{actions.map((action) => (
|
|
<MenuItem key={action.id} icon={action.icon} onClick={action.onClick}>
|
|
{action.label}
|
|
</MenuItem>
|
|
))}
|
|
</MenuList>
|
|
</MenuPopover>
|
|
</Menu>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface SidebarProps {
|
|
explorer: Explorer;
|
|
}
|
|
|
|
const CollapseThreshold = 140;
|
|
|
|
export const SidebarContainer: React.FC<SidebarProps> = ({ explorer }) => {
|
|
const styles = useSidebarStyles();
|
|
const [expanded, setExpanded] = React.useState(true);
|
|
const [loading, setLoading] = React.useState(false);
|
|
const [expandedSize, setExpandedSize] = React.useState(300);
|
|
const hasSidebar = userContext.apiType !== "Postgres" && userContext.apiType !== "VCoreMongo";
|
|
const allotment = useRef<AllotmentHandle>(null);
|
|
|
|
const expand = useCallback(() => {
|
|
if (!expanded) {
|
|
allotment.current.resize([expandedSize, Infinity]);
|
|
setExpanded(true);
|
|
}
|
|
}, [expanded, expandedSize, setExpanded]);
|
|
|
|
const collapse = useCallback(() => {
|
|
if (expanded) {
|
|
allotment.current.resize([24, Infinity]);
|
|
setExpanded(false);
|
|
}
|
|
}, [expanded, setExpanded]);
|
|
|
|
const onChange = debounce((sizes: number[]) => {
|
|
if (expanded && sizes[0] <= CollapseThreshold) {
|
|
collapse();
|
|
} else if (!expanded && sizes[0] > CollapseThreshold) {
|
|
expand();
|
|
}
|
|
}, 10);
|
|
|
|
const onDragEnd = useCallback(
|
|
(sizes: number[]) => {
|
|
if (expanded) {
|
|
// Remember the last size we had when expanded
|
|
setExpandedSize(sizes[0]);
|
|
} else {
|
|
allotment.current.resize([24, Infinity]);
|
|
}
|
|
},
|
|
[expanded, setExpandedSize],
|
|
);
|
|
|
|
const onRefreshClick = useCallback(async () => {
|
|
setLoading(true);
|
|
await explorer.onRefreshResourcesClick();
|
|
setLoading(false);
|
|
}, [setLoading]);
|
|
|
|
const hasGlobalCommands = !(
|
|
isFabricMirrored() ||
|
|
userContext.apiType === "Postgres" ||
|
|
userContext.apiType === "VCoreMongo"
|
|
);
|
|
|
|
return (
|
|
<div className="sidebarContainer">
|
|
<Allotment ref={allotment} onChange={onChange} onDragEnd={onDragEnd} className="resourceTreeAndTabs">
|
|
{/* Collections Tree - Start */}
|
|
{hasSidebar && (
|
|
// When collapsed, we force the pane to 24 pixels wide and make it non-resizable.
|
|
<Allotment.Pane minSize={24} preferredSize={250}>
|
|
<CosmosFluentProvider className={mergeClasses(styles.sidebar)}>
|
|
<div className={styles.sidebarContainer}>
|
|
{loading && (
|
|
// The Fluent UI progress bar has some issues in reduced-motion environments so we use a simple CSS animation here.
|
|
// https://github.com/microsoft/fluentui/issues/29076
|
|
<div className={styles.loadingProgressBar} title="Refreshing tree..." />
|
|
)}
|
|
{expanded ? (
|
|
<>
|
|
<div className={styles.floatingControlsContainer}>
|
|
<div className={styles.floatingControls}>
|
|
<button
|
|
type="button"
|
|
data-test="Sidebar/RefreshButton"
|
|
className={styles.floatingControlButton}
|
|
disabled={loading}
|
|
title="Refresh"
|
|
onClick={onRefreshClick}
|
|
>
|
|
<ArrowSync12Regular />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={styles.floatingControlButton}
|
|
title="Collapse sidebar"
|
|
onClick={() => collapse()}
|
|
>
|
|
<ChevronLeft12Regular />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={styles.expandedContent}
|
|
style={!hasGlobalCommands ? { gridTemplateRows: "1fr" } : undefined}
|
|
>
|
|
{hasGlobalCommands && <GlobalCommands explorer={explorer} />}
|
|
<ResourceTree explorer={explorer} />
|
|
</div>
|
|
</>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className={styles.floatingControlButton}
|
|
title="Expand sidebar"
|
|
onClick={() => expand()}
|
|
>
|
|
<ChevronRight12Regular />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</CosmosFluentProvider>
|
|
</Allotment.Pane>
|
|
)}
|
|
<Allotment.Pane minSize={200}>
|
|
<Tabs explorer={explorer} />
|
|
</Allotment.Pane>
|
|
</Allotment>
|
|
</div>
|
|
);
|
|
};
|