Initial implementation of two-way communication with Fabric host (#1622)

* Listen to iframe messages. Test posting message.

* Plug new container message to show New Container dialog

* Rename message action to type

* Fix format

* Fix format

* Remove console.log() statement

* Rework fabric init flow. Implement open Collection Tab from fabric.

* Rename method to better match its purpose

* Update src/hooks/useKnockoutExplorer.ts

Use connectionString from message

Co-authored-by: Vsevolod Kukol <sevoku@microsoft.com>

* Fix format

* For openTab action open first collection if not specified. Clean up FabricContract.

* Reformat FabricContracts

* Highlight current node selection using them token

* Reformat

* Automatically expand nodes in resource tree if underlying database or collection is expanded. Fix AllowedOrigins. Cleanup code.

* Fix format

* Fix lint issue

* Don't show the home screen for Fabric (#1636)

* Fix formatting

* Database name to open can be overridden by value in session storage

---------

Co-authored-by: Vsevolod Kukol <sevoku@microsoft.com>
This commit is contained in:
Laurent Nguyen 2023-09-28 15:26:50 +00:00 committed by GitHub
parent dfdb44bdc9
commit d9e142d7a6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 817 additions and 651 deletions

1182
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +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",
"@fluentui/react-components": "9.32.1",
"@jupyterlab/services": "6.0.2",
"@jupyterlab/terminal": "3.0.3",
"@microsoft/applicationinsights-web": "2.6.1",

View File

@ -61,6 +61,9 @@ let configContext: Readonly<ConfigContext> = {
`^https:\\/\\/[\\.\\w]*ext\\.azure\\.(com|cn|us)$`,
`^https:\\/\\/[\\.\\w]*\\.ext\\.microsoftazure\\.de$`,
`^https:\\/\\/cosmos-db-dataexplorer-germanycentral\\.azurewebsites\\.de$`,
`^https:\\/\\/.*\\.fabric\\.microsoft\\.com$`,
`^https:\\/\\/.*\\.powerbi\\.com$`,
`^https:\\/\\/.*\\.analysis-df\\.net$`,
], // Webpack injects this at build time
gitSha: process.env.GIT_SHA,
hostedExplorerURL: "https://cosmos.azure.com/",

View File

@ -0,0 +1,25 @@
export type FabricMessage =
| {
type: "newContainer";
databaseName: string;
}
| {
type: "initialize";
connectionString: string | undefined;
}
| {
type: "openTab";
databaseName: string;
collectionName: string | undefined;
};
export type DataExploreMessage =
| "ready"
| {
type: number;
data: {
action: "LoadDatabases";
actionModifier: "success" | "start";
defaultExperience: "SQL";
};
};

View File

@ -9,8 +9,11 @@ import {
Tree,
TreeItem,
TreeItemLayout,
TreeOpenChangeData,
TreeOpenChangeEvent,
} from "@fluentui/react-components";
import { MoreHorizontal20Regular } from "@fluentui/react-icons";
import { tokens } from "@fluentui/react-theme";
import * as React from "react";
export interface TreeNode2MenuItem {
@ -27,7 +30,7 @@ export interface TreeNode2 {
children?: TreeNode2[];
contextMenu?: TreeNode2MenuItem[];
iconSrc?: string;
// isExpanded?: boolean;
isExpanded?: boolean;
className?: string;
isAlphaSorted?: boolean;
// data?: any; // Piece of data corresponding to this node
@ -37,7 +40,7 @@ export interface TreeNode2 {
isScrollable?: boolean;
isSelected?: () => boolean;
onClick?: () => void; // Only if a leaf, other click will expand/collapse
onExpanded?: () => void;
onExpanded?: () => Promise<void>;
onCollapsed?: () => void;
onContextMenuOpen?: () => void;
}
@ -46,7 +49,6 @@ export interface TreeNode2ComponentProps {
node: TreeNode2;
className?: string;
treeNodeId: string;
globalOpenIds: string[];
}
const getTreeIcon = (iconSrc: string): JSX.Element => <img src={iconSrc} alt="" style={{ width: 20, height: 20 }} />;
@ -54,20 +56,8 @@ const getTreeIcon = (iconSrc: string): JSX.Element => <img src={iconSrc} alt=""
export const TreeNode2Component: React.FC<TreeNode2ComponentProps> = ({
node,
treeNodeId,
globalOpenIds,
}: TreeNode2ComponentProps): JSX.Element => {
// const defaultOpenItems = node.isExpanded ? children?.map((child: TreeNode2) => child.label) : undefined;
const [isExpanded, setIsExpanded] = React.useState<boolean>(false);
// Compute whether node is expanded
React.useEffect(() => {
const isNowExpanded = globalOpenIds && globalOpenIds.includes(treeNodeId);
if (!isExpanded && isNowExpanded) {
// Catch the transition non-expanded to expanded
node.onExpanded?.();
}
setIsExpanded(isNowExpanded);
}, [globalOpenIds, treeNodeId, node, isExpanded]);
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const getSortedChildren = (treeNode: TreeNode2): TreeNode2[] => {
if (!treeNode || !treeNode.children) {
@ -95,8 +85,24 @@ export const TreeNode2Component: React.FC<TreeNode2ComponentProps> = ({
return unsortedChildren;
};
const onOpenChange = (_: TreeOpenChangeEvent, data: TreeOpenChangeData) => {
if (!node.isExpanded && data.open && node.onExpanded) {
// Catch the transition non-expanded to expanded
setIsLoading(true);
node.onExpanded?.().then(() => setIsLoading(false));
} else if (node.isExpanded && !data.open && node.onCollapsed) {
// Catch the transition expanded to non-expanded
node.onCollapsed?.();
}
};
return (
<TreeItem value={treeNodeId} itemType={node.children !== undefined ? "branch" : "leaf"} style={{ height: "100%" }}>
<TreeItem
value={treeNodeId}
itemType={node.children !== undefined ? "branch" : "leaf"}
style={{ height: "100%" }}
onOpenChange={onOpenChange}
>
<TreeItemLayout
className={node.className}
actions={
@ -117,22 +123,21 @@ export const TreeNode2Component: React.FC<TreeNode2ComponentProps> = ({
</Menu>
)
}
expandIcon={node.isLoading ? <Spinner size="extra-tiny" /> : undefined}
expandIcon={isLoading ? <Spinner size="extra-tiny" /> : undefined}
iconBefore={node.iconSrc && getTreeIcon(node.iconSrc)}
style={{
backgroundColor: node.isSelected && node.isSelected() ? tokens.colorNeutralBackground1Selected : undefined,
}}
>
<span onClick={() => node.onClick?.()}>{node.label}</span>
</TreeItemLayout>
{!node.isLoading && node.children?.length > 0 && (
<Tree
// defaultOpenItems={defaultOpenItems}
style={{ overflow: node.isScrollable ? "auto" : undefined }}
>
<Tree style={{ overflow: node.isScrollable ? "auto" : undefined }}>
{getSortedChildren(node).map((childNode: TreeNode2) => (
<TreeNode2Component
key={childNode.label}
node={childNode}
treeNodeId={`${treeNodeId}/${childNode.label}`}
globalOpenIds={globalOpenIds}
/>
))}
</Tree>

View File

@ -8,7 +8,7 @@ import {
TreeOpenChangeEvent,
createLightTheme,
} from "@fluentui/react-components";
import { TreeNode2Component } from "Explorer/Controls/TreeComponent2/TreeNode2Component";
import { TreeNode2, TreeNode2Component } from "Explorer/Controls/TreeComponent2/TreeNode2Component";
import { useDatabaseTreeNodes } from "Explorer/Tree2/useDatabaseTreeNodes";
import * as React from "react";
import shallow from "zustand/shallow";
@ -69,16 +69,45 @@ export const ResourceTree2: React.FC<ResourceTreeProps> = ({ container }: Resour
);
// const { activeTab } = useTabs();
const databaseTreeNodes = useDatabaseTreeNodes(container, isNotebookEnabled);
const dataNodeTree = {
const [openItems, setOpenItems] = React.useState<Iterable<TreeItemValue>>([DATA_TREE_LABEL]);
const dataNodeTree: TreeNode2 = {
id: "data",
label: DATA_TREE_LABEL,
isExpanded: true,
className: "accordionItemHeader",
children: databaseTreeNodes,
isScrollable: true,
};
const [openItems, setOpenItems] = React.useState<Iterable<TreeItemValue>>([DATA_TREE_LABEL]);
React.useEffect(() => {
// Compute open items based on node.isExpanded
const updateOpenItems = (node: TreeNode2, 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);
}
}
};
updateOpenItems(dataNodeTree, undefined);
}, [databaseTreeNodes]);
const handleOpenChange = (event: TreeOpenChangeEvent, data: TreeOpenChangeData) => setOpenItems(data.openItems);
@ -93,13 +122,7 @@ export const ResourceTree2: React.FC<ResourceTreeProps> = ({ container }: Resour
style={{ height: "100%", width: "290px" }}
>
{[dataNodeTree].map((node) => (
<TreeNode2Component
key={node.label}
className="dataResourceTree"
node={node}
treeNodeId={node.label}
globalOpenIds={[...openItems].map((item) => item.toString())}
/>
<TreeNode2Component key={node.label} className="dataResourceTree" node={node} treeNodeId={node.label} />
))}
</Tree>
</FluentProvider>

View File

@ -45,7 +45,7 @@ export const buildCollectionNode = (
// push to most recent
mostRecentActivity.collectionWasOpened(userContext.databaseAccount?.id, collection);
},
onExpanded: () => {
onExpanded: async () => {
// Rewritten version of expandCollapseCollection
useSelectedNode.getState().setSelectedNode(collection);
useCommandBar.getState().setContextButtons([]);
@ -53,9 +53,16 @@ export const buildCollectionNode = (
(tab: TabsBase) =>
tab.collection?.id() === collection.id() && tab.collection.databaseId === collection.databaseId
);
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(),
};
};

View File

@ -25,15 +25,22 @@ export const useDatabaseTreeNodes = (container: Explorer, isNotebookEnabled: boo
contextMenu: ResourceTreeContextMenuButtonFactory.createDatabaseContextMenu(container, database.id()),
onExpanded: async () => {
useSelectedNode.getState().setSelectedNode(database);
if (databaseNode.children?.length === 0) {
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);
},
};
if (database.isDatabaseShared() && configContext.platform !== Platform.Fabric) {

View File

@ -1,5 +1,7 @@
import { createUri } from "Common/UrlUtility";
import { FabricMessage } from "Contracts/FabricContract";
import Explorer from "Explorer/Explorer";
import { useSelectedNode } from "Explorer/useSelectedNode";
import { fetchEncryptedToken } from "Platform/Hosted/Components/ConnectExplorer";
import { getNetworkSettingsWarningMessage } from "Utils/NetworkUtility";
import { fetchAccessData } from "hooks/usePortalAccessToken";
@ -10,7 +12,7 @@ import { AccountKind, Flights } from "../Common/Constants";
import { normalizeArmEndpoint } from "../Common/EnvironmentUtility";
import { sendMessage, sendReadyMessage } from "../Common/MessageHandler";
import { Platform, configContext, updateConfigContext } from "../ConfigContext";
import { ActionType, DataExplorerAction } from "../Contracts/ActionContracts";
import { ActionType, DataExplorerAction, TabKind } from "../Contracts/ActionContracts";
import { MessageTypes } from "../Contracts/ExplorerContracts";
import { DataExplorerInputsFrame } from "../Contracts/ViewModels";
import { handleOpenAction } from "../Explorer/OpenActions/OpenActions";
@ -63,24 +65,7 @@ export function useKnockoutExplorer(platform: Platform): Explorer {
const explorer = await configurePortal();
setExplorer(explorer);
} else if (platform === Platform.Fabric) {
// TODO For now, retrieve info from session storage. Replace with info injected into Data Explorer
const connectionString = sessionStorage.getItem("connectionString");
if (!connectionString) {
console.error("No connection string found in session storage");
return;
}
const encryptedToken = await fetchEncryptedToken(connectionString);
// TODO Duplicated from useTokenMetadata
const encryptedTokenMetadata = await fetchAccessData(encryptedToken);
const win = (window as unknown) as HostedExplorerChildFrame;
win.hostedConfig = {
authType: AuthType.EncryptedToken,
encryptedToken,
encryptedTokenMetadata,
};
const explorer = await configureHosted();
const explorer = await configureFabric();
setExplorer(explorer);
}
}
@ -100,11 +85,104 @@ export function useKnockoutExplorer(platform: Platform): Explorer {
return explorer;
}
async function configureFabric(): Promise<Explorer> {
let explorer: Explorer;
return new Promise<Explorer>((resolve) => {
window.addEventListener(
"message",
async (event) => {
if (isInvalidParentFrameOrigin(event)) {
return;
}
if (!shouldProcessMessage(event)) {
return;
}
const data: FabricMessage = event.data?.data;
if (!data) {
return;
}
switch (data.type) {
case "initialize": {
// TODO For now, retrieve info from session storage. Replace with info injected into Data Explorer
const connectionString = data.connectionString ?? sessionStorage.getItem("connectionString");
if (!connectionString) {
console.error("No connection string found in session storage");
return undefined;
}
const encryptedToken = await fetchEncryptedToken(connectionString);
// TODO Duplicated from useTokenMetadata
const encryptedTokenMetadata = await fetchAccessData(encryptedToken);
const hostedConfig: EncryptedToken = {
authType: AuthType.EncryptedToken,
encryptedToken,
encryptedTokenMetadata,
};
explorer = await configureWithEncryptedToken(hostedConfig);
resolve(explorer);
break;
}
case "newContainer":
explorer.onNewCollectionClicked();
break;
case "openTab": {
// Expand database first
const databaseName = sessionStorage.getItem("openDatabaseName") ?? data.databaseName;
const database = useDatabases.getState().databases.find((db) => db.id() === databaseName);
if (database) {
await database.expandDatabase();
useDatabases.getState().updateDatabase(database);
useSelectedNode.getState().setSelectedNode(database);
let collectionResourceId = data.collectionName;
if (collectionResourceId === undefined) {
// Pick first collection if collectionName not specified in message
collectionResourceId = database.collections()[0]?.id();
}
if (collectionResourceId !== undefined) {
// Expand collection
const collection = database.collections().find((coll) => coll.id() === collectionResourceId);
collection.expandCollection();
useSelectedNode.getState().setSelectedNode(collection);
handleOpenAction(
{
actionType: ActionType.OpenCollectionTab,
databaseResourceId: databaseName,
collectionResourceId: data.collectionName,
tabKind: TabKind.SQLDocuments,
} as DataExplorerAction,
useDatabases.getState().databases,
explorer
);
}
}
break;
}
default:
console.error(`Unknown Fabric message type: ${JSON.stringify(data)}`);
break;
}
},
false
);
sendReadyMessage();
});
}
async function configureHosted(): Promise<Explorer> {
const win = (window as unknown) as HostedExplorerChildFrame;
let explorer: Explorer;
if (win.hostedConfig.authType === AuthType.EncryptedToken) {
explorer = configureHostedWithEncryptedToken(win.hostedConfig);
explorer = configureWithEncryptedToken(win.hostedConfig);
} else if (win.hostedConfig.authType === AuthType.ResourceToken) {
explorer = configureHostedWithResourceToken(win.hostedConfig);
} else if (win.hostedConfig.authType === AuthType.ConnectionString) {
@ -237,7 +315,7 @@ function configureHostedWithResourceToken(config: ResourceToken): Explorer {
return explorer;
}
function configureHostedWithEncryptedToken(config: EncryptedToken): Explorer {
function configureWithEncryptedToken(config: EncryptedToken): Explorer {
const apiExperience = DefaultExperienceUtility.getDefaultExperienceFromApiKind(config.encryptedTokenMetadata.apiKind);
updateUserContext({
authType: AuthType.EncryptedToken,

View File

@ -3,6 +3,7 @@ import * as ViewModels from "../Contracts/ViewModels";
import { CollectionTabKind } from "../Contracts/ViewModels";
import NotebookTabV2 from "../Explorer/Tabs/NotebookV2Tab";
import TabsBase from "../Explorer/Tabs/TabsBase";
import { Platform, configContext } from "./../ConfigContext";
interface TabsState {
openedTabs: TabsBase[];
@ -37,11 +38,22 @@ export enum ReactTabKind {
QueryCopilot,
}
// HACK: using this const when the configuration context is not initialized yet.
// Since Fabric is always setting the url param, use that instead of the regular config.
const isPlatformFabric = (() => {
const params = new URLSearchParams(window.location.search);
if (params.has("platform")) {
const platform = params.get("platform");
return platform === Platform.Fabric;
}
return false;
})();
export const useTabs: UseStore<TabsState> = create((set, get) => ({
openedTabs: [],
openedReactTabs: [ReactTabKind.Home],
openedReactTabs: !isPlatformFabric ? [ReactTabKind.Home] : [],
activeTab: undefined,
activeReactTab: ReactTabKind.Home,
activeReactTab: !isPlatformFabric ? ReactTabKind.Home : undefined,
networkSettingsWarning: "",
queryCopilotTabInitialInput: "",
isTabExecuting: false,
@ -92,7 +104,7 @@ export const useTabs: UseStore<TabsState> = create((set, get) => ({
}
return true;
});
if (updatedTabs.length === 0) {
if (updatedTabs.length === 0 && configContext.platform !== Platform.Fabric) {
set({ activeTab: undefined, activeReactTab: ReactTabKind.Home });
}
@ -130,7 +142,7 @@ export const useTabs: UseStore<TabsState> = create((set, get) => ({
}
});
if (get().openedTabs.length === 0) {
if (get().openedTabs.length === 0 && configContext.platform !== Platform.Fabric) {
set({ activeTab: undefined, activeReactTab: ReactTabKind.Home });
}
}