Compare commits

..

4 Commits

Author SHA1 Message Date
sunilyadav840
4b8ed7a555 Merge branch 'master' 2021-05-06 14:08:02 +05:30
sunilyadav840
c2923768f8 update snapshot 2021-04-30 10:29:45 +05:30
sunilyadav840
101ddbec17 Merge branch 'master' 2021-04-30 09:20:13 +05:30
sunilyadav840
b8ed534c90 Move Explorer._refreshNotebooksEnabledStateForAccount into React 2021-04-29 17:51:05 +05:30
169 changed files with 28458 additions and 21062 deletions

View File

@@ -54,6 +54,7 @@ src/Explorer/Controls/ErrorDisplayComponent/ErrorDisplayComponent.ts
src/Explorer/Controls/InputTypeahead/InputTypeahead.ts src/Explorer/Controls/InputTypeahead/InputTypeahead.ts
src/Explorer/Controls/JsonEditor/JsonEditorComponent.ts src/Explorer/Controls/JsonEditor/JsonEditorComponent.ts
src/Explorer/Controls/Notebook/NotebookAppMessageHandler.ts src/Explorer/Controls/Notebook/NotebookAppMessageHandler.ts
src/Explorer/Controls/ThroughputInput/ThroughputInput.test.ts
src/Explorer/Controls/ThroughputInput/ThroughputInputComponent.ts src/Explorer/Controls/ThroughputInput/ThroughputInputComponent.ts
src/Explorer/Controls/ThroughputInput/ThroughputInputComponentAutoPilotV3.ts src/Explorer/Controls/ThroughputInput/ThroughputInputComponentAutoPilotV3.ts
src/Explorer/Controls/Toolbar/IToolbarAction.ts src/Explorer/Controls/Toolbar/IToolbarAction.ts
@@ -84,8 +85,8 @@ src/Explorer/Graph/GraphExplorerComponent/GremlinClient.test.ts
src/Explorer/Graph/GraphExplorerComponent/GremlinClient.ts src/Explorer/Graph/GraphExplorerComponent/GremlinClient.ts
src/Explorer/Graph/GraphExplorerComponent/GremlinSimpleClient.test.ts src/Explorer/Graph/GraphExplorerComponent/GremlinSimpleClient.test.ts
src/Explorer/Graph/GraphExplorerComponent/GremlinSimpleClient.ts src/Explorer/Graph/GraphExplorerComponent/GremlinSimpleClient.ts
# src/Explorer/Graph/GraphStyleComponent/GraphStyle.test.ts src/Explorer/Graph/GraphStyleComponent/GraphStyle.test.ts
# src/Explorer/Graph/GraphStyleComponent/GraphStyleComponent.ts src/Explorer/Graph/GraphStyleComponent/GraphStyleComponent.ts
src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.test.ts src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.test.ts
src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.ts src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.ts
@@ -108,19 +109,24 @@ src/Explorer/Notebook/NotebookUtil.ts
src/Explorer/OpenActions.test.ts src/Explorer/OpenActions.test.ts
src/Explorer/OpenActions.ts src/Explorer/OpenActions.ts
src/Explorer/OpenActionsStubs.ts src/Explorer/OpenActionsStubs.ts
src/Explorer/Panes/AddDatabasePane.ts src/Explorer/Panes/AddCollectionPane.test.ts
src/Explorer/Panes/AddCollectionPane.ts
src/Explorer/Panes/AddDatabasePane.test.ts src/Explorer/Panes/AddDatabasePane.test.ts
src/Explorer/Panes/AddDatabasePane.ts
src/Explorer/Panes/BrowseQueriesPane.ts src/Explorer/Panes/BrowseQueriesPane.ts
src/Explorer/Panes/CassandraAddCollectionPane.ts src/Explorer/Panes/CassandraAddCollectionPane.ts
src/Explorer/Panes/ContextualPaneBase.ts src/Explorer/Panes/ContextualPaneBase.ts
src/Explorer/Panes/DeleteDatabaseConfirmationPane.test.ts src/Explorer/Panes/DeleteDatabaseConfirmationPane.test.ts
src/Explorer/Panes/DeleteDatabaseConfirmationPane.ts src/Explorer/Panes/DeleteDatabaseConfirmationPane.ts
# src/Explorer/Panes/GraphStylingPane.ts src/Explorer/Panes/GraphStylingPane.ts
# src/Explorer/Panes/NewVertexPane.ts # src/Explorer/Panes/NewVertexPane.ts
src/Explorer/Panes/PaneComponents.ts src/Explorer/Panes/PaneComponents.ts
src/Explorer/Panes/RenewAdHocAccessPane.ts src/Explorer/Panes/RenewAdHocAccessPane.ts
src/Explorer/Panes/SetupNotebooksPane.ts src/Explorer/Panes/SetupNotebooksPane.ts
src/Explorer/Panes/SwitchDirectoryPane.ts src/Explorer/Panes/SwitchDirectoryPane.ts
src/Explorer/Panes/Tables/EditTableEntityPane.ts
src/Explorer/Panes/Tables/EntityPropertyViewModel.ts
src/Explorer/Panes/Tables/TableEntityPane.ts
src/Explorer/Panes/Tables/Validators/EntityPropertyNameValidator.ts src/Explorer/Panes/Tables/Validators/EntityPropertyNameValidator.ts
src/Explorer/Panes/Tables/Validators/EntityPropertyValidationCommon.ts src/Explorer/Panes/Tables/Validators/EntityPropertyValidationCommon.ts
src/Explorer/Panes/Tables/Validators/EntityPropertyValueValidator.ts src/Explorer/Panes/Tables/Validators/EntityPropertyValueValidator.ts

View File

@@ -141,7 +141,6 @@ jobs:
- ./test/graph/container.spec.ts - ./test/graph/container.spec.ts
- ./test/sql/container.spec.ts - ./test/sql/container.spec.ts
- ./test/mongo/container.spec.ts - ./test/mongo/container.spec.ts
- ./test/mongo/container32.spec.ts
- ./test/selfServe/selfServeExample.spec.ts - ./test/selfServe/selfServeExample.spec.ts
- ./test/notebooks/upload.spec.ts - ./test/notebooks/upload.spec.ts
- ./test/sql/resourceToken.spec.ts - ./test/sql/resourceToken.spec.ts

View File

@@ -188,3 +188,7 @@ Cosmos Explorer has been under constant development for over 5 years. As a resul
✅ DO ✅ DO
- Support all [browsers supported by the Azure Portal](https://docs.microsoft.com/en-us/azure/azure-portal/azure-portal-supported-browsers-devices) - Support all [browsers supported by the Azure Portal](https://docs.microsoft.com/en-us/azure/azure-portal/azure-portal-supported-browsers-devices)
- Support IE11
- In practice, this should not need to be considered as part of a normal development workflow
- Polyfills and transpilation are already provided by our engineering systems.
- This requirement will be removed on March 30th, 2021 when Azure drops IE11 support.

View File

@@ -4,7 +4,7 @@
@font-face { @font-face {
font-family: wf_segoe-ui_normal; font-family: wf_segoe-ui_normal;
src: local("Segoe UI"), url("../../fonts/segoe-ui/west-european/normal/latest.woff"); src: url("../../fonts/segoe-ui/west-european/normal/latest.woff");
} }
@DataExplorerFont: wf_segoe-ui_normal, "Segoe UI", "Segoe WP", Tahoma, Arial, sans-serif; @DataExplorerFont: wf_segoe-ui_normal, "Segoe UI", "Segoe WP", Tahoma, Arial, sans-serif;

View File

@@ -1757,7 +1757,7 @@ input::-webkit-calendar-picker-indicator {
cursor: pointer; cursor: pointer;
} }
.paneMainContent { .contextual-pane .paneMainContent {
flex: 1; flex: 1;
padding-left: 34px; padding-left: 34px;
padding-right: 34px; padding-right: 34px;
@@ -2099,7 +2099,7 @@ a:link {
display: flex; display: flex;
flex: 1 1 auto; flex: 1 1 auto;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: auto;
height: 100%; height: 100%;
} }
@@ -3085,7 +3085,3 @@ settings-pane {
padding-left: @SmallSpace; padding-left: @SmallSpace;
} }
} }
.hiddenMain {
visibility: hidden;
height: 0px;
}

View File

@@ -3,7 +3,6 @@
.resourceTree { .resourceTree {
height: 100%; height: 100%;
width: 20%;
flex: 0 0 auto; flex: 0 0 auto;
.main { .main {
height: 100%; height: 100%;

1032
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@
"@azure/ms-rest-nodeauth": "3.0.7", "@azure/ms-rest-nodeauth": "3.0.7",
"@babel/plugin-proposal-class-properties": "7.12.1", "@babel/plugin-proposal-class-properties": "7.12.1",
"@babel/plugin-proposal-decorators": "7.12.12", "@babel/plugin-proposal-decorators": "7.12.12",
"@fluentui/react": "8.14.3", "@fluentui/react": "8.10.1",
"@jupyterlab/services": "6.0.2", "@jupyterlab/services": "6.0.2",
"@jupyterlab/terminal": "3.0.3", "@jupyterlab/terminal": "3.0.3",
"@microsoft/applicationinsights-web": "2.6.1", "@microsoft/applicationinsights-web": "2.6.1",
@@ -43,6 +43,7 @@
"@testing-library/jest-dom": "5.11.9", "@testing-library/jest-dom": "5.11.9",
"@types/mkdirp": "1.0.1", "@types/mkdirp": "1.0.1",
"@types/node-fetch": "2.5.7", "@types/node-fetch": "2.5.7",
"@uifabric/react-cards": "0.109.110",
"applicationinsights": "1.8.0", "applicationinsights": "1.8.0",
"bootstrap": "3.4.1", "bootstrap": "3.4.1",
"canvas": "file:./canvas", "canvas": "file:./canvas",
@@ -56,7 +57,6 @@
"datatables.net-dt": "1.10.19", "datatables.net-dt": "1.10.19",
"date-fns": "1.29.0", "date-fns": "1.29.0",
"dayjs": "1.8.19", "dayjs": "1.8.19",
"dom-to-image": "2.6.0",
"dotenv": "8.2.0", "dotenv": "8.2.0",
"eslint-plugin-jest": "23.13.2", "eslint-plugin-jest": "23.13.2",
"eslint-plugin-react": "7.20.0", "eslint-plugin-react": "7.20.0",
@@ -98,8 +98,7 @@
"swr": "0.4.0", "swr": "0.4.0",
"terser-webpack-plugin": "3.1.0", "terser-webpack-plugin": "3.1.0",
"underscore": "1.9.1", "underscore": "1.9.1",
"utility-types": "3.10.0", "utility-types": "3.10.0"
"zustand": "3.5.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.9.0", "@babel/core": "7.9.0",
@@ -111,7 +110,6 @@
"@types/codemirror": "0.0.56", "@types/codemirror": "0.0.56",
"@types/crossroads": "0.0.30", "@types/crossroads": "0.0.30",
"@types/d3": "5.9.2", "@types/d3": "5.9.2",
"@types/dom-to-image": "2.6.2",
"@types/enzyme": "3.10.7", "@types/enzyme": "3.10.7",
"@types/enzyme-adapter-react-16": "1.0.6", "@types/enzyme-adapter-react-16": "1.0.6",
"@types/hasher": "0.0.31", "@types/hasher": "0.0.31",

View File

@@ -1,15 +1,10 @@
.schema-analyzer-cell-outputs { .schema-analyzer-cell-outputs {
padding: 10px 2px; padding: 10px;
} }
// Mimic FluentUI8's DocumentCard style
.schema-analyzer-cell-output { .schema-analyzer-cell-output {
margin-bottom: 20px; margin-bottom: 20px;
padding: 14px 20px; padding: 10px;
border: 1px solid rgb(237, 235, 233); border-radius: 2px;
} box-shadow: rgba(0, 0, 0, 13%) 0px 1.6px 3.6px 0px, rgba(0, 0, 0, 11%) 0px 0.3px 0.9px 0px;
.schema-analyzer-cell-output:hover {
border-color: rgb(200, 198, 196);
box-shadow: inset 0 0 0 1px rgb(200, 198, 196)
} }

View File

@@ -9,17 +9,11 @@ import postRobot from "post-robot";
import * as React from "react"; import * as React from "react";
import * as ReactDOM from "react-dom"; import * as ReactDOM from "react-dom";
import "../../externals/iframeResizer.contentWindow.min.js"; // Required for iFrameResizer to work import "../../externals/iframeResizer.contentWindow.min.js"; // Required for iFrameResizer to work
import { SnapshotRequest } from "../Explorer/Notebook/NotebookComponent/types";
import "../Explorer/Notebook/NotebookRenderer/base.css"; import "../Explorer/Notebook/NotebookRenderer/base.css";
import "../Explorer/Notebook/NotebookRenderer/default.css"; import "../Explorer/Notebook/NotebookRenderer/default.css";
import { NotebookUtil } from "../Explorer/Notebook/NotebookUtil";
import "./CellOutputViewer.less"; import "./CellOutputViewer.less";
import { TransformMedia } from "./TransformMedia"; import { TransformMedia } from "./TransformMedia";
export interface SnapshotResponse {
imageSrc: string;
requestId: string;
}
export interface CellOutputViewerProps { export interface CellOutputViewerProps {
id: string; id: string;
contentRef: ContentRef; contentRef: ContentRef;
@@ -68,36 +62,6 @@ const onInit = async () => {
ReactDOM.render(outputs, document.getElementById("cellOutput")); ReactDOM.render(outputs, document.getElementById("cellOutput"));
} }
); );
postRobot.on(
"snapshotRequest",
{
window: window.parent,
domain: window.location.origin,
},
async (event): Promise<SnapshotResponse> => {
const topNode = document.getElementById("cellOutput");
if (!topNode) {
const errorMsg = "No top node to snapshot";
return Promise.reject(new Error(errorMsg));
}
// Typescript definition for event is wrong. So read props by casting to <any>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const snapshotRequest = (event as any).data as SnapshotRequest;
const result = await NotebookUtil.takeScreenshotDomToImage(
topNode,
snapshotRequest.aspectRatio,
undefined,
snapshotRequest.downloadFilename
);
return {
imageSrc: result.imageSrc,
requestId: snapshotRequest.requestId,
};
}
);
}; };
// Entry point // Entry point

View File

@@ -1,35 +0,0 @@
import React, { FunctionComponent } from "react";
import arrowLeftImg from "../../images/imgarrowlefticon.svg";
export interface CollapsedResourceTreeProps {
toggleLeftPaneExpanded: () => void;
isLeftPaneExpanded: boolean;
}
export const CollapsedResourceTree: FunctionComponent<CollapsedResourceTreeProps> = ({
toggleLeftPaneExpanded,
isLeftPaneExpanded,
}: CollapsedResourceTreeProps): JSX.Element => {
return (
<div id="mini" className={!isLeftPaneExpanded ? "mini toggle-mini" : "hiddenMain"}>
<div className="main-nav nav">
<ul className="nav">
<li
className="resourceTreeCollapse"
id="collapseToggleLeftPaneButton"
role="button"
tabIndex={0}
aria-label="Expand Tree"
>
<span className="leftarrowCollapsed" onClick={toggleLeftPaneExpanded}>
<img className="arrowCollapsed" src={arrowLeftImg} alt="Expand" />
</span>
<span className="collectionCollapsed" onClick={toggleLeftPaneExpanded}>
<span data-bind="text: collectionTitle" />
</span>
</li>
</ul>
</div>
</div>
);
};

View File

@@ -94,7 +94,6 @@ export class Flights {
public static readonly MongoIndexEditor = "mongoindexeditor"; public static readonly MongoIndexEditor = "mongoindexeditor";
public static readonly MongoIndexing = "mongoindexing"; public static readonly MongoIndexing = "mongoindexing";
public static readonly AutoscaleTest = "autoscaletest"; public static readonly AutoscaleTest = "autoscaletest";
public static readonly SchemaAnalyzer = "schemaanalyzer";
} }
export class AfecFeatures { export class AfecFeatures {

View File

@@ -1,59 +0,0 @@
import React, { FunctionComponent } from "react";
import arrowLeftImg from "../../images/imgarrowlefticon.svg";
import refreshImg from "../../images/refresh-cosmos.svg";
import { AuthType } from "../AuthType";
import { userContext } from "../UserContext";
export interface ResourceTreeProps {
toggleLeftPaneExpanded: () => void;
isLeftPaneExpanded: boolean;
}
export const ResourceTree: FunctionComponent<ResourceTreeProps> = ({
toggleLeftPaneExpanded,
isLeftPaneExpanded,
}: ResourceTreeProps): JSX.Element => {
return (
<div id="main" className={isLeftPaneExpanded ? "main" : "hiddenMain"}>
{/* Collections Window - - Start */}
<div id="mainslide" className="flexContainer">
{/* Collections Window Title/Command Bar - Start */}
<div className="collectiontitle">
<div className="coltitle">
<span className="titlepadcol" data-bind="text: collectionTitle" />
<div className="float-right">
<span
className="padimgcolrefresh"
data-test="refreshTree"
role="button"
data-bind="click: onRefreshResourcesClick, clickBubble: false, event: { keypress: onRefreshDatabasesKeyPress }"
tabIndex={0}
aria-label="Refresh tree"
title="Refresh tree"
>
<img className="refreshcol" src={refreshImg} alt="Refresh tree" />
</span>
<span
className="padimgcolrefresh1"
id="expandToggleLeftPaneButton"
role="button"
onClick={toggleLeftPaneExpanded}
tabIndex={0}
aria-label="Collapse Tree"
title="Collapse Tree"
>
<img className="refreshcol1" src={arrowLeftImg} alt="Hide" />
</span>
</div>
</div>
</div>
{userContext.authType === AuthType.ResourceToken ? (
<div style={{ overflowY: "auto" }} data-bind="react:resourceTreeForResourceToken" />
) : (
<div style={{ overflowY: "auto" }} data-bind="react:resourceTree" />
)}
</div>
{/* Collections Window - End */}
</div>
);
};

View File

@@ -1,16 +0,0 @@
import { Icon, TooltipHost } from "@fluentui/react";
import * as React from "react";
export interface TooltipProps {
children: string;
}
export const InfoTooltip: React.FunctionComponent<TooltipProps> = ({ children }: TooltipProps) => {
return (
<span>
<TooltipHost content={children}>
<Icon iconName="Info" ariaLabel="Info" className="panelInfoIcon" />
</TooltipHost>
</span>
);
};

View File

@@ -0,0 +1,24 @@
import { ITooltipHostStyles, TooltipHost } from "@fluentui/react";
import { useId } from "@fluentui/react-hooks";
import * as React from "react";
import InfoBubble from "../../../images/info-bubble.svg";
const calloutProps = { gapSpace: 0 };
const hostStyles: Partial<ITooltipHostStyles> = { root: { display: "inline-block" } };
export interface TooltipProps {
children: string;
}
export const Tooltip: React.FunctionComponent = ({ children }: TooltipProps) => {
const tooltipId = useId("tooltip");
return children ? (
<span>
<TooltipHost content={children} id={tooltipId} calloutProps={calloutProps} styles={hostStyles}>
<img className="infoImg" src={InfoBubble} alt="More information" />
</TooltipHost>
</span>
) : (
<></>
);
};

View File

@@ -2,7 +2,7 @@ import { Image, Stack, TextField } from "@fluentui/react";
import React, { ChangeEvent, FunctionComponent, KeyboardEvent, useRef, useState } from "react"; import React, { ChangeEvent, FunctionComponent, KeyboardEvent, useRef, useState } from "react";
import FolderIcon from "../../../images/folder_16x16.svg"; import FolderIcon from "../../../images/folder_16x16.svg";
import * as Constants from "../Constants"; import * as Constants from "../Constants";
import { InfoTooltip } from "../Tooltip/InfoTooltip"; import { Tooltip } from "../Tooltip/Tooltip";
interface UploadProps { interface UploadProps {
label: string; label: string;
@@ -51,7 +51,7 @@ export const Upload: FunctionComponent<UploadProps> = ({
return ( return (
<div> <div>
<span className="renewUploadItemsHeader">{label}</span> <span className="renewUploadItemsHeader">{label}</span>
{tooltip && <InfoTooltip>{tooltip}</InfoTooltip>} <Tooltip>{tooltip}</Tooltip>
<Stack horizontal> <Stack horizontal>
<TextField styles={{ fieldGroup: { width: 300 } }} readOnly value={selectedFilesTitle.toString()} /> <TextField styles={{ fieldGroup: { width: 300 } }} readOnly value={selectedFilesTitle.toString()} />
<input <input

View File

@@ -206,14 +206,17 @@ export enum NeighborType {
BOTH, BOTH,
} }
export interface IGraphConfigUiData { /**
showNeighborType: NeighborType; * Set of observable related to graph configuration by user
nodeProperties: string[]; */
nodePropertiesWithNone: string[]; export interface GraphConfigUiData {
nodeCaptionChoice: string; showNeighborType: ko.Observable<NeighborType>;
nodeColorKeyChoice: string; nodeProperties: ko.ObservableArray<string>;
nodeIconChoice: string; nodePropertiesWithNone: ko.ObservableArray<string>;
nodeIconSet: string; nodeCaptionChoice: ko.Observable<string>;
nodeColorKeyChoice: ko.Observable<string>;
nodeIconChoice: ko.Observable<string>;
nodeIconSet: ko.Observable<string>;
} }
/** /**

View File

@@ -4,10 +4,30 @@ import * as ko from "knockout";
import "./ComponentRegisterer"; import "./ComponentRegisterer";
describe("Component Registerer", () => { describe("Component Registerer", () => {
it("should register input-typeahead component", () => {
expect(ko.components.isRegistered("input-typeahead")).toBe(true);
});
it("should register error-display component", () => {
expect(ko.components.isRegistered("error-display")).toBe(true);
});
it("should register graph-style component", () => {
expect(ko.components.isRegistered("graph-style")).toBe(true);
});
it("should register json-editor component", () => { it("should register json-editor component", () => {
expect(ko.components.isRegistered("json-editor")).toBe(true); expect(ko.components.isRegistered("json-editor")).toBe(true);
}); });
it("should registeradd-collection-pane component", () => {
expect(ko.components.isRegistered("add-collection-pane")).toBe(true);
});
it("should register graph-styling-pane component", () => {
expect(ko.components.isRegistered("graph-styling-pane")).toBe(true);
});
it("should register dynamic-list component", () => { it("should register dynamic-list component", () => {
expect(ko.components.isRegistered("dynamic-list")).toBe(true); expect(ko.components.isRegistered("dynamic-list")).toBe(true);
}); });

View File

@@ -2,10 +2,16 @@ import * as ko from "knockout";
import { DiffEditorComponent } from "./Controls/DiffEditor/DiffEditorComponent"; import { DiffEditorComponent } from "./Controls/DiffEditor/DiffEditorComponent";
import { DynamicListComponent } from "./Controls/DynamicList/DynamicListComponent"; import { DynamicListComponent } from "./Controls/DynamicList/DynamicListComponent";
import { EditorComponent } from "./Controls/Editor/EditorComponent"; import { EditorComponent } from "./Controls/Editor/EditorComponent";
import { ErrorDisplayComponent } from "./Controls/ErrorDisplayComponent/ErrorDisplayComponent";
import { InputTypeaheadComponent } from "./Controls/InputTypeahead/InputTypeahead";
import { JsonEditorComponent } from "./Controls/JsonEditor/JsonEditorComponent"; import { JsonEditorComponent } from "./Controls/JsonEditor/JsonEditorComponent";
import { ThroughputInputComponentAutoPilotV3 } from "./Controls/ThroughputInput/ThroughputInputComponentAutoPilotV3"; import { ThroughputInputComponentAutoPilotV3 } from "./Controls/ThroughputInput/ThroughputInputComponentAutoPilotV3";
import { GraphStyleComponent } from "./Graph/GraphStyleComponent/GraphStyleComponent";
import * as PaneComponents from "./Panes/PaneComponents"; import * as PaneComponents from "./Panes/PaneComponents";
ko.components.register("input-typeahead", new InputTypeaheadComponent());
ko.components.register("error-display", new ErrorDisplayComponent());
ko.components.register("graph-style", GraphStyleComponent);
ko.components.register("editor", new EditorComponent()); ko.components.register("editor", new EditorComponent());
ko.components.register("json-editor", new JsonEditorComponent()); ko.components.register("json-editor", new JsonEditorComponent());
ko.components.register("diff-editor", new DiffEditorComponent()); ko.components.register("diff-editor", new DiffEditorComponent());
@@ -13,4 +19,9 @@ ko.components.register("dynamic-list", DynamicListComponent);
ko.components.register("throughput-input-autopilot-v3", ThroughputInputComponentAutoPilotV3); ko.components.register("throughput-input-autopilot-v3", ThroughputInputComponentAutoPilotV3);
// Panes // Panes
ko.components.register("add-database-pane", new PaneComponents.AddDatabasePaneComponent());
ko.components.register("add-collection-pane", new PaneComponents.AddCollectionPaneComponent());
ko.components.register("graph-styling-pane", new PaneComponents.GraphStylingPaneComponent());
ko.components.register("table-add-entity-pane", new PaneComponents.TableAddEntityPaneComponent());
ko.components.register("table-edit-entity-pane", new PaneComponents.TableEditEntityPaneComponent());
ko.components.register("cassandra-add-collection-pane", new PaneComponents.CassandraAddCollectionPaneComponent()); ko.components.register("cassandra-add-collection-pane", new PaneComponents.CassandraAddCollectionPaneComponent());

View File

@@ -103,7 +103,7 @@ export const Dialog: FunctionComponent<DialogProps> = ({
text: secondaryButtonText, text: secondaryButtonText,
onClick: onSecondaryButtonClick, onClick: onSecondaryButtonClick,
} }
: {}; : undefined;
return ( return (
<FluentDialog {...dialogProps}> <FluentDialog {...dialogProps}>

View File

@@ -1959,7 +1959,7 @@ exports[`test render renders with filters 1`] = `
</div> </div>
</span> </span>
</button> </button>
<FocusRects /> <Component />
</BaseButton> </BaseButton>
</DefaultButton> </DefaultButton>
</CustomizedDefaultButton> </CustomizedDefaultButton>

View File

@@ -0,0 +1,27 @@
import template from "./error-display-component.html";
/**
* Helper class for ko component registration
* This component displays an error as designed in:
* https://microsoft.sharepoint.com/teams/DPX/Modern/DocDB/_layouts/15/WopiFrame.aspx?sourcedoc={66864d4a-f925-4cbe-9eb4-79f8d191a115}&action=edit&wd=target%28DocumentDB%20emulator%2Eone%7CE617D0A7-F77C-4968-B75A-1451049F4FEA%2FError%20notification%7CAA1E4BC9-4D72-472C-B40C-2437FA217226%2F%29
* TODO: support "More details"
*/
export class ErrorDisplayComponent {
constructor() {
return {
viewModel: ErrorDisplayViewModel,
template,
};
}
}
/**
* Parameters for this component
*/
interface ErrorDisplayParams {
errorMsg: ko.Observable<string>; // Primary message
}
class ErrorDisplayViewModel {
public constructor(public params: ErrorDisplayParams) {}
}

View File

@@ -0,0 +1,6 @@
<div class="warningErrorContainer" data-bind="visible: !!params.errorMsg()">
<div class="warningErrorContent">
<span><img src="/error_red.svg" alt="Error" /></span>
<span class="settingErrorMsg warningErrorDetailsLinkContainer" data-bind="text: params.errorMsg()"></span>
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { CommandButton, FontIcon, FontWeights, ITextProps, Separator, Stack, Text } from "@fluentui/react";
import * as React from "react"; import * as React from "react";
import { Stack, Text, Separator, FontIcon, CommandButton, FontWeights, ITextProps } from "@fluentui/react";
export class GalleryHeaderComponent extends React.Component { export class GalleryHeaderComponent extends React.Component {
private static readonly azureText = "Microsoft Azure"; private static readonly azureText = "Microsoft Azure";
@@ -61,7 +61,7 @@ export class GalleryHeaderComponent extends React.Component {
<Stack.Item> <Stack.Item>
{this.renderHeaderItem( {this.renderHeaderItem(
GalleryHeaderComponent.galleryText, GalleryHeaderComponent.galleryText,
() => "", undefined,
GalleryHeaderComponent.headerItemTextProps GalleryHeaderComponent.headerItemTextProps
)} )}
</Stack.Item> </Stack.Item>

View File

@@ -0,0 +1,186 @@
/**
* How to use this component:
*
* In your html markup, use:
* <input-typeahead params="{
choices:choices,
selection:selection,
inputValue:inputValue,
placeholder:'Enter source',
typeaheadOverrideOptions:typeaheadOverrideOptions
}"></input-typeahead>
* The parameters are documented below.
*
* Notes:
* - dynamic:true by default, this allows choices to change after initialization.
* To turn it off, use:
* typeaheadOverrideOptions: { dynamic:false }
*
*/
import "jquery-typeahead";
import template from "./input-typeahead.html";
/**
* Helper class for ko component registration
*/
export class InputTypeaheadComponent {
constructor() {
return {
viewModel: InputTypeaheadViewModel,
template,
};
}
}
export interface Item {
caption: string;
value: any;
}
/**
* Parameters for this component
*/
interface InputTypeaheadParams {
/**
* List of choices available in the dropdown.
*/
choices: ko.ObservableArray<Item>;
/**
* Gets updated when user clicks on the choice in the dropdown
*/
selection?: ko.Observable<Item>;
/**
* The current string value of <input>
*/
inputValue?: ko.Observable<string>;
/**
* Define what text you want as the input placeholder
*/
placeholder: string;
/**
* Override default jquery-typeahead options
* WARNING: do not override input, source or callback to avoid breaking the components behavior.
*/
typeaheadOverrideOptions?: any;
/**
* This function gets called when pressing ENTER on the input box
*/
submitFct?: (inputValue: string | null, selection: Item | null) => void;
/**
* Typehead comes with a Search button that we normally remove.
* If you want to use it, turn this on
*/
showSearchButton?: boolean;
}
interface OnClickItem {
matchedKey: string;
value: any;
caption: string;
group: string;
}
interface Cache {
inputValue: string | null;
selection: Item | null;
}
class InputTypeaheadViewModel {
private static instanceCount = 0; // Generate unique id for each component's typeahead instance
private instanceNumber: number;
private params: InputTypeaheadParams;
private cache: Cache;
public constructor(params: InputTypeaheadParams) {
this.instanceNumber = InputTypeaheadViewModel.instanceCount++;
this.params = params;
this.params.choices.subscribe(this.initializeTypeahead.bind(this));
this.cache = {
inputValue: null,
selection: null,
};
}
/**
* Must execute once ko is rendered, so that it can find the input element by id
*/
private initializeTypeahead() {
let params = this.params;
let cache = this.cache;
let options: any = {
input: `#${this.getComponentId()}`, //'.input-typeahead',
order: "asc",
minLength: 0,
searchOnFocus: true,
source: {
display: "caption",
data: () => {
return this.params.choices();
},
},
callback: {
onClick: (_node: unknown, _a: unknown, item: OnClickItem) => {
cache.selection = item;
if (params.selection) {
params.selection(item);
}
},
onResult(_node: unknown, query: any) {
cache.inputValue = query;
if (params.inputValue) {
params.inputValue(query);
}
},
},
template: (_query: string, item: any) => {
// Don't display id if caption *IS* the id
return item.caption === item.value
? "<span>{{caption}}</span>"
: "<span><div>{{caption}}</div><div><small>{{value}}</small></div></span>";
},
dynamic: true,
};
// Override options
if (params.typeaheadOverrideOptions) {
for (let p in params.typeaheadOverrideOptions) {
options[p] = params.typeaheadOverrideOptions[p];
}
}
($ as any).typeahead(options);
}
/**
* Get this component id
* @return unique id per instance
*/
private getComponentId(): string {
return `input-typeahead${this.instanceNumber}`;
}
/**
* Executed once ko is done rendering bindings
* Use ko's "template: afterRender" callback to do that without actually using any template.
* Another way is to call it within setTimeout() in constructor.
*/
public afterRender(): void {
this.initializeTypeahead();
}
public submit(): void {
if (this.params.submitFct) {
this.params.submitFct(this.cache.inputValue, this.cache.selection);
}
}
}

View File

@@ -0,0 +1,19 @@
<span class="input-typeahead-container">
<form class="input-typehead" data-bind="submit:submit">
<div class="typeahead__container">
<div class="typeahead__field">
<span class="typeahead__query">
<input
name="q"
type="search"
autocomplete="off"
data-bind="attr: { placeholder: params.placeholder, id:getComponentId() }, value:params.inputValue, template: { afterRender:afterRender() }"
/>
</span>
<span class="typeahead__button" data-bind="visible:params.showSearchButton">
<button type="submit"><span class="typeahead__search-icon"></span></button>
</span>
</div>
</div>
</form>
</span>

View File

@@ -1,23 +1,20 @@
import { import {
BaseButton, BaseButton,
Button, Button,
DocumentCard, FontWeights,
DocumentCardActivity,
DocumentCardDetails,
DocumentCardPreview,
DocumentCardTitle,
Icon, Icon,
IconButton, IconButton,
IDocumentCardPreviewProps, Image,
IDocumentCardStyles,
ImageFit, ImageFit,
Link, Link,
Persona,
Separator, Separator,
Spinner, Spinner,
SpinnerSize, SpinnerSize,
Text, Text,
TooltipHost, TooltipHost,
} from "@fluentui/react"; } from "@fluentui/react";
import { Card } from "@uifabric/react-cards";
import React, { FunctionComponent, useState } from "react"; import React, { FunctionComponent, useState } from "react";
import CosmosDBLogo from "../../../../../images/CosmosDB-logo.svg"; import CosmosDBLogo from "../../../../../images/CosmosDB-logo.svg";
import { IGalleryItem } from "../../../../Juno/JunoClient"; import { IGalleryItem } from "../../../../Juno/JunoClient";
@@ -51,6 +48,7 @@ export const GalleryCardComponent: FunctionComponent<GalleryCardComponentProps>
const CARD_WIDTH = 256; const CARD_WIDTH = 256;
const cardImageHeight = 144; const cardImageHeight = 144;
const cardDescriptionMaxChars = 80; const cardDescriptionMaxChars = 80;
const cardItemGapBig = 10;
const cardItemGapSmall = 8; const cardItemGapSmall = 8;
const cardDeleteSpinnerHeight = 360; const cardDeleteSpinnerHeight = 360;
const smallTextLineHeight = 18; const smallTextLineHeight = 18;
@@ -66,9 +64,9 @@ export const GalleryCardComponent: FunctionComponent<GalleryCardComponentProps>
const dateString = new Date(data.created).toLocaleString("default", options); const dateString = new Date(data.created).toLocaleString("default", options);
const cardTitle = FileSystemUtil.stripExtension(data.name, "ipynb"); const cardTitle = FileSystemUtil.stripExtension(data.name, "ipynb");
const renderTruncated = (text: string, totalLength: number): string => { const renderTruncatedDescription = (): string => {
let truncatedDescription = text.substr(0, totalLength); let truncatedDescription = data.description.substr(0, cardDescriptionMaxChars);
if (text.length > totalLength) { if (data.description.length > cardDescriptionMaxChars) {
truncatedDescription = `${truncatedDescription} ...`; truncatedDescription = `${truncatedDescription} ...`;
} }
return truncatedDescription; return truncatedDescription;
@@ -122,35 +120,42 @@ export const GalleryCardComponent: FunctionComponent<GalleryCardComponentProps>
event.preventDefault(); event.preventDefault();
activate(); activate();
}; };
const DocumentCardActivityPeople = [{ name: data.author, profileImageSrc: data.isSample && CosmosDBLogo }];
const previewProps: IDocumentCardPreviewProps = {
previewImages: [
{
previewImageSrc: data.thumbnailUrl,
imageFit: ImageFit.cover,
width: CARD_WIDTH,
height: cardImageHeight,
},
],
};
const cardStyles: IDocumentCardStyles = {
root: { display: "inline-block", marginRight: 20, width: CARD_WIDTH },
};
return ( return (
<DocumentCard aria-label={cardTitle} styles={cardStyles} onClick={onClick}> <Card
style={{ background: "white" }}
aria-label={cardTitle}
data-is-focusable="true"
tokens={{ width: CARD_WIDTH, childrenGap: 0 }}
onClick={(event) => handlerOnClick(event, onClick)}
>
{isDeletingPublishedNotebook && ( {isDeletingPublishedNotebook && (
<Spinner <Card.Item tokens={{ padding: cardItemGapBig }}>
size={SpinnerSize.large} <Spinner
label={`Deleting '${cardTitle}'`} size={SpinnerSize.large}
styles={{ root: { height: cardDeleteSpinnerHeight } }} label={`Deleting '${cardTitle}'`}
/> styles={{ root: { height: cardDeleteSpinnerHeight } }}
/>
</Card.Item>
)} )}
{!isDeletingPublishedNotebook && ( {!isDeletingPublishedNotebook && (
<> <>
<DocumentCardActivity activity={dateString} people={DocumentCardActivityPeople} /> <Card.Item tokens={{ padding: cardItemGapBig }}>
<DocumentCardPreview {...previewProps} /> <Persona imageUrl={data.isSample && CosmosDBLogo} text={data.author} secondaryText={dateString} />
<DocumentCardDetails> </Card.Item>
<Text variant="small" nowrap styles={{ root: { height: smallTextLineHeight, padding: "2px 16px" } }}>
<Card.Item>
<Image
src={data.thumbnailUrl}
width={CARD_WIDTH}
height={cardImageHeight}
imageFit={ImageFit.cover}
alt={`${cardTitle} cover image`}
/>
</Card.Item>
<Card.Section styles={{ root: { padding: cardItemGapBig } }}>
<Text variant="small" nowrap styles={{ root: { height: smallTextLineHeight } }}>
{data.tags ? ( {data.tags ? (
data.tags.map((tag, index, array) => ( data.tags.map((tag, index, array) => (
<span key={tag}> <span key={tag}>
@@ -162,22 +167,43 @@ export const GalleryCardComponent: FunctionComponent<GalleryCardComponentProps>
<br /> <br />
)} )}
</Text> </Text>
<DocumentCardTitle title={renderTruncated(cardTitle, 20)} shouldTruncate />
<DocumentCardTitle <Text
title={renderTruncated(data.description, cardDescriptionMaxChars)} styles={{
showAsSecondaryTitle root: {
/> fontWeight: FontWeights.semibold,
<span style={{ padding: "8px 16px" }}> paddingTop: cardItemGapSmall,
paddingBottom: cardItemGapSmall,
},
}}
nowrap
>
{cardTitle}
</Text>
<Text variant="small" styles={{ root: { height: smallTextLineHeight * 2 } }}>
{renderTruncatedDescription()}
</Text>
<span>
{data.views !== undefined && generateIconText("RedEye", data.views.toString())} {data.views !== undefined && generateIconText("RedEye", data.views.toString())}
{data.downloads !== undefined && generateIconText("Download", data.downloads.toString())} {data.downloads !== undefined && generateIconText("Download", data.downloads.toString())}
{data.favorites !== undefined && generateIconText("Heart", data.favorites.toString())} {data.favorites !== undefined && generateIconText("Heart", data.favorites.toString())}
</span> </span>
</DocumentCardDetails> </Card.Section>
{cardButtonsVisible && ( {cardButtonsVisible && (
<DocumentCardDetails> <Card.Section
styles={{
root: {
marginLeft: cardItemGapBig,
marginRight: cardItemGapBig,
},
}}
>
<Separator styles={{ root: { padding: 0, height: 1 } }} /> <Separator styles={{ root: { padding: 0, height: 1 } }} />
<span style={{ padding: "0px 16px" }}> <span>
{isFavorite !== undefined && {isFavorite !== undefined &&
generateIconButtonWithTooltip( generateIconButtonWithTooltip(
isFavorite ? "HeartFill" : "Heart", isFavorite ? "HeartFill" : "Heart",
@@ -196,10 +222,10 @@ export const GalleryCardComponent: FunctionComponent<GalleryCardComponentProps>
) )
)} )}
</span> </span>
</DocumentCardDetails> </Card.Section>
)} )}
</> </>
)} )}
</DocumentCard> </Card>
); );
}; };

View File

@@ -1,49 +1,59 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GalleryCardComponent renders 1`] = ` exports[`GalleryCardComponent renders 1`] = `
<StyledDocumentCardBase <Card
aria-label="name" aria-label="name"
styles={ data-is-focusable="true"
onClick={[Function]}
style={
Object { Object {
"root": Object { "background": "white",
"display": "inline-block", }
"marginRight": 20, }
"width": 256, tokens={
}, Object {
"childrenGap": 0,
"width": 256,
} }
} }
> >
<StyledDocumentCardActivityBase <CardItem
activity="Invalid Date" tokens={
people={ Object {
Array [ "padding": 10,
Object { }
"name": "author",
"profileImageSrc": false,
},
]
} }
/> >
<StyledDocumentCardPreviewBase <StyledPersonaBase
previewImages={ imageUrl={false}
Array [ secondaryText="Invalid Date"
Object { text="author"
"height": 144, />
"imageFit": 2, </CardItem>
"previewImageSrc": "thumbnailUrl", <CardItem>
"width": 256, <Image
alt="name cover image"
height={144}
imageFit={2}
src="thumbnailUrl"
width={256}
/>
</CardItem>
<CardSection
styles={
Object {
"root": Object {
"padding": 10,
}, },
] }
} }
/> >
<StyledDocumentCardDetailsBase>
<Text <Text
nowrap={true} nowrap={true}
styles={ styles={
Object { Object {
"root": Object { "root": Object {
"height": 18, "height": 18,
"padding": "2px 16px",
}, },
} }
} }
@@ -59,21 +69,33 @@ exports[`GalleryCardComponent renders 1`] = `
</StyledLinkBase> </StyledLinkBase>
</span> </span>
</Text> </Text>
<StyledDocumentCardTitleBase <Text
shouldTruncate={true} nowrap={true}
title="name" styles={
/>
<StyledDocumentCardTitleBase
showAsSecondaryTitle={true}
title="description"
/>
<span
style={
Object { Object {
"padding": "8px 16px", "root": Object {
"fontWeight": 600,
"paddingBottom": 8,
"paddingTop": 8,
},
} }
} }
> >
name
</Text>
<Text
styles={
Object {
"root": Object {
"height": 36,
},
}
}
variant="small"
>
description
</Text>
<span>
<Text <Text
styles={ styles={
Object { Object {
@@ -147,8 +169,17 @@ exports[`GalleryCardComponent renders 1`] = `
0 0
</Text> </Text>
</span> </span>
</StyledDocumentCardDetailsBase> </CardSection>
<StyledDocumentCardDetailsBase> <CardSection
styles={
Object {
"root": Object {
"marginLeft": 10,
"marginRight": 10,
},
}
}
>
<Separator <Separator
styles={ styles={
Object { Object {
@@ -159,13 +190,7 @@ exports[`GalleryCardComponent renders 1`] = `
} }
} }
/> />
<span <span>
style={
Object {
"padding": "0px 16px",
}
}
>
<StyledTooltipHostBase <StyledTooltipHostBase
calloutProps={ calloutProps={
Object { Object {
@@ -251,6 +276,6 @@ exports[`GalleryCardComponent renders 1`] = `
/> />
</StyledTooltipHostBase> </StyledTooltipHostBase>
</span> </span>
</StyledDocumentCardDetailsBase> </CardSection>
</StyledDocumentCardBase> </Card>
`; `;

View File

@@ -0,0 +1,123 @@
import * as React from "react";
import { JunoClient } from "../../../Juno/JunoClient";
import { HttpStatusCodes, CodeOfConductEndpoints } from "../../../Common/Constants";
import { Stack, Text, Checkbox, PrimaryButton, Link } from "@fluentui/react";
import { getErrorMessage, getErrorStack, handleError } from "../../../Common/ErrorHandlingUtils";
import { trace, traceFailure, traceStart, traceSuccess } from "../../../Shared/Telemetry/TelemetryProcessor";
import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
export interface CodeOfConductComponentProps {
junoClient: JunoClient;
onAcceptCodeOfConduct: (result: boolean) => void;
}
interface CodeOfConductComponentState {
readCodeOfConduct: boolean;
}
export class CodeOfConductComponent extends React.Component<CodeOfConductComponentProps, CodeOfConductComponentState> {
private viewCodeOfConductTraced: boolean;
private descriptionPara1: string;
private descriptionPara2: string;
private descriptionPara3: string;
private link1: { label: string; url: string };
constructor(props: CodeOfConductComponentProps) {
super(props);
this.state = {
readCodeOfConduct: false,
};
this.descriptionPara1 = "Azure Cosmos DB Notebook Gallery - Code of Conduct";
this.descriptionPara2 = "The notebook public gallery contains notebook samples shared by users of Azure Cosmos DB.";
this.descriptionPara3 = "In order to view and publish your samples to the gallery, you must accept the ";
this.link1 = { label: "code of conduct.", url: CodeOfConductEndpoints.codeOfConduct };
}
private async acceptCodeOfConduct(): Promise<void> {
const startKey = traceStart(Action.NotebooksGalleryAcceptCodeOfConduct);
try {
const response = await this.props.junoClient.acceptCodeOfConduct();
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
throw new Error(`Received HTTP ${response.status} when accepting code of conduct`);
}
traceSuccess(Action.NotebooksGalleryAcceptCodeOfConduct, {}, startKey);
this.props.onAcceptCodeOfConduct(response.data);
} catch (error) {
traceFailure(
Action.NotebooksGalleryAcceptCodeOfConduct,
{
error: getErrorMessage(error),
errorStack: getErrorStack(error),
},
startKey
);
handleError(error, "CodeOfConductComponent/acceptCodeOfConduct", "Failed to accept code of conduct");
}
}
private onChangeCheckbox = (): void => {
this.setState({ readCodeOfConduct: !this.state.readCodeOfConduct });
};
public render(): JSX.Element {
if (!this.viewCodeOfConductTraced) {
this.viewCodeOfConductTraced = true;
trace(Action.NotebooksGalleryViewCodeOfConduct);
}
return (
<Stack tokens={{ childrenGap: 20 }}>
<Stack.Item>
<Text style={{ fontWeight: 500, fontSize: "20px" }}>{this.descriptionPara1}</Text>
</Stack.Item>
<Stack.Item>
<Text>{this.descriptionPara2}</Text>
</Stack.Item>
<Stack.Item>
<Text>
{this.descriptionPara3}
<Link href={this.link1.url} target="_blank">
{this.link1.label}
</Link>
</Text>
</Stack.Item>
<Stack.Item>
<Checkbox
styles={{
label: {
margin: 0,
padding: "2 0 2 0",
},
text: {
fontSize: 12,
},
}}
label="I have read and accept the code of conduct."
onChange={this.onChangeCheckbox}
/>
</Stack.Item>
<Stack.Item>
<PrimaryButton
ariaLabel="Continue"
title="Continue"
onClick={async () => await this.acceptCodeOfConduct()}
tabIndex={0}
className="genericPaneSubmitBtn"
text="Continue"
disabled={!this.state.readCodeOfConduct}
/>
</Stack.Item>
</Stack>
);
}
}

View File

@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CodeOfConduct renders 1`] = ` exports[`CodeOfConductComponent renders 1`] = `
<Stack <Stack
tokens={ tokens={
Object { Object {

View File

@@ -1,12 +1,12 @@
jest.mock("../../../../Juno/JunoClient"); jest.mock("../../../../Juno/JunoClient");
import { shallow } from "enzyme"; import { shallow } from "enzyme";
import React from "react"; import React from "react";
import { CodeOfConductComponent, CodeOfConductComponentProps } from ".";
import { HttpStatusCodes } from "../../../../Common/Constants"; import { HttpStatusCodes } from "../../../../Common/Constants";
import { JunoClient } from "../../../../Juno/JunoClient"; import { JunoClient } from "../../../../Juno/JunoClient";
import { CodeOfConduct, CodeOfConductProps } from "./CodeOfConduct";
describe("CodeOfConduct", () => { describe("CodeOfConductComponent", () => {
let codeOfConductProps: CodeOfConductProps; let codeOfConductProps: CodeOfConductComponentProps;
beforeEach(() => { beforeEach(() => {
const junoClient = new JunoClient(); const junoClient = new JunoClient();
@@ -21,12 +21,12 @@ describe("CodeOfConduct", () => {
}); });
it("renders", () => { it("renders", () => {
const wrapper = shallow(<CodeOfConduct {...codeOfConductProps} />); const wrapper = shallow(<CodeOfConductComponent {...codeOfConductProps} />);
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
it("onAcceptedCodeOfConductCalled", async () => { it("onAcceptedCodeOfConductCalled", async () => {
const wrapper = shallow(<CodeOfConduct {...codeOfConductProps} />); const wrapper = shallow(<CodeOfConductComponent {...codeOfConductProps} />);
wrapper.find(".genericPaneSubmitBtn").first().simulate("click"); wrapper.find(".genericPaneSubmitBtn").first().simulate("click");
await Promise.resolve(); await Promise.resolve();
expect(codeOfConductProps.onAcceptCodeOfConduct).toBeCalled(); expect(codeOfConductProps.onAcceptCodeOfConduct).toBeCalled();

View File

@@ -6,15 +6,15 @@ import { JunoClient } from "../../../../Juno/JunoClient";
import { Action } from "../../../../Shared/Telemetry/TelemetryConstants"; import { Action } from "../../../../Shared/Telemetry/TelemetryConstants";
import { trace, traceFailure, traceStart, traceSuccess } from "../../../../Shared/Telemetry/TelemetryProcessor"; import { trace, traceFailure, traceStart, traceSuccess } from "../../../../Shared/Telemetry/TelemetryProcessor";
export interface CodeOfConductProps { export interface CodeOfConductComponentProps {
junoClient: JunoClient; junoClient: JunoClient;
onAcceptCodeOfConduct: (result: boolean) => void; onAcceptCodeOfConduct: (result: boolean) => void;
} }
export const CodeOfConduct: FunctionComponent<CodeOfConductProps> = ({ export const CodeOfConductComponent: FunctionComponent<CodeOfConductComponentProps> = ({
junoClient, junoClient,
onAcceptCodeOfConduct, onAcceptCodeOfConduct,
}: CodeOfConductProps) => { }: CodeOfConductComponentProps) => {
const descriptionPara1 = "Azure Cosmos DB Notebook Gallery - Code of Conduct"; const descriptionPara1 = "Azure Cosmos DB Notebook Gallery - Code of Conduct";
const descriptionPara2 = "The notebook public gallery contains notebook samples shared by users of Azure Cosmos DB."; const descriptionPara2 = "The notebook public gallery contains notebook samples shared by users of Azure Cosmos DB.";
const descriptionPara3 = "In order to view and publish your samples to the gallery, you must accept the "; const descriptionPara3 = "In order to view and publish your samples to the gallery, you must accept the ";
@@ -47,7 +47,7 @@ export const CodeOfConduct: FunctionComponent<CodeOfConductProps> = ({
startKey startKey
); );
handleError(error, "CodeOfConduct/acceptCodeOfConduct", "Failed to accept code of conduct"); handleError(error, "CodeOfConductComponent/acceptCodeOfConduct", "Failed to accept code of conduct");
} }
}; };

View File

@@ -30,10 +30,11 @@ import * as GalleryUtils from "../../../Utils/GalleryUtils";
import Explorer from "../../Explorer"; import Explorer from "../../Explorer";
import { Dialog, DialogProps } from "../Dialog"; import { Dialog, DialogProps } from "../Dialog";
import { GalleryCardComponent, GalleryCardComponentProps } from "./Cards/GalleryCardComponent"; import { GalleryCardComponent, GalleryCardComponentProps } from "./Cards/GalleryCardComponent";
import { CodeOfConduct } from "./CodeOfConduct/CodeOfConduct"; import { CodeOfConductComponent } from "./CodeOfConductComponent";
import "./GalleryViewerComponent.less"; import "./GalleryViewerComponent.less";
import { InfoComponent } from "./InfoComponent/InfoComponent"; import { InfoComponent } from "./InfoComponent/InfoComponent";
const CARD_WIDTH = 256;
export interface GalleryViewerComponentProps { export interface GalleryViewerComponentProps {
container?: Explorer; container?: Explorer;
junoClient: JunoClient; junoClient: JunoClient;
@@ -86,7 +87,7 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
public static readonly PublishedTitle = "My published work"; public static readonly PublishedTitle = "My published work";
private static readonly rowsPerPage = 5; private static readonly rowsPerPage = 5;
private static readonly CARD_WIDTH = 256;
private static readonly mostViewedText = "Most viewed"; private static readonly mostViewedText = "Most viewed";
private static readonly mostDownloadedText = "Most downloaded"; private static readonly mostDownloadedText = "Most downloaded";
private static readonly mostFavoritedText = "Most favorited"; private static readonly mostFavoritedText = "Most favorited";
@@ -372,7 +373,7 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
{acceptedCodeOfConduct === false && ( {acceptedCodeOfConduct === false && (
<Overlay isDarkThemed> <Overlay isDarkThemed>
<div className="publicGalleryTabOverlayContent"> <div className="publicGalleryTabOverlayContent">
<CodeOfConduct <CodeOfConductComponent
junoClient={this.props.junoClient} junoClient={this.props.junoClient}
onAcceptCodeOfConduct={(result: boolean) => { onAcceptCodeOfConduct={(result: boolean) => {
this.setState({ isCodeOfConductAccepted: result }); this.setState({ isCodeOfConductAccepted: result });
@@ -643,7 +644,7 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
private getPageSpecification = (itemIndex?: number, visibleRect?: IRectangle): IPageSpecification => { private getPageSpecification = (itemIndex?: number, visibleRect?: IRectangle): IPageSpecification => {
if (itemIndex === 0) { if (itemIndex === 0) {
this.columnCount = Math.floor(visibleRect.width / GalleryViewerComponent.CARD_WIDTH) || this.columnCount; this.columnCount = Math.floor(visibleRect.width / CARD_WIDTH) || this.columnCount;
this.rowCount = GalleryViewerComponent.rowsPerPage; this.rowCount = GalleryViewerComponent.rowsPerPage;
} }
@@ -671,7 +672,7 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
}; };
return ( return (
<div style={{ float: "left", padding: 5 }}> <div style={{ float: "left", padding: 10 }}>
<GalleryCardComponent {...props} /> <GalleryCardComponent {...props} />
</div> </div>
); );

View File

@@ -252,7 +252,7 @@ exports[`ThroughputInputAutoPilotV3Component spendAck checkbox visible 1`] = `
> >
capacity calculator capacity calculator
<FontIcon <Component
iconName="NavigateExternalInline" iconName="NavigateExternalInline"
/> />
</StyledLinkBase> </StyledLinkBase>
@@ -526,7 +526,7 @@ exports[`ThroughputInputAutoPilotV3Component throughput input visible 1`] = `
> >
capacity calculator capacity calculator
<FontIcon <Component
iconName="NavigateExternalInline" iconName="NavigateExternalInline"
/> />
</StyledLinkBase> </StyledLinkBase>

View File

@@ -1,13 +0,0 @@
import { shallow } from "enzyme";
import React from "react";
import { CostEstimateText } from "./CostEstimateText";
const props = {
requestUnits: 5,
isAutoscale: false,
};
describe("CostEstimateText Pane", () => {
it("should render Default properly", () => {
const wrapper = shallow(<CostEstimateText {...props} />);
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -1,77 +0,0 @@
import { Text } from "@fluentui/react";
import React, { FunctionComponent } from "react";
import { InfoTooltip } from "../../../../Common/Tooltip/InfoTooltip";
import * as SharedConstants from "../../../../Shared/Constants";
import { userContext } from "../../../../UserContext";
import {
calculateEstimateNumber,
computeRUUsagePriceHourly,
getAutoscalePricePerRu,
getCurrencySign,
getMultimasterMultiplier,
getPriceCurrency,
getPricePerRu,
} from "../../../../Utils/PricingUtils";
interface CostEstimateTextProps {
requestUnits: number;
isAutoscale: boolean;
}
export const CostEstimateText: FunctionComponent<CostEstimateTextProps> = ({
requestUnits,
isAutoscale,
}: CostEstimateTextProps) => {
const { databaseAccount } = userContext;
if (!databaseAccount?.properties) {
return <></>;
}
const serverId: string = userContext.portalEnv;
const numberOfRegions: number = databaseAccount.properties.readLocations?.length || 1;
const multimasterEnabled: boolean = databaseAccount.properties.enableMultipleWriteLocations;
const hourlyPrice: number = computeRUUsagePriceHourly({
serverId,
requestUnits,
numberOfRegions,
multimasterEnabled,
isAutoscale,
});
const dailyPrice: number = hourlyPrice * 24;
const monthlyPrice: number = hourlyPrice * SharedConstants.hoursInAMonth;
const currency: string = getPriceCurrency(serverId);
const currencySign: string = getCurrencySign(serverId);
const multiplier = getMultimasterMultiplier(numberOfRegions, multimasterEnabled);
const pricePerRu = isAutoscale
? getAutoscalePricePerRu(serverId, multiplier) * multiplier
: getPricePerRu(serverId) * multiplier;
const iconWithEstimatedCostDisclaimer: JSX.Element = <InfoTooltip>PricingUtils.estimatedCostDisclaimer</InfoTooltip>;
if (isAutoscale) {
return (
<Text variant="small">
Estimated monthly cost ({currency}){iconWithEstimatedCostDisclaimer}:{" "}
<b>
{currencySign + calculateEstimateNumber(monthlyPrice / 10)} -{" "}
{currencySign + calculateEstimateNumber(monthlyPrice)}{" "}
</b>
({numberOfRegions + (numberOfRegions === 1 ? " region" : " regions")}, {requestUnits / 10} - {requestUnits}{" "}
RU/s, {currencySign + pricePerRu}/RU)
</Text>
);
}
return (
<Text variant="small">
Estimated cost ({currency}){iconWithEstimatedCostDisclaimer}:{" "}
<b>
{currencySign + calculateEstimateNumber(hourlyPrice)} hourly /{" "}
{currencySign + calculateEstimateNumber(dailyPrice)} daily /{" "}
{currencySign + calculateEstimateNumber(monthlyPrice)} monthly{" "}
</b>
({numberOfRegions + (numberOfRegions === 1 ? " region" : " regions")}, {requestUnits}RU/s,{" "}
{currencySign + pricePerRu}/RU)
</Text>
);
};

View File

@@ -1,3 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CostEstimateText Pane should render Default properly 1`] = `<Fragment />`;

View File

@@ -1,32 +0,0 @@
import { mount, ReactWrapper } from "enzyme";
import React from "react";
import { ThroughputInput } from "./ThroughputInput";
const props = {
isDatabase: false,
showFreeTierExceedThroughputTooltip: true,
isSharded: false,
setThroughputValue: () => jest.fn(),
setIsAutoscale: () => jest.fn(),
onCostAcknowledgeChange: () => jest.fn(),
};
describe("ThroughputInput Pane", () => {
let wrapper: ReactWrapper;
beforeEach(() => {
wrapper = mount(<ThroughputInput {...props} />);
});
it("should render Default properly", () => {
expect(wrapper).toMatchSnapshot();
});
it("should switch mode properly", () => {
wrapper.find('[aria-label="Manual mode"]').simulate("change");
expect(wrapper.find('[aria-label="Throughput header"]').at(0).text()).toBe(
"Container throughput (400 - unlimited RU/s)"
);
wrapper.find('[aria-label="Autoscale mode"]').simulate("change");
expect(wrapper.find('[aria-label="Throughput header"]').at(0).text()).toBe("Container throughput (autoscale)");
});
});

View File

@@ -1,14 +1,11 @@
import { Checkbox, DirectionalHint, Link, Stack, Text, TextField, TooltipHost } from "@fluentui/react"; import { Checkbox, DirectionalHint, Icon, Link, Stack, Text, TextField, TooltipHost } from "@fluentui/react";
import React, { FunctionComponent, useState } from "react"; import React from "react";
import * as Constants from "../../../Common/Constants"; import * as Constants from "../../../Common/Constants";
import { InfoTooltip } from "../../../Common/Tooltip/InfoTooltip";
import * as SharedConstants from "../../../Shared/Constants"; import * as SharedConstants from "../../../Shared/Constants";
import { userContext } from "../../../UserContext"; import { userContext } from "../../../UserContext";
import { getCollectionName } from "../../../Utils/APITypeUtils"; import { getCollectionName } from "../../../Utils/APITypeUtils";
import * as AutoPilotUtils from "../../../Utils/AutoPilotUtils"; import * as AutoPilotUtils from "../../../Utils/AutoPilotUtils";
import * as PricingUtils from "../../../Utils/PricingUtils"; import * as PricingUtils from "../../../Utils/PricingUtils";
import { CostEstimateText } from "./CostEstimateText/CostEstimateText";
import "./ThroughputInput.less";
export interface ThroughputInputProps { export interface ThroughputInputProps {
isDatabase: boolean; isDatabase: boolean;
@@ -19,25 +16,176 @@ export interface ThroughputInputProps {
onCostAcknowledgeChange: (isAcknowledged: boolean) => void; onCostAcknowledgeChange: (isAcknowledged: boolean) => void;
} }
export const ThroughputInput: FunctionComponent<ThroughputInputProps> = ({ export interface ThroughputInputState {
isDatabase, isAutoscaleSelected: boolean;
showFreeTierExceedThroughputTooltip, throughput: number;
setThroughputValue, isCostAcknowledged: boolean;
setIsAutoscale, throughputError: string;
isSharded, }
onCostAcknowledgeChange,
}: ThroughputInputProps) => {
const [isAutoscaleSelected, setIsAutoScaleSelected] = useState<boolean>(true);
const [throughput, setThroughput] = useState<number>(AutoPilotUtils.minAutoPilotThroughput);
const [isCostAcknowledged, setIsCostAcknowledged] = useState<boolean>(false);
const [throughputError, setThroughputError] = useState<string>("");
setIsAutoscale(isAutoscaleSelected); export class ThroughputInput extends React.Component<ThroughputInputProps, ThroughputInputState> {
setThroughputValue(throughput); constructor(props: ThroughputInputProps) {
super(props);
const getThroughputLabelText = (): string => { this.state = {
isAutoscaleSelected: true,
throughput: AutoPilotUtils.minAutoPilotThroughput,
isCostAcknowledged: false,
throughputError: undefined,
};
this.props.setThroughputValue(AutoPilotUtils.minAutoPilotThroughput);
this.props.setIsAutoscale(true);
}
render(): JSX.Element {
return (
<div className="throughputInputContainer throughputInputSpacing">
<Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span>
<Text variant="small" style={{ lineHeight: "20px", fontWeight: 600 }}>
{this.getThroughputLabelText()}
</Text>
<TooltipHost directionalHint={DirectionalHint.bottomLeftEdge} content={PricingUtils.getRuToolTipText()}>
<Icon iconName="Info" className="panelInfoIcon" />
</TooltipHost>
</Stack>
<Stack horizontal verticalAlign="center">
<input
className="throughputInputRadioBtn"
aria-label="Autoscale mode"
checked={this.state.isAutoscaleSelected}
type="radio"
role="radio"
tabIndex={0}
onChange={this.onAutoscaleRadioBtnChange.bind(this)}
/>
<span className="throughputInputRadioBtnLabel">Autoscale</span>
<input
className="throughputInputRadioBtn"
aria-label="Manual mode"
checked={!this.state.isAutoscaleSelected}
type="radio"
role="radio"
tabIndex={0}
onChange={this.onManualRadioBtnChange.bind(this)}
/>
<span className="throughputInputRadioBtnLabel">Manual</span>
</Stack>
{this.state.isAutoscaleSelected && (
<Stack className="throughputInputSpacing">
<Text variant="small">
Estimate your required RU/s with&nbsp;
<Link target="_blank" href="https://cosmos.azure.com/capacitycalculator/">
capacity calculator
</Link>
.
</Text>
<Stack horizontal>
<Text variant="small" style={{ lineHeight: "20px", fontWeight: 600 }}>
{this.props.isDatabase ? "Database" : getCollectionName()} max RU/s
</Text>
<TooltipHost directionalHint={DirectionalHint.bottomLeftEdge} content={this.getAutoScaleTooltip()}>
<Icon iconName="Info" className="panelInfoIcon" />
</TooltipHost>
</Stack>
<TextField
type="number"
styles={{
fieldGroup: { width: 300, height: 27 },
field: { fontSize: 12 },
}}
onChange={(event, newInput?: string) => this.onThroughputValueChange(newInput)}
step={AutoPilotUtils.autoPilotIncrementStep}
min={AutoPilotUtils.minAutoPilotThroughput}
value={this.state.throughput.toString()}
aria-label="Max request units per second"
errorMessage={this.state.throughputError}
/>
<Text variant="small">
Your {this.props.isDatabase ? "database" : getCollectionName().toLocaleLowerCase()} throughput will
automatically scale from{" "}
<b>
{AutoPilotUtils.getMinRUsBasedOnUserInput(this.state.throughput)} RU/s (10% of max RU/s) -{" "}
{this.state.throughput} RU/s
</b>{" "}
based on usage.
</Text>
</Stack>
)}
{!this.state.isAutoscaleSelected && (
<Stack className="throughputInputSpacing">
<Text variant="small">
Estimate your required RU/s with&nbsp;
<Link target="_blank" href="https://cosmos.azure.com/capacitycalculator/">
capacity calculator
</Link>
.
</Text>
<TooltipHost
directionalHint={DirectionalHint.topLeftEdge}
content={
this.props.showFreeTierExceedThroughputTooltip &&
this.state.throughput > SharedConstants.CollectionCreation.DefaultCollectionRUs400
? "The first 400 RU/s in this account are free. Billing will apply to any throughput beyond 400 RU/s."
: undefined
}
>
<TextField
type="number"
styles={{
fieldGroup: { width: 300, height: 27 },
field: { fontSize: 12 },
}}
onChange={(event, newInput?: string) => this.onThroughputValueChange(newInput)}
step={100}
min={SharedConstants.CollectionCreation.DefaultCollectionRUs400}
max={userContext.isTryCosmosDBSubscription ? Constants.TryCosmosExperience.maxRU : Infinity}
value={this.state.throughput.toString()}
aria-label="Max request units per second"
required={true}
errorMessage={this.state.throughputError}
/>
</TooltipHost>
</Stack>
)}
<CostEstimateText requestUnits={this.state.throughput} isAutoscale={this.state.isAutoscaleSelected} />
{this.state.throughput > SharedConstants.CollectionCreation.DefaultCollectionRUs100K && (
<Stack horizontal verticalAlign="start">
<span className="mandatoryStar">*&nbsp;</span>
<Checkbox
checked={this.state.isCostAcknowledged}
styles={{
checkbox: { width: 12, height: 12 },
label: { padding: 0, margin: "4px 4px 0 0" },
}}
onChange={(ev: React.FormEvent<HTMLElement>, isChecked: boolean) => {
this.setState({ isCostAcknowledged: isChecked });
this.props.onCostAcknowledgeChange(isChecked);
}}
/>
<Text variant="small" style={{ lineHeight: "20px" }}>
{this.getCostAcknowledgeText()}
</Text>
</Stack>
)}
</div>
);
}
private getThroughputLabelText(): string {
let throughputHeaderText: string; let throughputHeaderText: string;
if (isAutoscaleSelected) { if (this.state.isAutoscaleSelected) {
throughputHeaderText = AutoPilotUtils.getAutoPilotHeaderText().toLocaleLowerCase(); throughputHeaderText = AutoPilotUtils.getAutoPilotHeaderText().toLocaleLowerCase();
} else { } else {
const minRU: string = SharedConstants.CollectionCreation.DefaultCollectionRUs400.toLocaleString(); const minRU: string = SharedConstants.CollectionCreation.DefaultCollectionRUs400.toLocaleString();
@@ -46,27 +194,29 @@ export const ThroughputInput: FunctionComponent<ThroughputInputProps> = ({
: "unlimited"; : "unlimited";
throughputHeaderText = `throughput (${minRU} - ${maxRU} RU/s)`; throughputHeaderText = `throughput (${minRU} - ${maxRU} RU/s)`;
} }
return `${isDatabase ? "Database" : getCollectionName()} ${throughputHeaderText}`;
};
const onThroughputValueChange = (newInput: string): void => { return `${this.props.isDatabase ? "Database" : getCollectionName()} ${throughputHeaderText}`;
}
private onThroughputValueChange(newInput: string): void {
const newThroughput = parseInt(newInput); const newThroughput = parseInt(newInput);
setThroughput(newThroughput); this.setState({ throughput: newThroughput });
setThroughputValue(newThroughput); this.props.setThroughputValue(newThroughput);
if (!isSharded && newThroughput > 10000) {
setThroughputError("Unsharded collections support up to 10,000 RUs");
} else {
setThroughputError("");
}
};
const getAutoScaleTooltip = (): string => { if (!this.props.isSharded && newThroughput > 10000) {
this.setState({ throughputError: "Unsharded collections support up to 10,000 RUs" });
} else {
this.setState({ throughputError: undefined });
}
}
private getAutoScaleTooltip(): string {
const collectionName = getCollectionName().toLocaleLowerCase(); const collectionName = getCollectionName().toLocaleLowerCase();
return `Set the max RU/s to the highest RU/s you want your ${collectionName} to scale to. The ${collectionName} will scale between 10% of max RU/s to the max RU/s based on usage.`; return `Set the max RU/s to the highest RU/s you want your ${collectionName} to scale to. The ${collectionName} will scale between 10% of max RU/s to the max RU/s based on usage.`;
}; }
const getCostAcknowledgeText = (): string => { private getCostAcknowledgeText(): string {
const databaseAccount = userContext.databaseAccount; const { databaseAccount } = userContext;
if (!databaseAccount || !databaseAccount.properties) { if (!databaseAccount || !databaseAccount.properties) {
return ""; return "";
} }
@@ -75,163 +225,98 @@ export const ThroughputInput: FunctionComponent<ThroughputInputProps> = ({
const multimasterEnabled: boolean = databaseAccount.properties.enableMultipleWriteLocations; const multimasterEnabled: boolean = databaseAccount.properties.enableMultipleWriteLocations;
return PricingUtils.getEstimatedSpendAcknowledgeString( return PricingUtils.getEstimatedSpendAcknowledgeString(
throughput, this.state.throughput,
userContext.portalEnv, userContext.portalEnv,
numberOfRegions, numberOfRegions,
multimasterEnabled, multimasterEnabled,
isAutoscaleSelected this.state.isAutoscaleSelected
); );
}; }
const handleOnChangeMode = (event: React.ChangeEvent<HTMLInputElement>, mode: string): void => { private onAutoscaleRadioBtnChange(event: React.ChangeEvent<HTMLInputElement>): void {
if (mode === "Autoscale") { if (event.target.checked && !this.state.isAutoscaleSelected) {
setThroughput(AutoPilotUtils.minAutoPilotThroughput); this.setState({ isAutoscaleSelected: true, throughput: AutoPilotUtils.minAutoPilotThroughput });
setIsAutoScaleSelected(true); this.props.setIsAutoscale(true);
setThroughputValue(AutoPilotUtils.minAutoPilotThroughput);
setIsAutoscale(true);
} else {
setThroughput(SharedConstants.CollectionCreation.DefaultCollectionRUs400);
setIsAutoScaleSelected(false);
setThroughputValue(SharedConstants.CollectionCreation.DefaultCollectionRUs400);
setIsAutoscale(false);
} }
}; }
private onManualRadioBtnChange(event: React.ChangeEvent<HTMLInputElement>): void {
if (event.target.checked && this.state.isAutoscaleSelected) {
this.setState({
isAutoscaleSelected: false,
throughput: SharedConstants.CollectionCreation.DefaultCollectionRUs400,
});
this.props.setIsAutoscale(false);
this.props.setThroughputValue(SharedConstants.CollectionCreation.DefaultCollectionRUs400);
}
}
}
interface CostEstimateTextProps {
requestUnits: number;
isAutoscale: boolean;
}
const CostEstimateText: React.FunctionComponent<CostEstimateTextProps> = (props: CostEstimateTextProps) => {
const { requestUnits, isAutoscale } = props;
const { databaseAccount } = userContext;
if (!databaseAccount?.properties) {
return <></>;
}
const serverId: string = userContext.portalEnv;
const numberOfRegions: number = databaseAccount.properties.readLocations?.length || 1;
const multimasterEnabled: boolean = databaseAccount.properties.enableMultipleWriteLocations;
const hourlyPrice: number = PricingUtils.computeRUUsagePriceHourly({
serverId,
requestUnits,
numberOfRegions,
multimasterEnabled,
isAutoscale,
});
const dailyPrice: number = hourlyPrice * 24;
const monthlyPrice: number = hourlyPrice * SharedConstants.hoursInAMonth;
const currency: string = PricingUtils.getPriceCurrency(serverId);
const currencySign: string = PricingUtils.getCurrencySign(serverId);
const multiplier = PricingUtils.getMultimasterMultiplier(numberOfRegions, multimasterEnabled);
const pricePerRu = isAutoscale
? PricingUtils.getAutoscalePricePerRu(serverId, multiplier) * multiplier
: PricingUtils.getPricePerRu(serverId) * multiplier;
const iconWithEstimatedCostDisclaimer: JSX.Element = (
<TooltipHost
directionalHint={DirectionalHint.bottomLeftEdge}
content={PricingUtils.estimatedCostDisclaimer}
styles={{ root: { verticalAlign: "bottom" } }}
>
<Icon iconName="Info" className="panelInfoIcon" />
</TooltipHost>
);
if (isAutoscale) {
return (
<Text variant="small">
Estimated monthly cost ({currency}){iconWithEstimatedCostDisclaimer}:{" "}
<b>
{currencySign + PricingUtils.calculateEstimateNumber(monthlyPrice / 10)} -{" "}
{currencySign + PricingUtils.calculateEstimateNumber(monthlyPrice)}{" "}
</b>
({numberOfRegions + (numberOfRegions === 1 ? " region" : " regions")}, {requestUnits / 10} - {requestUnits}{" "}
RU/s, {currencySign + pricePerRu}/RU)
</Text>
);
}
return ( return (
<div className="throughputInputContainer throughputInputSpacing"> <Text variant="small">
<Stack horizontal> Estimated cost ({currency}){iconWithEstimatedCostDisclaimer}:{" "}
<span className="mandatoryStar">*&nbsp;</span> <b>
<Text aria-label="Throughput header" variant="small" style={{ lineHeight: "20px", fontWeight: 600 }}> {currencySign + PricingUtils.calculateEstimateNumber(hourlyPrice)} hourly /{" "}
{getThroughputLabelText()} {currencySign + PricingUtils.calculateEstimateNumber(dailyPrice)} daily /{" "}
</Text> {currencySign + PricingUtils.calculateEstimateNumber(monthlyPrice)} monthly{" "}
<InfoTooltip>{PricingUtils.getRuToolTipText()}</InfoTooltip> </b>
</Stack> ({numberOfRegions + (numberOfRegions === 1 ? " region" : " regions")}, {requestUnits}RU/s,{" "}
{currencySign + pricePerRu}/RU)
<Stack horizontal verticalAlign="center"> </Text>
<input
className="throughputInputRadioBtn"
aria-label="Autoscale mode"
checked={isAutoscaleSelected}
type="radio"
role="radio"
tabIndex={0}
onChange={(e) => handleOnChangeMode(e, "Autoscale")}
/>
<span className="throughputInputRadioBtnLabel">Autoscale</span>
<input
className="throughputInputRadioBtn"
aria-label="Manual mode"
checked={!isAutoscaleSelected}
type="radio"
role="radio"
tabIndex={0}
onChange={(e) => handleOnChangeMode(e, "Manual")}
/>
<span className="throughputInputRadioBtnLabel">Manual</span>
</Stack>
{isAutoscaleSelected && (
<Stack className="throughputInputSpacing">
<Text variant="small" aria-label="ruDescription">
Estimate your required RU/s with{" "}
<Link target="_blank" href="https://cosmos.azure.com/capacitycalculator/" aria-label="ruDescription">
capacity calculator
</Link>
.
</Text>
<Stack horizontal>
<Text variant="small" style={{ lineHeight: "20px", fontWeight: 600 }} aria-label="maxRUDescription">
{isDatabase ? "Database" : getCollectionName()} Max RU/s
</Text>
<InfoTooltip>{getAutoScaleTooltip()}</InfoTooltip>
</Stack>
<TextField
type="number"
styles={{
fieldGroup: { width: 300, height: 27 },
field: { fontSize: 12 },
}}
onChange={(event, newInput?: string) => onThroughputValueChange(newInput)}
step={AutoPilotUtils.autoPilotIncrementStep}
min={AutoPilotUtils.minAutoPilotThroughput}
value={throughput.toString()}
aria-label="Max request units per second"
required={true}
errorMessage={throughputError}
/>
<Text variant="small">
Your {isDatabase ? "database" : getCollectionName().toLocaleLowerCase()} throughput will automatically scale
from{" "}
<b>
{AutoPilotUtils.getMinRUsBasedOnUserInput(throughput)} RU/s (10% of max RU/s) - {throughput} RU/s
</b>{" "}
based on usage.
</Text>
</Stack>
)}
{!isAutoscaleSelected && (
<Stack className="throughputInputSpacing">
<Text variant="small" aria-label="ruDescription">
Estimate your required RU/s with&nbsp;
<Link target="_blank" href="https://cosmos.azure.com/capacitycalculator/" aria-label="capacityLink">
capacity calculator
</Link>
.
</Text>
<TooltipHost
directionalHint={DirectionalHint.topLeftEdge}
content={
showFreeTierExceedThroughputTooltip &&
throughput > SharedConstants.CollectionCreation.DefaultCollectionRUs400
? "The first 400 RU/s in this account are free. Billing will apply to any throughput beyond 400 RU/s."
: undefined
}
>
<TextField
type="number"
styles={{
fieldGroup: { width: 300, height: 27 },
field: { fontSize: 12 },
}}
onChange={(event, newInput?: string) => onThroughputValueChange(newInput)}
step={100}
min={SharedConstants.CollectionCreation.DefaultCollectionRUs400}
max={userContext.isTryCosmosDBSubscription ? Constants.TryCosmosExperience.maxRU : Infinity}
value={throughput.toString()}
aria-label="Max request units per second"
required={true}
errorMessage={throughputError}
/>
</TooltipHost>
</Stack>
)}
<CostEstimateText requestUnits={throughput} isAutoscale={isAutoscaleSelected} />
{throughput > SharedConstants.CollectionCreation.DefaultCollectionRUs100K && (
<Stack horizontal verticalAlign="start">
<Checkbox
checked={isCostAcknowledged}
styles={{
checkbox: { width: 12, height: 12 },
label: { padding: 0, margin: "4px 4px 0 0" },
}}
onChange={(ev: React.FormEvent<HTMLElement>, isChecked: boolean) => {
setIsCostAcknowledged(isChecked);
onCostAcknowledgeChange(isChecked);
}}
/>
<Text variant="small" style={{ lineHeight: "20px" }}>
{getCostAcknowledgeText()}
</Text>
</Stack>
)}
</div>
); );
}; };

View File

@@ -1,5 +1,6 @@
import { IChoiceGroupProps } from "@fluentui/react"; import { IChoiceGroupProps } from "@fluentui/react";
import * as ko from "knockout"; import * as ko from "knockout";
import * as path from "path";
import Q from "q"; import Q from "q";
import React from "react"; import React from "react";
import _ from "underscore"; import _ from "underscore";
@@ -31,10 +32,9 @@ import { Action, ActionModifiers } from "../Shared/Telemetry/TelemetryConstants"
import * as TelemetryProcessor from "../Shared/Telemetry/TelemetryProcessor"; import * as TelemetryProcessor from "../Shared/Telemetry/TelemetryProcessor";
import { ArcadiaResourceManager } from "../SparkClusterManager/ArcadiaResourceManager"; import { ArcadiaResourceManager } from "../SparkClusterManager/ArcadiaResourceManager";
import { updateUserContext, userContext } from "../UserContext"; import { updateUserContext, userContext } from "../UserContext";
import { getCollectionName, getDatabaseName, getUploadName } from "../Utils/APITypeUtils"; import { getCollectionName } from "../Utils/APITypeUtils";
import { decryptJWTToken, getAuthorizationHeader } from "../Utils/AuthorizationUtils"; import { decryptJWTToken, getAuthorizationHeader } from "../Utils/AuthorizationUtils";
import { stringToBlob } from "../Utils/BlobUtils"; import { stringToBlob } from "../Utils/BlobUtils";
import { isCapabilityEnabled } from "../Utils/CapabilityUtils";
import { fromContentUri, toRawContentUri } from "../Utils/GitHubUtils"; import { fromContentUri, toRawContentUri } from "../Utils/GitHubUtils";
import * as NotificationConsoleUtils from "../Utils/NotificationConsoleUtils"; import * as NotificationConsoleUtils from "../Utils/NotificationConsoleUtils";
import { logConsoleError, logConsoleInfo, logConsoleProgress } from "../Utils/NotificationConsoleUtils"; import { logConsoleError, logConsoleInfo, logConsoleProgress } from "../Utils/NotificationConsoleUtils";
@@ -46,13 +46,13 @@ import { GalleryTab as GalleryTabKind } from "./Controls/NotebookGallery/Gallery
import { CommandBarComponentAdapter } from "./Menus/CommandBar/CommandBarComponentAdapter"; import { CommandBarComponentAdapter } from "./Menus/CommandBar/CommandBarComponentAdapter";
import { ConsoleData } from "./Menus/NotificationConsole/NotificationConsoleComponent"; import { ConsoleData } from "./Menus/NotificationConsole/NotificationConsoleComponent";
import * as FileSystemUtil from "./Notebook/FileSystemUtil"; import * as FileSystemUtil from "./Notebook/FileSystemUtil";
import { SnapshotRequest } from "./Notebook/NotebookComponent/types";
import { NotebookContentItem, NotebookContentItemType } from "./Notebook/NotebookContentItem"; import { NotebookContentItem, NotebookContentItemType } from "./Notebook/NotebookContentItem";
import type NotebookManager from "./Notebook/NotebookManager"; import type NotebookManager from "./Notebook/NotebookManager";
import type { NotebookPaneContent } from "./Notebook/NotebookManager"; import type { NotebookPaneContent } from "./Notebook/NotebookManager";
import { NotebookUtil } from "./Notebook/NotebookUtil"; import { NotebookUtil } from "./Notebook/NotebookUtil";
import AddCollectionPane from "./Panes/AddCollectionPane";
import { AddCollectionPanel } from "./Panes/AddCollectionPanel"; import { AddCollectionPanel } from "./Panes/AddCollectionPanel";
import { AddDatabasePanel } from "./Panes/AddDatabasePanel/AddDatabasePanel"; import AddDatabasePane from "./Panes/AddDatabasePane";
import { BrowseQueriesPane } from "./Panes/BrowseQueriesPane/BrowseQueriesPane"; import { BrowseQueriesPane } from "./Panes/BrowseQueriesPane/BrowseQueriesPane";
import CassandraAddCollectionPane from "./Panes/CassandraAddCollectionPane"; import CassandraAddCollectionPane from "./Panes/CassandraAddCollectionPane";
import { ContextualPaneBase } from "./Panes/ContextualPaneBase"; import { ContextualPaneBase } from "./Panes/ContextualPaneBase";
@@ -60,6 +60,7 @@ import { DeleteCollectionConfirmationPane } from "./Panes/DeleteCollectionConfir
import { DeleteDatabaseConfirmationPanel } from "./Panes/DeleteDatabaseConfirmationPanel"; import { DeleteDatabaseConfirmationPanel } from "./Panes/DeleteDatabaseConfirmationPanel";
import { ExecuteSprocParamsPane } from "./Panes/ExecuteSprocParamsPane/ExecuteSprocParamsPane"; import { ExecuteSprocParamsPane } from "./Panes/ExecuteSprocParamsPane/ExecuteSprocParamsPane";
import { GitHubReposPanel } from "./Panes/GitHubReposPanel/GitHubReposPanel"; import { GitHubReposPanel } from "./Panes/GitHubReposPanel/GitHubReposPanel";
import GraphStylingPane from "./Panes/GraphStylingPane";
import { LoadQueryPane } from "./Panes/LoadQueryPane/LoadQueryPane"; import { LoadQueryPane } from "./Panes/LoadQueryPane/LoadQueryPane";
import { SaveQueryPane } from "./Panes/SaveQueryPane/SaveQueryPane"; import { SaveQueryPane } from "./Panes/SaveQueryPane/SaveQueryPane";
import { SettingsPane } from "./Panes/SettingsPane/SettingsPane"; import { SettingsPane } from "./Panes/SettingsPane/SettingsPane";
@@ -67,7 +68,7 @@ import { SetupNoteBooksPanel } from "./Panes/SetupNotebooksPanel/SetupNotebooksP
import { StringInputPane } from "./Panes/StringInputPane/StringInputPane"; import { StringInputPane } from "./Panes/StringInputPane/StringInputPane";
import { AddTableEntityPanel } from "./Panes/Tables/AddTableEntityPanel"; import { AddTableEntityPanel } from "./Panes/Tables/AddTableEntityPanel";
import { EditTableEntityPanel } from "./Panes/Tables/EditTableEntityPanel"; import { EditTableEntityPanel } from "./Panes/Tables/EditTableEntityPanel";
import { TableQuerySelectPanel } from "./Panes/Tables/TableQuerySelectPanel/TableQuerySelectPanel"; import { TableQuerySelectPanel } from "./Panes/Tables/TableQuerySelectPanel";
import { UploadFilePane } from "./Panes/UploadFilePane/UploadFilePane"; import { UploadFilePane } from "./Panes/UploadFilePane/UploadFilePane";
import { UploadItemsPane } from "./Panes/UploadItemsPane/UploadItemsPane"; import { UploadItemsPane } from "./Panes/UploadItemsPane/UploadItemsPane";
import TableListViewModal from "./Tables/DataTable/TableEntityListViewModel"; import TableListViewModal from "./Tables/DataTable/TableEntityListViewModel";
@@ -91,15 +92,18 @@ export interface ExplorerParams {
setIsNotificationConsoleExpanded: (isExpanded: boolean) => void; setIsNotificationConsoleExpanded: (isExpanded: boolean) => void;
setNotificationConsoleData: (consoleData: ConsoleData) => void; setNotificationConsoleData: (consoleData: ConsoleData) => void;
setInProgressConsoleDataIdToBeDeleted: (id: string) => void; setInProgressConsoleDataIdToBeDeleted: (id: string) => void;
openSidePanel: (headerText: string, panelContent: JSX.Element, onClose?: () => void) => void; openSidePanel: (headerText: string, panelContent: JSX.Element) => void;
closeSidePanel: () => void; closeSidePanel: () => void;
closeDialog: () => void; closeDialog: () => void;
openDialog: (props: DialogProps) => void; openDialog: (props: DialogProps) => void;
tabsManager: TabsManager; tabsManager: TabsManager;
refreshSparkEnabledStateForAccount: () => void;
isSparkEnabledForAccount: boolean;
} }
export default class Explorer { export default class Explorer {
public addCollectionText: ko.Observable<string>; public addCollectionText: ko.Observable<string>;
public addDatabaseText: ko.Observable<string>;
public collectionTitle: ko.Observable<string>; public collectionTitle: ko.Observable<string>;
public deleteCollectionText: ko.Observable<string>; public deleteCollectionText: ko.Observable<string>;
public deleteDatabaseText: ko.Observable<string>; public deleteDatabaseText: ko.Observable<string>;
@@ -124,13 +128,14 @@ export default class Explorer {
// Panes // Panes
public contextPanes: ContextualPaneBase[]; public contextPanes: ContextualPaneBase[];
public openSidePanel: (headerText: string, panelContent: JSX.Element, onClose?: () => void) => void; public openSidePanel: (headerText: string, panelContent: JSX.Element) => void;
public closeSidePanel: () => void; public closeSidePanel: () => void;
// Resource Tree // Resource Tree
public databases: ko.ObservableArray<ViewModels.Database>; public databases: ko.ObservableArray<ViewModels.Database>;
public selectedDatabaseId: ko.Computed<string>; public selectedDatabaseId: ko.Computed<string>;
public selectedCollectionId: ko.Computed<string>; public selectedCollectionId: ko.Computed<string>;
public isLeftPaneExpanded: ko.Observable<boolean>;
public selectedNode: ko.Observable<ViewModels.TreeNode>; public selectedNode: ko.Observable<ViewModels.TreeNode>;
private resourceTree: ResourceTreeAdapter; private resourceTree: ResourceTreeAdapter;
@@ -147,6 +152,9 @@ export default class Explorer {
public tabsManager: TabsManager; public tabsManager: TabsManager;
// Contextual panes // Contextual panes
public addDatabasePane: AddDatabasePane;
public addCollectionPane: AddCollectionPane;
public graphStylingPane: GraphStylingPane;
public cassandraAddCollectionPane: CassandraAddCollectionPane; public cassandraAddCollectionPane: CassandraAddCollectionPane;
private gitHubClient: GitHubClient; private gitHubClient: GitHubClient;
public gitHubOAuthService: GitHubOAuthService; public gitHubOAuthService: GitHubOAuthService;
@@ -158,6 +166,7 @@ export default class Explorer {
public isMongoIndexingEnabled: ko.Observable<boolean>; public isMongoIndexingEnabled: ko.Observable<boolean>;
public canExceedMaximumValue: ko.Computed<boolean>; public canExceedMaximumValue: ko.Computed<boolean>;
public isAutoscaleDefaultEnabled: ko.Observable<boolean>; public isAutoscaleDefaultEnabled: ko.Observable<boolean>;
public isSchemaEnabled: ko.Computed<boolean>; public isSchemaEnabled: ko.Computed<boolean>;
// Notebooks // Notebooks
@@ -167,7 +176,6 @@ export default class Explorer {
public notebookWorkspaceManager: NotebookWorkspaceManager; public notebookWorkspaceManager: NotebookWorkspaceManager;
public sparkClusterConnectionInfo: ko.Observable<DataModels.SparkClusterConnectionInfo>; public sparkClusterConnectionInfo: ko.Observable<DataModels.SparkClusterConnectionInfo>;
public isSparkEnabled: ko.Observable<boolean>; public isSparkEnabled: ko.Observable<boolean>;
public isSparkEnabledForAccount: ko.Observable<boolean>;
public arcadiaToken: ko.Observable<string>; public arcadiaToken: ko.Observable<string>;
public arcadiaWorkspaces: ko.ObservableArray<ArcadiaWorkspaceItem>; public arcadiaWorkspaces: ko.ObservableArray<ArcadiaWorkspaceItem>;
public hasStorageAnalyticsAfecFeature: ko.Observable<boolean>; public hasStorageAnalyticsAfecFeature: ko.Observable<boolean>;
@@ -177,6 +185,7 @@ export default class Explorer {
public openDialog: ExplorerParams["openDialog"]; public openDialog: ExplorerParams["openDialog"];
public closeDialog: ExplorerParams["closeDialog"]; public closeDialog: ExplorerParams["closeDialog"];
private _panes: ContextualPaneBase[] = [];
private _isInitializingNotebooks: boolean; private _isInitializingNotebooks: boolean;
private notebookBasePath: ko.Observable<string>; private notebookBasePath: ko.Observable<string>;
private _arcadiaManager: ArcadiaResourceManager; private _arcadiaManager: ArcadiaResourceManager;
@@ -185,6 +194,10 @@ export default class Explorer {
content: string; content: string;
}; };
// Refresh spark
public refreshSparkEnabledStateForAccount: () => void;
public isSparkEnabledForAccount: boolean;
// React adapters // React adapters
private commandBarComponentAdapter: CommandBarComponentAdapter; private commandBarComponentAdapter: CommandBarComponentAdapter;
@@ -200,11 +213,14 @@ export default class Explorer {
this.closeSidePanel = params?.closeSidePanel; this.closeSidePanel = params?.closeSidePanel;
this.closeDialog = params?.closeDialog; this.closeDialog = params?.closeDialog;
this.openDialog = params?.openDialog; this.openDialog = params?.openDialog;
this.refreshSparkEnabledStateForAccount = params?.refreshSparkEnabledStateForAccount;
this.isSparkEnabledForAccount = params?.isSparkEnabledForAccount;
const startKey: number = TelemetryProcessor.traceStart(Action.InitializeDataExplorer, { const startKey: number = TelemetryProcessor.traceStart(Action.InitializeDataExplorer, {
dataExplorerArea: Constants.Areas.ResourceTree, dataExplorerArea: Constants.Areas.ResourceTree,
}); });
this.addCollectionText = ko.observable<string>("New Collection"); this.addCollectionText = ko.observable<string>("New Collection");
this.addDatabaseText = ko.observable<string>("New Database");
this.collectionTitle = ko.observable<string>("Collections"); this.collectionTitle = ko.observable<string>("Collections");
this.collectionTreeNodeAltText = ko.observable<string>("Collection"); this.collectionTreeNodeAltText = ko.observable<string>("Collection");
this.deleteCollectionText = ko.observable<string>("Delete Collection"); this.deleteCollectionText = ko.observable<string>("Delete Collection");
@@ -222,11 +238,9 @@ export default class Explorer {
}); });
} }
}); });
this.isNotebooksEnabledForAccount = ko.observable(false); this.isNotebooksEnabledForAccount = ko.observable(false);
this.isNotebooksEnabledForAccount.subscribe((isEnabledForAccount: boolean) => this.refreshCommandBarButtons()); this.isNotebooksEnabledForAccount.subscribe((isEnabledForAccount: boolean) => this.refreshCommandBarButtons());
this.isSparkEnabledForAccount = ko.observable(false); // this.isSparkEnabledForAccount.subscribe((isEnabledForAccount: boolean) => this.refreshCommandBarButtons());
this.isSparkEnabledForAccount.subscribe((isEnabledForAccount: boolean) => this.refreshCommandBarButtons());
this.hasStorageAnalyticsAfecFeature = ko.observable(false); this.hasStorageAnalyticsAfecFeature = ko.observable(false);
this.hasStorageAnalyticsAfecFeature.subscribe((enabled: boolean) => this.refreshCommandBarButtons()); this.hasStorageAnalyticsAfecFeature.subscribe((enabled: boolean) => this.refreshCommandBarButtons());
this.isSynapseLinkUpdating = ko.observable<boolean>(false); this.isSynapseLinkUpdating = ko.observable<boolean>(false);
@@ -242,7 +256,7 @@ export default class Explorer {
this._isAfecFeatureRegistered(Constants.AfecFeatures.StorageAnalytics).then((isRegistered) => this._isAfecFeatureRegistered(Constants.AfecFeatures.StorageAnalytics).then((isRegistered) =>
this.hasStorageAnalyticsAfecFeature(isRegistered) this.hasStorageAnalyticsAfecFeature(isRegistered)
); );
Promise.all([this._refreshNotebooksEnabledStateForAccount(), this._refreshSparkEnabledStateForAccount()]).then( Promise.all([this._refreshNotebooksEnabledStateForAccount(), this.refreshSparkEnabledStateForAccount()]).then(
async () => { async () => {
this.isNotebookEnabled( this.isNotebookEnabled(
userContext.authType !== AuthType.ResourceToken && userContext.authType !== AuthType.ResourceToken &&
@@ -265,7 +279,7 @@ export default class Explorer {
this.isSparkEnabled( this.isSparkEnabled(
(this.isNotebookEnabled() && (this.isNotebookEnabled() &&
this.isSparkEnabledForAccount() && this.isSparkEnabledForAccount &&
this.arcadiaWorkspaces() && this.arcadiaWorkspaces() &&
this.arcadiaWorkspaces().length > 0) || this.arcadiaWorkspaces().length > 0) ||
userContext.features.enableSpark userContext.features.enableSpark
@@ -327,6 +341,7 @@ export default class Explorer {
} }
return true; return true;
}); });
this.isLeftPaneExpanded = ko.observable<boolean>(true);
this.selectedNode = ko.observable<ViewModels.TreeNode>(); this.selectedNode = ko.observable<ViewModels.TreeNode>();
this.selectedNode.subscribe((nodeSelected: ViewModels.TreeNode) => { this.selectedNode.subscribe((nodeSelected: ViewModels.TreeNode) => {
// Make sure switching tabs restores tabs display // Make sure switching tabs restores tabs display
@@ -360,7 +375,7 @@ export default class Explorer {
return false; return false;
} }
return isCapabilityEnabled("EnableMongo"); return userContext.apiType === "Mongo";
}); });
this.isServerlessEnabled = ko.computed( this.isServerlessEnabled = ko.computed(
@@ -397,6 +412,28 @@ export default class Explorer {
} }
}); });
this.addDatabasePane = new AddDatabasePane({
id: "adddatabasepane",
visible: ko.observable<boolean>(false),
container: this,
});
this.addCollectionPane = new AddCollectionPane({
isPreferredApiTable: ko.computed(() => userContext.apiType === "Tables"),
id: "addcollectionpane",
visible: ko.observable<boolean>(false),
container: this,
});
this.graphStylingPane = new GraphStylingPane({
id: "graphstylingpane",
visible: ko.observable<boolean>(false),
container: this,
});
this.cassandraAddCollectionPane = new CassandraAddCollectionPane({ this.cassandraAddCollectionPane = new CassandraAddCollectionPane({
id: "cassandraaddcollectionpane", id: "cassandraaddcollectionpane",
visible: ko.observable<boolean>(false), visible: ko.observable<boolean>(false),
@@ -412,6 +449,13 @@ export default class Explorer {
} }
}); });
this._panes = [
this.addDatabasePane,
this.addCollectionPane,
this.graphStylingPane,
this.cassandraAddCollectionPane,
];
this.addDatabaseText.subscribe((addDatabaseText: string) => this.addDatabasePane.title(addDatabaseText));
this.isTabsContentExpanded = ko.observable(false); this.isTabsContentExpanded = ko.observable(false);
document.addEventListener( document.addEventListener(
@@ -429,43 +473,67 @@ export default class Explorer {
switch (userContext.apiType) { switch (userContext.apiType) {
case "SQL": case "SQL":
this.addCollectionText("New Container"); this.addCollectionText("New Container");
this.addDatabaseText("New Database");
this.collectionTitle("SQL API"); this.collectionTitle("SQL API");
this.collectionTreeNodeAltText("Container"); this.collectionTreeNodeAltText("Container");
this.deleteCollectionText("Delete Container"); this.deleteCollectionText("Delete Container");
this.deleteDatabaseText("Delete Database"); this.deleteDatabaseText("Delete Database");
this.addCollectionPane.title("Add Container");
this.addCollectionPane.collectionIdTitle("Container id");
this.addCollectionPane.collectionWithThroughputInSharedTitle(
"Provision dedicated throughput for this container"
);
this.refreshTreeTitle("Refresh containers"); this.refreshTreeTitle("Refresh containers");
break; break;
case "Mongo": case "Mongo":
this.addCollectionText("New Collection"); this.addCollectionText("New Collection");
this.addDatabaseText("New Database");
this.collectionTitle("Collections"); this.collectionTitle("Collections");
this.collectionTreeNodeAltText("Collection"); this.collectionTreeNodeAltText("Collection");
this.deleteCollectionText("Delete Collection"); this.deleteCollectionText("Delete Collection");
this.deleteDatabaseText("Delete Database"); this.deleteDatabaseText("Delete Database");
this.addCollectionPane.title("Add Collection");
this.addCollectionPane.collectionIdTitle("Collection id");
this.addCollectionPane.collectionWithThroughputInSharedTitle(
"Provision dedicated throughput for this collection"
);
this.refreshTreeTitle("Refresh collections"); this.refreshTreeTitle("Refresh collections");
break; break;
case "Gremlin": case "Gremlin":
this.addCollectionText("New Graph"); this.addCollectionText("New Graph");
this.addDatabaseText("New Database");
this.deleteCollectionText("Delete Graph"); this.deleteCollectionText("Delete Graph");
this.deleteDatabaseText("Delete Database"); this.deleteDatabaseText("Delete Database");
this.collectionTitle("Gremlin API"); this.collectionTitle("Gremlin API");
this.collectionTreeNodeAltText("Graph"); this.collectionTreeNodeAltText("Graph");
this.addCollectionPane.title("Add Graph");
this.addCollectionPane.collectionIdTitle("Graph id");
this.addCollectionPane.collectionWithThroughputInSharedTitle("Provision dedicated throughput for this graph");
this.refreshTreeTitle("Refresh graphs"); this.refreshTreeTitle("Refresh graphs");
break; break;
case "Tables": case "Tables":
this.addCollectionText("New Table"); this.addCollectionText("New Table");
this.addDatabaseText("New Database");
this.deleteCollectionText("Delete Table"); this.deleteCollectionText("Delete Table");
this.deleteDatabaseText("Delete Database"); this.deleteDatabaseText("Delete Database");
this.collectionTitle("Azure Table API"); this.collectionTitle("Azure Table API");
this.collectionTreeNodeAltText("Table"); this.collectionTreeNodeAltText("Table");
this.addCollectionPane.title("Add Table");
this.addCollectionPane.collectionIdTitle("Table id");
this.addCollectionPane.collectionWithThroughputInSharedTitle("Provision dedicated throughput for this table");
this.refreshTreeTitle("Refresh tables"); this.refreshTreeTitle("Refresh tables");
this.tableDataClient = new TablesAPIDataClient(); this.tableDataClient = new TablesAPIDataClient();
break; break;
case "Cassandra": case "Cassandra":
this.addCollectionText("New Table"); this.addCollectionText("New Table");
this.addDatabaseText("New Keyspace");
this.deleteCollectionText("Delete Table"); this.deleteCollectionText("Delete Table");
this.deleteDatabaseText("Delete Keyspace"); this.deleteDatabaseText("Delete Keyspace");
this.collectionTitle("Cassandra API"); this.collectionTitle("Cassandra API");
this.collectionTreeNodeAltText("Table"); this.collectionTreeNodeAltText("Table");
this.addCollectionPane.title("Add Table");
this.addCollectionPane.collectionIdTitle("Table id");
this.addCollectionPane.collectionWithThroughputInSharedTitle("Provision dedicated throughput for this table");
this.refreshTreeTitle("Refresh tables"); this.refreshTreeTitle("Refresh tables");
this.tableDataClient = new CassandraAPIDataClient(); this.tableDataClient = new CassandraAPIDataClient();
break; break;
@@ -645,8 +713,16 @@ export default class Explorer {
this.setIsNotificationConsoleExpanded(true); this.setIsNotificationConsoleExpanded(true);
} }
public collapseConsole(): void { public toggleLeftPaneExpanded() {
this.setIsNotificationConsoleExpanded(false); this.isLeftPaneExpanded(!this.isLeftPaneExpanded());
if (this.isLeftPaneExpanded()) {
document.getElementById("expandToggleLeftPaneButton").focus();
this.splitter.expandLeft();
} else {
document.getElementById("collapseToggleLeftPaneButton").focus();
this.splitter.collapseLeft();
}
} }
public refreshDatabaseForResourceToken(): Q.Promise<any> { public refreshDatabaseForResourceToken(): Q.Promise<any> {
@@ -765,6 +841,14 @@ export default class Explorer {
this.refreshNotebookList(); this.refreshNotebookList();
}; };
public toggleLeftPaneExpandedKeyPress = (source: any, event: KeyboardEvent): boolean => {
if (event.keyCode === Constants.KeyCodes.Space || event.keyCode === Constants.KeyCodes.Enter) {
this.toggleLeftPaneExpanded();
return false;
}
return true;
};
// Facade // Facade
public provideFeedbackEmail = () => { public provideFeedbackEmail = () => {
window.open(Constants.Urls.feedbackEmail, "_blank"); window.open(Constants.Urls.feedbackEmail, "_blank");
@@ -1019,9 +1103,6 @@ export default class Explorer {
if (flights.indexOf(Constants.Flights.MongoIndexing) !== -1) { if (flights.indexOf(Constants.Flights.MongoIndexing) !== -1) {
this.isMongoIndexingEnabled(true); this.isMongoIndexingEnabled(true);
} }
if (flights.indexOf(Constants.Flights.SchemaAnalyzer) !== -1) {
userContext.features.enableSchemaAnalyzer = true;
}
} }
public findSelectedCollection(): ViewModels.Collection { public findSelectedCollection(): ViewModels.Collection {
@@ -1030,6 +1111,10 @@ export default class Explorer {
: this.selectedNode().collection) as ViewModels.Collection; : this.selectedNode().collection) as ViewModels.Collection;
} }
public closeAllPanes(): void {
this._panes.forEach((pane: ContextualPaneBase) => pane.close());
}
public isRunningOnNationalCloud(): boolean { public isRunningOnNationalCloud(): boolean {
return ( return (
userContext.portalEnv === "blackforest" || userContext.portalEnv === "blackforest" ||
@@ -1225,18 +1310,10 @@ export default class Explorer {
public async publishNotebook( public async publishNotebook(
name: string, name: string,
content: NotebookPaneContent, content: NotebookPaneContent,
notebookContentRef?: string, parentDomElement?: HTMLElement
onTakeSnapshot?: (request: SnapshotRequest) => void,
onClosePanel?: () => void
): Promise<void> { ): Promise<void> {
if (this.notebookManager) { if (this.notebookManager) {
await this.notebookManager.openPublishNotebookPane( await this.notebookManager.openPublishNotebookPane(name, content, parentDomElement);
name,
content,
notebookContentRef,
onTakeSnapshot,
onClosePanel
);
this.isPublishNotebookPaneEnabled(true); this.isPublishNotebookPaneEnabled(true);
} }
} }
@@ -1367,7 +1444,7 @@ export default class Explorer {
this.showOkModalDialog("Unable to rename file", "This file is being edited. Please close the tab and try again."); this.showOkModalDialog("Unable to rename file", "This file is being edited. Please close the tab and try again.");
} else { } else {
this.openSidePanel( this.openSidePanel(
"Rename Notebook", "",
<StringInputPane <StringInputPane
explorer={this} explorer={this}
closePanel={() => { closePanel={() => {
@@ -1398,7 +1475,7 @@ export default class Explorer {
} }
this.openSidePanel( this.openSidePanel(
"Create new directory", "",
<StringInputPane <StringInputPane
explorer={this} explorer={this}
closePanel={() => { closePanel={() => {
@@ -1516,34 +1593,6 @@ export default class Explorer {
} }
} }
public _refreshSparkEnabledStateForAccount = async (): Promise<void> => {
const { subscriptionId, authType } = userContext;
const armEndpoint = configContext.ARM_ENDPOINT;
if (!subscriptionId || !armEndpoint || authType === AuthType.EncryptedToken) {
// explorer is not aware of the database account yet
this.isSparkEnabledForAccount(false);
return;
}
const featureUri = `subscriptions/${subscriptionId}/providers/Microsoft.Features/providers/Microsoft.DocumentDb/features/${Constants.AfecFeatures.Spark}`;
const resourceProviderClient = new ResourceProviderClientFactory().getOrCreate(featureUri);
try {
const sparkNotebooksFeature: DataModels.AfecFeature = await resourceProviderClient.getAsync(
featureUri,
Constants.ArmApiVersions.armFeatures
);
const isEnabled =
(sparkNotebooksFeature &&
sparkNotebooksFeature.properties &&
sparkNotebooksFeature.properties.state === "Registered") ||
false;
this.isSparkEnabledForAccount(isEnabled);
} catch (error) {
Logger.logError(getErrorMessage(error), "Explorer/isSparkEnabledForAccount");
this.isSparkEnabledForAccount(false);
}
};
public _isAfecFeatureRegistered = async (featureName: string): Promise<boolean> => { public _isAfecFeatureRegistered = async (featureName: string): Promise<boolean> => {
const { subscriptionId, authType } = userContext; const { subscriptionId, authType } = userContext;
const armEndpoint = configContext.ARM_ENDPOINT; const armEndpoint = configContext.ARM_ENDPOINT;
@@ -1772,9 +1821,45 @@ export default class Explorer {
} }
} }
public async openNotebookViewer(notebookUrl: string) {
const title = path.basename(notebookUrl);
const hashLocation = notebookUrl;
const NotebookViewerTab = await (
await import(/* webpackChunkName: "NotebookViewerTab" */ "./Tabs/NotebookViewerTab")
).default;
const notebookViewerTab = this.tabsManager.getTabs(ViewModels.CollectionTabKind.NotebookV2).find((tab) => {
return tab.hashLocation() == hashLocation && tab instanceof NotebookViewerTab && tab.notebookUrl === notebookUrl;
});
if (notebookViewerTab) {
this.tabsManager.activateNewTab(notebookViewerTab);
} else {
const notebookViewerTab = new NotebookViewerTab({
account: userContext.databaseAccount,
tabKind: ViewModels.CollectionTabKind.NotebookViewer,
node: null,
title: title,
tabPath: title,
collection: null,
hashLocation: hashLocation,
isTabsContentExpanded: ko.observable(true),
onLoadStartKey: null,
onUpdateTabsButtons: this.onUpdateTabsButtons,
container: this,
notebookUrl,
});
this.tabsManager.activateNewTab(notebookViewerTab);
}
}
public onNewCollectionClicked(databaseId?: string): void { public onNewCollectionClicked(databaseId?: string): void {
if (userContext.apiType === "Cassandra") { if (userContext.apiType === "Cassandra") {
this.cassandraAddCollectionPane.open(); this.cassandraAddCollectionPane.open();
} else if (userContext.features.enableKOPanel) {
this.addCollectionPane.open(this.selectedDatabaseId());
document.getElementById("linkAddCollection").focus();
} else { } else {
this.openAddCollectionPanel(databaseId); this.openAddCollectionPanel(databaseId);
} }
@@ -1823,6 +1908,7 @@ export default class Explorer {
public async handleOpenFileAction(path: string): Promise<void> { public async handleOpenFileAction(path: string): Promise<void> {
if (this.isAccountReady() && !(await this._containsDefaultNotebookWorkspace(userContext.databaseAccount))) { if (this.isAccountReady() && !(await this._containsDefaultNotebookWorkspace(userContext.databaseAccount))) {
this.closeAllPanes();
this._openSetupNotebooksPaneForQuickstart(); this._openSetupNotebooksPaneForQuickstart();
} }
@@ -1887,10 +1973,10 @@ export default class Explorer {
public openDeleteDatabaseConfirmationPane(): void { public openDeleteDatabaseConfirmationPane(): void {
this.openSidePanel( this.openSidePanel(
"Delete " + getDatabaseName(), "Delete Database",
<DeleteDatabaseConfirmationPanel <DeleteDatabaseConfirmationPanel
explorer={this} explorer={this}
openNotificationConsole={() => this.expandConsole()} openNotificationConsole={this.expandConsole}
closePanel={this.closeSidePanel} closePanel={this.closeSidePanel}
selectedDatabase={this.findSelectedDatabase()} selectedDatabase={this.findSelectedDatabase()}
/> />
@@ -1898,12 +1984,12 @@ export default class Explorer {
} }
public openUploadItemsPanePane(): void { public openUploadItemsPanePane(): void {
this.openSidePanel("Upload " + getUploadName(), <UploadItemsPane explorer={this} />); this.openSidePanel("Upload", <UploadItemsPane explorer={this} closePanel={this.closeSidePanel} />);
} }
public openSettingPane(): void { public openSettingPane(): void {
this.openSidePanel( this.openSidePanel(
"Setting", "Settings",
<SettingsPane expandConsole={() => this.expandConsole()} closePanel={this.closeSidePanel} /> <SettingsPane expandConsole={() => this.expandConsole()} closePanel={this.closeSidePanel} />
); );
} }
@@ -1931,16 +2017,6 @@ export default class Explorer {
/> />
); );
} }
public openAddDatabasePane(): void {
this.openSidePanel(
"New " + getDatabaseName(),
<AddDatabasePanel
explorer={this}
openNotificationConsole={() => this.expandConsole()}
closePanel={this.closeSidePanel}
/>
);
}
public openBrowseQueriesPanel(): void { public openBrowseQueriesPanel(): void {
this.openSidePanel("Open Saved Queries", <BrowseQueriesPane explorer={this} closePanel={this.closeSidePanel} />); this.openSidePanel("Open Saved Queries", <BrowseQueriesPane explorer={this} closePanel={this.closeSidePanel} />);
@@ -1957,7 +2033,7 @@ export default class Explorer {
public openUploadFilePanel(parent?: NotebookContentItem): void { public openUploadFilePanel(parent?: NotebookContentItem): void {
parent = parent || this.resourceTree.myNotebooksContentRoot; parent = parent || this.resourceTree.myNotebooksContentRoot;
this.openSidePanel( this.openSidePanel(
"Upload file to notebook server", "Upload File",
<UploadFilePane <UploadFilePane
expandConsole={() => this.expandConsole()} expandConsole={() => this.expandConsole()}
closePanel={this.closeSidePanel} closePanel={this.closeSidePanel}

View File

@@ -1,7 +1,7 @@
import * as sinon from "sinon"; import * as sinon from "sinon";
import { D3ForceGraph, LoadMoreDataAction, D3GraphNodeData } from "./D3ForceGraph";
import { D3Node, D3Link, GraphData } from "../GraphExplorerComponent/GraphData";
import GraphTab from "../../Tabs/GraphTab"; import GraphTab from "../../Tabs/GraphTab";
import { D3Link, D3Node, GraphData } from "../GraphExplorerComponent/GraphData";
import { D3ForceGraph, D3GraphNodeData, LoadMoreDataAction } from "./D3ForceGraph";
describe("D3ForceGraph", () => { describe("D3ForceGraph", () => {
const v1Id = "v1"; const v1Id = "v1";
@@ -68,7 +68,7 @@ describe("D3ForceGraph", () => {
beforeEach(() => { beforeEach(() => {
forceGraph = new D3ForceGraph({ forceGraph = new D3ForceGraph({
igraphConfig: GraphTab.createIGraphConfig(), graphConfig: GraphTab.createGraphConfig(),
onHighlightedNode: sinon.spy(), onHighlightedNode: sinon.spy(),
onLoadMoreData: (action: LoadMoreDataAction): void => {}, onLoadMoreData: (action: LoadMoreDataAction): void => {},
@@ -141,7 +141,6 @@ describe("D3ForceGraph", () => {
const mouseoverEvent = document.createEvent("Events"); const mouseoverEvent = document.createEvent("Events");
mouseoverEvent.initEvent("mouseover", true, false); mouseoverEvent.initEvent("mouseover", true, false);
$(rootNode).find(".node")[0].dispatchEvent(mouseoverEvent); // [0] is v1 vertex $(rootNode).find(".node")[0].dispatchEvent(mouseoverEvent); // [0] is v1 vertex
expect($(rootNode).find(".node")[0]).toBe(1);
// onHighlightedNode is always called once to clear the selection // onHighlightedNode is always called once to clear the selection
expect((forceGraph.params.onHighlightedNode as sinon.SinonSpy).calledTwice).toBe(true); expect((forceGraph.params.onHighlightedNode as sinon.SinonSpy).calledTwice).toBe(true);
@@ -151,7 +150,7 @@ describe("D3ForceGraph", () => {
expect(onHighlightedNode.id).toEqual(v1Id); expect(onHighlightedNode.id).toEqual(v1Id);
}; };
forceGraph.updateGraph(newGraph, forceGraph.igraphConfig); forceGraph.updateGraph(newGraph);
}); });
}); });
}); });

View File

@@ -13,7 +13,7 @@ import _ from "underscore";
import * as Constants from "../../../Common/Constants"; import * as Constants from "../../../Common/Constants";
import { NeighborType } from "../../../Contracts/ViewModels"; import { NeighborType } from "../../../Contracts/ViewModels";
import { logConsoleError } from "../../../Utils/NotificationConsoleUtils"; import { logConsoleError } from "../../../Utils/NotificationConsoleUtils";
import { IGraphConfig } from "./../../Tabs/GraphTab"; import { GraphConfig } from "../../Tabs/GraphTab";
import { D3Link, D3Node, GraphData } from "./GraphData"; import { D3Link, D3Node, GraphData } from "./GraphData";
import { GraphExplorer } from "./GraphExplorer"; import { GraphExplorer } from "./GraphExplorer";
@@ -48,22 +48,21 @@ interface ZoomTransform extends Point2D {
export interface D3ForceGraphParameters { export interface D3ForceGraphParameters {
// Graph to parent // Graph to parent
graphConfig: GraphConfig;
igraphConfig: IGraphConfig; onHighlightedNode: (highlightedNode: D3GraphNodeData) => void; // a new node has been highlighted in the graph
onHighlightedNode?: (highlightedNode: D3GraphNodeData) => void; // a new node has been highlighted in the graph onLoadMoreData: (action: LoadMoreDataAction) => void;
onLoadMoreData?: (action: LoadMoreDataAction) => void;
// parent to graph // parent to graph
onInitialized?: (instance: GraphRenderer) => void; onInitialized: (instance: GraphRenderer) => void;
// For unit testing purposes // For unit testing purposes
onGraphUpdated?: (timestamp: number) => void; onGraphUpdated: (timestamp: number) => void;
} }
export interface GraphRenderer { export interface GraphRenderer {
selectNode(id: string): void; selectNode(id: string): void;
resetZoom(): void; resetZoom(): void;
updateGraph(graphData: GraphData<D3Node, D3Link>, igraphConfigParam?: IGraphConfig): void; updateGraph(graphData: GraphData<D3Node, D3Link>): void;
enableHighlight(enable: boolean): void; enableHighlight(enable: boolean): void;
} }
@@ -109,7 +108,7 @@ export class D3ForceGraph implements GraphRenderer {
private viewCenter: Point2D; private viewCenter: Point2D;
// Map a property to a graph node attribute (such as color) // Map a property to a graph node attribute (such as color)
private uniqueValues: (string | number)[] = []; // keep track of unique values private uniqueValues: (string | number)[]; // keep track of unique values
private graphDataWrapper: GraphData<D3Node, D3Link>; private graphDataWrapper: GraphData<D3Node, D3Link>;
// Communication with outside // Communication with outside
@@ -120,11 +119,9 @@ export class D3ForceGraph implements GraphRenderer {
// outside -> Graph // outside -> Graph
private idToSelect: ko.Observable<string>; // Programmatically select node by id outside graph private idToSelect: ko.Observable<string>; // Programmatically select node by id outside graph
private isHighlightDisabled: boolean; private isHighlightDisabled: boolean;
public igraphConfig: IGraphConfig;
public constructor(params: D3ForceGraphParameters) { public constructor(params: D3ForceGraphParameters) {
this.params = params; this.params = params;
this.igraphConfig = this.params.igraphConfig;
this.idToSelect = ko.observable(null); this.idToSelect = ko.observable(null);
this.errorMsgs = ko.observableArray([]); this.errorMsgs = ko.observableArray([]);
this.graphDataWrapper = null; this.graphDataWrapper = null;
@@ -154,10 +151,7 @@ export class D3ForceGraph implements GraphRenderer {
this.g.remove(); this.g.remove();
} }
public updateGraph(newGraph: GraphData<D3Node, D3Link>, igraphConfigParam?: IGraphConfig): void { public updateGraph(newGraph: GraphData<D3Node, D3Link>): void {
if (igraphConfigParam) {
this.igraphConfig = igraphConfigParam;
}
if (!newGraph || !this.simulation) { if (!newGraph || !this.simulation) {
return; return;
} }
@@ -165,8 +159,7 @@ export class D3ForceGraph implements GraphRenderer {
this.graphDataWrapper = new GraphData<D3Node, D3Link>(); this.graphDataWrapper = new GraphData<D3Node, D3Link>();
this.graphDataWrapper.setData(newGraph); this.graphDataWrapper.setData(newGraph);
const key = this.igraphConfig.nodeColorKey; const key = this.params.graphConfig.nodeColorKey();
if (key !== GraphExplorer.NONE_CHOICE) { if (key !== GraphExplorer.NONE_CHOICE) {
this.updateUniqueValues(key); this.updateUniqueValues(key);
} }
@@ -272,7 +265,20 @@ export class D3ForceGraph implements GraphRenderer {
}); });
}); });
this.redrawGraph(); // Redraw if any of these configs change
this.params.graphConfig.nodeColor.subscribe(this.redrawGraph.bind(this));
this.params.graphConfig.nodeColorKey.subscribe((key: string) => {
// Compute colormap
this.uniqueValues = [];
this.updateUniqueValues(key);
this.redrawGraph();
});
this.params.graphConfig.linkColor.subscribe(() => this.redrawGraph());
this.params.graphConfig.showNeighborType.subscribe(() => this.redrawGraph());
this.params.graphConfig.nodeCaption.subscribe(() => this.redrawGraph());
this.params.graphConfig.nodeSize.subscribe(() => this.redrawGraph());
this.params.graphConfig.linkWidth.subscribe(() => this.redrawGraph());
this.params.graphConfig.nodeIconKey.subscribe(() => this.redrawGraph());
this.instantiateSimulation(); this.instantiateSimulation();
} // initialize } // initialize
@@ -365,10 +371,7 @@ export class D3ForceGraph implements GraphRenderer {
*/ */
private shiftGraph(targetPosition: Point2D): Q.Promise<Point2D> { private shiftGraph(targetPosition: Point2D): Q.Promise<Point2D> {
const deferred: Q.Deferred<Point2D> = Q.defer<Point2D>(); const deferred: Q.Deferred<Point2D> = Q.defer<Point2D>();
const offset = { const offset = { x: this.width / 2 - targetPosition.x, y: this.height / 2 - targetPosition.y };
x: this.width / 2 - targetPosition.x,
y: this.height / 2 - targetPosition.y,
};
this.viewCenter = targetPosition; this.viewCenter = targetPosition;
if (Math.abs(offset.x) > 0.5 && Math.abs(offset.y) > 0.5) { if (Math.abs(offset.x) > 0.5 && Math.abs(offset.y) > 0.5) {
@@ -523,10 +526,7 @@ export class D3ForceGraph implements GraphRenderer {
.transition() .transition()
.duration(D3ForceGraph.TRANSITION_STEP3_MS) .duration(D3ForceGraph.TRANSITION_STEP3_MS)
.attrTween("transform", (d: D3Node) => { .attrTween("transform", (d: D3Node) => {
const finalPos = nodeFinalPositionMap.get(d.id) || { const finalPos = nodeFinalPositionMap.get(d.id) || { x: viewCenter.x, y: viewCenter.y };
x: viewCenter.x,
y: viewCenter.y,
};
const ix = interpolateNumber(viewCenter.x, finalPos.x); const ix = interpolateNumber(viewCenter.x, finalPos.x);
const iy = interpolateNumber(viewCenter.y, finalPos.y); const iy = interpolateNumber(viewCenter.y, finalPos.y);
return (t: number) => { return (t: number) => {
@@ -626,10 +626,10 @@ export class D3ForceGraph implements GraphRenderer {
this.addNewLinks(); this.addNewLinks();
const nodes1 = this.simulation.nodes(); const nodes = this.simulation.nodes();
this.redrawGraph(); this.redrawGraph();
this.animateBigBang(nodes1, newNodes); this.animateBigBang(nodes, newNodes);
this.simulation.alpha(1).restart(); this.simulation.alpha(1).restart();
this.params.onGraphUpdated(new Date().getTime()); this.params.onGraphUpdated(new Date().getTime());
@@ -657,8 +657,8 @@ export class D3ForceGraph implements GraphRenderer {
.append("path") .append("path")
.attr("class", "link") .attr("class", "link")
.attr("fill", "none") .attr("fill", "none")
.attr("stroke-width", this.igraphConfig.linkWidth) .attr("stroke-width", this.params.graphConfig.linkWidth())
.attr("stroke", this.igraphConfig.linkColor); .attr("stroke", this.params.graphConfig.linkColor());
if (D3ForceGraph.useSvgMarkerEnd()) { if (D3ForceGraph.useSvgMarkerEnd()) {
line.attr("marker-end", `url(#${this.getArrowHeadSymbolId()}-marker)`); line.attr("marker-end", `url(#${this.getArrowHeadSymbolId()}-marker)`);
@@ -668,7 +668,7 @@ export class D3ForceGraph implements GraphRenderer {
.append("use") .append("use")
.attr("xlink:href", `#${this.getArrowHeadSymbolId()}-nonMarker`) .attr("xlink:href", `#${this.getArrowHeadSymbolId()}-nonMarker`)
.attr("class", "markerEnd link") .attr("class", "markerEnd link")
.attr("fill", this.igraphConfig.linkColor) .attr("fill", this.params.graphConfig.linkColor())
.classed(`${this.getArrowHeadSymbolId()}`, true); .classed(`${this.getArrowHeadSymbolId()}`, true);
} }
@@ -724,7 +724,7 @@ export class D3ForceGraph implements GraphRenderer {
.append("circle") .append("circle")
.attr("fill", this.getNodeColor.bind(this)) .attr("fill", this.getNodeColor.bind(this))
.attr("class", "main") .attr("class", "main")
.attr("r", this.igraphConfig.nodeSize); .attr("r", this.params.graphConfig.nodeSize());
var iconGroup = newNodes var iconGroup = newNodes
.append("g") .append("g")
@@ -733,23 +733,22 @@ export class D3ForceGraph implements GraphRenderer {
.attr("aria-label", (d: D3Node) => { .attr("aria-label", (d: D3Node) => {
return this.retrieveNodeCaption(d); return this.retrieveNodeCaption(d);
}) })
.on("dblclick", function (this: Element, _: MouseEvent, d: D3Node) { .on("dblclick", function (_: MouseEvent, d: D3Node) {
// https://stackoverflow.com/a/41945742 ('this' implicitly has type 'any' because it does not have a type annotation)
// this is the <g> element // this is the <g> element
self.onNodeClicked(this.parentNode, d); self.onNodeClicked(this.parentNode, d);
}) })
.on("click", function (this: Element, _: MouseEvent, d: D3Node) { .on("click", function (_: MouseEvent, d: D3Node) {
// this is the <g> element // this is the <g> element
self.onNodeClicked(this.parentNode, d); self.onNodeClicked(this.parentNode, d);
}) })
.on("keypress", function (this: Element, event: KeyboardEvent, d: D3Node) { .on("keypress", function (event: KeyboardEvent, d: D3Node) {
if (event.charCode === Constants.KeyCodes.Space || event.charCode === Constants.KeyCodes.Enter) { if (event.charCode === Constants.KeyCodes.Space || event.charCode === Constants.KeyCodes.Enter) {
event.stopPropagation(); event.stopPropagation();
// this is the <g> element // this is the <g> element
self.onNodeClicked(this.parentNode, d); self.onNodeClicked(this.parentNode, d);
} }
}); });
var nodeSize = this.igraphConfig.nodeSize; var nodeSize = this.params.graphConfig.nodeSize();
var bgsize = nodeSize + 1; var bgsize = nodeSize + 1;
iconGroup iconGroup
@@ -759,7 +758,7 @@ export class D3ForceGraph implements GraphRenderer {
.attr("width", bgsize * 2) .attr("width", bgsize * 2)
.attr("height", bgsize * 2) .attr("height", bgsize * 2)
.attr("fill-opacity", (d: D3Node) => { .attr("fill-opacity", (d: D3Node) => {
return this.igraphConfig.nodeIconKey ? 1 : 0; return this.params.graphConfig.nodeIconKey() ? 1 : 0;
}) })
.attr("class", "icon-background"); .attr("class", "icon-background");
@@ -767,13 +766,14 @@ export class D3ForceGraph implements GraphRenderer {
iconGroup iconGroup
.append("svg:image") .append("svg:image")
.attr("xlink:href", (d: D3Node) => { .attr("xlink:href", (d: D3Node) => {
return D3ForceGraph.computeImageData(d, this.igraphConfig); return D3ForceGraph.computeImageData(d, this.params.graphConfig);
}) })
.attr("x", -nodeSize) .attr("x", -nodeSize)
.attr("y", -nodeSize) .attr("y", -nodeSize)
.attr("height", nodeSize * 2) .attr("height", nodeSize * 2)
.attr("width", nodeSize * 2) .attr("width", nodeSize * 2)
.attr("class", "icon"); .attr("class", "icon");
newNodes newNodes
.append("text") .append("text")
.attr("class", "caption") .attr("class", "caption")
@@ -807,7 +807,7 @@ export class D3ForceGraph implements GraphRenderer {
.attr("x2", 0) .attr("x2", 0)
.attr("y2", gaugeYOffset) .attr("y2", gaugeYOffset)
.style("stroke-width", 1) .style("stroke-width", 1)
.style("stroke", this.igraphConfig.linkColor); .style("stroke", this.params.graphConfig.linkColor());
parent parent
.append("use") .append("use")
.attr("xlink:href", "#triangleRight") .attr("xlink:href", "#triangleRight")
@@ -876,7 +876,7 @@ export class D3ForceGraph implements GraphRenderer {
.attr("height", gaugeHeight) .attr("height", gaugeHeight)
.style("fill", "white") .style("fill", "white")
.style("stroke-width", 1) .style("stroke-width", 1)
.style("stroke", this.igraphConfig.linkColor); .style("stroke", this.params.graphConfig.linkColor());
parent parent
.append("rect") .append("rect")
.attr("x", (d: D3Node) => { .attr("x", (d: D3Node) => {
@@ -893,7 +893,7 @@ export class D3ForceGraph implements GraphRenderer {
: 0; : 0;
}) })
.attr("height", gaugeHeight) .attr("height", gaugeHeight)
.style("fill", this.igraphConfig.nodeColor) .style("fill", this.params.graphConfig.nodeColor())
.attr("visibility", (d: D3Node) => (d._pagination && d._pagination.total ? "visible" : "hidden")); .attr("visibility", (d: D3Node) => (d._pagination && d._pagination.total ? "visible" : "hidden"));
parent parent
.append("text") .append("text")
@@ -970,7 +970,7 @@ export class D3ForceGraph implements GraphRenderer {
const self = this; const self = this;
nodeSelection.selectAll(".loadmore").remove(); nodeSelection.selectAll(".loadmore").remove();
var nodeSize = this.igraphConfig.nodeSize; var nodeSize = this.params.graphConfig.nodeSize();
const rootSelectionG = nodeSelection const rootSelectionG = nodeSelection
.filter((d: D3Node) => { .filter((d: D3Node) => {
return !!d._isRoot && !!d._pagination; return !!d._isRoot && !!d._pagination;
@@ -994,7 +994,7 @@ export class D3ForceGraph implements GraphRenderer {
this.createLoadMoreControl(missingNeighborNonRootG, nodeSize); this.createLoadMoreControl(missingNeighborNonRootG, nodeSize);
// Don't color icons individually, just the definitions // Don't color icons individually, just the definitions
this.svg.selectAll("#loadMoreIcon ellipse").attr("fill", this.igraphConfig.nodeColor); this.svg.selectAll("#loadMoreIcon ellipse").attr("fill", this.params.graphConfig.nodeColor());
} }
/** /**
@@ -1031,11 +1031,11 @@ export class D3ForceGraph implements GraphRenderer {
* @param d * @param d
*/ */
private getNodeColor(d: D3Node): string { private getNodeColor(d: D3Node): string {
if (this.igraphConfig.nodeColorKey) { if (this.params.graphConfig.nodeColorKey()) {
const val = GraphData.getNodePropValue(d, this.igraphConfig.nodeColorKey); const val = GraphData.getNodePropValue(d, this.params.graphConfig.nodeColorKey());
return this.lookupColorFromKey(<string>val); return this.lookupColorFromKey(<string>val);
} else { } else {
return this.igraphConfig.nodeColor; return this.params.graphConfig.nodeColor();
} }
} }
@@ -1102,12 +1102,12 @@ export class D3ForceGraph implements GraphRenderer {
this.graphDataWrapper.getTargetsForId(nodeId) this.graphDataWrapper.getTargetsForId(nodeId)
); );
} }
})(this.igraphConfig.showNeighborType); })(this.params.graphConfig.showNeighborType());
return (!neighbors || neighbors.indexOf(d.id) === -1) && d.id !== nodeId; return (!neighbors || neighbors.indexOf(d.id) === -1) && d.id !== nodeId;
}); });
this.g.selectAll(".link").classed("inactive", (l: D3Link) => { this.g.selectAll(".link").classed("inactive", (l: D3Link) => {
switch (this.igraphConfig.showNeighborType) { switch (this.params.graphConfig.showNeighborType()) {
case NeighborType.SOURCES_ONLY: case NeighborType.SOURCES_ONLY:
return (<D3Node>l.target).id !== nodeId; return (<D3Node>l.target).id !== nodeId;
case NeighborType.TARGETS_ONLY: case NeighborType.TARGETS_ONLY:
@@ -1151,7 +1151,7 @@ export class D3ForceGraph implements GraphRenderer {
} }
private retrieveNodeCaption(d: D3Node) { private retrieveNodeCaption(d: D3Node) {
let key = this.igraphConfig.nodeCaption; let key = this.params.graphConfig.nodeCaption();
let value: string = d.id || d.label; let value: string = d.id || d.label;
if (key) { if (key) {
value = <string>GraphData.getNodePropValue(d, key) || ""; value = <string>GraphData.getNodePropValue(d, key) || "";
@@ -1193,16 +1193,10 @@ export class D3ForceGraph implements GraphRenderer {
} }
private positionLinkEnd(l: D3Link) { private positionLinkEnd(l: D3Link) {
const source: Point2D = { const source: Point2D = { x: (<D3Node>l.source).x, y: (<D3Node>l.source).y };
x: (<D3Node>l.source).x, const target: Point2D = { x: (<D3Node>l.target).x, y: (<D3Node>l.target).y };
y: (<D3Node>l.source).y,
};
const target: Point2D = {
x: (<D3Node>l.target).x,
y: (<D3Node>l.target).y,
};
const d1 = D3ForceGraph.calculateControlPoint(source, target); const d1 = D3ForceGraph.calculateControlPoint(source, target);
var radius = this.igraphConfig.nodeSize + 3; var radius = this.params.graphConfig.nodeSize() + 3;
// End // End
const dx = target.x - d1.x; const dx = target.x - d1.x;
@@ -1215,16 +1209,10 @@ export class D3ForceGraph implements GraphRenderer {
} }
private positionLink(l: D3Link) { private positionLink(l: D3Link) {
const source: Point2D = { const source: Point2D = { x: (<D3Node>l.source).x, y: (<D3Node>l.source).y };
x: (<D3Node>l.source).x, const target: Point2D = { x: (<D3Node>l.target).x, y: (<D3Node>l.target).y };
y: (<D3Node>l.source).y,
};
const target: Point2D = {
x: (<D3Node>l.target).x,
y: (<D3Node>l.target).y,
};
const d1 = D3ForceGraph.calculateControlPoint(source, target); const d1 = D3ForceGraph.calculateControlPoint(source, target);
var radius = this.igraphConfig.nodeSize + 3; var radius = this.params.graphConfig.nodeSize() + 3;
// Start // Start
var dx = d1.x - source.x; var dx = d1.x - source.x;
@@ -1256,13 +1244,13 @@ export class D3ForceGraph implements GraphRenderer {
return d._isRoot ? "node root" : "node"; return d._isRoot ? "node root" : "node";
}); });
this.applyConfig(this.igraphConfig); this.applyConfig(this.params.graphConfig);
} }
private static computeImageData(d: D3Node, config: IGraphConfig): string { private static computeImageData(d: D3Node, config: GraphConfig): string {
let propValue = <string>GraphData.getNodePropValue(d, config.nodeIconKey) || ""; let propValue = <string>GraphData.getNodePropValue(d, config.nodeIconKey()) || "";
// Trim leading and trailing spaces to make comparison more forgiving. // Trim leading and trailing spaces to make comparison more forgiving.
let value = config.iconsMap[propValue.trim()]; let value = config.iconsMap()[propValue.trim()];
if (!value) { if (!value) {
return undefined; return undefined;
} }
@@ -1272,46 +1260,48 @@ export class D3ForceGraph implements GraphRenderer {
/** /**
* Update graph according to configuration or use default * Update graph according to configuration or use default
*/ */
private applyConfig(config: IGraphConfig) { private applyConfig(config: GraphConfig) {
if (config.nodeIconKey) { if (config.nodeIconKey()) {
this.g this.g
.selectAll(".node .icon") .selectAll(".node .icon")
.attr("xlink:href", (d: D3Node) => { .attr("xlink:href", (d: D3Node) => {
return D3ForceGraph.computeImageData(d, config); return D3ForceGraph.computeImageData(d, config);
}) })
.attr("x", -config.nodeSize) .attr("x", -config.nodeSize())
.attr("y", -config.nodeSize) .attr("y", -config.nodeSize())
.attr("height", config.nodeSize * 2) .attr("height", config.nodeSize() * 2)
.attr("width", config.nodeSize * 2) .attr("width", config.nodeSize() * 2)
.attr("class", "icon"); .attr("class", "icon");
} else { } else {
// clear icons // clear icons
this.g.selectAll(".node .icon").attr("xlink:href", undefined); this.g.selectAll(".node .icon").attr("xlink:href", undefined);
} }
this.g.selectAll(".node .icon-background").attr("fill-opacity", (d: D3Node) => { this.g.selectAll(".node .icon-background").attr("fill-opacity", (d: D3Node) => {
return config.nodeIconKey ? 1 : 0; return config.nodeIconKey() ? 1 : 0;
}); });
this.g.selectAll(".node text.caption").text((d: D3Node) => { this.g.selectAll(".node text.caption").text((d: D3Node) => {
return this.retrieveNodeCaption(d); return this.retrieveNodeCaption(d);
}); });
this.g.selectAll(".node circle.main").attr("r", config.nodeSize); this.g.selectAll(".node circle.main").attr("r", config.nodeSize());
this.g.selectAll(".node text.caption").attr("dx", config.nodeSize + 2); this.g.selectAll(".node text.caption").attr("dx", config.nodeSize() + 2);
this.g.selectAll(".node circle").attr("fill", this.getNodeColor.bind(this)); this.g.selectAll(".node circle").attr("fill", this.getNodeColor.bind(this));
// Can't color nodes individually if using defs // Can't color nodes individually if using defs
this.svg.selectAll("#loadMoreIcon ellipse").attr("fill", config.nodeColor); this.svg.selectAll("#loadMoreIcon ellipse").attr("fill", this.params.graphConfig.nodeColor());
this.g.selectAll(".link").attr("stroke-width", config.linkWidth);
this.g.selectAll(".link").attr("stroke", config.linkColor); this.g.selectAll(".link").attr("stroke-width", config.linkWidth());
this.g.selectAll(".link").attr("stroke", config.linkColor());
if (D3ForceGraph.useSvgMarkerEnd()) { if (D3ForceGraph.useSvgMarkerEnd()) {
this.svg this.svg
.select(`#${this.getArrowHeadSymbolId()}-marker`) .select(`#${this.getArrowHeadSymbolId()}-marker`)
.attr("fill", config.linkColor) .attr("fill", config.linkColor())
.attr("stroke", config.linkColor); .attr("stroke", config.linkColor());
} else { } else {
this.svg.select(`#${this.getArrowHeadSymbolId()}-nonMarker`).attr("fill", config.linkColor); this.svg.select(`#${this.getArrowHeadSymbolId()}-nonMarker`).attr("fill", config.linkColor());
} }
// Reset highlight // Reset highlight

View File

@@ -1,20 +1,20 @@
jest.mock("../../../Common/dataAccess/queryDocuments"); jest.mock("../../../Common/dataAccess/queryDocuments");
jest.mock("../../../Common/dataAccess/queryDocumentsPage"); jest.mock("../../../Common/dataAccess/queryDocumentsPage");
import { mount, ReactWrapper } from "enzyme";
import * as Q from "q";
import React from "react"; import React from "react";
import * as sinon from "sinon"; import * as sinon from "sinon";
import { mount, ReactWrapper } from "enzyme";
import * as Q from "q";
import "../../../../externals/jquery.typeahead.min"; import "../../../../externals/jquery.typeahead.min";
import { queryDocuments } from "../../../Common/dataAccess/queryDocuments"; import { GraphExplorer, GraphExplorerProps, GraphAccessor, GraphHighlightedNodeData } from "./GraphExplorer";
import { queryDocumentsPage } from "../../../Common/dataAccess/queryDocumentsPage";
import * as DataModels from "../../../Contracts/DataModels";
import * as StorageUtility from "../../../Shared/StorageUtility";
import { TabComponent } from "../../Controls/Tabs/TabComponent";
import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent";
import GraphTab from "../../Tabs/GraphTab";
import * as D3ForceGraph from "./D3ForceGraph"; import * as D3ForceGraph from "./D3ForceGraph";
import { GraphData } from "./GraphData"; import { GraphData } from "./GraphData";
import { GraphAccessor, GraphExplorer, GraphExplorerProps, GraphHighlightedNodeData } from "./GraphExplorer"; import { TabComponent } from "../../Controls/Tabs/TabComponent";
import * as DataModels from "../../../Contracts/DataModels";
import * as StorageUtility from "../../../Shared/StorageUtility";
import GraphTab from "../../Tabs/GraphTab";
import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent";
import { queryDocuments } from "../../../Common/dataAccess/queryDocuments";
import { queryDocumentsPage } from "../../../Common/dataAccess/queryDocumentsPage";
describe("Check whether query result is vertex array", () => { describe("Check whether query result is vertex array", () => {
it("should reject null as vertex array", () => { it("should reject null as vertex array", () => {
@@ -146,8 +146,8 @@ describe("GraphExplorer", () => {
const gremlinRU = 789.12; const gremlinRU = 789.12;
const createMockProps = (): GraphExplorerProps => { const createMockProps = (): GraphExplorerProps => {
const igraphConfig = GraphTab.createIGraphConfig(); const graphConfig = GraphTab.createGraphConfig();
const igraphConfigUi = GraphTab.createIGraphConfigUiData(igraphConfig); const graphConfigUi = GraphTab.createGraphConfigUiData(graphConfig);
return { return {
onGraphAccessorCreated: (instance: GraphAccessor): void => {}, onGraphAccessorCreated: (instance: GraphAccessor): void => {},
@@ -170,9 +170,8 @@ describe("GraphExplorer", () => {
resourceId: "resourceId", resourceId: "resourceId",
/* TODO Figure out how to make this Knockout-free */ /* TODO Figure out how to make this Knockout-free */
igraphConfigUiData: igraphConfigUi, graphConfigUiData: graphConfigUi,
igraphConfig: igraphConfig, graphConfig: graphConfig,
setIConfigUiData: (data: string[]): void => {},
}; };
}; };

View File

@@ -19,7 +19,7 @@ import { EditorReact } from "../../Controls/Editor/EditorReact";
import * as InputTypeaheadComponent from "../../Controls/InputTypeahead/InputTypeaheadComponent"; import * as InputTypeaheadComponent from "../../Controls/InputTypeahead/InputTypeaheadComponent";
import * as TabComponent from "../../Controls/Tabs/TabComponent"; import * as TabComponent from "../../Controls/Tabs/TabComponent";
import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent"; import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent";
import { IGraphConfig } from "../../Tabs/GraphTab"; import { GraphConfig } from "../../Tabs/GraphTab";
import { ArraysByKeyCache } from "./ArraysByKeyCache"; import { ArraysByKeyCache } from "./ArraysByKeyCache";
import * as D3ForceGraph from "./D3ForceGraph"; import * as D3ForceGraph from "./D3ForceGraph";
import { EdgeInfoCache } from "./EdgeInfoCache"; import { EdgeInfoCache } from "./EdgeInfoCache";
@@ -31,10 +31,10 @@ import * as LeftPane from "./LeftPaneComponent";
import { MiddlePaneComponent } from "./MiddlePaneComponent"; import { MiddlePaneComponent } from "./MiddlePaneComponent";
import * as NodeProperties from "./NodePropertiesComponent"; import * as NodeProperties from "./NodePropertiesComponent";
import { QueryContainerComponent } from "./QueryContainerComponent"; import { QueryContainerComponent } from "./QueryContainerComponent";
export interface GraphAccessor { export interface GraphAccessor {
applyFilter: () => void; applyFilter: () => void;
addVertex: (v: ViewModels.NewVertexData) => Q.Promise<void>; addVertex: (v: ViewModels.NewVertexData) => Q.Promise<void>;
shareIGraphConfig: (igraphConfig: IGraphConfig) => void;
} }
export interface GraphExplorerProps { export interface GraphExplorerProps {
@@ -58,10 +58,9 @@ export interface GraphExplorerProps {
onLoadStartKeyChange: (newKey: number) => void; onLoadStartKeyChange: (newKey: number) => void;
resourceId: string; resourceId: string;
igraphConfigUiData: ViewModels.IGraphConfigUiData; /* TODO Figure out how to make this Knockout-free */
igraphConfig: IGraphConfig; graphConfigUiData: ViewModels.GraphConfigUiData;
graphConfig?: GraphConfig;
setIConfigUiData?: (data: string[]) => void;
} }
export interface GraphHighlightedNodeData { export interface GraphHighlightedNodeData {
@@ -122,10 +121,6 @@ interface GraphExplorerState {
filterQueryError: string; filterQueryError: string;
filterQueryWarning: string; filterQueryWarning: string;
filterQueryStatus: FilterQueryStatus; filterQueryStatus: FilterQueryStatus;
change: string;
igraphConfigUiData: ViewModels.IGraphConfigUiData;
igraphConfig: IGraphConfig;
} }
export interface EditedProperties { export interface EditedProperties {
@@ -223,8 +218,6 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
private lastReportedIsPropertyEditing: boolean; private lastReportedIsPropertyEditing: boolean;
private lastReportedIsNewVertexDisabled: boolean; private lastReportedIsNewVertexDisabled: boolean;
public getNodeProperties: string[];
public igraphConfigUi: ViewModels.IGraphConfigUiData;
public constructor(props: GraphExplorerProps) { public constructor(props: GraphExplorerProps) {
super(props); super(props);
this.state = { this.state = {
@@ -244,9 +237,6 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
filterQueryError: null, filterQueryError: null,
filterQueryWarning: null, filterQueryWarning: null,
filterQueryStatus: FilterQueryStatus.NoResult, filterQueryStatus: FilterQueryStatus.NoResult,
change: null,
igraphConfigUiData: this.props.igraphConfigUiData,
igraphConfig: this.props.igraphConfig,
}; };
// Not part of React state // Not part of React state
@@ -294,27 +284,41 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
this.setGremlinParams(); this.setGremlinParams();
} }
const selectedNode = this.state.highlightedNode; /* TODO Make this Knockout-free ! */
this.props.graphConfigUiData.nodeCaptionChoice.subscribe((key) => {
this.props.graphConfig.nodeCaption(key);
const selectedNode = this.state.highlightedNode;
if (selectedNode) {
this.updatePropertiesPane(selectedNode.id);
}
this.render();
});
this.props.graphConfigUiData.nodeColorKeyChoice.subscribe((val) => {
this.props.graphConfig.nodeColorKey(val === GraphExplorer.NONE_CHOICE ? null : val);
this.render();
});
this.props.graphConfigUiData.showNeighborType.subscribe((val) => {
this.props.graphConfig.showNeighborType(val);
this.render();
});
this.props.graphConfigUiData.nodeIconChoice.subscribe((val) => {
this.updateNodeIcons(val, this.props.graphConfigUiData.nodeIconSet());
this.render();
});
this.props.graphConfigUiData.nodeIconSet.subscribe((val) => {
this.updateNodeIcons(this.props.graphConfigUiData.nodeIconChoice(), val);
this.render();
});
/* *************************************** */
props.onGraphAccessorCreated({ props.onGraphAccessorCreated({
applyFilter: this.submitQuery.bind(this), applyFilter: this.submitQuery.bind(this),
addVertex: this.addVertex.bind(this), addVertex: this.addVertex.bind(this),
shareIGraphConfig: this.shareIGraphConfig.bind(this),
}); });
} // constructor } // constructor
public shareIGraphConfig(igraphConfig: IGraphConfig) {
this.setState({
igraphConfig: { ...igraphConfig },
});
const selectedNode = this.state.highlightedNode;
if (selectedNode) {
this.updatePropertiesPane(selectedNode.id);
this.setResultDisplay(GraphExplorer.TAB_INDEX_GRAPH);
}
}
/** /**
* If pk is a string, return ["pk", "id"] * If pk is a string, return ["pk", "id"]
* else return [pk, "id"] * else return [pk, "id"]
@@ -404,7 +408,7 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
// Update graph (in case property is being shown) // Update graph (in case property is being shown)
this.updateInMemoryGraph(result.data); this.updateInMemoryGraph(result.data);
this.updateGraphData(this.originalGraphData, this.state.igraphConfig); this.updateGraphData(this.originalGraphData);
}) })
.then( .then(
() => { () => {
@@ -442,7 +446,7 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
// Remove vertex from local cache // Remove vertex from local cache
const graphData = this.originalGraphData; const graphData = this.originalGraphData;
graphData.removeVertex(id, false); graphData.removeVertex(id, false);
this.updateGraphData(graphData, this.state.igraphConfig); this.updateGraphData(graphData);
this.setState({ highlightedNode: null }); this.setState({ highlightedNode: null });
// Remove from root map // Remove from root map
@@ -578,7 +582,7 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
this.edgeInfoCache.addVertex(vertex); this.edgeInfoCache.addVertex(vertex);
graphData.setAsRoot(vertex.id); graphData.setAsRoot(vertex.id);
this.updateGraphData(graphData, this.state.igraphConfig); this.updateGraphData(graphData);
}; };
vertex._outEdgeIds = vertex._outEdgeIds || []; vertex._outEdgeIds = vertex._outEdgeIds || [];
@@ -784,7 +788,7 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
graphData.getVertexById(edge.outV)._inEAllLoaded = false; graphData.getVertexById(edge.outV)._inEAllLoaded = false;
} }
this.updateGraphData(graphData, this.state.igraphConfig); this.updateGraphData(graphData);
}, },
(error: string) => { (error: string) => {
GraphExplorer.reportToConsole( GraphExplorer.reportToConsole(
@@ -805,7 +809,7 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
() => { () => {
let graphData = this.originalGraphData; let graphData = this.originalGraphData;
graphData.removeEdge(edgeId, false); graphData.removeEdge(edgeId, false);
this.updateGraphData(graphData, this.state.igraphConfig); this.updateGraphData(graphData);
}, },
(error: string) => { (error: string) => {
GraphExplorer.reportToConsole( GraphExplorer.reportToConsole(
@@ -854,7 +858,7 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
if (vertices.length === 0) { if (vertices.length === 0) {
// Clean graph // Clean graph
this.updateGraphData(new GraphData.GraphData(), this.state.igraphConfig); this.updateGraphData(new GraphData.GraphData());
this.setState({ highlightedNode: null }); this.setState({ highlightedNode: null });
GraphExplorer.reportToConsole(ConsoleDataType.Info, "Query result is empty"); GraphExplorer.reportToConsole(ConsoleDataType.Info, "Query result is empty");
} }
@@ -936,7 +940,7 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
let vertex = vertices[0]; let vertex = vertices[0];
const graphData = this.originalGraphData; const graphData = this.originalGraphData;
graphData.addVertex(vertex); graphData.addVertex(vertex);
this.updateGraphData(graphData, this.state.igraphConfig); this.updateGraphData(graphData);
this.collectNodeProperties(this.originalGraphData.vertices); this.collectNodeProperties(this.originalGraphData.vertices);
// Keep new vertex selected // Keep new vertex selected
@@ -1117,13 +1121,8 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
return rootMap[id]; return rootMap[id];
}) })
); );
if (this.state.igraphConfigUiData.nodeProperties.indexOf(GraphExplorer.DISPLAY_DEFAULT_PROPERTY_KEY) !== -1) { if (this.props.graphConfigUiData.nodeProperties().indexOf(GraphExplorer.DISPLAY_DEFAULT_PROPERTY_KEY) !== -1) {
this.setState({ this.props.graphConfigUiData.nodeCaptionChoice(GraphExplorer.DISPLAY_DEFAULT_PROPERTY_KEY);
igraphConfigUiData: {
...this.state.igraphConfigUiData,
nodeCaptionChoice: GraphExplorer.DISPLAY_DEFAULT_PROPERTY_KEY,
},
});
} }
// Let react instantiate and render graph, before updating // Let react instantiate and render graph, before updating
@@ -1140,12 +1139,7 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
*/ */
public updateNodeIcons(nodeProp: string, iconSet: string): void { public updateNodeIcons(nodeProp: string, iconSet: string): void {
if (nodeProp === GraphExplorer.NONE_CHOICE) { if (nodeProp === GraphExplorer.NONE_CHOICE) {
this.setState({ this.props.graphConfig.nodeIconKey(null);
igraphConfig: {
...this.state.igraphConfig,
nodeIconKey: undefined,
},
});
return; return;
} }
@@ -1169,13 +1163,8 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
}); });
// Update graph configuration // Update graph configuration
this.setState({ this.props.graphConfig.iconsMap(newIconsMap);
igraphConfig: { this.props.graphConfig.nodeIconKey(nodeProp);
...this.state.igraphConfig,
iconsMap: newIconsMap,
nodeIconKey: nodeProp,
},
});
}, },
() => { () => {
GraphExplorer.reportToConsole(ConsoleDataType.Error, `Failed to retrieve icons. iconSet:${iconSet}`); GraphExplorer.reportToConsole(ConsoleDataType.Error, `Failed to retrieve icons. iconSet:${iconSet}`);
@@ -1220,7 +1209,7 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
} }
private getPossibleRootNodes(): LeftPane.CaptionId[] { private getPossibleRootNodes(): LeftPane.CaptionId[] {
const key = this.state.igraphConfig.nodeCaption; const key = this.props.graphConfigUiData.nodeCaptionChoice();
return $.map( return $.map(
this.state.rootMap, this.state.rootMap,
(value: any, index: number): LeftPane.CaptionId => { (value: any, index: number): LeftPane.CaptionId => {
@@ -1331,7 +1320,7 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
return ""; return "";
} }
const nodeCaption = this.state.igraphConfigUiData.nodeCaptionChoice; const nodeCaption = this.props.graphConfigUiData.nodeCaptionChoice();
const node = this.originalGraphData.getVertexById(this.state.highlightedNode.id); const node = this.originalGraphData.getVertexById(this.state.highlightedNode.id);
return GraphData.GraphData.getNodePropValue(node, nodeCaption) as string; return GraphData.GraphData.getNodePropValue(node, nodeCaption) as string;
} }
@@ -1421,7 +1410,7 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
const highlightedNodeId = this.state.highlightedNode ? this.state.highlightedNode.id : null; const highlightedNodeId = this.state.highlightedNode ? this.state.highlightedNode.id : null;
const q = `SELECT c.id, c["${ const q = `SELECT c.id, c["${
this.state.igraphConfigUiData.nodeCaptionChoice || "id" this.props.graphConfigUiData.nodeCaptionChoice() || "id"
}"] AS p FROM c WHERE NOT IS_DEFINED(c._isEdge)`; }"] AS p FROM c WHERE NOT IS_DEFINED(c._isEdge)`;
return this.executeNonPagedDocDbQuery(q).then( return this.executeNonPagedDocDbQuery(q).then(
(documents: DataModels.DocumentId[]) => { (documents: DataModels.DocumentId[]) => {
@@ -1550,14 +1539,9 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
}); });
const values = Object.keys(props); const values = Object.keys(props);
this.setState({ this.props.graphConfigUiData.nodeProperties(values);
igraphConfigUiData: { // TODO This should move out of GraphExplorer
...this.state.igraphConfigUiData, this.props.graphConfigUiData.nodePropertiesWithNone([GraphExplorer.NONE_CHOICE].concat(values));
nodeProperties: values,
},
});
this.props.setIConfigUiData(values);
} }
/** /**
@@ -1582,8 +1566,9 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
let sources: NeighborVertexBasicInfo[] = []; let sources: NeighborVertexBasicInfo[] = [];
let targets: NeighborVertexBasicInfo[] = []; let targets: NeighborVertexBasicInfo[] = [];
this.props.onResetDefaultGraphConfigValues(); this.props.onResetDefaultGraphConfigValues();
let nodeCaption = this.state.igraphConfigUiData.nodeCaptionChoice; let nodeCaption = this.props.graphConfigUiData.nodeCaptionChoice();
this.updateSelectedNodeNeighbors(data.id, nodeCaption, sources, targets); this.updateSelectedNodeNeighbors(data.id, nodeCaption, sources, targets);
let sData: GraphHighlightedNodeData = { let sData: GraphHighlightedNodeData = {
id: data.id, id: data.id,
label: data.label, label: data.label,
@@ -1630,12 +1615,7 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
return; return;
} }
let caption = GraphData.GraphData.getNodePropValue(gd.getVertexById(neighborId), nodeCaption) as string; let caption = GraphData.GraphData.getNodePropValue(gd.getVertexById(neighborId), nodeCaption) as string;
sources.push({ sources.push({ name: caption, id: neighborId, edgeId: edge.id, edgeLabel: p });
name: caption,
id: neighborId,
edgeId: edge.id,
edgeLabel: p,
});
}); });
} }
@@ -1649,12 +1629,7 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
return; return;
} }
let caption = GraphData.GraphData.getNodePropValue(gd.getVertexById(neighborId), nodeCaption) as string; let caption = GraphData.GraphData.getNodePropValue(gd.getVertexById(neighborId), nodeCaption) as string;
targets.push({ targets.push({ name: caption, id: neighborId, edgeId: edge.id, edgeLabel: p });
name: caption,
id: neighborId,
edgeId: edge.id,
edgeLabel: p,
});
}); });
} }
@@ -1703,17 +1678,14 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
/** /**
* Clone object and keep the original untouched (by d3) * Clone object and keep the original untouched (by d3)
*/ */
private updateGraphData( private updateGraphData(graphData: GraphData.GraphData<GraphData.GremlinVertex, GraphData.GremlinEdge>) {
graphData: GraphData.GraphData<GraphData.GremlinVertex, GraphData.GremlinEdge>,
igraphConfig?: IGraphConfig
) {
this.originalGraphData = graphData; this.originalGraphData = graphData;
let gd = JSON.parse(JSON.stringify(this.originalGraphData)); let gd = JSON.parse(JSON.stringify(this.originalGraphData));
if (!this.d3ForceGraph) { if (!this.d3ForceGraph) {
console.warn("Attempting to update graph, but d3ForceGraph not initialized, yet."); console.warn("Attempting to update graph, but d3ForceGraph not initialized, yet.");
return; return;
} }
this.d3ForceGraph.updateGraph(gd, igraphConfig); this.d3ForceGraph.updateGraph(gd);
} }
public onMiddlePaneInitialized(instance: D3ForceGraph.GraphRenderer): void { public onMiddlePaneInitialized(instance: D3ForceGraph.GraphRenderer): void {
@@ -1722,12 +1694,10 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
private renderMiddlePane(): JSX.Element { private renderMiddlePane(): JSX.Element {
const forceGraphParams: D3ForceGraph.D3ForceGraphParameters = { const forceGraphParams: D3ForceGraph.D3ForceGraphParameters = {
igraphConfig: this.state.igraphConfig, graphConfig: this.props.graphConfig,
onHighlightedNode: this.onHighlightedNode.bind(this), onHighlightedNode: this.onHighlightedNode.bind(this),
onLoadMoreData: this.onLoadMoreData.bind(this), onLoadMoreData: this.onLoadMoreData.bind(this),
onInitialized: (instance: D3ForceGraph.GraphRenderer): void => { onInitialized: (instance: D3ForceGraph.GraphRenderer): void => this.onMiddlePaneInitialized(instance),
this.onMiddlePaneInitialized(instance);
},
onGraphUpdated: this.onGraphUpdated.bind(this), onGraphUpdated: this.onGraphUpdated.bind(this),
}; };

View File

@@ -1,74 +0,0 @@
import * as React from "react";
import { ReactAdapter } from "../../../Bindings/ReactBindingHandler";
import * as ViewModels from "../../../Contracts/ViewModels";
import { IGraphConfig } from "../../Tabs/GraphTab";
import { GraphAccessor, GraphExplorer } from "./GraphExplorer";
interface Parameter {
onIsNewVertexDisabledChange: (isEnabled: boolean) => void;
onGraphAccessorCreated: (instance: GraphAccessor) => void;
onIsFilterQueryLoading: (isFilterQueryLoading: boolean) => void;
onIsValidQuery: (isValidQuery: boolean) => void;
onIsPropertyEditing: (isEditing: boolean) => void;
onIsGraphDisplayed: (isDisplayed: boolean) => void;
onResetDefaultGraphConfigValues: () => void;
collectionPartitionKeyProperty: string;
graphBackendEndpoint: string;
databaseId: string;
collectionId: string;
masterKey: string;
onLoadStartKey: number;
onLoadStartKeyChange: (newKey: number) => void;
resourceId: string;
igraphConfigUiData: ViewModels.IGraphConfigUiData;
igraphConfig: IGraphConfig;
setIConfigUiData?: (data: string[]) => void;
}
interface IGraphExplorerProps {
isChanged: boolean;
}
interface IGraphExplorerStates {
isChangedState: boolean;
}
export interface GraphExplorerAdapter
extends ReactAdapter,
React.Component<IGraphExplorerProps, IGraphExplorerStates> {}
export class GraphExplorerAdapter implements ReactAdapter {
public params: Parameter;
public parameters = {};
public isNewVertexDisabled: boolean;
public constructor(params: Parameter, props?: IGraphExplorerProps) {
this.params = params;
}
public renderComponent(): JSX.Element {
return (
<GraphExplorer
onIsNewVertexDisabledChange={this.params.onIsNewVertexDisabledChange}
onGraphAccessorCreated={this.params.onGraphAccessorCreated}
onIsFilterQueryLoadingChange={this.params.onIsFilterQueryLoading}
onIsValidQueryChange={this.params.onIsValidQuery}
onIsPropertyEditing={this.params.onIsPropertyEditing}
onIsGraphDisplayed={this.params.onIsGraphDisplayed}
onResetDefaultGraphConfigValues={this.params.onResetDefaultGraphConfigValues}
collectionPartitionKeyProperty={this.params.collectionPartitionKeyProperty}
graphBackendEndpoint={this.params.graphBackendEndpoint}
databaseId={this.params.databaseId}
collectionId={this.params.collectionId}
masterKey={this.params.masterKey}
onLoadStartKey={this.params.onLoadStartKey}
onLoadStartKeyChange={this.params.onLoadStartKeyChange}
resourceId={this.params.resourceId}
igraphConfigUiData={this.params.igraphConfigUiData}
igraphConfig={this.params.igraphConfig}
setIConfigUiData={this.params.setIConfigUiData}
/>
);
}
}

View File

@@ -0,0 +1,51 @@
import * as ko from "knockout";
import { GraphStyleComponent, GraphStyleParams } from "./GraphStyleComponent";
import * as ViewModels from "../../../Contracts/ViewModels";
function buildComponent(buttonOptions: any) {
document.body.innerHTML = GraphStyleComponent.template as any;
const vm = new GraphStyleComponent.viewModel(buttonOptions);
ko.applyBindings(vm);
}
describe("Graph Style Component", () => {
let buildParams = (config: ViewModels.GraphConfigUiData): GraphStyleParams => {
return {
config: config,
};
};
afterEach(() => {
ko.cleanNode(document);
});
describe("Rendering", () => {
it("should display proper list of choices passed in component parameters", () => {
const PROP2 = "prop2";
const PROPC = "prop3";
const params = buildParams({
nodeCaptionChoice: ko.observable(null),
nodeIconChoice: ko.observable(null),
nodeColorKeyChoice: ko.observable(null),
nodeIconSet: ko.observable(null),
nodeProperties: ko.observableArray(["prop1", PROP2]),
nodePropertiesWithNone: ko.observableArray(["propa", "propb", PROPC]),
showNeighborType: ko.observable(null),
});
buildComponent(params);
var e: any = document.querySelector(".graphStyle #nodeCaptionChoices");
expect(e.options.length).toBe(2);
expect(e.options[1].value).toBe(PROP2);
e = document.querySelector(".graphStyle #nodeColorKeyChoices");
expect(e.options.length).toBe(3);
expect(e.options[2].value).toBe(PROPC);
e = document.querySelector(".graphStyle #nodeIconChoices");
expect(e.options.length).toBe(3);
expect(e.options[2].value).toBe(PROPC);
});
});
});

View File

@@ -1,67 +0,0 @@
import { render, screen } from "@testing-library/react";
import React from "react";
import * as ViewModels from "../../../Contracts/ViewModels";
import { IGraphConfig } from "../../Tabs/GraphTab";
import { GraphStyleComponent, GraphStyleProps } from "./GraphStyleComponent";
describe("Graph Style Component", () => {
let fakeGraphConfig: IGraphConfig;
let fakeGraphConfigUiData: ViewModels.IGraphConfigUiData;
let props: GraphStyleProps;
beforeEach(() => {
fakeGraphConfig = {
nodeColor: "orange",
nodeColorKey: "node2",
linkColor: "orange",
showNeighborType: 0,
nodeCaption: "node1",
nodeSize: 10,
linkWidth: 1,
nodeIconKey: undefined,
iconsMap: {},
};
fakeGraphConfigUiData = {
nodeCaptionChoice: "node1",
nodeIconChoice: undefined,
nodeColorKeyChoice: "node2",
nodeIconSet: undefined,
nodeProperties: ["node1", "node2", "node3"],
nodePropertiesWithNone: ["none", "node1", "node2", "node3"],
showNeighborType: undefined,
};
props = {
igraphConfig: fakeGraphConfig,
igraphConfigUiData: fakeGraphConfigUiData,
getValues: (): void => undefined,
};
render(<GraphStyleComponent {...props} />);
});
it("should render default property", () => {
const { asFragment } = render(<GraphStyleComponent {...props} />);
expect(asFragment).toMatchSnapshot();
});
it("should render node properties dropdown list ", () => {
const dropDownList = screen.getByText("Show vertex (node) as");
expect(dropDownList).toBeDefined();
});
it("should render Map this property to node color dropdown list", () => {
const nodeColorDropdownList = screen.getByText("Map this property to node color");
expect(nodeColorDropdownList).toBeDefined();
});
it("should render show neighbor options", () => {
const nodeShowNeighborOptions = screen.getByText("Show");
expect(nodeShowNeighborOptions).toBeDefined();
});
it("should call handleOnChange method", () => {
const handleOnChange = jest.fn();
const nodeCaptionDropdownList = screen.getByText("Show vertex (node) as");
nodeCaptionDropdownList.onchange = handleOnChange();
expect(handleOnChange).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,103 @@
import * as Constants from "../../../Common/Constants";
import * as ViewModels from "../../../Contracts/ViewModels";
import { WaitsForTemplateViewModel } from "../../WaitsForTemplateViewModel";
/**
* Parameters for this component
*/
export interface GraphStyleParams {
config: ViewModels.GraphConfigUiData;
firstFieldHasFocus?: ko.Observable<boolean>;
/**
* Callback triggered when the template is bound to the component (for testing purposes)
*/
onTemplateReady?: () => void;
}
class GraphStyleViewModel extends WaitsForTemplateViewModel {
private params: GraphStyleParams;
public constructor(params: GraphStyleParams) {
super();
super.onTemplateReady((isTemplateReady: boolean) => {
if (isTemplateReady && params.onTemplateReady) {
params.onTemplateReady();
}
});
this.params = params;
}
public onAllNeighborsKeyPress = (source: any, event: KeyboardEvent): boolean => {
if (event.keyCode === Constants.KeyCodes.Space || event.keyCode === Constants.KeyCodes.Enter) {
this.params.config.showNeighborType(ViewModels.NeighborType.BOTH);
event.stopPropagation();
return false;
}
return true;
};
public onSourcesKeyPress = (source: any, event: KeyboardEvent): boolean => {
if (event.keyCode === Constants.KeyCodes.Space || event.keyCode === Constants.KeyCodes.Enter) {
this.params.config.showNeighborType(ViewModels.NeighborType.SOURCES_ONLY);
event.stopPropagation();
return false;
}
return true;
};
public onTargetsKeyPress = (source: any, event: KeyboardEvent): boolean => {
if (event.keyCode === Constants.KeyCodes.Space || event.keyCode === Constants.KeyCodes.Enter) {
this.params.config.showNeighborType(ViewModels.NeighborType.TARGETS_ONLY);
event.stopPropagation();
return false;
}
return true;
};
}
const template = `
<div id="graphStyle" class="graphStyle" data-bind="setTemplateReady: true, with:params.config">
<div class="seconddivpadding">
<p>Show vertex (node) as</p>
<select id="nodeCaptionChoices" class="formTree paneselect" required data-bind="options:nodeProperties,
value:nodeCaptionChoice, hasFocus: $parent.params.firstFieldHasFocus"></select>
</div>
<div class="seconddivpadding">
<p>Map this property to node color</p>
<select id="nodeColorKeyChoices" class="formTree paneselect" required data-bind="options:nodePropertiesWithNone,
value:nodeColorKeyChoice"></select>
</div>
<div class="seconddivpadding">
<p>Map this property to node icon</p>
<select id="nodeIconChoices" class="formTree paneselect" required data-bind="options:nodePropertiesWithNone,
value:nodeIconChoice"></select>
<input type="text" data-bind="value:nodeIconSet" placeholder="Icon set: blank for collection id" class="nodeIconSet" autocomplete="off" />
</div>
<p class="seconddivpadding">Show</p>
<div class="tabs">
<div class="tab">
<input type="radio" id="tab11" name="graphneighbortype" class="radio" data-bind="checkedValue:2, checked:showNeighborType" />
<label for="tab11" tabindex="0" data-bind="event: { keypress: $parent.onAllNeighborsKeyPress }">All neighbors</label>
</div>
<div class="tab">
<input type="radio" id="tab12" name="graphneighbortype" class="radio" data-bind="checkedValue:0, checked:showNeighborType" />
<label for="tab12" tabindex="0" data-bind="event: { keypress: $parent.onSourcesKeyPress }">Sources</label>
</div>
<div class="tab">
<input type="radio" id="tab13" name="graphneighbortype" class="radio" data-bind="checkedValue:1, checked:showNeighborType" />
<label for="tab13" tabindex="0" data-bind="event: { keypress: $parent.onTargetsKeyPress }">Targets</label>
</div>
</div>
</div>`;
export const GraphStyleComponent = {
viewModel: GraphStyleViewModel,
template,
};

View File

@@ -1,131 +0,0 @@
import { ChoiceGroup, Dropdown, IChoiceGroupOption, IDropdownOption, IDropdownStyles, Stack } from "@fluentui/react";
import React, { FunctionComponent, useEffect, useState } from "react";
import { IGraphConfigUiData, NeighborType } from "../../../Contracts/ViewModels";
import { IGraphConfig } from "../../Tabs/GraphTab";
const IGraphConfigType = {
NODE_CAPTION: "NODE_CAPTION",
NODE_COLOR: "NODE_COLOR",
NODE_ICON: "NODE_ICON",
SHOW_NEIGHBOR_TYPE: "SHOW_NEIGHBOR_TYPE",
};
export interface GraphStyleProps {
igraphConfig: IGraphConfig;
igraphConfigUiData: IGraphConfigUiData;
getValues: (igraphConfig?: IGraphConfig) => void;
}
export const GraphStyleComponent: FunctionComponent<GraphStyleProps> = ({
igraphConfig,
igraphConfigUiData,
getValues,
}: GraphStyleProps): JSX.Element => {
const [igraphConfigState, setIGraphConfig] = useState<IGraphConfig>(igraphConfig);
const [selected, setSelected] = useState<boolean>(false);
const nodePropertiesOptions = igraphConfigUiData.nodeProperties.map((nodeProperty) => ({
key: nodeProperty,
text: nodeProperty,
}));
const nodePropertiesWithNoneOptions = igraphConfigUiData.nodePropertiesWithNone.map((nodePropertyWithNone) => ({
key: nodePropertyWithNone,
text: nodePropertyWithNone,
}));
const showNeighborTypeOptions: IChoiceGroupOption[] = [
{ key: NeighborType.BOTH.toString(), text: "All neighbors" },
{ key: NeighborType.SOURCES_ONLY.toString(), text: "Sources" },
{ key: NeighborType.TARGETS_ONLY.toString(), text: "Targets" },
];
const dropdownStyles: Partial<IDropdownStyles> = {
dropdown: { height: 32, marginRight: 10 },
};
const choiceButtonStyles = {
flexContainer: [
{
selectors: {
".ms-ChoiceField-wrapper label": {
fontSize: 14,
paddingTop: 0,
},
},
},
],
};
useEffect(() => {
if (selected) {
getValues(igraphConfigState);
}
//eslint-disable-next-line
}, [igraphConfigState]);
const handleOnChange = (val: string, igraphConfigType: string) => {
switch (igraphConfigType) {
case IGraphConfigType.NODE_CAPTION:
setSelected(true);
setIGraphConfig({
...igraphConfigState,
nodeCaption: val,
});
break;
case IGraphConfigType.NODE_COLOR:
setSelected(true);
setIGraphConfig({
...igraphConfigState,
nodeColorKey: val,
});
break;
case IGraphConfigType.SHOW_NEIGHBOR_TYPE:
setSelected(true);
setIGraphConfig({
...igraphConfigState,
showNeighborType: parseInt(val),
});
break;
}
};
return (
<Stack>
<div id="graphStyle" className="graphStyle">
<div className="seconddivpadding">
<Dropdown
label="Show vertex (node) as"
options={nodePropertiesOptions}
required
selectedKey={igraphConfigState.nodeCaption}
styles={dropdownStyles}
onChange={(_, options: IDropdownOption) =>
handleOnChange(options.key.toString(), IGraphConfigType.NODE_CAPTION)
}
/>
</div>
<div className="seconddivpadding">
<Dropdown
label="Map this property to node color"
options={nodePropertiesWithNoneOptions}
required
selectedKey={igraphConfigState.nodeColorKey}
styles={dropdownStyles}
onChange={(_, options: IDropdownOption) =>
handleOnChange(options.key.toString(), IGraphConfigType.NODE_COLOR)
}
/>
</div>
<div className="seconddivpadding">
<ChoiceGroup
label="Show"
styles={choiceButtonStyles}
options={showNeighborTypeOptions}
selectedKey={igraphConfigState.showNeighborType.toString()}
onChange={(_, options: IChoiceGroupOption) =>
handleOnChange(options.key.toString(), IGraphConfigType.SHOW_NEIGHBOR_TYPE)
}
/>
</div>
</div>
</Stack>
);
};

View File

@@ -1,3 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Graph Style Component should render default property 1`] = `[Function]`;

View File

@@ -0,0 +1,74 @@
<div id="graphStyle" class="graphStyle" data-bind="setTemplateReady: true, with:params.config">
<div class="seconddivpadding">
<p>Show vertex (node) as</p>
<select
id="nodeCaptionChoices"
class="formTree paneselect"
required
data-bind="options:nodeProperties,
value:nodeCaptionChoice, hasFocus: $parent.params.firstFieldHasFocus"
></select>
</div>
<div class="seconddivpadding">
<p>Map this property to node color</p>
<select
id="nodeColorKeyChoices"
class="formTree paneselect"
required
data-bind="options:nodePropertiesWithNone,
value:nodeColorKeyChoice"
></select>
</div>
<div class="seconddivpadding">
<p>Map this property to node icon</p>
<select
id="nodeIconChoices"
class="formTree paneselect"
required
data-bind="options:nodePropertiesWithNone,
value:nodeIconChoice"
></select>
<input
type="text"
data-bind="value:nodeIconSet"
placeholder="Icon set: blank for collection id"
class="nodeIconSet"
autocomplete="off"
/>
</div>
<p class="seconddivpadding">Show</p>
<div class="tabs">
<div class="tab">
<input
type="radio"
id="tab11"
name="graphneighbortype"
class="radio"
data-bind="checkedValue:2, checked:showNeighborType"
/>
<label for="tab11">All neighbors</label>
</div>
<div class="tab">
<input
type="radio"
id="tab12"
name="graphneighbortype"
class="radio"
data-bind="checkedValue:0, checked:showNeighborType"
/>
<label for="tab12">Sources</label>
</div>
<div class="tab">
<input
type="radio"
id="tab13"
name="graphneighbortype"
class="radio"
data-bind="checkedValue:1, checked:showNeighborType"
/>
<label for="tab13">Targets</label>
</div>
</div>
</div>

View File

@@ -132,7 +132,6 @@ export const NewVertexComponent: FunctionComponent<INewVertexComponentProps> = (
onChange={(event: React.ChangeEvent<HTMLInputElement>) => { onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
onLabelChange(event); onLabelChange(event);
}} }}
autoFocus
/> />
<div className="actionCol"></div> <div className="actionCol"></div>
</div> </div>

View File

@@ -33,6 +33,7 @@ export class CommandBarComponentAdapter implements ReactAdapter {
container.deleteCollectionText, container.deleteCollectionText,
container.deleteDatabaseText, container.deleteDatabaseText,
container.addCollectionText, container.addCollectionText,
container.addDatabaseText,
container.isDatabaseNodeOrNoneSelected, container.isDatabaseNodeOrNoneSelected,
container.isDatabaseNodeSelected, container.isDatabaseNodeSelected,
container.isNoneSelected, container.isNoneSelected,

View File

@@ -126,6 +126,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
beforeAll(() => { beforeAll(() => {
mockExplorer = {} as Explorer; mockExplorer = {} as Explorer;
mockExplorer.addDatabaseText = ko.observable("mockText");
mockExplorer.addCollectionText = ko.observable("mockText"); mockExplorer.addCollectionText = ko.observable("mockText");
updateUserContext({ updateUserContext({
databaseAccount: { databaseAccount: {
@@ -220,6 +221,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
beforeAll(() => { beforeAll(() => {
mockExplorer = {} as Explorer; mockExplorer = {} as Explorer;
mockExplorer.addDatabaseText = ko.observable("mockText");
mockExplorer.addCollectionText = ko.observable("mockText"); mockExplorer.addCollectionText = ko.observable("mockText");
updateUserContext({ updateUserContext({
databaseAccount: { databaseAccount: {

View File

@@ -22,7 +22,6 @@ import * as Constants from "../../../Common/Constants";
import { configContext, Platform } from "../../../ConfigContext"; import { configContext, Platform } from "../../../ConfigContext";
import * as ViewModels from "../../../Contracts/ViewModels"; import * as ViewModels from "../../../Contracts/ViewModels";
import { userContext } from "../../../UserContext"; import { userContext } from "../../../UserContext";
import { getDatabaseName } from "../../../Utils/APITypeUtils";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import Explorer from "../../Explorer"; import Explorer from "../../Explorer";
import { OpenFullScreen } from "../../OpenFullScreen"; import { OpenFullScreen } from "../../OpenFullScreen";
@@ -262,12 +261,13 @@ function createOpenSynapseLinkDialogButton(container: Explorer): CommandButtonCo
} }
function createNewDatabase(container: Explorer): CommandButtonComponentProps { function createNewDatabase(container: Explorer): CommandButtonComponentProps {
const label = "New " + getDatabaseName(); const label = container.addDatabaseText();
return { return {
iconSrc: AddDatabaseIcon, iconSrc: AddDatabaseIcon,
iconAlt: label, iconAlt: label,
onCommandClick: () => { onCommandClick: () => {
container.openAddDatabasePane(); container.addDatabasePane.open();
document.getElementById("linkAddDatabase").focus();
}, },
commandButtonLabel: label, commandButtonLabel: label,
ariaLabel: label, ariaLabel: label,

View File

@@ -1,108 +0,0 @@
import { FileType, IContent, IContentProvider, ServerConfig } from "@nteract/core";
import { Observable, of } from "rxjs";
import { AjaxResponse } from "rxjs/ajax";
import { HttpStatusCodes } from "../../../../Common/Constants";
import { getErrorMessage } from "../../../../Common/ErrorHandlingUtils";
import * as Logger from "../../../../Common/Logger";
export interface InMemoryContentProviderParams {
[path: string]: { readonly: boolean; content: IContent<FileType> };
}
// Nteract relies on `errno` property to figure out the kind of failure
// That's why we need a custom wrapper around Error to include `errno` property
class InMemoryContentProviderError extends Error {
constructor(error: string, public errno: number = InMemoryContentProvider.SelfErrorCode) {
super(error);
}
}
export class InMemoryContentProvider implements IContentProvider {
public static readonly SelfErrorCode = 666;
constructor(private params: InMemoryContentProviderParams) {}
public remove(): Observable<AjaxResponse> {
return this.errorResponse("Not implemented", "remove");
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public get(_config: ServerConfig, uri: string): Observable<AjaxResponse> {
const item = this.params[uri];
if (item) {
return of(this.createSuccessAjaxResponse(HttpStatusCodes.OK, item.content));
}
return this.errorResponse(`${uri} not found`, "get");
}
public update(): Observable<AjaxResponse> {
return this.errorResponse("Not implemented", "update");
}
public create(): Observable<AjaxResponse> {
return this.errorResponse("Not implemented", "create");
}
public save<FT extends FileType>(
_config: ServerConfig, // eslint-disable-line @typescript-eslint/no-unused-vars
uri: string,
model: Partial<IContent<FT>>
): Observable<AjaxResponse> {
const item = this.params[uri];
if (item) {
if (!item.readonly) {
Object.assign(item.content, model);
}
return of(this.createSuccessAjaxResponse(HttpStatusCodes.OK, item.content));
}
return this.errorResponse(`${uri} not found`, "save");
}
public listCheckpoints(): Observable<AjaxResponse> {
return this.errorResponse("Not implemented", "listCheckpoints");
}
public createCheckpoint(): Observable<AjaxResponse> {
return this.errorResponse("Not implemented", "createCheckpoint");
}
public deleteCheckpoint(): Observable<AjaxResponse> {
return this.errorResponse("Not implemented", "deleteCheckpoint");
}
public restoreFromCheckpoint(): Observable<AjaxResponse> {
return this.errorResponse("Not implemented", "restoreFromCheckpoint");
}
private errorResponse(message: string, functionName: string): Observable<AjaxResponse> {
const error = new InMemoryContentProviderError(message);
Logger.logError(error.message, `InMemoryContentProvider/${functionName}`, error.errno);
return of(this.createErrorAjaxResponse(error));
}
private createSuccessAjaxResponse(status: number, content: IContent<FileType>): AjaxResponse {
return {
originalEvent: new Event("no-op"),
xhr: new XMLHttpRequest(),
request: {},
status,
response: content ? content : undefined,
responseText: content ? JSON.stringify(content) : undefined,
responseType: "json",
};
}
private createErrorAjaxResponse(error: InMemoryContentProviderError): AjaxResponse {
return {
originalEvent: new Event("no-op"),
xhr: new XMLHttpRequest(),
request: {},
status: error.errno,
response: error,
responseText: getErrorMessage(error),
responseType: "json",
};
}
}

View File

@@ -1,15 +0,0 @@
// memory://<path>
// Custom scheme for in memory content
export const ContentUriPattern = /memory:\/\/([^/]*)/;
export function fromContentUri(contentUri: string): undefined | string {
const matches = contentUri.match(ContentUriPattern);
if (matches && matches.length > 1) {
return matches[1];
}
return undefined;
}
export function toContentUri(path: string): string {
return `memory://${path}`;
}

View File

@@ -1,11 +1,14 @@
// Vendor modules
import { actions, createContentRef, createKernelRef, selectors } from "@nteract/core";
import * as React from "react"; import * as React from "react";
import { ReactAdapter } from "../../../Bindings/ReactBindingHandler"; import { ReactAdapter } from "../../../Bindings/ReactBindingHandler";
import { NotebookClientV2 } from "../NotebookClientV2"; import { NotebookClientV2 } from "../NotebookClientV2";
// Vendor modules
import { actions, createContentRef, createKernelRef, selectors } from "@nteract/core";
import VirtualCommandBarComponent from "./VirtualCommandBarComponent";
import { NotebookContentItem } from "../NotebookContentItem"; import { NotebookContentItem } from "../NotebookContentItem";
import { NotebookComponentBootstrapper } from "./NotebookComponentBootstrapper"; import { NotebookComponentBootstrapper } from "./NotebookComponentBootstrapper";
import VirtualCommandBarComponent from "./VirtualCommandBarComponent"; import { CdbAppState } from "./types";
export interface NotebookComponentAdapterOptions { export interface NotebookComponentAdapterOptions {
contentItem: NotebookContentItem; contentItem: NotebookContentItem;
@@ -16,6 +19,7 @@ export interface NotebookComponentAdapterOptions {
export class NotebookComponentAdapter extends NotebookComponentBootstrapper implements ReactAdapter { export class NotebookComponentAdapter extends NotebookComponentBootstrapper implements ReactAdapter {
private onUpdateKernelInfo: () => void; private onUpdateKernelInfo: () => void;
public getNotebookParentElement: () => HTMLElement;
public parameters: any; public parameters: any;
constructor(options: NotebookComponentAdapterOptions) { constructor(options: NotebookComponentAdapterOptions) {
@@ -42,6 +46,11 @@ export class NotebookComponentAdapter extends NotebookComponentBootstrapper impl
}) })
); );
} }
this.getNotebookParentElement = () => {
const cdbAppState = this.getStore().getState() as CdbAppState;
return cdbAppState.cdb.currentNotebookParentElements.get(this.contentRef);
};
} }
protected renderExtraComponent = (): JSX.Element => { protected renderExtraComponent = (): JSX.Element => {

View File

@@ -1,29 +1,35 @@
import { CellId, CellType, ImmutableNotebook } from "@nteract/commutable"; import * as React from "react";
import { NotebookComponent } from "./NotebookComponent";
import { NotebookClientV2 } from "../NotebookClientV2";
import { NotebookUtil } from "../NotebookUtil";
// Vendor modules // Vendor modules
import { import {
actions, actions,
AppState, AppState,
ContentRef, createKernelRef,
DocumentRecordProps, DocumentRecordProps,
ContentRef,
KernelRef, KernelRef,
NotebookContentRecord, NotebookContentRecord,
selectors, selectors,
} from "@nteract/core"; } from "@nteract/core";
import "@nteract/styles/editor-overrides.css"; import * as Immutable from "immutable";
import "@nteract/styles/global-variables.css"; import { Provider } from "react-redux";
import { CellType, CellId, ImmutableNotebook } from "@nteract/commutable";
import { Store, AnyAction } from "redux";
import "./NotebookComponent.less";
import "codemirror/addon/hint/show-hint.css"; import "codemirror/addon/hint/show-hint.css";
import "codemirror/lib/codemirror.css"; import "codemirror/lib/codemirror.css";
import * as Immutable from "immutable"; import "@nteract/styles/editor-overrides.css";
import * as React from "react"; import "@nteract/styles/global-variables.css";
import { Provider } from "react-redux";
import "react-table/react-table.css"; import "react-table/react-table.css";
import { AnyAction, Store } from "redux";
import { NotebookClientV2 } from "../NotebookClientV2";
import { NotebookUtil } from "../NotebookUtil";
import * as NteractUtil from "../NTeractUtil";
import * as CdbActions from "./actions"; import * as CdbActions from "./actions";
import { NotebookComponent } from "./NotebookComponent"; import * as NteractUtil from "../NTeractUtil";
import "./NotebookComponent.less";
export interface NotebookComponentBootstrapperOptions { export interface NotebookComponentBootstrapperOptions {
notebookClient: NotebookClientV2; notebookClient: NotebookClientV2;
@@ -31,7 +37,7 @@ export interface NotebookComponentBootstrapperOptions {
} }
export class NotebookComponentBootstrapper { export class NotebookComponentBootstrapper {
public contentRef: ContentRef; protected contentRef: ContentRef;
protected renderExtraComponent: () => JSX.Element; protected renderExtraComponent: () => JSX.Element;
private notebookClient: NotebookClientV2; private notebookClient: NotebookClientV2;

View File

@@ -1,17 +1,11 @@
import { FileType, IContent, IContentProvider, IGetParams, ServerConfig } from "@nteract/core"; import { ServerConfig, IContentProvider, FileType, IContent, IGetParams } from "@nteract/core";
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { AjaxResponse } from "rxjs/ajax"; import { AjaxResponse } from "rxjs/ajax";
import { GitHubContentProvider } from "../../../GitHub/GitHubContentProvider"; import { GitHubContentProvider } from "../../../GitHub/GitHubContentProvider";
import * as GitHubUtils from "../../../Utils/GitHubUtils"; import * as GitHubUtils from "../../../Utils/GitHubUtils";
import { InMemoryContentProvider } from "./ContentProviders/InMemoryContentProvider";
import * as InMemoryContentProviderUtils from "./ContentProviders/InMemoryContentProviderUtils";
export class NotebookContentProvider implements IContentProvider { export class NotebookContentProvider implements IContentProvider {
constructor( constructor(private gitHubContentProvider: GitHubContentProvider, private jupyterContentProvider: IContentProvider) {}
private inMemoryContentProvider: InMemoryContentProvider,
private gitHubContentProvider: GitHubContentProvider,
private jupyterContentProvider: IContentProvider
) {}
public remove(serverConfig: ServerConfig, path: string): Observable<AjaxResponse> { public remove(serverConfig: ServerConfig, path: string): Observable<AjaxResponse> {
return this.getContentProvider(path).remove(serverConfig, path); return this.getContentProvider(path).remove(serverConfig, path);
@@ -66,10 +60,6 @@ export class NotebookContentProvider implements IContentProvider {
} }
private getContentProvider(path: string): IContentProvider { private getContentProvider(path: string): IContentProvider {
if (InMemoryContentProviderUtils.fromContentUri(path)) {
return this.inMemoryContentProvider;
}
if (GitHubUtils.fromContentUri(path)) { if (GitHubUtils.fromContentUri(path)) {
return this.gitHubContentProvider; return this.gitHubContentProvider;
} }

View File

@@ -1,7 +1,6 @@
import { CellId } from "@nteract/commutable"; import { CellId } from "@nteract/commutable";
import { ContentRef } from "@nteract/core"; import { ContentRef } from "@nteract/core";
import { Action } from "../../../Shared/Telemetry/TelemetryConstants"; import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
import { SnapshotFragment, SnapshotRequest } from "./types";
export const CLOSE_NOTEBOOK = "CLOSE_NOTEBOOK"; export const CLOSE_NOTEBOOK = "CLOSE_NOTEBOOK";
export interface CloseNotebookAction { export interface CloseNotebookAction {
@@ -86,68 +85,21 @@ export const traceNotebookTelemetry = (payload: {
}; };
}; };
export const STORE_CELL_OUTPUT_SNAPSHOT = "STORE_CELL_OUTPUT_SNAPSHOT"; export const UPDATE_NOTEBOOK_PARENT_DOM_ELTS = "UPDATE_NOTEBOOK_PARENT_DOM_ELTS";
export interface StoreCellOutputSnapshotAction { export interface UpdateNotebookParentDomEltAction {
type: "STORE_CELL_OUTPUT_SNAPSHOT"; type: "UPDATE_NOTEBOOK_PARENT_DOM_ELTS";
payload: { payload: {
cellId: string; contentRef: ContentRef;
snapshot: SnapshotFragment; parentElt: HTMLElement;
}; };
} }
export const storeCellOutputSnapshot = (payload: { export const UpdateNotebookParentDomElt = (payload: {
cellId: string; contentRef: ContentRef;
snapshot: SnapshotFragment; parentElt: HTMLElement;
}): StoreCellOutputSnapshotAction => { }): UpdateNotebookParentDomEltAction => {
return { return {
type: STORE_CELL_OUTPUT_SNAPSHOT, type: UPDATE_NOTEBOOK_PARENT_DOM_ELTS,
payload,
};
};
export const STORE_NOTEBOOK_SNAPSHOT = "STORE_NOTEBOOK_SNAPSHOT";
export interface StoreNotebookSnapshotAction {
type: "STORE_NOTEBOOK_SNAPSHOT";
payload: {
imageSrc: string;
requestId: string;
};
}
export const storeNotebookSnapshot = (payload: {
imageSrc: string;
requestId: string;
}): StoreNotebookSnapshotAction => {
return {
type: STORE_NOTEBOOK_SNAPSHOT,
payload,
};
};
export const TAKE_NOTEBOOK_SNAPSHOT = "TAKE_NOTEBOOK_SNAPSHOT";
export interface TakeNotebookSnapshotAction {
type: "TAKE_NOTEBOOK_SNAPSHOT";
payload: SnapshotRequest;
}
export const takeNotebookSnapshot = (payload: SnapshotRequest): TakeNotebookSnapshotAction => {
return {
type: TAKE_NOTEBOOK_SNAPSHOT,
payload,
};
};
export const NOTEBOOK_SNAPSHOT_ERROR = "NOTEBOOK_SNAPSHOT_ERROR";
export interface NotebookSnapshotErrorAction {
type: "NOTEBOOK_SNAPSHOT_ERROR";
payload: {
error: string;
};
}
export const notebookSnapshotError = (payload: { error: string }): NotebookSnapshotErrorAction => {
return {
type: NOTEBOOK_SNAPSHOT_ERROR,
payload, payload,
}; };
}; };

View File

@@ -70,32 +70,17 @@ export const cdbReducer = (state: CdbRecord, action: Action) => {
return state.set("hoveredCellId", typedAction.payload.cellId); return state.set("hoveredCellId", typedAction.payload.cellId);
} }
case cdbActions.STORE_CELL_OUTPUT_SNAPSHOT: { case cdbActions.UPDATE_NOTEBOOK_PARENT_DOM_ELTS: {
const typedAction = action as cdbActions.StoreCellOutputSnapshotAction; const typedAction = action as cdbActions.UpdateNotebookParentDomEltAction;
state.cellOutputSnapshots.set(typedAction.payload.cellId, typedAction.payload.snapshot); var parentEltsMap = state.get("currentNotebookParentElements");
// TODO Simpler datastructure to instantiate new Map? const contentRef = typedAction.payload.contentRef;
return state.set("cellOutputSnapshots", new Map(state.cellOutputSnapshots)); const parentElt = typedAction.payload.parentElt;
} if (parentElt) {
parentEltsMap.set(contentRef, parentElt);
case cdbActions.STORE_NOTEBOOK_SNAPSHOT: { } else {
const typedAction = action as cdbActions.StoreNotebookSnapshotAction; parentEltsMap.delete(contentRef);
// Clear pending request }
return state.set("notebookSnapshot", typedAction.payload).set("pendingSnapshotRequest", undefined); return state.set("currentNotebookParentElements", parentEltsMap);
}
case cdbActions.TAKE_NOTEBOOK_SNAPSHOT: {
const typedAction = action as cdbActions.TakeNotebookSnapshotAction;
// Clear previous snapshots
return state
.set("cellOutputSnapshots", new Map())
.set("notebookSnapshot", undefined)
.set("notebookSnapshotError", undefined)
.set("pendingSnapshotRequest", typedAction.payload);
}
case cdbActions.NOTEBOOK_SNAPSHOT_ERROR: {
const typedAction = action as cdbActions.NotebookSnapshotErrorAction;
return state.set("notebookSnapshotError", typedAction.payload.error);
} }
} }
return state; return state;

View File

@@ -1,40 +1,15 @@
import { CellId } from "@nteract/commutable";
import { AppState } from "@nteract/core";
import * as Immutable from "immutable"; import * as Immutable from "immutable";
import { AppState, ContentRef } from "@nteract/core";
import { Notebook } from "../../../Common/Constants"; import { Notebook } from "../../../Common/Constants";
import { CellId } from "@nteract/commutable";
export interface SnapshotFragment {
image: HTMLImageElement;
boundingClientRect: DOMRect;
requestId: string;
}
export type SnapshotRequest = NotebookSnapshotRequest | CellSnapshotRequest;
interface NotebookSnapshotRequestBase {
requestId: string;
aspectRatio: number;
notebookContentRef: string; // notebook redux contentRef
downloadFilename?: string; // Optional: will download as a file
}
interface NotebookSnapshotRequest extends NotebookSnapshotRequestBase {
type: "notebook";
}
interface CellSnapshotRequest extends NotebookSnapshotRequestBase {
type: "celloutput";
cellId: string;
}
export interface CdbRecordProps { export interface CdbRecordProps {
databaseAccountName: string | undefined; databaseAccountName: string | undefined;
defaultExperience: string | undefined; defaultExperience: string | undefined;
kernelRestartDelayMs: number; kernelRestartDelayMs: number;
hoveredCellId: CellId | undefined; hoveredCellId: CellId | undefined;
cellOutputSnapshots: Map<string, SnapshotFragment>; currentNotebookParentElements: Map<ContentRef, HTMLElement>;
notebookSnapshot?: { imageSrc: string; requestId: string };
pendingSnapshotRequest?: SnapshotRequest;
notebookSnapshotError?: string;
} }
export type CdbRecord = Immutable.RecordOf<CdbRecordProps>; export type CdbRecord = Immutable.RecordOf<CdbRecordProps>;
@@ -48,8 +23,5 @@ export const makeCdbRecord = Immutable.Record<CdbRecordProps>({
defaultExperience: undefined, defaultExperience: undefined,
kernelRestartDelayMs: Notebook.kernelRestartInitialDelayMs, kernelRestartDelayMs: Notebook.kernelRestartInitialDelayMs,
hoveredCellId: undefined, hoveredCellId: undefined,
cellOutputSnapshots: new Map(), currentNotebookParentElements: new Map<ContentRef, HTMLElement>(),
notebookSnapshot: undefined,
pendingSnapshotRequest: undefined,
notebookSnapshotError: undefined,
}); });

View File

@@ -22,14 +22,12 @@ import { getFullName } from "../../Utils/UserUtils";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
import { ContextualPaneBase } from "../Panes/ContextualPaneBase"; import { ContextualPaneBase } from "../Panes/ContextualPaneBase";
import { CopyNotebookPane } from "../Panes/CopyNotebookPane/CopyNotebookPane"; import { CopyNotebookPane } from "../Panes/CopyNotebookPane/CopyNotebookPane";
// import { GitHubReposPane } from "../Panes/GitHubReposPane";
import { PublishNotebookPane } from "../Panes/PublishNotebookPane/PublishNotebookPane"; import { PublishNotebookPane } from "../Panes/PublishNotebookPane/PublishNotebookPane";
import { ResourceTreeAdapter } from "../Tree/ResourceTreeAdapter"; import { ResourceTreeAdapter } from "../Tree/ResourceTreeAdapter";
import { InMemoryContentProvider } from "./NotebookComponent/ContentProviders/InMemoryContentProvider";
import { NotebookContentProvider } from "./NotebookComponent/NotebookContentProvider"; import { NotebookContentProvider } from "./NotebookComponent/NotebookContentProvider";
import { SnapshotRequest } from "./NotebookComponent/types";
import { NotebookContainerClient } from "./NotebookContainerClient"; import { NotebookContainerClient } from "./NotebookContainerClient";
import { NotebookContentClient } from "./NotebookContentClient"; import { NotebookContentClient } from "./NotebookContentClient";
import { SchemaAnalyzerNotebook } from "./SchemaAnalyzer/SchemaAnalyzerUtils";
type NotebookPaneContent = string | ImmutableNotebook; type NotebookPaneContent = string | ImmutableNotebook;
@@ -51,7 +49,6 @@ export default class NotebookManager {
public notebookClient: NotebookContainerClient; public notebookClient: NotebookContainerClient;
public notebookContentClient: NotebookContentClient; public notebookContentClient: NotebookContentClient;
private inMemoryContentProvider: InMemoryContentProvider;
private gitHubContentProvider: GitHubContentProvider; private gitHubContentProvider: GitHubContentProvider;
public gitHubOAuthService: GitHubOAuthService; public gitHubOAuthService: GitHubOAuthService;
public gitHubClient: GitHubClient; public gitHubClient: GitHubClient;
@@ -65,20 +62,12 @@ export default class NotebookManager {
this.gitHubOAuthService = new GitHubOAuthService(this.junoClient); this.gitHubOAuthService = new GitHubOAuthService(this.junoClient);
this.gitHubClient = new GitHubClient(this.onGitHubClientError); this.gitHubClient = new GitHubClient(this.onGitHubClientError);
this.inMemoryContentProvider = new InMemoryContentProvider({
[SchemaAnalyzerNotebook.path]: {
readonly: true,
content: SchemaAnalyzerNotebook,
},
});
this.gitHubContentProvider = new GitHubContentProvider({ this.gitHubContentProvider = new GitHubContentProvider({
gitHubClient: this.gitHubClient, gitHubClient: this.gitHubClient,
promptForCommitMsg: this.promptForCommitMsg, promptForCommitMsg: this.promptForCommitMsg,
}); });
this.notebookContentProvider = new NotebookContentProvider( this.notebookContentProvider = new NotebookContentProvider(
this.inMemoryContentProvider,
this.gitHubContentProvider, this.gitHubContentProvider,
contents.JupyterContentProvider contents.JupyterContentProvider
); );
@@ -123,13 +112,11 @@ export default class NotebookManager {
public async openPublishNotebookPane( public async openPublishNotebookPane(
name: string, name: string,
content: NotebookPaneContent, content: NotebookPaneContent,
notebookContentRef: string, parentDomElement: HTMLElement
onTakeSnapshot: (request: SnapshotRequest) => void,
onClosePanel: () => void
): Promise<void> { ): Promise<void> {
const explorer = this.params.container; const explorer = this.params.container;
explorer.openSidePanel( explorer.openSidePanel(
"Publish Notebook", "New Collection",
<PublishNotebookPane <PublishNotebookPane
explorer={this.params.container} explorer={this.params.container}
junoClient={this.junoClient} junoClient={this.junoClient}
@@ -138,10 +125,8 @@ export default class NotebookManager {
name={name} name={name}
author={getFullName()} author={getFullName()}
notebookContent={content} notebookContent={content}
notebookContentRef={notebookContentRef} parentDomElement={parentDomElement}
onTakeSnapshot={onTakeSnapshot} />
/>,
onClosePanel
); );
} }

View File

@@ -1,6 +1,6 @@
import { CellId } from "@nteract/commutable"; import { CellId } from "@nteract/commutable";
import { CellType } from "@nteract/commutable/src"; import { CellType } from "@nteract/commutable/src";
import { actions, ContentRef, selectors } from "@nteract/core"; import { actions, ContentRef } from "@nteract/core";
import { Cells, CodeCell, RawCell } from "@nteract/stateful-components"; import { Cells, CodeCell, RawCell } from "@nteract/stateful-components";
import MonacoEditor from "@nteract/stateful-components/lib/inputs/connected-editors/monacoEditor"; import MonacoEditor from "@nteract/stateful-components/lib/inputs/connected-editors/monacoEditor";
import { PassedEditorProps } from "@nteract/stateful-components/lib/inputs/editor"; import { PassedEditorProps } from "@nteract/stateful-components/lib/inputs/editor";
@@ -12,8 +12,6 @@ import { Dispatch } from "redux";
import { userContext } from "../../../UserContext"; import { userContext } from "../../../UserContext";
import * as cdbActions from "../NotebookComponent/actions"; import * as cdbActions from "../NotebookComponent/actions";
import loadTransform from "../NotebookComponent/loadTransform"; import loadTransform from "../NotebookComponent/loadTransform";
import { CdbAppState, SnapshotFragment, SnapshotRequest } from "../NotebookComponent/types";
import { NotebookUtil } from "../NotebookUtil";
import { AzureTheme } from "./AzureTheme"; import { AzureTheme } from "./AzureTheme";
import "./base.css"; import "./base.css";
import CellCreator from "./decorators/CellCreator"; import CellCreator from "./decorators/CellCreator";
@@ -34,18 +32,10 @@ export interface NotebookRendererBaseProps {
} }
interface NotebookRendererDispatchProps { interface NotebookRendererDispatchProps {
storeNotebookSnapshot: (imageSrc: string, requestId: string) => void; updateNotebookParentDomElt: (contentRef: ContentRef, parentElt: HTMLElement) => void;
notebookSnapshotError: (error: string) => void;
} }
interface StateProps { type NotebookRendererProps = NotebookRendererBaseProps & NotebookRendererDispatchProps;
pendingSnapshotRequest: SnapshotRequest;
cellOutputSnapshots: Map<string, SnapshotFragment>;
notebookSnapshot: { imageSrc: string; requestId: string };
nbCodeCells: number;
}
type NotebookRendererProps = NotebookRendererBaseProps & NotebookRendererDispatchProps & StateProps;
const decorate = (id: string, contentRef: ContentRef, cell_type: CellType, children: React.ReactNode) => { const decorate = (id: string, contentRef: ContentRef, cell_type: CellType, children: React.ReactNode) => {
const Cell = () => ( const Cell = () => (
@@ -70,37 +60,27 @@ const decorate = (id: string, contentRef: ContentRef, cell_type: CellType, child
class BaseNotebookRenderer extends React.Component<NotebookRendererProps> { class BaseNotebookRenderer extends React.Component<NotebookRendererProps> {
private notebookRendererRef = React.createRef<HTMLDivElement>(); private notebookRendererRef = React.createRef<HTMLDivElement>();
constructor(props: NotebookRendererProps) {
super(props);
this.state = {
hoveredCellId: undefined,
};
}
componentDidMount() { componentDidMount() {
if (!userContext.features.sandboxNotebookOutputs) { if (!userContext.features.sandboxNotebookOutputs) {
loadTransform(this.props as any); loadTransform(this.props as any);
} }
this.props.updateNotebookParentDomElt(this.props.contentRef, this.notebookRendererRef.current);
} }
async componentDidUpdate(): Promise<void> { componentDidUpdate() {
// Take a snapshot if there's a pending request and all the outputs are also saved this.props.updateNotebookParentDomElt(this.props.contentRef, this.notebookRendererRef.current);
if ( }
this.props.pendingSnapshotRequest &&
this.props.pendingSnapshotRequest.type === "notebook" && componentWillUnmount() {
this.props.pendingSnapshotRequest.notebookContentRef === this.props.contentRef && this.props.updateNotebookParentDomElt(this.props.contentRef, undefined);
(!this.props.notebookSnapshot ||
this.props.pendingSnapshotRequest.requestId !== this.props.notebookSnapshot.requestId) &&
this.props.cellOutputSnapshots.size === this.props.nbCodeCells
) {
try {
// Use Html2Canvas because it is much more reliable and fast than dom-to-file
const result = await NotebookUtil.takeScreenshotHtml2Canvas(
this.notebookRendererRef.current,
this.props.pendingSnapshotRequest.aspectRatio,
[...this.props.cellOutputSnapshots.values()],
this.props.pendingSnapshotRequest.downloadFilename
);
this.props.storeNotebookSnapshot(result.imageSrc, this.props.pendingSnapshotRequest.requestId);
} catch (error) {
this.props.notebookSnapshotError(error.message);
} finally {
this.setState({ processedSnapshotRequest: undefined });
}
}
} }
render(): JSX.Element { render(): JSX.Element {
@@ -176,40 +156,28 @@ class BaseNotebookRenderer extends React.Component<NotebookRendererProps> {
} }
} }
export const makeMapStateToProps = (
initialState: CdbAppState,
ownProps: NotebookRendererProps
): ((state: CdbAppState) => StateProps) => {
const mapStateToProps = (state: CdbAppState): StateProps => {
const { contentRef } = ownProps;
const model = selectors.model(state, { contentRef });
let nbCodeCells;
if (model && model.type === "notebook") {
nbCodeCells = NotebookUtil.findCodeCellWithDisplay(model.notebook).length;
}
const { pendingSnapshotRequest, cellOutputSnapshots, notebookSnapshot } = state.cdb;
return { pendingSnapshotRequest, cellOutputSnapshots, notebookSnapshot, nbCodeCells };
};
return mapStateToProps;
};
const makeMapDispatchToProps = (initialDispatch: Dispatch, initialProps: NotebookRendererBaseProps) => { const makeMapDispatchToProps = (initialDispatch: Dispatch, initialProps: NotebookRendererBaseProps) => {
const mapDispatchToProps = (dispatch: Dispatch) => { const mapDispatchToProps = (dispatch: Dispatch) => {
return { return {
addTransform: (transform: React.ComponentType & { MIMETYPE: string }) => addTransform: (transform: React.ComponentType & { MIMETYPE: string }) => {
dispatch( return dispatch(
actions.addTransform({ actions.addTransform({
mediaType: transform.MIMETYPE, mediaType: transform.MIMETYPE,
component: transform, component: transform,
}) })
), );
storeNotebookSnapshot: (imageSrc: string, requestId: string) => },
dispatch(cdbActions.storeNotebookSnapshot({ imageSrc, requestId })), updateNotebookParentDomElt: (contentRef: ContentRef, parentElt: HTMLElement) => {
notebookSnapshotError: (error: string) => dispatch(cdbActions.notebookSnapshotError({ error })), return dispatch(
cdbActions.UpdateNotebookParentDomElt({
contentRef,
parentElt,
})
);
},
}; };
}; };
return mapDispatchToProps; return mapDispatchToProps;
}; };
export default connect(makeMapStateToProps, makeMapDispatchToProps)(BaseNotebookRenderer); export default connect(null, makeMapDispatchToProps)(BaseNotebookRenderer);

View File

@@ -53,7 +53,7 @@ export class PromptPure extends React.Component<Props> {
} }
} }
const makeMapStateToProps = (_state: CdbAppState, ownProps: ComponentProps): ((state: CdbAppState) => StateProps) => { const makeMapStateToProps = (state: CdbAppState, ownProps: ComponentProps): ((state: CdbAppState) => StateProps) => {
const mapStateToProps = (state: CdbAppState) => { const mapStateToProps = (state: CdbAppState) => {
const { contentRef, id } = ownProps; const { contentRef, id } = ownProps;
const model = selectors.model(state, { contentRef }); const model = selectors.model(state, { contentRef });

View File

@@ -1,5 +1,5 @@
import { ContextualMenuItemType, DirectionalHint, IconButton, IContextualMenuItem } from "@fluentui/react"; import { ContextualMenuItemType, DirectionalHint, IconButton, IContextualMenuItem } from "@fluentui/react";
import { CellId, CellType, ImmutableCodeCell } from "@nteract/commutable"; import { CellId, CellType } from "@nteract/commutable";
import { actions, AppState, DocumentRecordProps } from "@nteract/core"; import { actions, AppState, DocumentRecordProps } from "@nteract/core";
import * as selectors from "@nteract/selectors"; import * as selectors from "@nteract/selectors";
import { CellToolbarContext } from "@nteract/stateful-components"; import { CellToolbarContext } from "@nteract/stateful-components";
@@ -10,8 +10,6 @@ import { connect } from "react-redux";
import { Dispatch } from "redux"; import { Dispatch } from "redux";
import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants"; import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants";
import * as cdbActions from "../NotebookComponent/actions"; import * as cdbActions from "../NotebookComponent/actions";
import { SnapshotRequest } from "../NotebookComponent/types";
import { NotebookUtil } from "../NotebookUtil";
export interface ComponentProps { export interface ComponentProps {
contentRef: ContentRef; contentRef: ContentRef;
@@ -28,14 +26,12 @@ interface DispatchProps {
clearOutputs: () => void; clearOutputs: () => void;
deleteCell: () => void; deleteCell: () => void;
traceNotebookTelemetry: (action: Action, actionModifier?: string, data?: any) => void; traceNotebookTelemetry: (action: Action, actionModifier?: string, data?: any) => void;
takeNotebookSnapshot: (payload: SnapshotRequest) => void;
} }
interface StateProps { interface StateProps {
cellType: CellType; cellType: CellType;
cellIdAbove: CellId; cellIdAbove: CellId;
cellIdBelow: CellId; cellIdBelow: CellId;
hasCodeOutput: boolean;
} }
class BaseToolbar extends React.PureComponent<ComponentProps & DispatchProps & StateProps> { class BaseToolbar extends React.PureComponent<ComponentProps & DispatchProps & StateProps> {
@@ -62,29 +58,11 @@ class BaseToolbar extends React.PureComponent<ComponentProps & DispatchProps & S
this.props.traceNotebookTelemetry(Action.NotebooksClearOutputsFromMenu, ActionModifiers.Mark); this.props.traceNotebookTelemetry(Action.NotebooksClearOutputsFromMenu, ActionModifiers.Mark);
}, },
}, },
{
key: "Divider",
itemType: ContextualMenuItemType.Divider,
},
]); ]);
if (this.props.hasCodeOutput) {
items.push({
key: "Export output to image",
text: "Export output to image",
onClick: () => {
this.props.takeNotebookSnapshot({
requestId: new Date().getTime().toString(),
aspectRatio: undefined,
type: "celloutput",
cellId: this.props.id,
notebookContentRef: this.props.contentRef,
downloadFilename: `celloutput-${this.props.contentRef}_${this.props.id}.png`,
});
},
});
}
items.push({
key: "Divider",
itemType: ContextualMenuItemType.Divider,
});
} }
items = items.concat([ items = items.concat([
@@ -205,13 +183,12 @@ const mapDispatchToProps = (
deleteCell: () => dispatch(actions.deleteCell({ id, contentRef })), deleteCell: () => dispatch(actions.deleteCell({ id, contentRef })),
traceNotebookTelemetry: (action: Action, actionModifier?: string, data?: any) => traceNotebookTelemetry: (action: Action, actionModifier?: string, data?: any) =>
dispatch(cdbActions.traceNotebookTelemetry({ action, actionModifier, data })), dispatch(cdbActions.traceNotebookTelemetry({ action, actionModifier, data })),
takeNotebookSnapshot: (request: SnapshotRequest) => dispatch(cdbActions.takeNotebookSnapshot(request)),
}); });
const makeMapStateToProps = (state: AppState, ownProps: ComponentProps): ((state: AppState) => StateProps) => { const makeMapStateToProps = (state: AppState, ownProps: ComponentProps): ((state: AppState) => StateProps) => {
const mapStateToProps = (state: AppState) => { const mapStateToProps = (state: AppState) => {
const cell = selectors.cell.cellFromState(state, { id: ownProps.id, contentRef: ownProps.contentRef }); const cellType = selectors.cell.cellFromState(state, { id: ownProps.id, contentRef: ownProps.contentRef })
const cellType = cell.cell_type; .cell_type;
const model = selectors.model(state, { contentRef: ownProps.contentRef }); const model = selectors.model(state, { contentRef: ownProps.contentRef });
const cellOrder = selectors.notebook.cellOrder(model as RecordOf<DocumentRecordProps>); const cellOrder = selectors.notebook.cellOrder(model as RecordOf<DocumentRecordProps>);
const cellIndex = cellOrder.indexOf(ownProps.id); const cellIndex = cellOrder.indexOf(ownProps.id);
@@ -222,7 +199,6 @@ const makeMapStateToProps = (state: AppState, ownProps: ComponentProps): ((state
cellType, cellType,
cellIdAbove, cellIdAbove,
cellIdBelow, cellIdBelow,
hasCodeOutput: cellType === "code" && NotebookUtil.hasCodeCellOutput(cell as ImmutableCodeCell),
}; };
}; };
return mapStateToProps; return mapStateToProps;

View File

@@ -1,9 +1,10 @@
import { AppState, ContentRef, DocumentRecordProps, selectors } from "@nteract/core";
import { RecordOf } from "immutable";
import React from "react"; import React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import "./CellLabeler.less"; import "./CellLabeler.less";
import { AppState, ContentRef, selectors, DocumentRecordProps } from "@nteract/core";
import { RecordOf } from "immutable";
interface ComponentProps { interface ComponentProps {
id: string; id: string;
contentRef: ContentRef; // TODO: Make this per contentRef? contentRef: ContentRef; // TODO: Make this per contentRef?
@@ -28,7 +29,7 @@ class CellLabeler extends React.Component<ComponentProps & StateProps> {
} }
} }
const makeMapStateToProps = (_state: AppState, ownProps: ComponentProps): ((state: AppState) => StateProps) => { const makeMapStateToProps = (state: AppState, ownProps: ComponentProps): ((state: AppState) => StateProps) => {
const mapStateToProps = (state: AppState) => { const mapStateToProps = (state: AppState) => {
const model = selectors.model(state, { contentRef: ownProps.contentRef }); const model = selectors.model(state, { contentRef: ownProps.contentRef });
const cellOrder = selectors.notebook.cellOrder(model as RecordOf<DocumentRecordProps>); const cellOrder = selectors.notebook.cellOrder(model as RecordOf<DocumentRecordProps>);

View File

@@ -7,9 +7,7 @@ import postRobot from "post-robot";
import React from "react"; import React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Dispatch } from "redux"; import { Dispatch } from "redux";
import { CellOutputViewerProps, SnapshotResponse } from "../../../../CellOutputViewer/CellOutputViewer"; import { CellOutputViewerProps } from "../../../../CellOutputViewer/CellOutputViewer";
import * as cdbActions from "../../NotebookComponent/actions";
import { CdbAppState, SnapshotFragment, SnapshotRequest } from "../../NotebookComponent/types";
// Adapted from https://github.com/nteract/nteract/blob/main/packages/stateful-components/src/outputs/index.tsx // Adapted from https://github.com/nteract/nteract/blob/main/packages/stateful-components/src/outputs/index.tsx
// to add support for sandboxing using <iframe> // to add support for sandboxing using <iframe>
@@ -26,47 +24,27 @@ interface StateProps {
expanded: boolean; expanded: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
outputs: Immutable.List<any>; outputs: Immutable.List<any>;
pendingSnapshotRequest: SnapshotRequest;
} }
interface DispatchProps { interface DispatchProps {
onMetadataChange?: (metadata: JSONObject, mediaType: string, index?: number) => void; onMetadataChange?: (metadata: JSONObject, mediaType: string, index?: number) => void;
storeNotebookSnapshot: (imageSrc: string, requestId: string) => void;
storeSnapshotFragment: (cellId: string, snapshotFragment: SnapshotFragment) => void;
notebookSnapshotError: (error: string) => void;
} }
type SandboxOutputsProps = ComponentProps & StateProps & DispatchProps; export class SandboxOutputs extends React.PureComponent<ComponentProps & StateProps & DispatchProps> {
export class SandboxOutputs extends React.Component<SandboxOutputsProps> {
private childWindow: Window; private childWindow: Window;
private nodeRef = React.createRef<HTMLDivElement>();
constructor(props: SandboxOutputsProps) {
super(props);
this.state = {
processedSnapshotRequest: undefined,
};
}
render(): JSX.Element { render(): JSX.Element {
// Using min-width to set the width of the iFrame, works around an issue in iOS that can prevent the iFrame from sizing correctly. // Using min-width to set the width of the iFrame, works around an issue in iOS that can prevent the iFrame from sizing correctly.
return this.props.outputs && this.props.outputs.size > 0 ? ( return (
<div ref={this.nodeRef}> <IframeResizer
<IframeResizer checkOrigin={false}
checkOrigin={false} loading="lazy"
loading="lazy" heightCalculationMethod="taggedElement"
heightCalculationMethod="taggedElement" onLoad={(event) => this.handleFrameLoad(event)}
onLoad={(event) => this.handleFrameLoad(event)} src="./cellOutputViewer.html"
src="./cellOutputViewer.html" style={{ height: "1px", width: "1px", minWidth: "100%", border: "none" }}
style={{ height: "1px", width: "1px", minWidth: "100%", border: "none" }} sandbox="allow-downloads allow-popups allow-forms allow-pointer-lock allow-scripts allow-popups-to-escape-sandbox"
sandbox="allow-downloads allow-popups allow-forms allow-pointer-lock allow-scripts allow-popups-to-escape-sandbox" />
/>
</div>
) : (
<></>
); );
} }
@@ -98,48 +76,8 @@ export class SandboxOutputs extends React.Component<SandboxOutputsProps> {
this.sendPropsToFrame(); this.sendPropsToFrame();
} }
async componentDidUpdate(prevProps: SandboxOutputsProps): Promise<void> { componentDidUpdate(): void {
this.sendPropsToFrame(); this.sendPropsToFrame();
if (
this.props.pendingSnapshotRequest &&
prevProps.pendingSnapshotRequest !== this.props.pendingSnapshotRequest &&
this.props.pendingSnapshotRequest.notebookContentRef === this.props.contentRef &&
this.nodeRef?.current
) {
const boundingClientRect = this.nodeRef.current.getBoundingClientRect();
try {
const { data } = (await postRobot.send(
this.childWindow,
"snapshotRequest",
this.props.pendingSnapshotRequest
)) as { data: SnapshotResponse };
if (this.props.pendingSnapshotRequest.type === "notebook") {
if (data.imageSrc === undefined) {
this.props.storeSnapshotFragment(this.props.id, {
image: undefined,
boundingClientRect: boundingClientRect,
requestId: data.requestId,
});
return;
}
const image = new Image();
image.src = data.imageSrc;
image.onload = () => {
this.props.storeSnapshotFragment(this.props.id, {
image,
boundingClientRect: boundingClientRect,
requestId: data.requestId,
});
};
} else if (this.props.pendingSnapshotRequest.type === "celloutput") {
this.props.storeNotebookSnapshot(data.imageSrc, this.props.pendingSnapshotRequest.requestId);
}
} catch (error) {
this.props.notebookSnapshotError(error.message);
}
}
} }
} }
@@ -147,7 +85,7 @@ export const makeMapStateToProps = (
initialState: AppState, initialState: AppState,
ownProps: ComponentProps ownProps: ComponentProps
): ((state: AppState) => StateProps) => { ): ((state: AppState) => StateProps) => {
const mapStateToProps = (state: CdbAppState): StateProps => { const mapStateToProps = (state: AppState): StateProps => {
let outputs = Immutable.List(); let outputs = Immutable.List();
let hidden = false; let hidden = false;
let expanded = false; let expanded = false;
@@ -164,17 +102,7 @@ export const makeMapStateToProps = (
} }
} }
// Determine whether to take a snapshot or not return { outputs, hidden, expanded };
let pendingSnapshotRequest = state.cdb.pendingSnapshotRequest;
if (
pendingSnapshotRequest &&
pendingSnapshotRequest.type === "celloutput" &&
pendingSnapshotRequest.cellId !== id
) {
pendingSnapshotRequest = undefined;
}
return { outputs, hidden, expanded, pendingSnapshotRequest };
}; };
return mapStateToProps; return mapStateToProps;
}; };
@@ -197,11 +125,6 @@ export const makeMapDispatchToProps = (
}) })
); );
}, },
storeSnapshotFragment: (cellId: string, snapshot: SnapshotFragment) =>
dispatch(cdbActions.storeCellOutputSnapshot({ cellId, snapshot })),
storeNotebookSnapshot: (imageSrc: string, requestId: string) =>
dispatch(cdbActions.storeNotebookSnapshot({ imageSrc, requestId })),
notebookSnapshotError: (error: string) => dispatch(cdbActions.notebookSnapshotError({ error })),
}; };
}; };
return mapDispatchToProps; return mapDispatchToProps;

View File

@@ -1,15 +1,15 @@
import { NotebookUtil } from "./NotebookUtil";
import * as GitHubUtils from "../../Utils/GitHubUtils";
import { import {
CodeCellParams,
ImmutableNotebook, ImmutableNotebook,
MediaBundle,
CodeCellParams,
MarkdownCellParams,
makeCodeCell, makeCodeCell,
makeMarkdownCell, makeMarkdownCell,
makeNotebookRecord, makeNotebookRecord,
MarkdownCellParams,
MediaBundle,
} from "@nteract/commutable"; } from "@nteract/commutable";
import { List, Map } from "immutable"; import { List, Map } from "immutable";
import * as GitHubUtils from "../../Utils/GitHubUtils";
import { NotebookUtil } from "./NotebookUtil";
const fileName = "file"; const fileName = "file";
const notebookName = "file.ipynb"; const notebookName = "file.ipynb";
@@ -131,7 +131,7 @@ describe("NotebookUtil", () => {
describe("findFirstCodeCellWithDisplay", () => { describe("findFirstCodeCellWithDisplay", () => {
it("works for Notebook file", () => { it("works for Notebook file", () => {
const notebookObject = notebookRecord as ImmutableNotebook; const notebookObject = notebookRecord as ImmutableNotebook;
expect(NotebookUtil.findCodeCellWithDisplay(notebookObject)[0]).toEqual("1"); expect(NotebookUtil.findFirstCodeCellWithDisplay(notebookObject)).toEqual(1);
}); });
}); });
}); });

View File

@@ -1,11 +1,8 @@
import { ImmutableCodeCell, ImmutableNotebook } from "@nteract/commutable";
import domtoimage from "dom-to-image";
import Html2Canvas from "html2canvas";
import path from "path"; import path from "path";
import * as GitHubUtils from "../../Utils/GitHubUtils"; import { ImmutableNotebook, ImmutableCodeCell } from "@nteract/commutable";
import * as StringUtils from "../../Utils/StringUtils";
import { SnapshotFragment } from "./NotebookComponent/types";
import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem"; import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem";
import * as StringUtils from "../../Utils/StringUtils";
import * as GitHubUtils from "../../Utils/GitHubUtils";
// Must match rx-jupyter' FileType // Must match rx-jupyter' FileType
export type FileType = "directory" | "file" | "notebook"; export type FileType = "directory" | "file" | "notebook";
@@ -144,175 +141,23 @@ export class NotebookUtil {
return `${basePath}${newName}`; return `${basePath}${newName}`;
} }
public static hasCodeCellOutput(cell: ImmutableCodeCell): boolean { public static findFirstCodeCellWithDisplay(notebookObject: ImmutableNotebook): number {
return !!cell?.outputs?.find( let codeCellIndex = 0;
(output) => for (let i = 0; i < notebookObject.cellOrder.size; i++) {
output.output_type === "display_data" || const cellId = notebookObject.cellOrder.get(i);
output.output_type === "execute_result" || if (cellId) {
output.output_type === "stream" const cell = notebookObject.cellMap.get(cellId);
); if (cell?.cell_type === "code") {
} const displayOutput = (cell as ImmutableCodeCell)?.outputs?.find(
(output) => output.output_type === "display_data" || output.output_type === "execute_result"
/** );
* Find code cells with display if (displayOutput) {
* @param notebookObject return codeCellIndex;
* @returns array of cell ids }
*/ codeCellIndex++;
public static findCodeCellWithDisplay(notebookObject: ImmutableNotebook): string[] {
return notebookObject.cellOrder.reduce((accumulator: string[], cellId) => {
const cell = notebookObject.cellMap.get(cellId);
if (cell?.cell_type === "code") {
if (NotebookUtil.hasCodeCellOutput(cell as ImmutableCodeCell)) {
accumulator.push(cellId);
} }
} }
return accumulator; }
}, []); throw new Error("Output does not exist for any of the cells.");
}
public static takeScreenshotHtml2Canvas = (
target: HTMLElement,
aspectRatio: number,
subSnapshots: SnapshotFragment[],
downloadFilename?: string
): Promise<{ imageSrc: string | undefined }> => {
return new Promise(async (resolve, reject) => {
try {
// target.scrollIntoView();
const canvas = await Html2Canvas(target, {
useCORS: true,
allowTaint: true,
scale: 1,
logging: false,
});
//redraw canvas to fit aspect ratio
const originalImageData = canvas.toDataURL();
const width = parseInt(canvas.style.width.split("px")[0]);
if (aspectRatio) {
canvas.height = width * aspectRatio;
}
if (originalImageData === "data:,") {
// Empty output
resolve({ imageSrc: undefined });
return;
}
const context = canvas.getContext("2d");
const image = new Image();
image.src = originalImageData;
image.onload = () => {
if (!context) {
reject(new Error("No context to draw on"));
return;
}
context.drawImage(image, 0, 0);
// draw sub images
if (subSnapshots) {
const parentRect = target.getBoundingClientRect();
subSnapshots.forEach((snapshot) => {
if (snapshot.image) {
context.drawImage(
snapshot.image,
snapshot.boundingClientRect.x - parentRect.x,
snapshot.boundingClientRect.y - parentRect.y
);
}
});
}
resolve({ imageSrc: canvas.toDataURL() });
if (downloadFilename) {
NotebookUtil.downloadFile(
downloadFilename,
canvas.toDataURL("image/png").replace("image/png", "image/octet-stream")
);
}
};
} catch (error) {
return reject(error);
}
});
};
public static takeScreenshotDomToImage = (
target: HTMLElement,
aspectRatio: number,
subSnapshots: SnapshotFragment[],
downloadFilename?: string
): Promise<{ imageSrc?: string }> => {
return new Promise(async (resolve, reject) => {
// target.scrollIntoView();
try {
const filter = (node: Node): boolean => {
const excludedList = ["IMG", "CANVAS"];
return !excludedList.includes((node as HTMLElement).tagName);
};
const originalImageData = await domtoimage.toPng(target, { filter });
if (originalImageData === "data:,") {
// Empty output
resolve({});
return;
}
const baseImage = new Image();
baseImage.src = originalImageData;
baseImage.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = baseImage.width;
canvas.height = aspectRatio !== undefined ? baseImage.width * aspectRatio : baseImage.width;
const context = canvas.getContext("2d");
if (!context) {
reject(new Error("No Canvas to draw on"));
return;
}
// White background otherwise image is transparent
context.fillStyle = "white";
context.fillRect(0, 0, baseImage.width, baseImage.height);
context.drawImage(baseImage, 0, 0);
// draw sub images
if (subSnapshots) {
const parentRect = target.getBoundingClientRect();
subSnapshots.forEach((snapshot) => {
if (snapshot.image) {
context.drawImage(
snapshot.image,
snapshot.boundingClientRect.x - parentRect.x,
snapshot.boundingClientRect.y - parentRect.y
);
}
});
}
resolve({ imageSrc: canvas.toDataURL() });
if (downloadFilename) {
NotebookUtil.downloadFile(
downloadFilename,
canvas.toDataURL("image/png").replace("image/png", "image/octet-stream")
);
}
};
} catch (error) {
reject(error);
}
});
};
private static downloadFile(filename: string, content: string): void {
const link = document.createElement("a");
link.href = content;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} }
} }

View File

@@ -1,48 +0,0 @@
import { actions, createContentRef, createKernelRef, KernelRef } from "@nteract/core";
import * as React from "react";
import { Provider } from "react-redux";
import { ReactAdapter } from "../../../Bindings/ReactBindingHandler";
import {
NotebookComponentBootstrapper,
NotebookComponentBootstrapperOptions,
} from "../NotebookComponent/NotebookComponentBootstrapper";
import SchemaAnalyzer from "./SchemaAnalyzer";
import { SchemaAnalyzerNotebook } from "./SchemaAnalyzerUtils";
export class SchemaAnalyzerAdapter extends NotebookComponentBootstrapper implements ReactAdapter {
public parameters: unknown;
private kernelRef: KernelRef;
constructor(options: NotebookComponentBootstrapperOptions, private databaseId: string, private collectionId: string) {
super(options);
if (!this.contentRef) {
this.contentRef = createContentRef();
this.kernelRef = createKernelRef();
this.getStore().dispatch(
actions.fetchContent({
filepath: SchemaAnalyzerNotebook.path,
params: {},
kernelRef: this.kernelRef,
contentRef: this.contentRef,
})
);
}
}
public renderComponent(): JSX.Element {
const props = {
contentRef: this.contentRef,
kernelRef: this.kernelRef,
databaseId: this.databaseId,
collectionId: this.collectionId,
};
return (
<Provider store={this.getStore()}>
<SchemaAnalyzer {...props} />;
</Provider>
);
}
}

View File

@@ -1,101 +0,0 @@
import {
DefaultButton,
Icon,
IRenderFunction,
ITextFieldProps,
PrimaryButton,
Stack,
TextField,
TooltipHost,
} from "@fluentui/react";
import * as React from "react";
type SchemaAnalyzerHeaderProps = {
isKernelIdle: boolean;
isKernelBusy: boolean;
onSampleSizeUpdated: (sampleSize: string) => void;
onAnalyzeButtonClick: (filter: string, sampleSize: string) => void;
};
export const DefaultFilter = "";
export const DefaultSampleSize = "1000";
const FilterPlaceholder = "{ field: 'value' }";
const SampleSizePlaceholder = "1000";
const MinSampleSize = 1;
const MaxSampleSize = 5000;
export const SchemaAnalyzerHeader = ({
isKernelIdle,
isKernelBusy,
onSampleSizeUpdated,
onAnalyzeButtonClick,
}: SchemaAnalyzerHeaderProps): JSX.Element => {
const [filter, setFilter] = React.useState<string>(DefaultFilter);
const [sampleSize, setSampleSize] = React.useState<string>(DefaultSampleSize);
return (
<Stack horizontal tokens={{ childrenGap: 10 }}>
<Stack.Item grow>
<TextField
value={filter}
onChange={(event, newValue) => setFilter(newValue)}
label="Filter"
placeholder={FilterPlaceholder}
disabled={!isKernelIdle}
/>
</Stack.Item>
<Stack.Item>
<TextField
value={sampleSize}
onChange={(event, newValue) => {
const num = Number(newValue);
if (!newValue || (num >= MinSampleSize && num <= MaxSampleSize)) {
setSampleSize(newValue);
onSampleSizeUpdated(newValue);
}
}}
label="Sample size"
onRenderLabel={onSampleSizeWrapDefaultLabelRenderer}
placeholder={SampleSizePlaceholder}
disabled={!isKernelIdle}
/>
</Stack.Item>
<Stack.Item align="end">
<PrimaryButton
text={isKernelBusy ? "Analyzing..." : "Analyze"}
onClick={() => {
const sampleSizeToUse = sampleSize || DefaultSampleSize;
setSampleSize(sampleSizeToUse);
onAnalyzeButtonClick(filter, sampleSizeToUse);
}}
disabled={!isKernelIdle}
styles={{ root: { width: 120 } }}
/>
</Stack.Item>
<Stack.Item align="end">
<DefaultButton
text="Reset"
disabled={!isKernelIdle}
onClick={() => {
setFilter(DefaultFilter);
setSampleSize(DefaultSampleSize);
}}
/>
</Stack.Item>
</Stack>
);
};
const onSampleSizeWrapDefaultLabelRenderer = (
props: ITextFieldProps,
defaultRender: IRenderFunction<ITextFieldProps>
): JSX.Element => {
return (
<Stack horizontal verticalAlign="center" tokens={{ childrenGap: 4 }}>
<span>{defaultRender(props)}</span>
<TooltipHost content={`Number of documents to sample between ${MinSampleSize} and ${MaxSampleSize}`}>
<Icon iconName="Info" ariaLabel="Info" />
</TooltipHost>
</Stack>
);
};

View File

@@ -1,39 +0,0 @@
import { FontIcon, PrimaryButton, Spinner, SpinnerSize, Stack, Text } from "@fluentui/react";
import * as React from "react";
type SchemaAnalyzerSplashScreenProps = {
isKernelIdle: boolean;
isKernelBusy: boolean;
onAnalyzeButtonClick: () => void;
};
export const SchemaAnalyzerSplashScreen = ({
isKernelIdle,
isKernelBusy,
onAnalyzeButtonClick,
}: SchemaAnalyzerSplashScreenProps): JSX.Element => {
return (
<Stack horizontalAlign="center" tokens={{ childrenGap: 20, padding: 20 }}>
<Stack.Item>
<FontIcon iconName="Chart" style={{ fontSize: 100, color: "#43B1E5", marginTop: 40 }} />
</Stack.Item>
<Stack.Item>
<Text variant="xxLarge">Explore your schema</Text>
</Stack.Item>
<Stack.Item>
<Text variant="large">
Quickly visualize your schema to infer the frequency, types and ranges of fields in your data set.
</Text>
</Stack.Item>
<Stack.Item>
<PrimaryButton
styles={{ root: { fontSize: 18, padding: 30 } }}
text={isKernelBusy ? "Analyzing..." : "Analyze Schema"}
onClick={() => onAnalyzeButtonClick()}
disabled={!isKernelIdle}
/>
</Stack.Item>
<Stack.Item>{isKernelBusy && <Spinner size={SpinnerSize.large} />}</Stack.Item>
</Stack>
);
};

View File

@@ -1,44 +0,0 @@
import { Notebook } from "@nteract/commutable";
import { IContent } from "@nteract/types";
import * as InMemoryContentProviderUtils from "../NotebookComponent/ContentProviders/InMemoryContentProviderUtils";
const notebookName = "schema-analyzer-component-notebook.ipynb";
const notebookPath = InMemoryContentProviderUtils.toContentUri(notebookName);
const notebook: Notebook = {
cells: [
{
cell_type: "code",
metadata: {},
execution_count: 0,
outputs: [],
source: "",
},
],
metadata: {
kernelspec: {
displayName: "Mongo",
language: "mongocli",
name: "mongo",
},
language_info: {
file_extension: "ipynb",
mimetype: "application/json",
name: "mongo",
version: "1.0",
},
},
nbformat: 4,
nbformat_minor: 4,
};
export const SchemaAnalyzerNotebook: IContent<"notebook"> = {
name: notebookName,
path: notebookPath,
type: "notebook",
writable: true,
created: "",
last_modified: "",
mimetype: "application/x-ipynb+json",
content: notebook,
format: "json",
};

View File

@@ -1,4 +1,4 @@
.schemaAnalyzer { .schemaAnalyzerComponent {
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;

View File

@@ -1,26 +1,22 @@
import { Spinner, SpinnerSize, Stack } from "@fluentui/react"; import { FontIcon, PrimaryButton, Spinner, SpinnerSize, Stack, Text, TextField } from "@fluentui/react";
import { ImmutableExecuteResult, ImmutableOutput } from "@nteract/commutable"; import { ImmutableOutput } from "@nteract/commutable";
import { actions, AppState, ContentRef, KernelRef, selectors } from "@nteract/core"; import { actions, AppState, ContentRef, KernelRef, selectors } from "@nteract/core";
import Immutable from "immutable"; import Immutable from "immutable";
import * as React from "react"; import * as React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Dispatch } from "redux"; import { Dispatch } from "redux";
import { Action } from "../../../Shared/Telemetry/TelemetryConstants";
import { traceFailure, traceStart, traceSuccess } from "../../../Shared/Telemetry/TelemetryProcessor";
import loadTransform from "../NotebookComponent/loadTransform"; import loadTransform from "../NotebookComponent/loadTransform";
import SandboxOutputs from "../NotebookRenderer/outputs/SandboxOutputs"; import SandboxOutputs from "../NotebookRenderer/outputs/SandboxOutputs";
import "./SchemaAnalyzer.less"; import "./SchemaAnalyzerComponent.less";
import { DefaultFilter, DefaultSampleSize, SchemaAnalyzerHeader } from "./SchemaAnalyzerHeader";
import { SchemaAnalyzerSplashScreen } from "./SchemaAnalyzerSplashScreen";
interface SchemaAnalyzerPureProps { interface SchemaAnalyzerComponentPureProps {
contentRef: ContentRef; contentRef: ContentRef;
kernelRef: KernelRef; kernelRef: KernelRef;
databaseId: string; databaseId: string;
collectionId: string; collectionId: string;
} }
interface SchemaAnalyzerDispatchProps { interface SchemaAnalyzerComponentDispatchProps {
runCell: (contentRef: ContentRef, cellId: string) => void; runCell: (contentRef: ContentRef, cellId: string) => void;
addTransform: (transform: React.ComponentType & { MIMETYPE: string }) => void; addTransform: (transform: React.ComponentType & { MIMETYPE: string }) => void;
updateCell: (text: string, id: string, contentRef: ContentRef) => void; updateCell: (text: string, id: string, contentRef: ContentRef) => void;
@@ -28,23 +24,25 @@ interface SchemaAnalyzerDispatchProps {
type OutputType = "rich" | "json"; type OutputType = "rich" | "json";
interface SchemaAnalyzerState { interface SchemaAnalyzerComponentState {
outputType: OutputType; outputType: OutputType;
filter?: string;
isFiltering: boolean; isFiltering: boolean;
sampleSize: string;
} }
type SchemaAnalyzerProps = SchemaAnalyzerPureProps & StateProps & SchemaAnalyzerDispatchProps; type SchemaAnalyzerComponentProps = SchemaAnalyzerComponentPureProps &
StateProps &
SchemaAnalyzerComponentDispatchProps;
export class SchemaAnalyzer extends React.Component<SchemaAnalyzerProps, SchemaAnalyzerState> { export class SchemaAnalyzerComponent extends React.Component<
private clickAnalyzeTelemetryStartKey: number; SchemaAnalyzerComponentProps,
SchemaAnalyzerComponentState
constructor(props: SchemaAnalyzerProps) { > {
constructor(props: SchemaAnalyzerComponentProps) {
super(props); super(props);
this.state = { this.state = {
outputType: "rich", outputType: "rich",
isFiltering: false, isFiltering: false,
sampleSize: DefaultSampleSize,
}; };
} }
@@ -52,59 +50,34 @@ export class SchemaAnalyzer extends React.Component<SchemaAnalyzerProps, SchemaA
loadTransform(this.props); loadTransform(this.props);
} }
private onAnalyzeButtonClick = (filter: string = DefaultFilter, sampleSize: string = this.state.sampleSize) => { private onFilterTextFieldChange = (
event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
newValue?: string
): void => {
this.setState({
filter: newValue,
});
};
private onAnalyzeButtonClick = () => {
const query = { const query = {
command: "listSchema", command: "listSchema",
database: this.props.databaseId, database: this.props.databaseId,
collection: this.props.collectionId, collection: this.props.collectionId,
outputType: this.state.outputType, outputType: this.state.outputType,
filter, filter: this.state.filter,
sampleSize,
}; };
this.setState({ if (this.state.filter) {
isFiltering: true, this.setState({
}); isFiltering: true,
});
}
this.props.updateCell(JSON.stringify(query), this.props.firstCellId, this.props.contentRef); this.props.updateCell(JSON.stringify(query), this.props.firstCellId, this.props.contentRef);
this.clickAnalyzeTelemetryStartKey = traceStart(Action.SchemaAnalyzerClickAnalyze, {
database: this.props.databaseId,
collection: this.props.collectionId,
sampleSize,
});
this.props.runCell(this.props.contentRef, this.props.firstCellId); this.props.runCell(this.props.contentRef, this.props.firstCellId);
}; };
private traceClickAnalyzeComplete = (kernelStatus: string, outputs: Immutable.List<ImmutableOutput>) => {
/**
* CosmosMongoKernel always returns 1st output as "text/html"
* This output can be an error stack or information about how many documents were sampled
*/
let firstTextHtmlOutput: string;
if (outputs.size > 0 && outputs.get(0).output_type === "execute_result") {
const executeResult = outputs.get(0) as ImmutableExecuteResult;
firstTextHtmlOutput = executeResult.data["text/html"];
}
const data = {
database: this.props.databaseId,
collection: this.props.collectionId,
firstTextHtmlOutput,
sampleSize: this.state.sampleSize,
numOfOutputs: outputs.size,
kernelStatus,
};
// Only in cases where CosmosMongoKernel runs into an error we get a single output
if (outputs.size === 1) {
traceFailure(Action.SchemaAnalyzerClickAnalyze, data, this.clickAnalyzeTelemetryStartKey);
} else {
traceSuccess(Action.SchemaAnalyzerClickAnalyze, data, this.clickAnalyzeTelemetryStartKey);
}
};
render(): JSX.Element { render(): JSX.Element {
const { firstCellId: id, contentRef, kernelStatus, outputs } = this.props; const { firstCellId: id, contentRef, kernelStatus, outputs } = this.props;
if (!id) { if (!id) {
@@ -113,22 +86,31 @@ export class SchemaAnalyzer extends React.Component<SchemaAnalyzerProps, SchemaA
const isKernelBusy = kernelStatus === "busy"; const isKernelBusy = kernelStatus === "busy";
const isKernelIdle = kernelStatus === "idle"; const isKernelIdle = kernelStatus === "idle";
const showSchemaOutput = isKernelIdle && outputs?.size > 0; const showSchemaOutput = isKernelIdle && outputs.size > 0;
if (showSchemaOutput && this.clickAnalyzeTelemetryStartKey) {
this.traceClickAnalyzeComplete(kernelStatus, outputs);
this.clickAnalyzeTelemetryStartKey = undefined;
}
return ( return (
<div className="schemaAnalyzer"> <div className="schemaAnalyzerComponent">
<Stack tokens={{ childrenGap: 20, padding: 20 }}> <Stack horizontalAlign="center" tokens={{ childrenGap: 20, padding: 20 }}>
<SchemaAnalyzerHeader <Stack.Item grow styles={{ root: { display: "contents" } }}>
isKernelIdle={isKernelIdle} <Stack horizontal tokens={{ childrenGap: 20 }} styles={{ root: { width: "100%" } }}>
isKernelBusy={isKernelBusy} <Stack.Item grow align="end">
onSampleSizeUpdated={(sampleSize) => this.setState({ sampleSize })} <TextField
onAnalyzeButtonClick={this.onAnalyzeButtonClick} value={this.state.filter}
/> onChange={this.onFilterTextFieldChange}
label="Filter"
placeholder="{ field: 'value' }"
disabled={!isKernelIdle}
/>
</Stack.Item>
<Stack.Item align="end">
<PrimaryButton
text={isKernelBusy ? "Analyzing..." : "Analyze"}
onClick={this.onAnalyzeButtonClick}
disabled={!isKernelIdle}
/>
</Stack.Item>
</Stack>
</Stack.Item>
{showSchemaOutput ? ( {showSchemaOutput ? (
<SandboxOutputs <SandboxOutputs
@@ -138,13 +120,32 @@ export class SchemaAnalyzer extends React.Component<SchemaAnalyzerProps, SchemaA
outputClassName="schema-analyzer-cell-output" outputClassName="schema-analyzer-cell-output"
/> />
) : this.state.isFiltering ? ( ) : this.state.isFiltering ? (
<Spinner styles={{ root: { marginTop: 40 } }} size={SpinnerSize.large} /> <Stack.Item>
{isKernelBusy && <Spinner styles={{ root: { marginTop: 40 } }} size={SpinnerSize.large} />}
</Stack.Item>
) : ( ) : (
<SchemaAnalyzerSplashScreen <>
isKernelIdle={isKernelIdle} <Stack.Item>
isKernelBusy={isKernelBusy} <FontIcon iconName="Chart" style={{ fontSize: 100, color: "#43B1E5", marginTop: 40 }} />
onAnalyzeButtonClick={this.onAnalyzeButtonClick} </Stack.Item>
/> <Stack.Item>
<Text variant="xxLarge">Explore your schema</Text>
</Stack.Item>
<Stack.Item>
<Text variant="large">
Quickly visualize your schema to infer the frequency, types and ranges of fields in your data set.
</Text>
</Stack.Item>
<Stack.Item>
<PrimaryButton
styles={{ root: { fontSize: 18, padding: 30 } }}
text={isKernelBusy ? "Analyzing..." : "Analyze Schema"}
onClick={this.onAnalyzeButtonClick}
disabled={kernelStatus !== "idle"}
/>
</Stack.Item>
<Stack.Item>{isKernelBusy && <Spinner size={SpinnerSize.large} />}</Stack.Item>
</>
)} )}
</Stack> </Stack>
</div> </div>
@@ -228,4 +229,4 @@ const makeMapDispatchToProps = () => {
return mapDispatchToProps; return mapDispatchToProps;
}; };
export default connect(makeMapStateToProps, makeMapDispatchToProps)(SchemaAnalyzer); export default connect(makeMapStateToProps, makeMapDispatchToProps)(SchemaAnalyzerComponent);

View File

@@ -0,0 +1,88 @@
import { Notebook } from "@nteract/commutable";
import { actions, createContentRef, createKernelRef, IContent, KernelRef } from "@nteract/core";
import * as React from "react";
import { Provider } from "react-redux";
import { ReactAdapter } from "../../../Bindings/ReactBindingHandler";
import {
NotebookComponentBootstrapper,
NotebookComponentBootstrapperOptions,
} from "../NotebookComponent/NotebookComponentBootstrapper";
import SchemaAnalyzerComponent from "./SchemaAnalyzerComponent";
export class SchemaAnalyzerComponentAdapter extends NotebookComponentBootstrapper implements ReactAdapter {
public parameters: unknown;
private kernelRef: KernelRef;
constructor(options: NotebookComponentBootstrapperOptions, private databaseId: string, private collectionId: string) {
super(options);
if (!this.contentRef) {
this.contentRef = createContentRef();
this.kernelRef = createKernelRef();
const notebook: Notebook = {
cells: [
{
cell_type: "code",
metadata: {},
execution_count: 0,
outputs: [],
source: "",
},
],
metadata: {
kernelspec: {
displayName: "Mongo",
language: "mongocli",
name: "mongo",
},
language_info: {
file_extension: "ipynb",
mimetype: "application/json",
name: "mongo",
version: "1.0",
},
},
nbformat: 4,
nbformat_minor: 4,
};
const model: IContent<"notebook"> = {
name: "schema-analyzer-component-notebook.ipynb",
path: "schema-analyzer-component-notebook.ipynb",
type: "notebook",
writable: true,
created: "",
last_modified: "",
mimetype: "application/x-ipynb+json",
content: notebook,
format: "json",
};
// Request fetching notebook content
this.getStore().dispatch(
actions.fetchContentFulfilled({
filepath: model.path,
model,
kernelRef: this.kernelRef,
contentRef: this.contentRef,
})
);
}
}
public renderComponent(): JSX.Element {
const props = {
contentRef: this.contentRef,
kernelRef: this.kernelRef,
databaseId: this.databaseId,
collectionId: this.collectionId,
};
return (
<Provider store={this.getStore()}>
<SchemaAnalyzerComponent {...props} />;
</Provider>
);
}
}

View File

@@ -17,6 +17,7 @@ describe("OpenActions", () => {
explorer.onNewCollectionClicked = jest.fn(); explorer.onNewCollectionClicked = jest.fn();
explorer.cassandraAddCollectionPane = {} as CassandraAddCollectionPane; explorer.cassandraAddCollectionPane = {} as CassandraAddCollectionPane;
explorer.cassandraAddCollectionPane.open = jest.fn(); explorer.cassandraAddCollectionPane.open = jest.fn();
explorer.closeAllPanes = () => {};
database = { database = {
id: ko.observable("db"), id: ko.observable("db"),

View File

@@ -140,16 +140,19 @@ function openPane(action: ActionContracts.OpenPane, explorer: Explorer) {
action.paneKind === ActionContracts.PaneKind.AddCollection || action.paneKind === ActionContracts.PaneKind.AddCollection ||
(<any>action).paneKind === ActionContracts.PaneKind[ActionContracts.PaneKind.AddCollection] (<any>action).paneKind === ActionContracts.PaneKind[ActionContracts.PaneKind.AddCollection]
) { ) {
explorer.closeAllPanes();
explorer.onNewCollectionClicked(); explorer.onNewCollectionClicked();
} else if ( } else if (
action.paneKind === ActionContracts.PaneKind.CassandraAddCollection || action.paneKind === ActionContracts.PaneKind.CassandraAddCollection ||
(<any>action).paneKind === ActionContracts.PaneKind[ActionContracts.PaneKind.CassandraAddCollection] (<any>action).paneKind === ActionContracts.PaneKind[ActionContracts.PaneKind.CassandraAddCollection]
) { ) {
explorer.closeAllPanes();
explorer.cassandraAddCollectionPane.open(); explorer.cassandraAddCollectionPane.open();
} else if ( } else if (
action.paneKind === ActionContracts.PaneKind.GlobalSettings || action.paneKind === ActionContracts.PaneKind.GlobalSettings ||
(<any>action).paneKind === ActionContracts.PaneKind[ActionContracts.PaneKind.GlobalSettings] (<any>action).paneKind === ActionContracts.PaneKind[ActionContracts.PaneKind.GlobalSettings]
) { ) {
explorer.closeAllPanes();
explorer.openSettingPane(); explorer.openSettingPane();
} }
} }

View File

@@ -0,0 +1,602 @@
<div data-bind="visible: visible, event: { keydown: onPaneKeyDown }">
<div class="contextual-pane-out" data-bind="setTemplateReady: true, click: cancel, clickBubble: false"></div>
<div class="contextual-pane" data-bind="attr: { id: id }">
<!-- Add collection form -- Start -->
<div class="contextual-pane-in">
<form data-bind="submit: submit" style="height: 100%">
<div
class="paneContentContainer"
role="dialog"
aria-labelledby="containerTitle"
data-bind="template: { name: 'add-collection-inputs' }"
></div>
</form>
</div>
<!-- Add collection form -- End -->
<!-- Loader - Start -->
<div class="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" data-bind="visible: isExecuting">
<img class="dataExplorerLoader" src="/LoadingIndicator_3Squares.gif" />
</div>
<!-- Loader - End -->
</div>
</div>
<script type="text/html" id="add-collection-inputs">
<!-- Add collection header - Start -->
<div class="firstdivbg headerline">
<span id="containerTitle" role="heading" aria-level="2" data-bind="text: title"></span>
<div
class="closeImg"
id="closeBtnAddCollection"
role="button"
aria-label="Add collection close pane"
data-bind="click: cancel, event: { keypress: onCloseKeyPress }"
tabindex="0"
>
<img src="../../../images/close-black.svg" title="Close" alt="Close" />
</div>
</div>
<!-- Add collection header - End -->
<!-- Add collection errors - Start -->
<div class="warningErrorContainer" aria-live="assertive" data-bind="visible: formErrors() && formErrors() !== ''">
<div class="warningErrorContent">
<span><img class="paneErrorIcon" src="/error_red.svg" alt="Error" /></span>
<span class="warningErrorDetailsLinkContainer">
<span class="formErrors" data-bind="text: formErrors, attr: { title: formErrors }"></span>
<a
class="errorLink"
role="link"
data-bind="visible: formErrorsDetails() && formErrorsDetails() !== '' , click: showErrorDetails, event: { keypress: onMoreDetailsKeyPress }"
tabindex="0"
>
More details</a
>
</span>
</div>
</div>
<div class="warningErrorContainer" aria-live="assertive" data-bind="visible: formWarnings() && formWarnings() !== ''">
<div class="warningErrorContent">
<span><img class="paneErrorIcon" src="/warning.svg" alt="Warning" /></span>
<span class="warningErrorDetailsLinkContainer">
<span class="formErrors" data-bind="text: formWarnings, attr: { title: formWarnings }"></span>
</span>
</div>
</div>
<!-- Add collection errors - End -->
<!-- upsell message - start -->
<div
class="infoBoxContainer"
aria-live="assertive"
data-bind="visible: showUpsellMessage && showUpsellMessage() && formErrors && !formErrors()"
>
<div class="infoBoxContent">
<span><img class="infoBoxIcon" src="/info_color.svg" alt="Promo" /></span>
<span class="infoBoxDetails">
<span class="infoBoxMessage" data-bind="text: upsellMessage, attr: { title: upsellMessage }"></span>
<a
class="underlinedLink"
id="linkAddCollection"
data-bind="text: upsellAnchorText, attr: { 'href': upsellAnchorUrl, 'aria-label': upsellMessageAriaLabel }"
target="_blank"
href=""
tabindex="0"
></a>
</span>
</div>
</div>
<!-- upsell message - end -->
<!-- Add collection inputs - Start -->
<div class="paneMainContent" data-bind="visible: !maxCollectionsReached()">
<div data-bind="visible: !isPreferredApiTable()">
<p>
<span class="mandatoryStar">*</span>
<span class="addCollectionLabel">Database id</span>
<span class="infoTooltip" role="tooltip" tabindex="0">
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
<span class="tooltiptext infoTooltipWidth"
>A database is analogous to a namespace. It is the unit of management for a set of containers.</span
>
</span>
</p>
<div class="createNewDatabaseOrUseExisting">
<input
class="createNewDatabaseOrUseExistingRadio"
aria-label="Create new database"
name="databaseType"
type="radio"
role="radio"
id="databaseCreateNew"
data-test="addCollection-createNewDatabase"
tabindex="0"
data-bind="checked: databaseCreateNew, checkedValue: true, attr: { 'aria-checked': databaseCreateNew() ? 'true' : 'false' }"
/>
<span class="createNewDatabaseOrUseExistingSpace" for="databaseCreateNew">Create new</span>
<input
class="createNewDatabaseOrUseExistingRadio"
aria-label="Use existing database"
name="databaseType"
type="radio"
role="radio"
id="databaseUseExisting"
data-test="addCollection-existingDatabase"
tabindex="0"
data-bind="checked: databaseCreateNew, checkedValue: false, attr: { 'aria-checked': !databaseCreateNew() ? 'true' : 'false' }"
/>
<span class="createNewDatabaseOrUseExistingSpace" for="databaseUseExisting">Use existing</span>
</div>
<input
name="newDatabaseId"
id="databaseId"
data-test="addCollection-newDatabaseId"
aria-required="true"
type="text"
autocomplete="off"
pattern="[^/?#\\]*[^/?# \\]"
title="May not end with space nor contain characters '\' '/' '#' '?'"
placeholder="Type a new database id"
size="40"
class="collid"
data-bind="visible: databaseCreateNew, textInput: databaseId, hasFocus: firstFieldHasFocus"
autofocus
/>
<input
name="existingDatabaseId"
id="existingDatabaseId"
data-test="addCollection-existingDatabaseId"
aria-required="true"
type="text"
autocomplete="off"
pattern="[^/?#\\]*[^/?# \\]"
title="May not end with space nor contain characters '\' '/' '#' '?'"
list="databasesList"
placeholder="Choose an existing database"
size="40"
class="collid"
data-bind="visible: !databaseCreateNew(), textInput: databaseId, hasFocus: firstFieldHasFocus"
/>
<datalist id="databasesList" data-bind="foreach: databaseIds" data-bind="visible: databaseCreateNew">
<option data-bind="value: $data"></option>
</datalist>
<!-- Database provisioned throughput - Start -->
<!-- ko if: canConfigureThroughput -->
<div class="databaseProvision" aria-label="Provision database throughput" data-bind="visible: databaseCreateNew">
<input
tabindex="0"
type="checkbox"
data-test="addCollectionPane-databaseSharedThroughput"
id="addCollection-databaseSharedThroughput"
title="Provision database throughput"
data-bind="checked: databaseCreateNewShared"
/>
<span class="databaseProvisionText" for="databaseSharedThroughput">Provision database throughput</span>
<span class="infoTooltip" role="tooltip" tabindex="0">
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
<span class="tooltiptext provisionDatabaseThroughput"
>Provisioned throughput at the database level will be shared across all containers within the
database.</span
>
</span>
</div>
<div data-bind="visible: databaseCreateNewShared() && databaseCreateNew()">
<!-- 1 -->
<throughput-input-autopilot-v3
params="{
testId: 'databaseThroughputValue',
value: throughputDatabase,
minimum: minThroughputRU,
maximum: maxThroughputRU,
isEnabled: databaseCreateNewShared() && databaseCreateNew(),
label: sharedThroughputRangeText,
ariaLabel: sharedThroughputRangeText,
costsVisible: costsVisible,
requestUnitsUsageCost: requestUnitsUsageCost,
spendAckChecked: throughputSpendAck,
spendAckId: 'throughputSpendAck',
spendAckText: throughputSpendAckText,
spendAckVisible: throughputSpendAckVisible,
showAsMandatory: true,
infoBubbleText: ruToolTipText,
throughputAutoPilotRadioId: 'newContainer-databaseThroughput-autoPilotRadio',
throughputProvisionedRadioId: 'newContainer-databaseThroughput-manualRadio',
throughputModeRadioName: 'sharedThroughputModeRadio',
isAutoPilotSelected: isSharedAutoPilotSelected,
maxAutoPilotThroughputSet: sharedAutoPilotThroughput,
autoPilotUsageCost: autoPilotUsageCost,
canExceedMaximumValue: canExceedMaximumValue,
freeTierExceedThroughputTooltip: freeTierExceedThroughputTooltip
}"
>
</throughput-input-autopilot-v3>
</div>
<!-- /ko -->
<!-- Database provisioned throughput - End -->
</div>
<div class="seconddivpadding">
<p>
<span class="mandatoryStar">*</span>
<span class="addCollectionLabel" data-bind="text: collectionIdTitle"></span>
<span class="infoTooltip" role="tooltip" tabindex="0">
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
<span class="tooltiptext infoTooltipWidth"
>Unique identifier for the container and used for id-based routing through REST and all SDKs</span
>
</span>
</p>
<input
name="collectionId"
id="containerId"
data-test="addCollection-collectionId"
type="text"
aria-required="true"
autocomplete="off"
pattern="[^/?#\\]*[^/?# \\]"
title="May not end with space nor contain characters '\' '/' '#' '?'"
placeholder="e.g., Container1"
size="40"
class="textfontclr collid"
data-bind="value: collectionId"
/>
</div>
<!-- Indexing For Shared Throughput - start -->
<div class="seconddivpadding" data-bind="visible: showIndexingOptionsForSharedThroughput() && !isMongo()">
<div
class="useIndexingForSharedThroughput createNewDatabaseOrUseExisting"
aria-label="Indexing For Shared Throughput"
>
<p>
<span class="mandatoryStar">*</span>
<span class="addCollectionLabel">Indexing</span>
</p>
<div>
<input
type="radio"
id="useIndexingForSharedThroughputOn"
name="useIndexingForSharedThroughput"
value="on"
class="createNewDatabaseOrUseExistingRadio"
data-bind="checked: useIndexingForSharedThroughput, checkedValue: true"
/>
<span class="createNewDatabaseOrUseExistingSpace" for="useIndexingForSharedThroughputOn">Automatic</span>
<input
type="radio"
id="useIndexingForSharedThroughputOff"
name="useIndexingForSharedThroughput"
value="off"
class="createNewDatabaseOrUseExistingRadio"
data-bind="checked: useIndexingForSharedThroughput, checkedValue: false"
/>
<span class="createNewDatabaseOrUseExistingSpace" for="useIndexingForSharedThroughputOff">Off</span>
</div>
<p data-bind="visible: useIndexingForSharedThroughput">
All properties in your documents will be indexed by default for flexible and efficient queries.
<a class="errorLink" href="https://aka.ms/cosmos-indexing-policy" target="_blank">Learn more</a>
</p>
<p data-bind="visible: useIndexingForSharedThroughput() === false">
Indexing will be turned off. Recommended if you don't need to run queries or only have key value operations.
<a class="errorLink" href="https://aka.ms/cosmos-indexing-policy" target="_blank">Learn more</a>
</p>
</div>
</div>
<!-- Indexing For Shared Throughput - end -->
<p
class="seconddivpadding"
data-bind="visible: isMongo() && !databaseHasSharedOffer() || container.isFixedCollectionWithSharedThroughputSupported"
>
<span class="mandatoryStar">*</span>
<span class="addCollectionLabel">Storage capacity</span>
<span class="infoTooltip" role="tooltip" tabindex="0">
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
<span class="tooltiptext infoTooltipWidth"
>This is the maximum storage size of the container. Storage is billed per GB based on consumption.</span
>
</span>
</p>
<div class="tabs">
<div
tabindex="0"
data-bind="event: { keydown: onStorageOptionsKeyDown }, visible: isMongo() && !databaseHasSharedOffer() || container.isFixedCollectionWithSharedThroughputSupported"
aria-label="Storage capacity"
>
<!-- Fixed option button - Start -->
<div class="tab">
<input type="radio" id="tab1" name="storage" value="10" class="radio" data-bind="checked: storage" />
<label for="tab1">Fixed (20 GB)</label>
</div>
<!-- Fixed option button - End -->
<!-- Unlimited option button - Start -->
<div class="tab">
<input type="radio" id="tab2" name="storage" value="100" class="radio" data-bind="checked: storage" />
<label for="tab2">Unlimited</label>
</div>
<!-- Unlimited option button - End -->
</div>
<!-- Unlimited Button Content - Start -->
<div class="tabcontent" data-bind="visible: isUnlimitedStorageSelected() || databaseHasSharedOffer()">
<div data-bind="visible: partitionKeyVisible">
<p>
<span class="mandatoryStar">*</span>
<span class="addCollectionLabel" data-bind="text: partitionKeyName"></span>
<span class="infoTooltip" role="tooltip" tabindex="0">
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
<span class="tooltiptext infoTooltipWidth"
>The <span data-bind="text: partitionKeyName"></span> is used to automatically partition data among
multiple servers for scalability. Choose a JSON property name that has a wide range of values and is
likely to have evenly distributed access patterns.</span
>
</span>
</p>
<input
type="text"
id="addCollection-partitionKeyValue"
data-test="addCollection-partitionKeyValue"
aria-required="true"
size="40"
class="textfontclr collid"
data-bind="textInput: partitionKey,
attr: {
placeholder: partitionKeyPlaceholder,
required: partitionKeyVisible(),
pattern: partitionKeyPattern,
title: partitionKeyTitle
}"
/>
</div>
<!-- large parition key - start -->
<div class="largePartitionKey" aria-label="Large Partition Key" data-bind="visible: partitionKeyVisible">
<input
tabindex="0"
type="checkbox"
id="largePartitionKey"
data-test="addCollection-largePartitionKey"
title="Large Partition Key"
data-bind="checked: largePartitionKey"
/>
<span for="largePartitionKey"
>My <span data-bind="text: lowerCasePartitionKeyName"></span> is larger than 100 bytes</span
>
<p
data-bind="visible: largePartitionKey"
class="largePartitionKeyDescription"
data-test="addCollection-largePartitionKeyDescription"
>
Old SDKs do not work with containers that support large
<span data-bind="text: lowerCasePartitionKeyName"></span>s, ensure you are using the right SDK version.
<a class="errorLink" href="https://aka.ms/cosmosdb/pkv2" target="_blank">Learn more</a>
</p>
</div>
<!-- large parition key - end -->
<!-- ko if: canConfigureThroughput -->
<!-- Provision collection throughput checkbox - start -->
<div class="pkPadding" data-bind="visible: databaseHasSharedOffer() && !databaseCreateNew()">
<input
type="checkbox"
id="collectionSharedThroughput"
data-bind="checked: collectionWithThroughputInShared, attr: {title:collectionWithThroughputInSharedTitle}"
/>
<span for="collectionSharedThroughput" data-bind="text: collectionWithThroughputInSharedTitle"></span>
<span class="leftAlignInfoTooltip" role="tooltip" tabindex="0">
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
<span class="tooltiptext sharedCollectionThroughputTooltipWidth"
>You can optionally provision dedicated throughput for a container within a database that has throughput
provisioned. This dedicated throughput amount will not be shared with other containers in the database and
does not count towards the throughput you provisioned for the database. This throughput amount will be
billed in addition to the throughput amount you provisioned at the database level.</span
>
</span>
</div>
<!-- Provision collection throughput checkbox - end -->
<!-- Provision collection throughput spinner - start -->
<div data-bind="visible: displayCollectionThroughput" data-test="addCollection-displayCollectionThroughput">
<!-- 3 -->
<throughput-input-autopilot-v3
params="{
testId: 'collectionThroughputValue',
value: throughputMultiPartition,
minimum: minThroughputRU,
maximum: maxThroughputRU,
isEnabled: displayCollectionThroughput,
label: throughputRangeText,
ariaLabel: throughputRangeText,
costsVisible: costsVisible,
requestUnitsUsageCost: dedicatedRequestUnitsUsageCost,
spendAckChecked: throughputSpendAck,
spendAckId: 'throughputSpendAckCollection',
spendAckText: throughputSpendAckText,
spendAckVisible: throughputSpendAckVisible,
showAsMandatory: true,
infoBubbleText: ruToolTipText,
throughputAutoPilotRadioId: 'newContainer-containerThroughput-autoPilotRadio',
throughputProvisionedRadioId: 'newContainer-containerThroughput-manualRadio',
throughputModeRadioName: 'throughputModeRadioName',
isAutoPilotSelected: isAutoPilotSelected,
maxAutoPilotThroughputSet: autoPilotThroughput,
autoPilotUsageCost: autoPilotUsageCost,
canExceedMaximumValue: canExceedMaximumValue,
freeTierExceedThroughputTooltip: freeTierExceedThroughputTooltip
}"
>
</throughput-input-autopilot-v3>
</div>
<!-- Provision collection throughput spinner - end -->
<!-- /ko -->
<!-- Provision collection throughput - end -->
<!-- Custom indexes for mongo checkbox - start -->
<div class="pkPadding" data-bind="visible: container.isEnableMongoCapabilityPresent()">
<p>
<span class="addCollectionLabel">Indexing</span>
</p>
<input
type="checkbox"
id="mongoWildcardIndex"
title="mongoWildcardIndex"
data-bind="checked: shouldCreateMongoWildcardIndex"
/>
<span>Create a Wildcard Index on all fields</span>
<span class="infoTooltip" role="tooltip" tabindex="0">
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
<span class="tooltiptext mongoWildcardIndexTooltipWidth">
By default, only the field _id is indexed. Creating a wildcard index on all fields will quickly optimize
query performance and is recommended during development.
</span>
</span>
</div>
<!-- Custom indexes for mongo checkbox - end -->
<!-- Enable analytical storage - start -->
<div
class="enableAnalyticalStorage pkPadding"
aria-label="Enable Analytical Store"
data-bind="visible: isSynapseLinkSupported"
>
<div>
<span class="mandatoryStar">*</span>
<span class="addCollectionLabel">Analytical store</span>
<span
class="infoTooltip"
role="tooltip"
tabindex="0"
data-bind="event: { focus: function(data, event) { transferFocus('tooltip1', 'link1') } }"
>
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
<span id="tooltip1" class="tooltiptext infoTooltipWidth" data-bind="event: { mouseout: onMouseOut }">
Enable analytical store capability to perform near real-time analytics on your operational data, without
impacting the performance of transactional workloads. Learn more
<a
id="link1"
class="errorLink"
href="https://aka.ms/analytical-store-overview"
target="_blank"
data-bind="event: { focusout: onFocusOut, keydown: onKeyDown.bind($data, 'largePartitionKey') }"
>here</a
>
</span>
</span>
</div>
<div class="paragraph">
<input
class="enableAnalyticalStorageRadio"
id="enableAnalyticalStorageRadioOn"
name="analyticalStore"
type="radio"
role="radio"
tabindex="0"
data-bind="
disable: showEnableSynapseLink,
checked: isAnalyticalStorageOn,
checkedValue: true,
attr: {
'aria-checked': isAnalyticalStorageOn() ? 'true' : 'false'
}"
/>
<label for="enableAnalyticalStorageRadioOn" class="enableAnalyticalStorageRadioLabel">
<span data-bind="disable: showEnableSynapseLink"> On </span>
</label>
<input
class="enableAnalyticalStorageRadio"
id="enableAnalyticalStorageRadioOff"
name="analyticalStore"
type="radio"
role="radio"
tabindex="0"
data-bind="
disable: showEnableSynapseLink,
checked: isAnalyticalStorageOn,
checkedValue: false,
attr: {
'aria-checked': isAnalyticalStorageOn() ? 'false' : 'true'
}"
/>
<label for="enableAnalyticalStorageRadioOff" class="enableAnalyticalStorageRadioLabel">
<span data-bind="disable: showEnableSynapseLink"> Off </span>
</label>
</div>
<div class="paragraph italic" data-bind="visible: ttl90DaysEnabled() && isAnalyticalStorageOn()">
By default, Analytical Time-to-Live will be configured to retain 90 days of data in the analytical store.
You can configure a custom retention policy in the 'Settings' tab.
<span
><a class="errorLink" href="https://aka.ms/cosmosdb-analytical-ttl" target="_blank">Learn more</a></span
>
</div>
<div class="paragraph" data-bind="visible: showEnableSynapseLink">
Azure Synapse Link is required for creating an analytical store container. Enable Synapse Link for this
Cosmos DB account.
<span><a class="errorLink" href="https://aka.ms/cosmosdb-synapselink" target="_blank">Learn more</a></span>
</div>
<div class="paragraph" data-bind="visible: showEnableSynapseLink">
<button
class="button"
type="button"
data-bind="
click: onEnableSynapseLinkButtonClicked,
disable: isSynapseLinkUpdating,
css: {
enabled: !isSynapseLinkUpdating(),
disabled: isSynapseLinkUpdating
}
"
>
Enable
</button>
</div>
</div>
<!-- Enable analytical storage - end -->
</div>
<!-- Unlimited Button Content - End -->
</div>
<div class="uniqueIndexesContainer" data-bind="visible: uniqueKeysVisible">
<p class="uniqueKeys">
<span class="addCollectionLabel">Unique keys</span>
<span class="uniqueInfoTooltip" role="tooltip" tabindex="0">
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
<span class="uniqueTooltiptext infoTooltipWidth"
>Unique keys provide developers with the ability to add a layer of data integrity to their database. By
creating a unique key policy when a container is created, you ensure the uniqueness of one or more values
per partition key.</span
>
</span>
</p>
<dynamic-list
params="{ listItems: uniqueKeys, placeholder: uniqueKeysPlaceholder(), ariaLabel: 'Write a comma separated path list of unique keys', buttonText: 'Add unique key' }"
>
</dynamic-list>
</div>
</div>
<div class="paneFooter">
<div class="leftpanel-okbut">
<input
name="createCollection"
id="submitBtnAddCollection"
data-test="addCollection-createCollection"
type="submit"
value="OK"
class="btncreatecoll1"
/>
</div>
</div>
<div data-bind="visible: maxCollectionsReached">
<error-display params="{ errorMsg: maxCollectionsReachedMessage }"></error-display>
</div>
<!-- Add collection inputs - End -->
</script>

View File

@@ -0,0 +1,108 @@
import * as Constants from "../../Common/Constants";
import { DatabaseAccount } from "../../Contracts/DataModels";
import { updateUserContext } from "../../UserContext";
import Explorer from "../Explorer";
import AddCollectionPane from "./AddCollectionPane";
const mockDatabaseAccount: DatabaseAccount = {
id: "mock",
kind: "DocumentDB",
location: "",
name: "mock",
properties: {
documentEndpoint: "",
cassandraEndpoint: "",
gremlinEndpoint: "",
tableEndpoint: "",
enableFreeTier: false,
},
type: undefined,
};
const mockFreeTierDatabaseAccount: DatabaseAccount = {
id: "mock",
kind: "DocumentDB",
location: "",
name: "mock",
properties: {
documentEndpoint: "",
cassandraEndpoint: "",
gremlinEndpoint: "",
tableEndpoint: "",
enableFreeTier: true,
},
type: undefined,
};
describe("Add Collection Pane", () => {
describe("isValid()", () => {
it("should be true if graph API and partition key is not /id nor /label", () => {
updateUserContext({
databaseAccount: {
properties: {
capabilities: [{ name: "EnableGremlin" }],
},
} as DatabaseAccount,
});
const explorer = new Explorer();
const addCollectionPane = explorer.addCollectionPane as AddCollectionPane;
addCollectionPane.partitionKey("/blah");
expect(addCollectionPane.isValid()).toBe(true);
});
it("should be false if graph API and partition key is /id or /label", () => {
updateUserContext({
databaseAccount: {
properties: {
capabilities: [{ name: "EnableGremlin" }],
},
} as DatabaseAccount,
});
const explorer = new Explorer();
const addCollectionPane = explorer.addCollectionPane as AddCollectionPane;
addCollectionPane.partitionKey("/id");
expect(addCollectionPane.isValid()).toBe(false);
addCollectionPane.partitionKey("/label");
expect(addCollectionPane.isValid()).toBe(false);
});
it("should be true for any non-graph API with /id or /label partition key", () => {
updateUserContext({
databaseAccount: {
properties: {
capabilities: [{ name: "EnableCassandra" }],
},
} as DatabaseAccount,
});
const explorer = new Explorer();
const addCollectionPane = explorer.addCollectionPane as AddCollectionPane;
addCollectionPane.partitionKey("/id");
expect(addCollectionPane.isValid()).toBe(true);
addCollectionPane.partitionKey("/label");
expect(addCollectionPane.isValid()).toBe(true);
});
it("should display free tier text in upsell messaging", () => {
updateUserContext({ databaseAccount: mockFreeTierDatabaseAccount });
const explorer = new Explorer();
const addCollectionPane = explorer.addCollectionPane as AddCollectionPane;
expect(addCollectionPane.isFreeTierAccount()).toBe(true);
expect(addCollectionPane.upsellMessage()).toContain("With free tier");
expect(addCollectionPane.upsellAnchorUrl()).toBe(Constants.Urls.freeTierInformation);
expect(addCollectionPane.upsellAnchorText()).toBe("Learn more");
});
it("should display standard texr in upsell messaging", () => {
updateUserContext({ databaseAccount: mockDatabaseAccount });
const explorer = new Explorer();
const addCollectionPane = explorer.addCollectionPane as AddCollectionPane;
expect(addCollectionPane.isFreeTierAccount()).toBe(false);
expect(addCollectionPane.upsellMessage()).toContain("Start at");
expect(addCollectionPane.upsellAnchorUrl()).toBe(Constants.Urls.cosmosPricing);
expect(addCollectionPane.upsellAnchorText()).toBe("More details");
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -25,7 +25,6 @@ import { Action } from "../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { userContext } from "../../UserContext"; import { userContext } from "../../UserContext";
import { getCollectionName } from "../../Utils/APITypeUtils"; import { getCollectionName } from "../../Utils/APITypeUtils";
import { isCapabilityEnabled, isServerlessAccount } from "../../Utils/CapabilityUtils";
import { getUpsellMessage } from "../../Utils/PricingUtils"; import { getUpsellMessage } from "../../Utils/PricingUtils";
import { CollapsibleSectionComponent } from "../Controls/CollapsiblePanel/CollapsibleSectionComponent"; import { CollapsibleSectionComponent } from "../Controls/CollapsiblePanel/CollapsibleSectionComponent";
import { ThroughputInput } from "../Controls/ThroughputInput/ThroughputInput"; import { ThroughputInput } from "../Controls/ThroughputInput/ThroughputInput";
@@ -81,7 +80,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
isSharded: userContext.apiType !== "Tables", isSharded: userContext.apiType !== "Tables",
partitionKey: "", partitionKey: "",
enableDedicatedThroughput: false, enableDedicatedThroughput: false,
createMongoWildCardIndex: isCapabilityEnabled("EnableMongo"), createMongoWildCardIndex: true,
useHashV2: false, useHashV2: false,
enableAnalyticalStore: false, enableAnalyticalStore: false,
uniqueKeys: [], uniqueKeys: [],
@@ -182,7 +181,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
} }
/> />
{!isServerlessAccount() && ( {!this.isServerlessAccount() && (
<Stack horizontal> <Stack horizontal>
<Checkbox <Checkbox
label={`Share throughput across ${getCollectionName(true).toLocaleLowerCase()}`} label={`Share throughput across ${getCollectionName(true).toLocaleLowerCase()}`}
@@ -207,7 +206,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
</Stack> </Stack>
)} )}
{!isServerlessAccount() && this.state.isSharedThroughputChecked && ( {!this.isServerlessAccount() && this.state.isSharedThroughputChecked && (
<ThroughputInput <ThroughputInput
showFreeTierExceedThroughputTooltip={ showFreeTierExceedThroughputTooltip={
this.isFreeTierAccount() && !this.props.explorer.isFirstResourceCreated() this.isFreeTierAccount() && !this.props.explorer.isFirstResourceCreated()
@@ -396,7 +395,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
onChange={(event: React.ChangeEvent<HTMLInputElement>) => { onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
if ( if (
userContext.apiType !== "Mongo" && userContext.apiType !== "Mongo" &&
!this.state.partitionKey && this.state.partitionKey === "" &&
!event.target.value.startsWith("/") !event.target.value.startsWith("/")
) { ) {
this.setState({ partitionKey: "/" + event.target.value }); this.setState({ partitionKey: "/" + event.target.value });
@@ -408,7 +407,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
</Stack> </Stack>
)} )}
{!isServerlessAccount() && !this.state.createNewDatabase && this.isSelectedDatabaseSharedThroughput() && ( {!this.isServerlessAccount() && !this.state.createNewDatabase && this.isSelectedDatabaseSharedThroughput() && (
<Stack horizontal verticalAlign="center"> <Stack horizontal verticalAlign="center">
<Checkbox <Checkbox
label={`Provision dedicated throughput for this ${getCollectionName().toLocaleLowerCase()}`} label={`Provision dedicated throughput for this ${getCollectionName().toLocaleLowerCase()}`}
@@ -526,7 +525,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
}} }}
> >
<Stack className="panelGroupSpacing" id="collapsibleSectionContent"> <Stack className="panelGroupSpacing" id="collapsibleSectionContent">
{isCapabilityEnabled("EnableMongo") && ( {userContext.databaseAccount.properties.capabilities.find((c) => c.name === "EnableMongo") && (
<Stack className="panelGroupSpacing"> <Stack className="panelGroupSpacing">
<Stack horizontal> <Stack horizontal>
<span className="mandatoryStar">*&nbsp;</span> <span className="mandatoryStar">*&nbsp;</span>
@@ -751,8 +750,14 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
return userContext.databaseAccount?.properties?.enableFreeTier; return userContext.databaseAccount?.properties?.enableFreeTier;
} }
private isServerlessAccount(): boolean {
return userContext.databaseAccount.properties?.capabilities?.some(
(capability) => capability.name === Constants.CapabilityNames.EnableServerless
);
}
private getSharedThroughputDefault(): boolean { private getSharedThroughputDefault(): boolean {
return userContext.subscriptionType !== SubscriptionType.EA && !isServerlessAccount(); return userContext.subscriptionType !== SubscriptionType.EA && !this.isServerlessAccount();
} }
private getFreeTierIndexingText(): string { private getFreeTierIndexingText(): string {
@@ -790,7 +795,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
} }
private shouldShowCollectionThroughputInput(): boolean { private shouldShowCollectionThroughputInput(): boolean {
if (isServerlessAccount()) { if (this.isServerlessAccount()) {
return false; return false;
} }
@@ -820,7 +825,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
return false; return false;
} }
if (isServerlessAccount()) { if (this.isServerlessAccount()) {
return false; return false;
} }
@@ -846,7 +851,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
return true; return true;
} }
return properties.capabilities?.some( return properties.capabilities.some(
(capability) => capability.name === Constants.CapabilityNames.EnableStorageAnalytics (capability) => capability.name === Constants.CapabilityNames.EnableStorageAnalytics
); );
} }
@@ -911,10 +916,6 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
} }
private getAnalyticalStorageTtl(): number { private getAnalyticalStorageTtl(): number {
if (!this.isSynapseLinkEnabled()) {
return undefined;
}
if (!this.shouldShowAnalyticalStoreOptions()) { if (!this.shouldShowAnalyticalStoreOptions()) {
return undefined; return undefined;
} }

View File

@@ -0,0 +1,174 @@
<div data-bind="visible: visible, event: { keydown: onPaneKeyDown }">
<div class="contextual-pane-out" data-bind="click: cancel, clickBubble: false"></div>
<div class="contextual-pane" data-bind="attr: { id: id }">
<!-- Add database form -- Start -->
<div class="contextual-pane-in">
<form data-bind="submit: submit" style="height: 100%">
<div
class="paneContentContainer"
role="dialog"
aria-labelledby="databaseTitle"
data-bind="template: { name: 'add-database-inputs' }"
></div>
</form>
</div>
<!-- Add database form -- End -->
<!-- Loader - Start -->
<div class="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" data-bind="visible: isExecuting">
<img class="dataExplorerLoader" src="/LoadingIndicator_3Squares.gif" />
</div>
<!-- Loader - End -->
</div>
</div>
<script type="text/html" id="add-database-inputs">
<!-- Add database header - Start -->
<div class="firstdivbg headerline">
<span id="databaseTitle" role="heading" aria-level="2" data-bind="text: title"></span>
<div
class="closeImg"
role="button"
aria-label="Close pane"
data-bind="click: cancel, event: { keypress: onCloseKeyPress }"
tabindex="0"
>
<img src="../../../images/close-black.svg" title="Close" alt="Close" />
</div>
</div>
<!-- Add database header - End -->
<!-- Add database errors - Start -->
<div class="warningErrorContainer" aria-live="assertive" data-bind="visible: formErrors() && formErrors() !== ''">
<div class="warningErrorContent">
<span><img class="paneErrorIcon" src="/error_red.svg" alt="Error" /></span>
<span class="warningErrorDetailsLinkContainer">
<span class="formErrors" data-bind="text: formErrors, attr: { title: formErrors }"></span>
<a
class="errorLink"
role="link"
data-bind="visible: formErrorsDetails() && formErrorsDetails() !== '', click: showErrorDetails, event: { keypress: onMoreDetailsKeyPress }"
tabindex="0"
>
More details</a
>
</span>
</div>
</div>
<!-- Add database errors - End -->
<!-- upsell message - start -->
<div
class="infoBoxContainer"
aria-live="assertive"
data-bind="visible: showUpsellMessage && showUpsellMessage() && formErrors && !formErrors()"
>
<div class="infoBoxContent">
<span><img class="infoBoxIcon" src="/info_color.svg" alt="Promo" /></span>
<span class="infoBoxDetails">
<span class="infoBoxMessage" data-bind="text: upsellMessage, attr: { title: upsellMessage }"></span>
<a
class="underlinedLink"
id="linkAddDatabase"
data-bind="text: upsellAnchorText, attr: { 'href': upsellAnchorUrl, 'aria-label': upsellMessageAriaLabel }"
target="_blank"
href=""
tabindex="0"
></a>
</span>
</div>
</div>
<!-- upsell message - end -->
<!-- Add database inputs - Start -->
<div class="paneMainContent">
<div>
<p>
<span class="mandatoryStar">*</span>
<span data-bind="text: databaseIdLabel"></span>
<span class="infoTooltip" role="tooltip" tabindex="0">
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
<span class="tooltiptext infoTooltipWidth" data-bind="text: databaseIdTooltipText"></span>
</span>
</p>
<input
id="database-id"
type="text"
aria-required="true"
autocomplete="off"
pattern="[^/?#\\]*[^/?# \\]"
title="May not end with space nor contain characters '\' '/' '#' '?'"
size="40"
class="collid"
data-bind="textInput: databaseId, hasFocus: firstFieldHasFocus, attr: { 'aria-label': databaseIdLabel, 'placeholder': databaseIdPlaceHolder }"
autofocus
/>
<!-- Database provisioned throughput - Start -->
<!-- ko if: canConfigureThroughput -->
<div class="databaseProvision" aria-label="New database provision support">
<input
tabindex="0"
type="checkbox"
id="addDatabasePane-databaseSharedThroughput"
title="Provision shared throughput"
data-bind="checked: databaseCreateNewShared"
/>
<span class="databaseProvisionText" for="databaseSharedThroughput">Provision throughput</span>
<span class="infoTooltip" role="tooltip" tabindex="0">
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
<span
class="tooltiptext provisionDatabaseThroughput"
data-bind="text: databaseLevelThroughputTooltipText"
></span>
</span>
</div>
<div data-bind="visible: databaseCreateNewShared">
<throughput-input-autopilot-v3
params="{
step: 100,
value: throughput,
testId: 'sharedThroughputValue',
minimum: minThroughputRU,
maximum: maxThroughputRU,
isEnabled: databaseCreateNewShared,
label: throughputRangeText,
ariaLabel: throughputRangeText,
costsVisible: costsVisible,
requestUnitsUsageCost: requestUnitsUsageCost,
spendAckChecked: throughputSpendAck,
spendAckId: 'throughputSpendAckDatabase',
spendAckText: throughputSpendAckText,
spendAckVisible: throughputSpendAckVisible,
showAsMandatory: true,
infoBubbleText: ruToolTipText,
throughputAutoPilotRadioId: 'newDatabase-databaseThroughput-autoPilotRadio',
throughputProvisionedRadioId: 'newDatabase-databaseThroughput-manualRadio',
throughputModeRadioName: 'throughputModeRadioName',
isAutoPilotSelected: isAutoPilotSelected,
maxAutoPilotThroughputSet: maxAutoPilotThroughputSet,
autoPilotUsageCost: autoPilotUsageCost,
canExceedMaximumValue: canExceedMaximumValue,
freeTierExceedThroughputTooltip: freeTierExceedThroughputTooltip
}"
>
</throughput-input-autopilot-v3>
<p data-bind="visible: canRequestSupport">
<!-- TODO: Replace link with call to the Azure Support blade --><a
href="https://aka.ms/cosmosdbfeedback?subject=Cosmos%20DB%20More%20Throughput%20Request"
>Contact support</a
>
for more than <span data-bind="text: maxThroughputRUText"></span> RU/s.
</p>
</div>
<!-- /ko -->
<!-- Database provisioned throughput - End -->
</div>
</div>
<div class="paneFooter">
<div class="leftpanel-okbut">
<input type="submit" value="OK" class="btncreatecoll1" />
</div>
</div>
<!-- Add database inputs - End -->
</script>

View File

@@ -0,0 +1,105 @@
import * as Constants from "../../Common/Constants";
import { DatabaseAccount } from "../../Contracts/DataModels";
import { SubscriptionType } from "../../Contracts/SubscriptionType";
import { updateUserContext } from "../../UserContext";
import Explorer from "../Explorer";
import AddDatabasePane from "./AddDatabasePane";
const mockDatabaseAccount: DatabaseAccount = {
id: "mock",
kind: "DocumentDB",
location: "",
name: "mock",
properties: {
documentEndpoint: "",
cassandraEndpoint: "",
gremlinEndpoint: "",
tableEndpoint: "",
enableFreeTier: false,
},
type: undefined,
};
const mockFreeTierDatabaseAccount: DatabaseAccount = {
id: "mock",
kind: "DocumentDB",
location: "",
name: "mock",
properties: {
documentEndpoint: "",
cassandraEndpoint: "",
gremlinEndpoint: "",
tableEndpoint: "",
enableFreeTier: true,
},
type: undefined,
};
describe("Add Database Pane", () => {
describe("getSharedThroughputDefault()", () => {
it("should be true if subscription type is Benefits", () => {
updateUserContext({
subscriptionType: SubscriptionType.Benefits,
});
const explorer = new Explorer();
const addDatabasePane = explorer.addDatabasePane as AddDatabasePane;
expect(addDatabasePane.getSharedThroughputDefault()).toBe(true);
});
it("should be false if subscription type is EA", () => {
updateUserContext({
subscriptionType: SubscriptionType.EA,
});
const explorer = new Explorer();
const addDatabasePane = explorer.addDatabasePane as AddDatabasePane;
expect(addDatabasePane.getSharedThroughputDefault()).toBe(false);
});
it("should be true if subscription type is Free", () => {
updateUserContext({
subscriptionType: SubscriptionType.Free,
});
const explorer = new Explorer();
const addDatabasePane = explorer.addDatabasePane as AddDatabasePane;
expect(addDatabasePane.getSharedThroughputDefault()).toBe(true);
});
it("should be true if subscription type is Internal", () => {
updateUserContext({
subscriptionType: SubscriptionType.Internal,
});
const explorer = new Explorer();
const addDatabasePane = explorer.addDatabasePane as AddDatabasePane;
expect(addDatabasePane.getSharedThroughputDefault()).toBe(true);
});
it("should be true if subscription type is PAYG", () => {
updateUserContext({
subscriptionType: SubscriptionType.PAYG,
});
const explorer = new Explorer();
const addDatabasePane = explorer.addDatabasePane as AddDatabasePane;
expect(addDatabasePane.getSharedThroughputDefault()).toBe(true);
});
it("should display free tier text in upsell messaging", () => {
updateUserContext({ databaseAccount: mockFreeTierDatabaseAccount });
const explorer = new Explorer();
const addDatabasePane = explorer.addDatabasePane as AddDatabasePane;
expect(addDatabasePane.isFreeTierAccount()).toBe(true);
expect(addDatabasePane.upsellMessage()).toContain("With free tier");
expect(addDatabasePane.upsellAnchorUrl()).toBe(Constants.Urls.freeTierInformation);
expect(addDatabasePane.upsellAnchorText()).toBe("Learn more");
});
it("should display standard texr in upsell messaging", () => {
updateUserContext({ databaseAccount: mockDatabaseAccount });
const explorer = new Explorer();
const addDatabasePane = explorer.addDatabasePane as AddDatabasePane;
expect(addDatabasePane.isFreeTierAccount()).toBe(false);
expect(addDatabasePane.upsellMessage()).toContain("Start at");
expect(addDatabasePane.upsellAnchorUrl()).toBe(Constants.Urls.cosmosPricing);
expect(addDatabasePane.upsellAnchorText()).toBe("More details");
});
});
});

View File

@@ -0,0 +1,452 @@
import * as ko from "knockout";
import * as Constants from "../../Common/Constants";
import { createDatabase } from "../../Common/dataAccess/createDatabase";
import editable from "../../Common/EditableUtility";
import { getErrorMessage, getErrorStack } from "../../Common/ErrorHandlingUtils";
import { configContext, Platform } from "../../ConfigContext";
import * as DataModels from "../../Contracts/DataModels";
import { SubscriptionType } from "../../Contracts/SubscriptionType";
import * as ViewModels from "../../Contracts/ViewModels";
import * as SharedConstants from "../../Shared/Constants";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { userContext } from "../../UserContext";
import * as AutoPilotUtils from "../../Utils/AutoPilotUtils";
import * as PricingUtils from "../../Utils/PricingUtils";
import { ContextualPaneBase } from "./ContextualPaneBase";
export default class AddDatabasePane extends ContextualPaneBase {
public defaultExperience: ko.Computed<string>;
public databaseIdLabel: ko.Computed<string>;
public databaseIdPlaceHolder: ko.Computed<string>;
public databaseId: ko.Observable<string>;
public databaseIdTooltipText: ko.Computed<string>;
public databaseLevelThroughputTooltipText: ko.Computed<string>;
public databaseCreateNewShared: ko.Observable<boolean>;
public formErrorsDetails: ko.Observable<string>;
public throughput: ViewModels.Editable<number>;
public maxThroughputRU: ko.Observable<number>;
public minThroughputRU: ko.Observable<number>;
public maxThroughputRUText: ko.PureComputed<string>;
public throughputRangeText: ko.Computed<string>;
public throughputSpendAckText: ko.Observable<string>;
public throughputSpendAck: ko.Observable<boolean>;
public throughputSpendAckVisible: ko.Computed<boolean>;
public requestUnitsUsageCost: ko.Computed<string>;
public canRequestSupport: ko.PureComputed<boolean>;
public costsVisible: ko.PureComputed<boolean>;
public upsellMessage: ko.PureComputed<string>;
public upsellMessageAriaLabel: ko.PureComputed<string>;
public upsellAnchorUrl: ko.PureComputed<string>;
public upsellAnchorText: ko.PureComputed<string>;
public isAutoPilotSelected: ko.Observable<boolean>;
public maxAutoPilotThroughputSet: ko.Observable<number>;
public autoPilotUsageCost: ko.Computed<string>;
public canExceedMaximumValue: ko.PureComputed<boolean>;
public ruToolTipText: ko.Computed<string>;
public freeTierExceedThroughputTooltip: ko.Computed<string>;
public isFreeTierAccount: ko.Computed<boolean>;
public canConfigureThroughput: ko.PureComputed<boolean>;
public showUpsellMessage: ko.PureComputed<boolean>;
constructor(options: ViewModels.PaneOptions) {
super(options);
this.title((this.container && this.container.addDatabaseText()) || "New Database");
this.databaseId = ko.observable<string>();
this.ruToolTipText = ko.pureComputed(() => PricingUtils.getRuToolTipText());
this.canConfigureThroughput = ko.pureComputed(() => !this.container.isServerlessEnabled());
this.canExceedMaximumValue = ko.pureComputed(() => this.container.canExceedMaximumValue());
// TODO 388844: get defaults from parent frame
this.databaseCreateNewShared = ko.observable<boolean>(this.getSharedThroughputDefault());
this.databaseIdLabel = ko.computed<string>(() =>
userContext.apiType === "Cassandra" ? "Keyspace id" : "Database id"
);
this.databaseIdPlaceHolder = ko.computed<string>(() =>
userContext.apiType === "Cassandra" ? "Type a new keyspace id" : "Type a new database id"
);
this.databaseIdTooltipText = ko.computed<string>(() => {
const isCassandraAccount: boolean = userContext.apiType === "Cassandra";
return `A ${isCassandraAccount ? "keyspace" : "database"} is a logical container of one or more ${
isCassandraAccount ? "tables" : "collections"
}`;
});
this.databaseLevelThroughputTooltipText = ko.computed<string>(() => {
const isCassandraAccount: boolean = userContext.apiType === "Cassandra";
const databaseLabel: string = isCassandraAccount ? "keyspace" : "database";
const collectionsLabel: string = isCassandraAccount ? "tables" : "collections";
return `Provisioned throughput at the ${databaseLabel} level will be shared across all ${collectionsLabel} within the ${databaseLabel}.`;
});
this.throughput = editable.observable<number>();
this.maxThroughputRU = ko.observable<number>();
this.minThroughputRU = ko.observable<number>();
this.throughputSpendAckText = ko.observable<string>();
this.throughputSpendAck = ko.observable<boolean>(false);
this.isAutoPilotSelected = ko.observable<boolean>(false);
this.maxAutoPilotThroughputSet = ko.observable<number>(AutoPilotUtils.minAutoPilotThroughput);
this.autoPilotUsageCost = ko.pureComputed<string>(() => {
const autoPilot = this._isAutoPilotSelectedAndWhatTier();
if (!autoPilot) {
return "";
}
return PricingUtils.getAutoPilotV3SpendHtml(autoPilot.maxThroughput, true /* isDatabaseThroughput */);
});
this.throughputRangeText = ko.pureComputed<string>(() => {
if (this.isAutoPilotSelected()) {
return AutoPilotUtils.getAutoPilotHeaderText();
}
return `Throughput (${this.minThroughputRU().toLocaleString()} - ${this.maxThroughputRU().toLocaleString()} RU/s)`;
});
this.requestUnitsUsageCost = ko.computed(() => {
const offerThroughput: number = this.throughput();
if (
offerThroughput < this.minThroughputRU() ||
(offerThroughput > this.maxThroughputRU() && !this.canExceedMaximumValue())
) {
return "";
}
const { databaseAccount: account } = userContext;
if (!account) {
return "";
}
const regions = account?.properties?.readLocations?.length || 1;
const multimaster = account?.properties?.enableMultipleWriteLocations || false;
let estimatedSpendAcknowledge: string;
let estimatedSpend: string;
if (!this.isAutoPilotSelected()) {
estimatedSpend = PricingUtils.getEstimatedSpendHtml(
offerThroughput,
userContext.portalEnv,
regions,
multimaster
);
estimatedSpendAcknowledge = PricingUtils.getEstimatedSpendAcknowledgeString(
offerThroughput,
userContext.portalEnv,
regions,
multimaster,
this.isAutoPilotSelected()
);
} else {
estimatedSpend = PricingUtils.getEstimatedAutoscaleSpendHtml(
this.maxAutoPilotThroughputSet(),
userContext.portalEnv,
regions,
multimaster
);
estimatedSpendAcknowledge = PricingUtils.getEstimatedSpendAcknowledgeString(
this.maxAutoPilotThroughputSet(),
userContext.portalEnv,
regions,
multimaster,
this.isAutoPilotSelected()
);
}
// TODO: change throughputSpendAckText to be a computed value, instead of having this side effect
this.throughputSpendAckText(estimatedSpendAcknowledge);
return estimatedSpend;
});
this.canRequestSupport = ko.pureComputed(() => {
if (
configContext.platform !== Platform.Emulator &&
!userContext.isTryCosmosDBSubscription &&
configContext.platform !== Platform.Portal
) {
const offerThroughput: number = this.throughput();
return offerThroughput <= 100000;
}
return false;
});
this.isFreeTierAccount = ko.computed<boolean>(() => {
return userContext?.databaseAccount?.properties?.enableFreeTier;
});
this.showUpsellMessage = ko.pureComputed(() => {
if (this.container.isServerlessEnabled()) {
return false;
}
if (this.isFreeTierAccount()) {
return this.databaseCreateNewShared();
}
return true;
});
this.maxThroughputRUText = ko.pureComputed(() => {
return this.maxThroughputRU().toLocaleString();
});
this.costsVisible = ko.pureComputed(() => {
return configContext.platform !== Platform.Emulator;
});
this.throughputSpendAckVisible = ko.pureComputed<boolean>(() => {
const autoscaleThroughput = this.maxAutoPilotThroughputSet() * 1;
if (this.isAutoPilotSelected()) {
return autoscaleThroughput > SharedConstants.CollectionCreation.DefaultCollectionRUs100K;
}
const selectedThroughput: number = this.throughput();
const maxRU: number = this.maxThroughputRU && this.maxThroughputRU();
const isMaxRUGreaterThanDefault: boolean = maxRU > SharedConstants.CollectionCreation.DefaultCollectionRUs100K;
const isThroughputSetGreaterThanDefault: boolean =
selectedThroughput > SharedConstants.CollectionCreation.DefaultCollectionRUs100K;
if (this.canExceedMaximumValue()) {
return isThroughputSetGreaterThanDefault;
}
return isThroughputSetGreaterThanDefault && isMaxRUGreaterThanDefault;
});
this.databaseCreateNewShared.subscribe((useShared: boolean) => {
this._updateThroughputLimitByDatabase();
});
this.resetData();
this.freeTierExceedThroughputTooltip = ko.pureComputed<string>(() =>
this.isFreeTierAccount() && !this.container.isFirstResourceCreated()
? "The first 400 RU/s in this account are free. Billing will apply to any throughput beyond 400 RU/s."
: ""
);
this.upsellMessage = ko.pureComputed<string>(() => {
return PricingUtils.getUpsellMessage(
userContext.portalEnv,
this.isFreeTierAccount(),
this.container.isFirstResourceCreated(),
false
);
});
this.upsellMessageAriaLabel = ko.pureComputed<string>(() => {
return `${this.upsellMessage()}. Click ${this.isFreeTierAccount() ? "to learn more" : "for more details"}`;
});
this.upsellAnchorUrl = ko.pureComputed<string>(() => {
return this.isFreeTierAccount() ? Constants.Urls.freeTierInformation : Constants.Urls.cosmosPricing;
});
this.upsellAnchorText = ko.pureComputed<string>(() => {
return this.isFreeTierAccount() ? "Learn more" : "More details";
});
}
public onMoreDetailsKeyPress = (source: any, event: KeyboardEvent): boolean => {
if (event.keyCode === Constants.KeyCodes.Space || event.keyCode === Constants.KeyCodes.Enter) {
this.showErrorDetails();
return false;
}
return true;
};
public open() {
super.open();
this.resetData();
const addDatabasePaneOpenMessage = {
subscriptionType: userContext.subscriptionType,
subscriptionQuotaId: userContext.quotaId,
defaultsCheck: {
throughput: this.throughput(),
flight: userContext.addCollectionFlight,
},
dataExplorerArea: Constants.Areas.ContextualPane,
};
const focusElement = document.getElementById("database-id");
focusElement && focusElement.focus();
TelemetryProcessor.trace(Action.CreateDatabase, ActionModifiers.Open, addDatabasePaneOpenMessage);
}
public submit() {
if (!this._isValid()) {
return;
}
const offerThroughput: number = this._computeOfferThroughput();
const addDatabasePaneStartMessage = {
database: ko.toJS({
id: this.databaseId(),
shared: this.databaseCreateNewShared(),
}),
offerThroughput,
subscriptionType: userContext.subscriptionType,
subscriptionQuotaId: userContext.quotaId,
defaultsCheck: {
flight: userContext.addCollectionFlight,
},
dataExplorerArea: Constants.Areas.ContextualPane,
};
const startKey: number = TelemetryProcessor.traceStart(Action.CreateDatabase, addDatabasePaneStartMessage);
this.formErrors("");
this.isExecuting(true);
const createDatabaseParams: DataModels.CreateDatabaseParams = {
databaseId: addDatabasePaneStartMessage.database.id,
databaseLevelThroughput: addDatabasePaneStartMessage.database.shared,
};
if (this.isAutoPilotSelected()) {
createDatabaseParams.autoPilotMaxThroughput = this.maxAutoPilotThroughputSet();
} else {
createDatabaseParams.offerThroughput = addDatabasePaneStartMessage.offerThroughput;
}
createDatabase(createDatabaseParams).then(
(database: DataModels.Database) => {
this._onCreateDatabaseSuccess(offerThroughput, startKey);
},
(error: any) => {
this._onCreateDatabaseFailure(error, offerThroughput, startKey);
}
);
}
public resetData() {
this.databaseId("");
this.databaseCreateNewShared(this.getSharedThroughputDefault());
this.isAutoPilotSelected(this.container.isAutoscaleDefaultEnabled());
this.maxAutoPilotThroughputSet(AutoPilotUtils.minAutoPilotThroughput);
this._updateThroughputLimitByDatabase();
this.throughputSpendAck(false);
super.resetData();
}
public getSharedThroughputDefault(): boolean {
const { subscriptionType } = userContext;
if (subscriptionType === SubscriptionType.EA || this.container.isServerlessEnabled()) {
return false;
}
return true;
}
private _onCreateDatabaseSuccess(offerThroughput: number, startKey: number): void {
this.isExecuting(false);
this.close();
this.container.refreshAllDatabases();
const addDatabasePaneSuccessMessage = {
database: ko.toJS({
id: this.databaseId(),
shared: this.databaseCreateNewShared(),
}),
offerThroughput: offerThroughput,
subscriptionType: userContext.subscriptionType,
subscriptionQuotaId: userContext.quotaId,
defaultsCheck: {
flight: userContext.addCollectionFlight,
},
dataExplorerArea: Constants.Areas.ContextualPane,
};
TelemetryProcessor.traceSuccess(Action.CreateDatabase, addDatabasePaneSuccessMessage, startKey);
this.resetData();
}
private _onCreateDatabaseFailure(error: any, offerThroughput: number, startKey: number): void {
this.isExecuting(false);
const errorMessage = getErrorMessage(error);
this.formErrors(errorMessage);
this.formErrorsDetails(errorMessage);
const addDatabasePaneFailedMessage = {
database: ko.toJS({
id: this.databaseId(),
shared: this.databaseCreateNewShared(),
}),
offerThroughput: offerThroughput,
subscriptionType: userContext.subscriptionType,
subscriptionQuotaId: userContext.quotaId,
defaultsCheck: {
flight: userContext.addCollectionFlight,
},
dataExplorerArea: Constants.Areas.ContextualPane,
error: errorMessage,
errorStack: getErrorStack(error),
};
TelemetryProcessor.traceFailure(Action.CreateDatabase, addDatabasePaneFailedMessage, startKey);
}
private _getThroughput(): number {
const throughput: number = this.throughput();
return isNaN(throughput) ? 0 : Number(throughput);
}
private _computeOfferThroughput(): number {
if (!this.canConfigureThroughput()) {
return undefined;
}
if (this.isAutoPilotSelected()) {
return undefined;
}
return this._getThroughput();
}
private _isValid(): boolean {
// TODO add feature flag that disables validation for customers with custom accounts
if (this.isAutoPilotSelected()) {
const autoPilot = this._isAutoPilotSelectedAndWhatTier();
if (
!autoPilot ||
!autoPilot.maxThroughput ||
!AutoPilotUtils.isValidAutoPilotThroughput(autoPilot.maxThroughput)
) {
this.formErrors(
`Please enter a value greater than ${AutoPilotUtils.minAutoPilotThroughput} for autopilot throughput`
);
return false;
}
}
const throughput = this._getThroughput();
if (throughput > SharedConstants.CollectionCreation.DefaultCollectionRUs100K && !this.throughputSpendAck()) {
this.formErrors(`Please acknowledge the estimated daily spend.`);
return false;
}
const autoscaleThroughput = this.maxAutoPilotThroughputSet() * 1;
if (
this.isAutoPilotSelected() &&
autoscaleThroughput > SharedConstants.CollectionCreation.DefaultCollectionRUs100K &&
!this.throughputSpendAck()
) {
this.formErrors(`Please acknowledge the estimated monthly spend.`);
return false;
}
return true;
}
private _isAutoPilotSelectedAndWhatTier(): DataModels.AutoPilotCreationSettings {
if (this.isAutoPilotSelected() && this.maxAutoPilotThroughputSet()) {
return {
maxThroughput: this.maxAutoPilotThroughputSet() * 1,
};
}
return undefined;
}
private _updateThroughputLimitByDatabase() {
const throughputDefaults = this.container.collectionCreationDefaults.throughput;
this.throughput(throughputDefaults.shared);
this.maxThroughputRU(throughputDefaults.unlimitedmax);
this.minThroughputRU(throughputDefaults.unlimitedmin);
}
}

View File

@@ -1,17 +0,0 @@
import { shallow } from "enzyme";
import React from "react";
import Explorer from "../../Explorer";
import { AddDatabasePanel } from "./AddDatabasePanel";
const props = {
explorer: new Explorer(),
closePanel: (): void => undefined,
openNotificationConsole: (): void => undefined,
};
describe("AddDatabasePane Pane", () => {
it("should render Default properly", () => {
const wrapper = shallow(<AddDatabasePanel {...props} />);
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -1,239 +0,0 @@
import { Checkbox, Stack, Text, TextField } from "@fluentui/react";
import React, { FunctionComponent, useEffect, useState } from "react";
import * as Constants from "../../../Common/Constants";
import { createDatabase } from "../../../Common/dataAccess/createDatabase";
import { getErrorMessage, getErrorStack } from "../../../Common/ErrorHandlingUtils";
import { InfoTooltip } from "../../../Common/Tooltip/InfoTooltip";
import * as DataModels from "../../../Contracts/DataModels";
import { SubscriptionType } from "../../../Contracts/SubscriptionType";
import * as SharedConstants from "../../../Shared/Constants";
import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
import { userContext } from "../../../UserContext";
import * as AutoPilotUtils from "../../../Utils/AutoPilotUtils";
import { isServerlessAccount } from "../../../Utils/CapabilityUtils";
import { getUpsellMessage } from "../../../Utils/PricingUtils";
import { ThroughputInput } from "../../Controls/ThroughputInput/ThroughputInput";
import Explorer from "../../Explorer";
import { PanelInfoErrorComponent } from "../PanelInfoErrorComponent";
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
export interface AddDatabasePaneProps {
explorer: Explorer;
closePanel: () => void;
openNotificationConsole: () => void;
}
export const AddDatabasePanel: FunctionComponent<AddDatabasePaneProps> = ({
explorer: container,
closePanel,
openNotificationConsole,
}: AddDatabasePaneProps) => {
let throughput: number;
let isAutoscaleSelected: boolean;
let isCostAcknowledged: boolean;
const { subscriptionType } = userContext;
const isCassandraAccount: boolean = userContext.apiType === "Cassandra";
const databaseLabel: string = isCassandraAccount ? "keyspace" : "database";
const collectionsLabel: string = isCassandraAccount ? "tables" : "collections";
const databaseIdLabel: string = isCassandraAccount ? "Keyspace id" : "Database id";
const databaseIdPlaceHolder: string = isCassandraAccount ? "Type a new keyspace id" : "Type a new database id";
const [databaseId, setDatabaseId] = useState<string>("");
const databaseIdTooltipText = `A ${
isCassandraAccount ? "keyspace" : "database"
} is a logical container of one or more ${isCassandraAccount ? "tables" : "collections"}`;
const databaseLevelThroughputTooltipText = `Provisioned throughput at the ${databaseLabel} level will be shared across all ${collectionsLabel} within the ${databaseLabel}.`;
const [databaseCreateNewShared, setDatabaseCreateNewShared] = useState<boolean>(
subscriptionType !== SubscriptionType.EA && !isServerlessAccount()
);
const [formErrors, setFormErrors] = useState<string>("");
const [isExecuting, setIsExecuting] = useState<boolean>(false);
const isFreeTierAccount: boolean = userContext.databaseAccount?.properties?.enableFreeTier;
const addDatabasePaneMessage = {
database: {
id: databaseId,
shared: databaseCreateNewShared,
},
subscriptionType: SubscriptionType[subscriptionType],
subscriptionQuotaId: userContext.quotaId,
defaultsCheck: {
flight: userContext.addCollectionFlight,
},
dataExplorerArea: Constants.Areas.ContextualPane,
};
useEffect(() => {
const addDatabasePaneOpenMessage = {
subscriptionType: SubscriptionType[subscriptionType],
subscriptionQuotaId: userContext.quotaId,
defaultsCheck: {
throughput,
flight: userContext.addCollectionFlight,
},
dataExplorerArea: Constants.Areas.ContextualPane,
};
TelemetryProcessor.trace(Action.CreateDatabase, ActionModifiers.Open, addDatabasePaneOpenMessage);
}, []);
const onSubmit = () => {
if (!_isValid()) {
return;
}
const addDatabasePaneStartMessage = {
...addDatabasePaneMessage,
throughput,
};
const startKey: number = TelemetryProcessor.traceStart(Action.CreateDatabase, addDatabasePaneStartMessage);
setFormErrors("");
setIsExecuting(true);
const createDatabaseParams: DataModels.CreateDatabaseParams = {
databaseId: addDatabasePaneStartMessage.database.id,
databaseLevelThroughput: addDatabasePaneStartMessage.database.shared,
};
if (isAutoscaleSelected) {
createDatabaseParams.autoPilotMaxThroughput = addDatabasePaneStartMessage.throughput;
} else {
createDatabaseParams.offerThroughput = addDatabasePaneStartMessage.throughput;
}
createDatabase(createDatabaseParams).then(
() => {
_onCreateDatabaseSuccess(throughput, startKey);
},
(error: string) => {
_onCreateDatabaseFailure(error, throughput, startKey);
}
);
};
const _onCreateDatabaseSuccess = (offerThroughput: number, startKey: number): void => {
setIsExecuting(false);
closePanel();
container.refreshAllDatabases();
const addDatabasePaneSuccessMessage = {
...addDatabasePaneMessage,
offerThroughput,
};
TelemetryProcessor.traceSuccess(Action.CreateDatabase, addDatabasePaneSuccessMessage, startKey);
};
const _onCreateDatabaseFailure = (error: string, offerThroughput: number, startKey: number): void => {
setIsExecuting(false);
const errorMessage = getErrorMessage(error);
setFormErrors(errorMessage);
const addDatabasePaneFailedMessage = {
...addDatabasePaneMessage,
offerThroughput,
error: errorMessage,
errorStack: getErrorStack(error),
};
TelemetryProcessor.traceFailure(Action.CreateDatabase, addDatabasePaneFailedMessage, startKey);
};
const _isValid = (): boolean => {
// TODO add feature flag that disables validation for customers with custom accounts
if (isAutoscaleSelected) {
if (!AutoPilotUtils.isValidAutoPilotThroughput(throughput)) {
setFormErrors(
`Please enter a value greater than ${AutoPilotUtils.minAutoPilotThroughput} for autopilot throughput`
);
return false;
}
}
if (throughput > SharedConstants.CollectionCreation.DefaultCollectionRUs100K && !isCostAcknowledged) {
setFormErrors(`Please acknowledge the estimated ${isAutoscaleSelected ? "monthly" : "daily"} spend.`);
return false;
}
return true;
};
const handleonChangeDBId = React.useCallback(
(event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>, newValue?: string) => {
setDatabaseId(newValue || "");
},
[]
);
const props: RightPaneFormProps = {
expandConsole: openNotificationConsole,
formError: formErrors,
isExecuting,
submitButtonText: "OK",
onSubmit,
};
return (
<RightPaneForm {...props}>
{!formErrors && isFreeTierAccount && (
<PanelInfoErrorComponent
message={getUpsellMessage(userContext.portalEnv, true, container.isFirstResourceCreated(), true)}
messageType="info"
showErrorDetails={false}
openNotificationConsole={openNotificationConsole}
link={Constants.Urls.freeTierInformation}
linkText="Learn more"
/>
)}
<div className="panelMainContent">
<div>
<Stack horizontal>
<span className="mandatoryStar">*</span>
<Text variant="small">{databaseIdLabel}</Text>
<InfoTooltip>{databaseIdTooltipText}</InfoTooltip>
</Stack>
<TextField
id="database-id"
type="text"
aria-required="true"
autoComplete="off"
pattern="[^/?#\\]*[^/?# \\]"
title="May not end with space nor contain characters '\' '/' '#' '?'"
size={40}
aria-label={databaseIdLabel}
placeholder={databaseIdPlaceHolder}
value={databaseId}
onChange={handleonChangeDBId}
autoFocus
style={{ fontSize: 12 }}
styles={{ root: { width: 300 } }}
/>
<Stack horizontal>
<Checkbox
title="Provision shared throughput"
styles={{
text: { fontSize: 12 },
checkbox: { width: 12, height: 12 },
label: { padding: 0, alignItems: "center" },
}}
label="Provision throughput"
checked={databaseCreateNewShared}
onChange={() => setDatabaseCreateNewShared(!databaseCreateNewShared)}
/>
<InfoTooltip>{databaseLevelThroughputTooltipText}</InfoTooltip>
</Stack>
{!isServerlessAccount() && databaseCreateNewShared && (
<ThroughputInput
showFreeTierExceedThroughputTooltip={isFreeTierAccount && !container?.isFirstResourceCreated()}
isDatabase={true}
isSharded={databaseCreateNewShared}
setThroughputValue={(newThroughput: number) => (throughput = newThroughput)}
setIsAutoscale={(isAutoscale: boolean) => (isAutoscaleSelected = isAutoscale)}
onCostAcknowledgeChange={(isAcknowledged: boolean) => (isCostAcknowledged = isAcknowledged)}
/>
)}
</div>
</div>
</RightPaneForm>
);
};

View File

@@ -1,96 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AddDatabasePane Pane should render Default properly 1`] = `
<RightPaneForm
expandConsole={[Function]}
formError=""
isExecuting={false}
onSubmit={[Function]}
submitButtonText="OK"
>
<div
className="panelMainContent"
>
<div>
<Stack
horizontal={true}
>
<span
className="mandatoryStar"
>
*
</span>
<Text
variant="small"
>
Database id
</Text>
<InfoTooltip>
A database is a logical container of one or more collections
</InfoTooltip>
</Stack>
<StyledTextFieldBase
aria-label="Database id"
aria-required="true"
autoComplete="off"
autoFocus={true}
id="database-id"
onChange={[Function]}
pattern="[^/?#\\\\\\\\]*[^/?# \\\\\\\\]"
placeholder="Type a new database id"
size={40}
style={
Object {
"fontSize": 12,
}
}
styles={
Object {
"root": Object {
"width": 300,
},
}
}
title="May not end with space nor contain characters '\\\\' '/' '#' '?'"
type="text"
value=""
/>
<Stack
horizontal={true}
>
<StyledCheckboxBase
checked={true}
label="Provision throughput"
onChange={[Function]}
styles={
Object {
"checkbox": Object {
"height": 12,
"width": 12,
},
"label": Object {
"alignItems": "center",
"padding": 0,
},
"text": Object {
"fontSize": 12,
},
}
}
title="Provision shared throughput"
/>
<InfoTooltip>
Provisioned throughput at the database level will be shared across all collections within the database.
</InfoTooltip>
</Stack>
<ThroughputInput
isDatabase={true}
isSharded={true}
onCostAcknowledgeChange={[Function]}
setIsAutoscale={[Function]}
setThroughputValue={[Function]}
/>
</div>
</div>
</RightPaneForm>
`;

View File

@@ -9,7 +9,10 @@ import * as NotificationConsoleUtils from "../../../Utils/NotificationConsoleUti
import Explorer from "../../Explorer"; import Explorer from "../../Explorer";
import { NotebookContentItem, NotebookContentItemType } from "../../Notebook/NotebookContentItem"; import { NotebookContentItem, NotebookContentItemType } from "../../Notebook/NotebookContentItem";
import { ResourceTreeAdapter } from "../../Tree/ResourceTreeAdapter"; import { ResourceTreeAdapter } from "../../Tree/ResourceTreeAdapter";
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm"; import {
GenericRightPaneComponent,
GenericRightPaneProps,
} from "../GenericRightPaneComponent/GenericRightPaneComponent";
import { CopyNotebookPaneComponent, CopyNotebookPaneProps } from "./CopyNotebookPaneComponent"; import { CopyNotebookPaneComponent, CopyNotebookPaneProps } from "./CopyNotebookPaneComponent";
interface Location { interface Location {
@@ -39,6 +42,7 @@ export const CopyNotebookPane: FunctionComponent<CopyNotebookPanelProps> = ({
}: CopyNotebookPanelProps) => { }: CopyNotebookPanelProps) => {
const [isExecuting, setIsExecuting] = useState<boolean>(); const [isExecuting, setIsExecuting] = useState<boolean>();
const [formError, setFormError] = useState<string>(""); const [formError, setFormError] = useState<string>("");
const [formErrorDetail, setFormErrorDetail] = useState<string>("");
const [pinnedRepos, setPinnedRepos] = useState<IPinnedRepo[]>(); const [pinnedRepos, setPinnedRepos] = useState<IPinnedRepo[]>();
const [selectedLocation, setSelectedLocation] = useState<Location>(); const [selectedLocation, setSelectedLocation] = useState<Location>();
@@ -88,6 +92,7 @@ export const CopyNotebookPane: FunctionComponent<CopyNotebookPanelProps> = ({
} catch (error) { } catch (error) {
const errorMessage = getErrorMessage(error); const errorMessage = getErrorMessage(error);
setFormError(`Failed to copy ${name} to ${destination}`); setFormError(`Failed to copy ${name} to ${destination}`);
setFormErrorDetail(`${errorMessage}`);
handleError(errorMessage, "CopyNotebookPaneAdapter/submit", formError); handleError(errorMessage, "CopyNotebookPaneAdapter/submit", formError);
} finally { } finally {
clearMessage && clearMessage(); clearMessage && clearMessage();
@@ -125,10 +130,14 @@ export const CopyNotebookPane: FunctionComponent<CopyNotebookPanelProps> = ({
setSelectedLocation(option?.data); setSelectedLocation(option?.data);
}; };
const props: RightPaneFormProps = { const genericPaneProps: GenericRightPaneProps = {
formError, formError,
formErrorDetail,
id: "copynotebookpane",
isExecuting: isExecuting, isExecuting: isExecuting,
title: "Copy notebook",
submitButtonText: "OK", submitButtonText: "OK",
onClose: closePanel,
onSubmit: () => submit(), onSubmit: () => submit(),
expandConsole: () => container.expandConsole(), expandConsole: () => container.expandConsole(),
}; };
@@ -140,8 +149,8 @@ export const CopyNotebookPane: FunctionComponent<CopyNotebookPanelProps> = ({
}; };
return ( return (
<RightPaneForm {...props}> <GenericRightPaneComponent {...genericPaneProps}>
<CopyNotebookPaneComponent {...copyNotebookPaneProps} /> <CopyNotebookPaneComponent {...copyNotebookPaneProps} />
</RightPaneForm> </GenericRightPaneComponent>
); );
}; };

View File

@@ -130,8 +130,8 @@ describe("Delete Collection Confirmation Pane", () => {
.hostNodes() .hostNodes()
.simulate("change", { target: { value: selectedCollectionId } }); .simulate("change", { target: { value: selectedCollectionId } });
expect(wrapper.exists("#sidePanelOkButton")).toBe(true); expect(wrapper.exists(".genericPaneSubmitBtn")).toBe(true);
wrapper.find("#sidePanelOkButton").hostNodes().simulate("submit"); wrapper.find(".genericPaneSubmitBtn").hostNodes().simulate("click");
expect(deleteCollection).toHaveBeenCalledWith(databaseId, selectedCollectionId); expect(deleteCollection).toHaveBeenCalledWith(databaseId, selectedCollectionId);
wrapper.unmount(); wrapper.unmount();
@@ -151,8 +151,8 @@ describe("Delete Collection Confirmation Pane", () => {
.hostNodes() .hostNodes()
.simulate("change", { target: { value: feedbackText } }); .simulate("change", { target: { value: feedbackText } });
expect(wrapper.exists("#sidePanelOkButton")).toBe(true); expect(wrapper.exists(".genericPaneSubmitBtn")).toBe(true);
wrapper.find("#sidePanelOkButton").hostNodes().simulate("submit"); wrapper.find(".genericPaneSubmitBtn").hostNodes().simulate("click");
expect(deleteCollection).toHaveBeenCalledWith(databaseId, selectedCollectionId); expect(deleteCollection).toHaveBeenCalledWith(databaseId, selectedCollectionId);
const deleteFeedback = new DeleteFeedback( const deleteFeedback = new DeleteFeedback(

View File

@@ -12,7 +12,10 @@ import { userContext } from "../../../UserContext";
import { getCollectionName } from "../../../Utils/APITypeUtils"; import { getCollectionName } from "../../../Utils/APITypeUtils";
import * as NotificationConsoleUtils from "../../../Utils/NotificationConsoleUtils"; import * as NotificationConsoleUtils from "../../../Utils/NotificationConsoleUtils";
import Explorer from "../../Explorer"; import Explorer from "../../Explorer";
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm"; import {
GenericRightPaneComponent,
GenericRightPaneProps,
} from "../GenericRightPaneComponent/GenericRightPaneComponent";
export interface DeleteCollectionConfirmationPaneProps { export interface DeleteCollectionConfirmationPaneProps {
explorer: Explorer; explorer: Explorer;
closePanel: () => void; closePanel: () => void;
@@ -32,7 +35,7 @@ export const DeleteCollectionConfirmationPane: FunctionComponent<DeleteCollectio
}; };
const collectionName = getCollectionName().toLocaleLowerCase(); const collectionName = getCollectionName().toLocaleLowerCase();
const paneTitle = "Delete " + collectionName; const paneTitle = "Delete " + collectionName;
const onSubmit = async (): Promise<void> => { const submit = async (): Promise<void> => {
const collection = explorer.findSelectedCollection(); const collection = explorer.findSelectedCollection();
if (!collection || inputCollectionName !== collection.id()) { if (!collection || inputCollectionName !== collection.id()) {
const errorMessage = "Input " + collectionName + " name does not match the selected " + collectionName; const errorMessage = "Input " + collectionName + " name does not match the selected " + collectionName;
@@ -97,15 +100,19 @@ export const DeleteCollectionConfirmationPane: FunctionComponent<DeleteCollectio
); );
} }
}; };
const props: RightPaneFormProps = { const genericPaneProps: GenericRightPaneProps = {
formError: formError, formError: formError,
formErrorDetail: formError,
id: "deleteCollectionpane",
isExecuting, isExecuting,
title: paneTitle,
submitButtonText: "OK", submitButtonText: "OK",
onSubmit, onClose: closePanel,
onSubmit: submit,
expandConsole: () => explorer.expandConsole(), expandConsole: () => explorer.expandConsole(),
}; };
return ( return (
<RightPaneForm {...props}> <GenericRightPaneComponent {...genericPaneProps}>
<div className="panelFormWrapper"> <div className="panelFormWrapper">
<div className="panelMainContent"> <div className="panelMainContent">
<div className="confirmDeleteInput"> <div className="confirmDeleteInput">
@@ -143,6 +150,6 @@ export const DeleteCollectionConfirmationPane: FunctionComponent<DeleteCollectio
)} )}
</div> </div>
</div> </div>
</RightPaneForm> </GenericRightPaneComponent>
); );
}; };

View File

@@ -98,8 +98,8 @@ describe("Delete Database Confirmation Pane", () => {
.find("#confirmDatabaseId") .find("#confirmDatabaseId")
.hostNodes() .hostNodes()
.simulate("change", { target: { value: selectedDatabaseId } }); .simulate("change", { target: { value: selectedDatabaseId } });
expect(wrapper.exists("button")).toBe(true); expect(wrapper.exists("#sidePanelOkButton")).toBe(true);
wrapper.find("button").hostNodes().simulate("submit"); wrapper.find("#sidePanelOkButton").hostNodes().simulate("submit");
expect(deleteDatabase).toHaveBeenCalledWith(selectedDatabaseId); expect(deleteDatabase).toHaveBeenCalledWith(selectedDatabaseId);
wrapper.unmount(); wrapper.unmount();
}); });

View File

@@ -1,5 +1,5 @@
import { Text, TextField } from "@fluentui/react";
import { useBoolean } from "@fluentui/react-hooks"; import { useBoolean } from "@fluentui/react-hooks";
import { Text, TextField } from "@fluentui/react";
import React, { FunctionComponent, useState } from "react"; import React, { FunctionComponent, useState } from "react";
import { Areas } from "../../Common/Constants"; import { Areas } from "../../Common/Constants";
import { deleteDatabase } from "../../Common/dataAccess/deleteDatabase"; import { deleteDatabase } from "../../Common/dataAccess/deleteDatabase";
@@ -12,8 +12,9 @@ import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { userContext } from "../../UserContext"; import { userContext } from "../../UserContext";
import { logConsoleError } from "../../Utils/NotificationConsoleUtils"; import { logConsoleError } from "../../Utils/NotificationConsoleUtils";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
import { PanelFooterComponent } from "./PanelFooterComponent";
import { PanelInfoErrorComponent, PanelInfoErrorProps } from "./PanelInfoErrorComponent"; import { PanelInfoErrorComponent, PanelInfoErrorProps } from "./PanelInfoErrorComponent";
import { RightPaneForm, RightPaneFormProps } from "./RightPaneForm/RightPaneForm"; import { PanelLoadingScreen } from "./PanelLoadingScreen";
interface DeleteDatabaseConfirmationPanelProps { interface DeleteDatabaseConfirmationPanelProps {
explorer: Explorer; explorer: Explorer;
@@ -22,19 +23,36 @@ interface DeleteDatabaseConfirmationPanelProps {
selectedDatabase: Database; selectedDatabase: Database;
} }
export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseConfirmationPanelProps> = ({ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseConfirmationPanelProps> = (
explorer, props: DeleteDatabaseConfirmationPanelProps
openNotificationConsole, ): JSX.Element => {
closePanel,
selectedDatabase,
}: DeleteDatabaseConfirmationPanelProps): JSX.Element => {
const [isLoading, { setTrue: setLoadingTrue, setFalse: setLoadingFalse }] = useBoolean(false); const [isLoading, { setTrue: setLoadingTrue, setFalse: setLoadingFalse }] = useBoolean(false);
const [formError, setFormError] = useState<string>(""); const [formError, setFormError] = useState<string>("");
const [databaseInput, setDatabaseInput] = useState<string>(""); const [databaseInput, setDatabaseInput] = useState<string>("");
const [databaseFeedbackInput, setDatabaseFeedbackInput] = useState<string>(""); const [databaseFeedbackInput, setDatabaseFeedbackInput] = useState<string>("");
const submit = async (): Promise<void> => { const getPanelErrorProps = (): PanelInfoErrorProps => {
if (formError) {
return {
messageType: "error",
message: formError,
showErrorDetails: true,
openNotificationConsole: props.openNotificationConsole,
};
}
return {
messageType: "warning",
showErrorDetails: false,
message:
"Warning! The action you are about to take cannot be undone. Continuing will permanently delete this resource and all of its children resources.",
};
};
const submit = async (event: React.FormEvent<HTMLFormElement>): Promise<void> => {
const { selectedDatabase, explorer } = props;
event.preventDefault();
if (selectedDatabase?.id() && databaseInput !== selectedDatabase.id()) { if (selectedDatabase?.id() && databaseInput !== selectedDatabase.id()) {
setFormError("Input database name does not match the selected database"); setFormError("Input database name does not match the selected database");
logConsoleError(`Error while deleting collection ${selectedDatabase && selectedDatabase.id()}`); logConsoleError(`Error while deleting collection ${selectedDatabase && selectedDatabase.id()}`);
@@ -51,7 +69,7 @@ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseCo
try { try {
await deleteDatabase(selectedDatabase.id()); await deleteDatabase(selectedDatabase.id());
closePanel(); props.closePanel();
explorer.refreshAllDatabases(); explorer.refreshAllDatabases();
explorer.tabsManager.closeTabsByComparator((tab) => tab.node?.id() === selectedDatabase.id()); explorer.tabsManager.closeTabsByComparator((tab) => tab.node?.id() === selectedDatabase.id());
explorer.selectedNode(undefined); explorer.selectedNode(undefined);
@@ -103,27 +121,13 @@ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseCo
}; };
const shouldRecordFeedback = (): boolean => { const shouldRecordFeedback = (): boolean => {
const { explorer } = props;
return explorer.isLastNonEmptyDatabase() || (explorer.isLastDatabase() && explorer.isSelectedDatabaseShared()); return explorer.isLastNonEmptyDatabase() || (explorer.isLastDatabase() && explorer.isSelectedDatabaseShared());
}; };
const props: RightPaneFormProps = {
formError,
isExecuting: isLoading,
submitButtonText: "OK",
onSubmit: () => submit(),
expandConsole: openNotificationConsole,
};
const errorProps: PanelInfoErrorProps = {
messageType: "warning",
showErrorDetails: false,
message:
"Warning! The action you are about to take cannot be undone. Continuing will permanently delete this resource and all of its children resources.",
};
return ( return (
<RightPaneForm {...props}> <form className="panelFormWrapper" onSubmit={submit}>
{!formError && <PanelInfoErrorComponent {...errorProps} />} <PanelInfoErrorComponent {...getPanelErrorProps()} />
<div className="panelMainContent"> <div className="panelMainContent">
<div className="confirmDeleteInput"> <div className="confirmDeleteInput">
<span className="mandatoryStar">* </span> <span className="mandatoryStar">* </span>
@@ -157,6 +161,8 @@ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseCo
</div> </div>
)} )}
</div> </div>
</RightPaneForm> <PanelFooterComponent buttonLabel="OK" />
{isLoading && <PanelLoadingScreen />}
</form>
); );
}; };

View File

@@ -1,10 +1,12 @@
import { IDropdownOption, IImageProps, Image, Stack, Text } from "@fluentui/react";
import { useBoolean } from "@fluentui/react-hooks"; import { useBoolean } from "@fluentui/react-hooks";
import { IDropdownOption, IImageProps, Image, Stack, Text } from "@fluentui/react";
import React, { FunctionComponent, useState } from "react"; import React, { FunctionComponent, useState } from "react";
import AddPropertyIcon from "../../../../images/Add-property.svg"; import AddPropertyIcon from "../../../../images/Add-property.svg";
import { logConsoleError } from "../../../Utils/NotificationConsoleUtils";
import StoredProcedure from "../../Tree/StoredProcedure"; import StoredProcedure from "../../Tree/StoredProcedure";
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm"; import {
GenericRightPaneComponent,
GenericRightPaneProps,
} from "../GenericRightPaneComponent/GenericRightPaneComponent";
import { InputParameter } from "./InputParameter"; import { InputParameter } from "./InputParameter";
interface ExecuteSprocParamsPaneProps { interface ExecuteSprocParamsPaneProps {
@@ -33,11 +35,24 @@ export const ExecuteSprocParamsPane: FunctionComponent<ExecuteSprocParamsPanePro
const [partitionValue, setPartitionValue] = useState<string>(); // Defaulting to undefined here is important. It is not the same partition key as "" const [partitionValue, setPartitionValue] = useState<string>(); // Defaulting to undefined here is important. It is not the same partition key as ""
const [selectedKey, setSelectedKey] = React.useState<IDropdownOption>({ key: "string", text: "" }); const [selectedKey, setSelectedKey] = React.useState<IDropdownOption>({ key: "string", text: "" });
const [formError, setFormError] = useState<string>(""); const [formError, setFormError] = useState<string>("");
const [formErrorsDetails, setFormErrorsDetails] = useState<string>("");
const onPartitionKeyChange = (event: React.FormEvent<HTMLDivElement>, item: IDropdownOption): void => { const onPartitionKeyChange = (event: React.FormEvent<HTMLDivElement>, item: IDropdownOption): void => {
setSelectedKey(item); setSelectedKey(item);
}; };
const genericPaneProps: GenericRightPaneProps = {
expandConsole,
formError: formError,
formErrorDetail: formErrorsDetails,
id: "executesprocparamspane",
isExecuting: isLoading,
title: "Input parameters",
submitButtonText: "Execute",
onClose: () => closePanel(),
onSubmit: () => submit(),
};
const validateUnwrappedParams = (): boolean => { const validateUnwrappedParams = (): boolean => {
const unwrappedParams: UnwrappedExecuteSprocParam[] = paramKeyValues; const unwrappedParams: UnwrappedExecuteSprocParam[] = paramKeyValues;
for (let i = 0; i < unwrappedParams.length; i++) { for (let i = 0; i < unwrappedParams.length; i++) {
@@ -51,7 +66,7 @@ export const ExecuteSprocParamsPane: FunctionComponent<ExecuteSprocParamsPanePro
const setInvalidParamError = (invalidParam: string): void => { const setInvalidParamError = (invalidParam: string): void => {
setFormError(`Invalid param specified: ${invalidParam}`); setFormError(`Invalid param specified: ${invalidParam}`);
logConsoleError(`Invalid param specified: ${invalidParam} is not a valid literal value`); setFormErrorsDetails(`Invalid param specified: ${invalidParam} is not a valid literal value`);
}; };
const submit = (): void => { const submit = (): void => {
@@ -113,16 +128,8 @@ export const ExecuteSprocParamsPane: FunctionComponent<ExecuteSprocParamsPanePro
setParamKeyValues(cloneParamKeyValue); setParamKeyValues(cloneParamKeyValue);
}; };
const props: RightPaneFormProps = {
expandConsole,
formError: formError,
isExecuting: isLoading,
submitButtonText: "Execute",
onSubmit: () => submit(),
};
return ( return (
<RightPaneForm {...props}> <GenericRightPaneComponent {...genericPaneProps}>
<div className="panelFormWrapper"> <div className="panelFormWrapper">
<div className="panelMainContent"> <div className="panelMainContent">
<InputParameter <InputParameter
@@ -162,6 +169,6 @@ export const ExecuteSprocParamsPane: FunctionComponent<ExecuteSprocParamsPanePro
</Stack> </Stack>
</div> </div>
</div> </div>
</RightPaneForm> </GenericRightPaneComponent>
); );
}; };

Some files were not shown because too many files have changed in this diff Show More