mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-28 21:32:05 +00:00
Compare commits
48 Commits
jbunster/t
...
genericRig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ed4465885 | ||
|
|
af5d77d754 | ||
|
|
a52a156005 | ||
|
|
f9e8b5eaaa | ||
|
|
a6b82c8340 | ||
|
|
404b1fc0f1 | ||
|
|
d7c62ac7f1 | ||
|
|
8e6d274b11 | ||
|
|
2d506f0312 | ||
|
|
d76aaca0dd | ||
|
|
14e58e5519 | ||
|
|
2f6dbd83f3 | ||
|
|
0a6c7c0ff9 | ||
|
|
66281447df | ||
|
|
c5f76ac2a9 | ||
|
|
861042c27e | ||
|
|
4ed8fe9e7d | ||
|
|
4c506da7b9 | ||
|
|
a81b1a40a3 | ||
|
|
9d5c9d6296 | ||
|
|
7efa8ca58f | ||
|
|
487fbf2072 | ||
|
|
aa308b3e4d | ||
|
|
ead93b9fa5 | ||
|
|
88491ba6a9 | ||
|
|
fc83484b6c | ||
|
|
e91145234f | ||
|
|
7c4bc9e0c0 | ||
|
|
40d71d3d7a | ||
|
|
0c3f8bd625 | ||
|
|
6bdf1c7f7c | ||
|
|
f048f21def | ||
|
|
38ffa6a003 | ||
|
|
7902df4d16 | ||
|
|
e701dcc881 | ||
|
|
3a6c7f9f94 | ||
|
|
7b5b752d9c | ||
|
|
a0d22960ff | ||
|
|
4c6650760b | ||
|
|
0b1ac8f445 | ||
|
|
96305f50f8 | ||
|
|
3e011f939d | ||
|
|
10961a2f9f | ||
|
|
ba25eea41e | ||
|
|
e17fe25292 | ||
|
|
9494c9cd55 | ||
|
|
8f0bb1add8 | ||
|
|
5c9ab15b3a |
@@ -54,7 +54,6 @@ 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
|
||||||
@@ -85,8 +84,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
|
||||||
@@ -109,16 +108,15 @@ 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/AddCollectionPane.test.ts
|
|
||||||
src/Explorer/Panes/AddCollectionPane.ts
|
|
||||||
src/Explorer/Panes/AddDatabasePane.test.ts
|
|
||||||
src/Explorer/Panes/AddDatabasePane.ts
|
src/Explorer/Panes/AddDatabasePane.ts
|
||||||
|
src/Explorer/Panes/AddDatabasePane.test.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/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
|
||||||
|
|||||||
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -141,6 +141,7 @@ 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
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: wf_segoe-ui_normal;
|
font-family: wf_segoe-ui_normal;
|
||||||
src: url("../../fonts/segoe-ui/west-european/normal/latest.woff");
|
src: local("Segoe UI"), 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;
|
||||||
|
|||||||
@@ -1757,7 +1757,7 @@ input::-webkit-calendar-picker-indicator {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contextual-pane .paneMainContent {
|
.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: auto;
|
overflow-y: hidden;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3085,3 +3085,7 @@ settings-pane {
|
|||||||
padding-left: @SmallSpace;
|
padding-left: @SmallSpace;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.hiddenMain {
|
||||||
|
visibility: hidden;
|
||||||
|
height: 0px;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
.resourceTree {
|
.resourceTree {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
width: 20%;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
.main {
|
.main {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
1030
package-lock.json
generated
1030
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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.10.1",
|
"@fluentui/react": "8.14.3",
|
||||||
"@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,7 +43,6 @@
|
|||||||
"@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",
|
||||||
@@ -57,6 +56,7 @@
|
|||||||
"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,7 +98,8 @@
|
|||||||
"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",
|
||||||
@@ -110,6 +111,7 @@
|
|||||||
"@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",
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
.schema-analyzer-cell-outputs {
|
.schema-analyzer-cell-outputs {
|
||||||
padding: 10px;
|
padding: 10px 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mimic FluentUI8's DocumentCard style
|
||||||
.schema-analyzer-cell-output {
|
.schema-analyzer-cell-output {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
padding: 10px;
|
padding: 14px 20px;
|
||||||
border-radius: 2px;
|
border: 1px solid rgb(237, 235, 233);
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -9,11 +9,17 @@ 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;
|
||||||
@@ -62,6 +68,36 @@ 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
|
||||||
|
|||||||
35
src/Common/CollapsedResourceTree.tsx
Normal file
35
src/Common/CollapsedResourceTree.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -94,6 +94,7 @@ 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 {
|
||||||
|
|||||||
59
src/Common/ResourceTree.tsx
Normal file
59
src/Common/ResourceTree.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
16
src/Common/Tooltip/InfoTooltip.tsx
Normal file
16
src/Common/Tooltip/InfoTooltip.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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>
|
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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 { Tooltip } from "../Tooltip/Tooltip";
|
import { InfoTooltip } from "../Tooltip/InfoTooltip";
|
||||||
|
|
||||||
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>{tooltip}</Tooltip>
|
{tooltip && <InfoTooltip>{tooltip}</InfoTooltip>}
|
||||||
<Stack horizontal>
|
<Stack horizontal>
|
||||||
<TextField styles={{ fieldGroup: { width: 300 } }} readOnly value={selectedFilesTitle.toString()} />
|
<TextField styles={{ fieldGroup: { width: 300 } }} readOnly value={selectedFilesTitle.toString()} />
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -206,17 +206,14 @@ export enum NeighborType {
|
|||||||
BOTH,
|
BOTH,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export interface IGraphConfigUiData {
|
||||||
* Set of observable related to graph configuration by user
|
showNeighborType: NeighborType;
|
||||||
*/
|
nodeProperties: string[];
|
||||||
export interface GraphConfigUiData {
|
nodePropertiesWithNone: string[];
|
||||||
showNeighborType: ko.Observable<NeighborType>;
|
nodeCaptionChoice: string;
|
||||||
nodeProperties: ko.ObservableArray<string>;
|
nodeColorKeyChoice: string;
|
||||||
nodePropertiesWithNone: ko.ObservableArray<string>;
|
nodeIconChoice: string;
|
||||||
nodeCaptionChoice: ko.Observable<string>;
|
nodeIconSet: string;
|
||||||
nodeColorKeyChoice: ko.Observable<string>;
|
|
||||||
nodeIconChoice: ko.Observable<string>;
|
|
||||||
nodeIconSet: ko.Observable<string>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,30 +4,10 @@ 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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,16 +2,10 @@ 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());
|
||||||
@@ -19,7 +13,4 @@ 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("cassandra-add-collection-pane", new PaneComponents.CassandraAddCollectionPaneComponent());
|
ko.components.register("cassandra-add-collection-pane", new PaneComponents.CassandraAddCollectionPaneComponent());
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export const Dialog: FunctionComponent<DialogProps> = ({
|
|||||||
text: secondaryButtonText,
|
text: secondaryButtonText,
|
||||||
onClick: onSecondaryButtonClick,
|
onClick: onSecondaryButtonClick,
|
||||||
}
|
}
|
||||||
: undefined;
|
: {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FluentDialog {...dialogProps}>
|
<FluentDialog {...dialogProps}>
|
||||||
|
|||||||
@@ -1959,7 +1959,7 @@ exports[`test render renders with filters 1`] = `
|
|||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<Component />
|
<FocusRects />
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</DefaultButton>
|
</DefaultButton>
|
||||||
</CustomizedDefaultButton>
|
</CustomizedDefaultButton>
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
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) {}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,20 +1,23 @@
|
|||||||
import {
|
import {
|
||||||
BaseButton,
|
BaseButton,
|
||||||
Button,
|
Button,
|
||||||
FontWeights,
|
DocumentCard,
|
||||||
|
DocumentCardActivity,
|
||||||
|
DocumentCardDetails,
|
||||||
|
DocumentCardPreview,
|
||||||
|
DocumentCardTitle,
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
Image,
|
IDocumentCardPreviewProps,
|
||||||
|
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";
|
||||||
@@ -48,7 +51,6 @@ 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;
|
||||||
@@ -64,9 +66,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 renderTruncatedDescription = (): string => {
|
const renderTruncated = (text: string, totalLength: number): string => {
|
||||||
let truncatedDescription = data.description.substr(0, cardDescriptionMaxChars);
|
let truncatedDescription = text.substr(0, totalLength);
|
||||||
if (data.description.length > cardDescriptionMaxChars) {
|
if (text.length > totalLength) {
|
||||||
truncatedDescription = `${truncatedDescription} ...`;
|
truncatedDescription = `${truncatedDescription} ...`;
|
||||||
}
|
}
|
||||||
return truncatedDescription;
|
return truncatedDescription;
|
||||||
@@ -120,42 +122,35 @@ 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 (
|
||||||
<Card
|
<DocumentCard aria-label={cardTitle} styles={cardStyles} onClick={onClick}>
|
||||||
style={{ background: "white" }}
|
|
||||||
aria-label={cardTitle}
|
|
||||||
data-is-focusable="true"
|
|
||||||
tokens={{ width: CARD_WIDTH, childrenGap: 0 }}
|
|
||||||
onClick={(event) => handlerOnClick(event, onClick)}
|
|
||||||
>
|
|
||||||
{isDeletingPublishedNotebook && (
|
{isDeletingPublishedNotebook && (
|
||||||
<Card.Item tokens={{ padding: cardItemGapBig }}>
|
<Spinner
|
||||||
<Spinner
|
size={SpinnerSize.large}
|
||||||
size={SpinnerSize.large}
|
label={`Deleting '${cardTitle}'`}
|
||||||
label={`Deleting '${cardTitle}'`}
|
styles={{ root: { height: cardDeleteSpinnerHeight } }}
|
||||||
styles={{ root: { height: cardDeleteSpinnerHeight } }}
|
/>
|
||||||
/>
|
|
||||||
</Card.Item>
|
|
||||||
)}
|
)}
|
||||||
{!isDeletingPublishedNotebook && (
|
{!isDeletingPublishedNotebook && (
|
||||||
<>
|
<>
|
||||||
<Card.Item tokens={{ padding: cardItemGapBig }}>
|
<DocumentCardActivity activity={dateString} people={DocumentCardActivityPeople} />
|
||||||
<Persona imageUrl={data.isSample && CosmosDBLogo} text={data.author} secondaryText={dateString} />
|
<DocumentCardPreview {...previewProps} />
|
||||||
</Card.Item>
|
<DocumentCardDetails>
|
||||||
|
<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}>
|
||||||
@@ -167,43 +162,22 @@ export const GalleryCardComponent: FunctionComponent<GalleryCardComponentProps>
|
|||||||
<br />
|
<br />
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
<DocumentCardTitle title={renderTruncated(cardTitle, 20)} shouldTruncate />
|
||||||
<Text
|
<DocumentCardTitle
|
||||||
styles={{
|
title={renderTruncated(data.description, cardDescriptionMaxChars)}
|
||||||
root: {
|
showAsSecondaryTitle
|
||||||
fontWeight: FontWeights.semibold,
|
/>
|
||||||
paddingTop: cardItemGapSmall,
|
<span style={{ padding: "8px 16px" }}>
|
||||||
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>
|
||||||
</Card.Section>
|
</DocumentCardDetails>
|
||||||
|
|
||||||
{cardButtonsVisible && (
|
{cardButtonsVisible && (
|
||||||
<Card.Section
|
<DocumentCardDetails>
|
||||||
styles={{
|
|
||||||
root: {
|
|
||||||
marginLeft: cardItemGapBig,
|
|
||||||
marginRight: cardItemGapBig,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Separator styles={{ root: { padding: 0, height: 1 } }} />
|
<Separator styles={{ root: { padding: 0, height: 1 } }} />
|
||||||
|
|
||||||
<span>
|
<span style={{ padding: "0px 16px" }}>
|
||||||
{isFavorite !== undefined &&
|
{isFavorite !== undefined &&
|
||||||
generateIconButtonWithTooltip(
|
generateIconButtonWithTooltip(
|
||||||
isFavorite ? "HeartFill" : "Heart",
|
isFavorite ? "HeartFill" : "Heart",
|
||||||
@@ -222,10 +196,10 @@ export const GalleryCardComponent: FunctionComponent<GalleryCardComponentProps>
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</Card.Section>
|
</DocumentCardDetails>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</DocumentCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,59 +1,49 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
exports[`GalleryCardComponent renders 1`] = `
|
exports[`GalleryCardComponent renders 1`] = `
|
||||||
<Card
|
<StyledDocumentCardBase
|
||||||
aria-label="name"
|
aria-label="name"
|
||||||
data-is-focusable="true"
|
styles={
|
||||||
onClick={[Function]}
|
|
||||||
style={
|
|
||||||
Object {
|
Object {
|
||||||
"background": "white",
|
"root": Object {
|
||||||
}
|
"display": "inline-block",
|
||||||
}
|
"marginRight": 20,
|
||||||
tokens={
|
"width": 256,
|
||||||
Object {
|
},
|
||||||
"childrenGap": 0,
|
|
||||||
"width": 256,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<CardItem
|
<StyledDocumentCardActivityBase
|
||||||
tokens={
|
activity="Invalid Date"
|
||||||
Object {
|
people={
|
||||||
"padding": 10,
|
Array [
|
||||||
}
|
Object {
|
||||||
}
|
"name": "author",
|
||||||
>
|
"profileImageSrc": false,
|
||||||
<StyledPersonaBase
|
|
||||||
imageUrl={false}
|
|
||||||
secondaryText="Invalid Date"
|
|
||||||
text="author"
|
|
||||||
/>
|
|
||||||
</CardItem>
|
|
||||||
<CardItem>
|
|
||||||
<Image
|
|
||||||
alt="name cover image"
|
|
||||||
height={144}
|
|
||||||
imageFit={2}
|
|
||||||
src="thumbnailUrl"
|
|
||||||
width={256}
|
|
||||||
/>
|
|
||||||
</CardItem>
|
|
||||||
<CardSection
|
|
||||||
styles={
|
|
||||||
Object {
|
|
||||||
"root": Object {
|
|
||||||
"padding": 10,
|
|
||||||
},
|
},
|
||||||
}
|
]
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
|
<StyledDocumentCardPreviewBase
|
||||||
|
previewImages={
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"height": 144,
|
||||||
|
"imageFit": 2,
|
||||||
|
"previewImageSrc": "thumbnailUrl",
|
||||||
|
"width": 256,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StyledDocumentCardDetailsBase>
|
||||||
<Text
|
<Text
|
||||||
nowrap={true}
|
nowrap={true}
|
||||||
styles={
|
styles={
|
||||||
Object {
|
Object {
|
||||||
"root": Object {
|
"root": Object {
|
||||||
"height": 18,
|
"height": 18,
|
||||||
|
"padding": "2px 16px",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,33 +59,21 @@ exports[`GalleryCardComponent renders 1`] = `
|
|||||||
</StyledLinkBase>
|
</StyledLinkBase>
|
||||||
</span>
|
</span>
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<StyledDocumentCardTitleBase
|
||||||
nowrap={true}
|
shouldTruncate={true}
|
||||||
styles={
|
title="name"
|
||||||
|
/>
|
||||||
|
<StyledDocumentCardTitleBase
|
||||||
|
showAsSecondaryTitle={true}
|
||||||
|
title="description"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={
|
||||||
Object {
|
Object {
|
||||||
"root": Object {
|
"padding": "8px 16px",
|
||||||
"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 {
|
||||||
@@ -169,17 +147,8 @@ exports[`GalleryCardComponent renders 1`] = `
|
|||||||
0
|
0
|
||||||
</Text>
|
</Text>
|
||||||
</span>
|
</span>
|
||||||
</CardSection>
|
</StyledDocumentCardDetailsBase>
|
||||||
<CardSection
|
<StyledDocumentCardDetailsBase>
|
||||||
styles={
|
|
||||||
Object {
|
|
||||||
"root": Object {
|
|
||||||
"marginLeft": 10,
|
|
||||||
"marginRight": 10,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Separator
|
<Separator
|
||||||
styles={
|
styles={
|
||||||
Object {
|
Object {
|
||||||
@@ -190,7 +159,13 @@ exports[`GalleryCardComponent renders 1`] = `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span>
|
<span
|
||||||
|
style={
|
||||||
|
Object {
|
||||||
|
"padding": "0px 16px",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
<StyledTooltipHostBase
|
<StyledTooltipHostBase
|
||||||
calloutProps={
|
calloutProps={
|
||||||
Object {
|
Object {
|
||||||
@@ -276,6 +251,6 @@ exports[`GalleryCardComponent renders 1`] = `
|
|||||||
/>
|
/>
|
||||||
</StyledTooltipHostBase>
|
</StyledTooltipHostBase>
|
||||||
</span>
|
</span>
|
||||||
</CardSection>
|
</StyledDocumentCardDetailsBase>
|
||||||
</Card>
|
</StyledDocumentCardBase>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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("CodeOfConductComponent", () => {
|
describe("CodeOfConduct", () => {
|
||||||
let codeOfConductProps: CodeOfConductComponentProps;
|
let codeOfConductProps: CodeOfConductProps;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const junoClient = new JunoClient();
|
const junoClient = new JunoClient();
|
||||||
@@ -21,12 +21,12 @@ describe("CodeOfConductComponent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders", () => {
|
it("renders", () => {
|
||||||
const wrapper = shallow(<CodeOfConductComponent {...codeOfConductProps} />);
|
const wrapper = shallow(<CodeOfConduct {...codeOfConductProps} />);
|
||||||
expect(wrapper).toMatchSnapshot();
|
expect(wrapper).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("onAcceptedCodeOfConductCalled", async () => {
|
it("onAcceptedCodeOfConductCalled", async () => {
|
||||||
const wrapper = shallow(<CodeOfConductComponent {...codeOfConductProps} />);
|
const wrapper = shallow(<CodeOfConduct {...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();
|
||||||
@@ -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 CodeOfConductComponentProps {
|
export interface CodeOfConductProps {
|
||||||
junoClient: JunoClient;
|
junoClient: JunoClient;
|
||||||
onAcceptCodeOfConduct: (result: boolean) => void;
|
onAcceptCodeOfConduct: (result: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CodeOfConductComponent: FunctionComponent<CodeOfConductComponentProps> = ({
|
export const CodeOfConduct: FunctionComponent<CodeOfConductProps> = ({
|
||||||
junoClient,
|
junoClient,
|
||||||
onAcceptCodeOfConduct,
|
onAcceptCodeOfConduct,
|
||||||
}: CodeOfConductComponentProps) => {
|
}: CodeOfConductProps) => {
|
||||||
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 CodeOfConductComponent: FunctionComponent<CodeOfConductComponentPro
|
|||||||
startKey
|
startKey
|
||||||
);
|
);
|
||||||
|
|
||||||
handleError(error, "CodeOfConductComponent/acceptCodeOfConduct", "Failed to accept code of conduct");
|
handleError(error, "CodeOfConduct/acceptCodeOfConduct", "Failed to accept code of conduct");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
exports[`CodeOfConductComponent renders 1`] = `
|
exports[`CodeOfConduct renders 1`] = `
|
||||||
<Stack
|
<Stack
|
||||||
tokens={
|
tokens={
|
||||||
Object {
|
Object {
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -30,11 +30,10 @@ 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 { CodeOfConductComponent } from "./CodeOfConductComponent";
|
import { CodeOfConduct } from "./CodeOfConduct/CodeOfConduct";
|
||||||
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;
|
||||||
@@ -87,7 +86,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";
|
||||||
@@ -373,7 +372,7 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
|
|||||||
{acceptedCodeOfConduct === false && (
|
{acceptedCodeOfConduct === false && (
|
||||||
<Overlay isDarkThemed>
|
<Overlay isDarkThemed>
|
||||||
<div className="publicGalleryTabOverlayContent">
|
<div className="publicGalleryTabOverlayContent">
|
||||||
<CodeOfConductComponent
|
<CodeOfConduct
|
||||||
junoClient={this.props.junoClient}
|
junoClient={this.props.junoClient}
|
||||||
onAcceptCodeOfConduct={(result: boolean) => {
|
onAcceptCodeOfConduct={(result: boolean) => {
|
||||||
this.setState({ isCodeOfConductAccepted: result });
|
this.setState({ isCodeOfConductAccepted: result });
|
||||||
@@ -644,7 +643,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 / CARD_WIDTH) || this.columnCount;
|
this.columnCount = Math.floor(visibleRect.width / GalleryViewerComponent.CARD_WIDTH) || this.columnCount;
|
||||||
this.rowCount = GalleryViewerComponent.rowsPerPage;
|
this.rowCount = GalleryViewerComponent.rowsPerPage;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -672,7 +671,7 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ float: "left", padding: 10 }}>
|
<div style={{ float: "left", padding: 5 }}>
|
||||||
<GalleryCardComponent {...props} />
|
<GalleryCardComponent {...props} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ exports[`ThroughputInputAutoPilotV3Component spendAck checkbox visible 1`] = `
|
|||||||
>
|
>
|
||||||
capacity calculator
|
capacity calculator
|
||||||
|
|
||||||
<Component
|
<FontIcon
|
||||||
iconName="NavigateExternalInline"
|
iconName="NavigateExternalInline"
|
||||||
/>
|
/>
|
||||||
</StyledLinkBase>
|
</StyledLinkBase>
|
||||||
@@ -526,7 +526,7 @@ exports[`ThroughputInputAutoPilotV3Component throughput input visible 1`] = `
|
|||||||
>
|
>
|
||||||
capacity calculator
|
capacity calculator
|
||||||
|
|
||||||
<Component
|
<FontIcon
|
||||||
iconName="NavigateExternalInline"
|
iconName="NavigateExternalInline"
|
||||||
/>
|
/>
|
||||||
</StyledLinkBase>
|
</StyledLinkBase>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`CostEstimateText Pane should render Default properly 1`] = `<Fragment />`;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Checkbox, DirectionalHint, Icon, Link, Stack, Text, TextField, TooltipHost } from "@fluentui/react";
|
import { Checkbox, DirectionalHint, Link, Stack, Text, TextField, TooltipHost } from "@fluentui/react";
|
||||||
import React from "react";
|
import React, { FunctionComponent, useState } 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;
|
||||||
@@ -16,176 +19,25 @@ export interface ThroughputInputProps {
|
|||||||
onCostAcknowledgeChange: (isAcknowledged: boolean) => void;
|
onCostAcknowledgeChange: (isAcknowledged: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThroughputInputState {
|
export const ThroughputInput: FunctionComponent<ThroughputInputProps> = ({
|
||||||
isAutoscaleSelected: boolean;
|
isDatabase,
|
||||||
throughput: number;
|
showFreeTierExceedThroughputTooltip,
|
||||||
isCostAcknowledged: boolean;
|
setThroughputValue,
|
||||||
throughputError: string;
|
setIsAutoscale,
|
||||||
}
|
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>("");
|
||||||
|
|
||||||
export class ThroughputInput extends React.Component<ThroughputInputProps, ThroughputInputState> {
|
setIsAutoscale(isAutoscaleSelected);
|
||||||
constructor(props: ThroughputInputProps) {
|
setThroughputValue(throughput);
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
const getThroughputLabelText = (): string => {
|
||||||
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">* </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
|
|
||||||
<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
|
|
||||||
<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">* </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 (this.state.isAutoscaleSelected) {
|
if (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();
|
||||||
@@ -194,29 +46,27 @@ export class ThroughputInput extends React.Component<ThroughputInputProps, Throu
|
|||||||
: "unlimited";
|
: "unlimited";
|
||||||
throughputHeaderText = `throughput (${minRU} - ${maxRU} RU/s)`;
|
throughputHeaderText = `throughput (${minRU} - ${maxRU} RU/s)`;
|
||||||
}
|
}
|
||||||
|
return `${isDatabase ? "Database" : getCollectionName()} ${throughputHeaderText}`;
|
||||||
|
};
|
||||||
|
|
||||||
return `${this.props.isDatabase ? "Database" : getCollectionName()} ${throughputHeaderText}`;
|
const onThroughputValueChange = (newInput: string): void => {
|
||||||
}
|
|
||||||
|
|
||||||
private onThroughputValueChange(newInput: string): void {
|
|
||||||
const newThroughput = parseInt(newInput);
|
const newThroughput = parseInt(newInput);
|
||||||
this.setState({ throughput: newThroughput });
|
setThroughput(newThroughput);
|
||||||
this.props.setThroughputValue(newThroughput);
|
setThroughputValue(newThroughput);
|
||||||
|
if (!isSharded && newThroughput > 10000) {
|
||||||
if (!this.props.isSharded && newThroughput > 10000) {
|
setThroughputError("Unsharded collections support up to 10,000 RUs");
|
||||||
this.setState({ throughputError: "Unsharded collections support up to 10,000 RUs" });
|
|
||||||
} else {
|
} else {
|
||||||
this.setState({ throughputError: undefined });
|
setThroughputError("");
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
private getAutoScaleTooltip(): string {
|
const 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.`;
|
||||||
}
|
};
|
||||||
|
|
||||||
private getCostAcknowledgeText(): string {
|
const getCostAcknowledgeText = (): string => {
|
||||||
const { databaseAccount } = userContext;
|
const databaseAccount = userContext.databaseAccount;
|
||||||
if (!databaseAccount || !databaseAccount.properties) {
|
if (!databaseAccount || !databaseAccount.properties) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -225,98 +75,163 @@ export class ThroughputInput extends React.Component<ThroughputInputProps, Throu
|
|||||||
const multimasterEnabled: boolean = databaseAccount.properties.enableMultipleWriteLocations;
|
const multimasterEnabled: boolean = databaseAccount.properties.enableMultipleWriteLocations;
|
||||||
|
|
||||||
return PricingUtils.getEstimatedSpendAcknowledgeString(
|
return PricingUtils.getEstimatedSpendAcknowledgeString(
|
||||||
this.state.throughput,
|
throughput,
|
||||||
userContext.portalEnv,
|
userContext.portalEnv,
|
||||||
numberOfRegions,
|
numberOfRegions,
|
||||||
multimasterEnabled,
|
multimasterEnabled,
|
||||||
this.state.isAutoscaleSelected
|
isAutoscaleSelected
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
private onAutoscaleRadioBtnChange(event: React.ChangeEvent<HTMLInputElement>): void {
|
const handleOnChangeMode = (event: React.ChangeEvent<HTMLInputElement>, mode: string): void => {
|
||||||
if (event.target.checked && !this.state.isAutoscaleSelected) {
|
if (mode === "Autoscale") {
|
||||||
this.setState({ isAutoscaleSelected: true, throughput: AutoPilotUtils.minAutoPilotThroughput });
|
setThroughput(AutoPilotUtils.minAutoPilotThroughput);
|
||||||
this.props.setIsAutoscale(true);
|
setIsAutoScaleSelected(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 (
|
||||||
<Text variant="small">
|
<div className="throughputInputContainer throughputInputSpacing">
|
||||||
Estimated cost ({currency}){iconWithEstimatedCostDisclaimer}:{" "}
|
<Stack horizontal>
|
||||||
<b>
|
<span className="mandatoryStar">* </span>
|
||||||
{currencySign + PricingUtils.calculateEstimateNumber(hourlyPrice)} hourly /{" "}
|
<Text aria-label="Throughput header" variant="small" style={{ lineHeight: "20px", fontWeight: 600 }}>
|
||||||
{currencySign + PricingUtils.calculateEstimateNumber(dailyPrice)} daily /{" "}
|
{getThroughputLabelText()}
|
||||||
{currencySign + PricingUtils.calculateEstimateNumber(monthlyPrice)} monthly{" "}
|
</Text>
|
||||||
</b>
|
<InfoTooltip>{PricingUtils.getRuToolTipText()}</InfoTooltip>
|
||||||
({numberOfRegions + (numberOfRegions === 1 ? " region" : " regions")}, {requestUnits}RU/s,{" "}
|
</Stack>
|
||||||
{currencySign + pricePerRu}/RU)
|
|
||||||
</Text>
|
<Stack horizontal verticalAlign="center">
|
||||||
|
<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
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -31,9 +31,10 @@ 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 } from "../Utils/APITypeUtils";
|
import { getCollectionName, getDatabaseName, getUploadName } 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";
|
||||||
@@ -45,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 AddDatabasePane from "./Panes/AddDatabasePane";
|
import { AddDatabasePanel } from "./Panes/AddDatabasePanel/AddDatabasePanel";
|
||||||
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";
|
||||||
@@ -59,7 +60,6 @@ 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 +67,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";
|
import { TableQuerySelectPanel } from "./Panes/Tables/TableQuerySelectPanel/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,7 +91,7 @@ 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) => void;
|
openSidePanel: (headerText: string, panelContent: JSX.Element, onClose?: () => void) => void;
|
||||||
closeSidePanel: () => void;
|
closeSidePanel: () => void;
|
||||||
closeDialog: () => void;
|
closeDialog: () => void;
|
||||||
openDialog: (props: DialogProps) => void;
|
openDialog: (props: DialogProps) => void;
|
||||||
@@ -100,7 +100,6 @@ export interface ExplorerParams {
|
|||||||
|
|
||||||
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>;
|
||||||
@@ -125,14 +124,13 @@ export default class Explorer {
|
|||||||
|
|
||||||
// Panes
|
// Panes
|
||||||
public contextPanes: ContextualPaneBase[];
|
public contextPanes: ContextualPaneBase[];
|
||||||
public openSidePanel: (headerText: string, panelContent: JSX.Element) => void;
|
public openSidePanel: (headerText: string, panelContent: JSX.Element, onClose?: () => void) => 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;
|
||||||
|
|
||||||
@@ -149,9 +147,6 @@ 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;
|
||||||
@@ -163,7 +158,6 @@ 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
|
||||||
@@ -183,7 +177,6 @@ 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;
|
||||||
@@ -212,7 +205,6 @@ export default class Explorer {
|
|||||||
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");
|
||||||
@@ -230,6 +222,7 @@ 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 = ko.observable(false);
|
||||||
@@ -334,7 +327,6 @@ 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
|
||||||
@@ -368,7 +360,7 @@ export default class Explorer {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return userContext.apiType === "Mongo";
|
return isCapabilityEnabled("EnableMongo");
|
||||||
});
|
});
|
||||||
|
|
||||||
this.isServerlessEnabled = ko.computed(
|
this.isServerlessEnabled = ko.computed(
|
||||||
@@ -405,28 +397,6 @@ 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),
|
||||||
@@ -442,13 +412,6 @@ 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(
|
||||||
@@ -466,67 +429,43 @@ 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;
|
||||||
@@ -706,16 +645,8 @@ export default class Explorer {
|
|||||||
this.setIsNotificationConsoleExpanded(true);
|
this.setIsNotificationConsoleExpanded(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public toggleLeftPaneExpanded() {
|
public collapseConsole(): void {
|
||||||
this.isLeftPaneExpanded(!this.isLeftPaneExpanded());
|
this.setIsNotificationConsoleExpanded(false);
|
||||||
|
|
||||||
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> {
|
||||||
@@ -834,14 +765,6 @@ 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");
|
||||||
@@ -1096,6 +1019,9 @@ 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 {
|
||||||
@@ -1104,10 +1030,6 @@ 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" ||
|
||||||
@@ -1303,10 +1225,18 @@ export default class Explorer {
|
|||||||
public async publishNotebook(
|
public async publishNotebook(
|
||||||
name: string,
|
name: string,
|
||||||
content: NotebookPaneContent,
|
content: NotebookPaneContent,
|
||||||
parentDomElement?: HTMLElement
|
notebookContentRef?: string,
|
||||||
|
onTakeSnapshot?: (request: SnapshotRequest) => void,
|
||||||
|
onClosePanel?: () => void
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (this.notebookManager) {
|
if (this.notebookManager) {
|
||||||
await this.notebookManager.openPublishNotebookPane(name, content, parentDomElement);
|
await this.notebookManager.openPublishNotebookPane(
|
||||||
|
name,
|
||||||
|
content,
|
||||||
|
notebookContentRef,
|
||||||
|
onTakeSnapshot,
|
||||||
|
onClosePanel
|
||||||
|
);
|
||||||
this.isPublishNotebookPaneEnabled(true);
|
this.isPublishNotebookPaneEnabled(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1437,7 +1367,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={() => {
|
||||||
@@ -1468,7 +1398,7 @@ export default class Explorer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.openSidePanel(
|
this.openSidePanel(
|
||||||
"",
|
"Create new directory",
|
||||||
<StringInputPane
|
<StringInputPane
|
||||||
explorer={this}
|
explorer={this}
|
||||||
closePanel={() => {
|
closePanel={() => {
|
||||||
@@ -1845,9 +1775,6 @@ export default class Explorer {
|
|||||||
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);
|
||||||
}
|
}
|
||||||
@@ -1896,7 +1823,6 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1961,10 +1887,10 @@ export default class Explorer {
|
|||||||
|
|
||||||
public openDeleteDatabaseConfirmationPane(): void {
|
public openDeleteDatabaseConfirmationPane(): void {
|
||||||
this.openSidePanel(
|
this.openSidePanel(
|
||||||
"Delete Database",
|
"Delete " + getDatabaseName(),
|
||||||
<DeleteDatabaseConfirmationPanel
|
<DeleteDatabaseConfirmationPanel
|
||||||
explorer={this}
|
explorer={this}
|
||||||
openNotificationConsole={this.expandConsole}
|
openNotificationConsole={() => this.expandConsole()}
|
||||||
closePanel={this.closeSidePanel}
|
closePanel={this.closeSidePanel}
|
||||||
selectedDatabase={this.findSelectedDatabase()}
|
selectedDatabase={this.findSelectedDatabase()}
|
||||||
/>
|
/>
|
||||||
@@ -1972,12 +1898,12 @@ export default class Explorer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public openUploadItemsPanePane(): void {
|
public openUploadItemsPanePane(): void {
|
||||||
this.openSidePanel("Upload", <UploadItemsPane explorer={this} closePanel={this.closeSidePanel} />);
|
this.openSidePanel("Upload " + getUploadName(), <UploadItemsPane explorer={this} />);
|
||||||
}
|
}
|
||||||
|
|
||||||
public openSettingPane(): void {
|
public openSettingPane(): void {
|
||||||
this.openSidePanel(
|
this.openSidePanel(
|
||||||
"Settings",
|
"Setting",
|
||||||
<SettingsPane expandConsole={() => this.expandConsole()} closePanel={this.closeSidePanel} />
|
<SettingsPane expandConsole={() => this.expandConsole()} closePanel={this.closeSidePanel} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2005,6 +1931,16 @@ 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} />);
|
||||||
@@ -2021,7 +1957,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",
|
"Upload file to notebook server",
|
||||||
<UploadFilePane
|
<UploadFilePane
|
||||||
expandConsole={() => this.expandConsole()}
|
expandConsole={() => this.expandConsole()}
|
||||||
closePanel={this.closeSidePanel}
|
closePanel={this.closeSidePanel}
|
||||||
|
|||||||
@@ -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({
|
||||||
graphConfig: GraphTab.createGraphConfig(),
|
igraphConfig: GraphTab.createIGraphConfig(),
|
||||||
onHighlightedNode: sinon.spy(),
|
onHighlightedNode: sinon.spy(),
|
||||||
onLoadMoreData: (action: LoadMoreDataAction): void => {},
|
onLoadMoreData: (action: LoadMoreDataAction): void => {},
|
||||||
|
|
||||||
@@ -141,6 +141,7 @@ 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);
|
||||||
@@ -150,7 +151,7 @@ describe("D3ForceGraph", () => {
|
|||||||
expect(onHighlightedNode.id).toEqual(v1Id);
|
expect(onHighlightedNode.id).toEqual(v1Id);
|
||||||
};
|
};
|
||||||
|
|
||||||
forceGraph.updateGraph(newGraph);
|
forceGraph.updateGraph(newGraph, forceGraph.igraphConfig);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { GraphConfig } from "../../Tabs/GraphTab";
|
import { IGraphConfig } from "./../../Tabs/GraphTab";
|
||||||
import { D3Link, D3Node, GraphData } from "./GraphData";
|
import { D3Link, D3Node, GraphData } from "./GraphData";
|
||||||
import { GraphExplorer } from "./GraphExplorer";
|
import { GraphExplorer } from "./GraphExplorer";
|
||||||
|
|
||||||
@@ -48,21 +48,22 @@ interface ZoomTransform extends Point2D {
|
|||||||
|
|
||||||
export interface D3ForceGraphParameters {
|
export interface D3ForceGraphParameters {
|
||||||
// Graph to parent
|
// Graph to parent
|
||||||
graphConfig: GraphConfig;
|
|
||||||
onHighlightedNode: (highlightedNode: D3GraphNodeData) => void; // a new node has been highlighted in the graph
|
igraphConfig: IGraphConfig;
|
||||||
onLoadMoreData: (action: LoadMoreDataAction) => void;
|
onHighlightedNode?: (highlightedNode: D3GraphNodeData) => void; // a new node has been highlighted in the graph
|
||||||
|
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>): void;
|
updateGraph(graphData: GraphData<D3Node, D3Link>, igraphConfigParam?: IGraphConfig): void;
|
||||||
enableHighlight(enable: boolean): void;
|
enableHighlight(enable: boolean): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +109,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
|
||||||
@@ -119,9 +120,11 @@ 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;
|
||||||
@@ -151,7 +154,10 @@ export class D3ForceGraph implements GraphRenderer {
|
|||||||
this.g.remove();
|
this.g.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
public updateGraph(newGraph: GraphData<D3Node, D3Link>): void {
|
public updateGraph(newGraph: GraphData<D3Node, D3Link>, igraphConfigParam?: IGraphConfig): void {
|
||||||
|
if (igraphConfigParam) {
|
||||||
|
this.igraphConfig = igraphConfigParam;
|
||||||
|
}
|
||||||
if (!newGraph || !this.simulation) {
|
if (!newGraph || !this.simulation) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -159,7 +165,8 @@ 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.params.graphConfig.nodeColorKey();
|
const key = this.igraphConfig.nodeColorKey;
|
||||||
|
|
||||||
if (key !== GraphExplorer.NONE_CHOICE) {
|
if (key !== GraphExplorer.NONE_CHOICE) {
|
||||||
this.updateUniqueValues(key);
|
this.updateUniqueValues(key);
|
||||||
}
|
}
|
||||||
@@ -265,20 +272,7 @@ export class D3ForceGraph implements GraphRenderer {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Redraw if any of these configs change
|
this.redrawGraph();
|
||||||
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
|
||||||
|
|
||||||
@@ -371,7 +365,10 @@ 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 = { x: this.width / 2 - targetPosition.x, y: this.height / 2 - targetPosition.y };
|
const offset = {
|
||||||
|
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) {
|
||||||
@@ -526,7 +523,10 @@ 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) || { x: viewCenter.x, y: viewCenter.y };
|
const finalPos = nodeFinalPositionMap.get(d.id) || {
|
||||||
|
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 nodes = this.simulation.nodes();
|
const nodes1 = this.simulation.nodes();
|
||||||
this.redrawGraph();
|
this.redrawGraph();
|
||||||
|
|
||||||
this.animateBigBang(nodes, newNodes);
|
this.animateBigBang(nodes1, 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.params.graphConfig.linkWidth())
|
.attr("stroke-width", this.igraphConfig.linkWidth)
|
||||||
.attr("stroke", this.params.graphConfig.linkColor());
|
.attr("stroke", this.igraphConfig.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.params.graphConfig.linkColor())
|
.attr("fill", this.igraphConfig.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.params.graphConfig.nodeSize());
|
.attr("r", this.igraphConfig.nodeSize);
|
||||||
|
|
||||||
var iconGroup = newNodes
|
var iconGroup = newNodes
|
||||||
.append("g")
|
.append("g")
|
||||||
@@ -733,22 +733,23 @@ 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 (_: MouseEvent, d: D3Node) {
|
.on("dblclick", function (this: Element, _: 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 (_: MouseEvent, d: D3Node) {
|
.on("click", function (this: Element, _: 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 (event: KeyboardEvent, d: D3Node) {
|
.on("keypress", function (this: Element, 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.params.graphConfig.nodeSize();
|
var nodeSize = this.igraphConfig.nodeSize;
|
||||||
var bgsize = nodeSize + 1;
|
var bgsize = nodeSize + 1;
|
||||||
|
|
||||||
iconGroup
|
iconGroup
|
||||||
@@ -758,7 +759,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.params.graphConfig.nodeIconKey() ? 1 : 0;
|
return this.igraphConfig.nodeIconKey ? 1 : 0;
|
||||||
})
|
})
|
||||||
.attr("class", "icon-background");
|
.attr("class", "icon-background");
|
||||||
|
|
||||||
@@ -766,14 +767,13 @@ 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.params.graphConfig);
|
return D3ForceGraph.computeImageData(d, this.igraphConfig);
|
||||||
})
|
})
|
||||||
.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.params.graphConfig.linkColor());
|
.style("stroke", this.igraphConfig.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.params.graphConfig.linkColor());
|
.style("stroke", this.igraphConfig.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.params.graphConfig.nodeColor())
|
.style("fill", this.igraphConfig.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.params.graphConfig.nodeSize();
|
var nodeSize = this.igraphConfig.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.params.graphConfig.nodeColor());
|
this.svg.selectAll("#loadMoreIcon ellipse").attr("fill", this.igraphConfig.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.params.graphConfig.nodeColorKey()) {
|
if (this.igraphConfig.nodeColorKey) {
|
||||||
const val = GraphData.getNodePropValue(d, this.params.graphConfig.nodeColorKey());
|
const val = GraphData.getNodePropValue(d, this.igraphConfig.nodeColorKey);
|
||||||
return this.lookupColorFromKey(<string>val);
|
return this.lookupColorFromKey(<string>val);
|
||||||
} else {
|
} else {
|
||||||
return this.params.graphConfig.nodeColor();
|
return this.igraphConfig.nodeColor;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1102,12 +1102,12 @@ export class D3ForceGraph implements GraphRenderer {
|
|||||||
this.graphDataWrapper.getTargetsForId(nodeId)
|
this.graphDataWrapper.getTargetsForId(nodeId)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})(this.params.graphConfig.showNeighborType());
|
})(this.igraphConfig.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.params.graphConfig.showNeighborType()) {
|
switch (this.igraphConfig.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.params.graphConfig.nodeCaption();
|
let key = this.igraphConfig.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,10 +1193,16 @@ export class D3ForceGraph implements GraphRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private positionLinkEnd(l: D3Link) {
|
private positionLinkEnd(l: D3Link) {
|
||||||
const source: Point2D = { x: (<D3Node>l.source).x, y: (<D3Node>l.source).y };
|
const source: Point2D = {
|
||||||
const target: Point2D = { x: (<D3Node>l.target).x, y: (<D3Node>l.target).y };
|
x: (<D3Node>l.source).x,
|
||||||
|
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.params.graphConfig.nodeSize() + 3;
|
var radius = this.igraphConfig.nodeSize + 3;
|
||||||
|
|
||||||
// End
|
// End
|
||||||
const dx = target.x - d1.x;
|
const dx = target.x - d1.x;
|
||||||
@@ -1209,10 +1215,16 @@ export class D3ForceGraph implements GraphRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private positionLink(l: D3Link) {
|
private positionLink(l: D3Link) {
|
||||||
const source: Point2D = { x: (<D3Node>l.source).x, y: (<D3Node>l.source).y };
|
const source: Point2D = {
|
||||||
const target: Point2D = { x: (<D3Node>l.target).x, y: (<D3Node>l.target).y };
|
x: (<D3Node>l.source).x,
|
||||||
|
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.params.graphConfig.nodeSize() + 3;
|
var radius = this.igraphConfig.nodeSize + 3;
|
||||||
|
|
||||||
// Start
|
// Start
|
||||||
var dx = d1.x - source.x;
|
var dx = d1.x - source.x;
|
||||||
@@ -1244,13 +1256,13 @@ export class D3ForceGraph implements GraphRenderer {
|
|||||||
return d._isRoot ? "node root" : "node";
|
return d._isRoot ? "node root" : "node";
|
||||||
});
|
});
|
||||||
|
|
||||||
this.applyConfig(this.params.graphConfig);
|
this.applyConfig(this.igraphConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static computeImageData(d: D3Node, config: GraphConfig): string {
|
private static computeImageData(d: D3Node, config: IGraphConfig): 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;
|
||||||
}
|
}
|
||||||
@@ -1260,48 +1272,46 @@ export class D3ForceGraph implements GraphRenderer {
|
|||||||
/**
|
/**
|
||||||
* Update graph according to configuration or use default
|
* Update graph according to configuration or use default
|
||||||
*/
|
*/
|
||||||
private applyConfig(config: GraphConfig) {
|
private applyConfig(config: IGraphConfig) {
|
||||||
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", this.params.graphConfig.nodeColor());
|
this.svg.selectAll("#loadMoreIcon ellipse").attr("fill", config.nodeColor);
|
||||||
|
this.g.selectAll(".link").attr("stroke-width", config.linkWidth);
|
||||||
|
|
||||||
this.g.selectAll(".link").attr("stroke-width", config.linkWidth());
|
this.g.selectAll(".link").attr("stroke", config.linkColor);
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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 React from "react";
|
|
||||||
import * as sinon from "sinon";
|
|
||||||
import { mount, ReactWrapper } from "enzyme";
|
import { mount, ReactWrapper } from "enzyme";
|
||||||
import * as Q from "q";
|
import * as Q from "q";
|
||||||
|
import React from "react";
|
||||||
|
import * as sinon from "sinon";
|
||||||
import "../../../../externals/jquery.typeahead.min";
|
import "../../../../externals/jquery.typeahead.min";
|
||||||
import { GraphExplorer, GraphExplorerProps, GraphAccessor, GraphHighlightedNodeData } from "./GraphExplorer";
|
|
||||||
import * as D3ForceGraph from "./D3ForceGraph";
|
|
||||||
import { GraphData } from "./GraphData";
|
|
||||||
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 { queryDocuments } from "../../../Common/dataAccess/queryDocuments";
|
||||||
import { queryDocumentsPage } from "../../../Common/dataAccess/queryDocumentsPage";
|
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 { GraphData } from "./GraphData";
|
||||||
|
import { GraphAccessor, GraphExplorer, GraphExplorerProps, GraphHighlightedNodeData } from "./GraphExplorer";
|
||||||
|
|
||||||
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 graphConfig = GraphTab.createGraphConfig();
|
const igraphConfig = GraphTab.createIGraphConfig();
|
||||||
const graphConfigUi = GraphTab.createGraphConfigUiData(graphConfig);
|
const igraphConfigUi = GraphTab.createIGraphConfigUiData(igraphConfig);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onGraphAccessorCreated: (instance: GraphAccessor): void => {},
|
onGraphAccessorCreated: (instance: GraphAccessor): void => {},
|
||||||
@@ -170,8 +170,9 @@ describe("GraphExplorer", () => {
|
|||||||
resourceId: "resourceId",
|
resourceId: "resourceId",
|
||||||
|
|
||||||
/* TODO Figure out how to make this Knockout-free */
|
/* TODO Figure out how to make this Knockout-free */
|
||||||
graphConfigUiData: graphConfigUi,
|
igraphConfigUiData: igraphConfigUi,
|
||||||
graphConfig: graphConfig,
|
igraphConfig: igraphConfig,
|
||||||
|
setIConfigUiData: (data: string[]): void => {},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 { GraphConfig } from "../../Tabs/GraphTab";
|
import { IGraphConfig } 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,9 +58,10 @@ export interface GraphExplorerProps {
|
|||||||
onLoadStartKeyChange: (newKey: number) => void;
|
onLoadStartKeyChange: (newKey: number) => void;
|
||||||
resourceId: string;
|
resourceId: string;
|
||||||
|
|
||||||
/* TODO Figure out how to make this Knockout-free */
|
igraphConfigUiData: ViewModels.IGraphConfigUiData;
|
||||||
graphConfigUiData: ViewModels.GraphConfigUiData;
|
igraphConfig: IGraphConfig;
|
||||||
graphConfig?: GraphConfig;
|
|
||||||
|
setIConfigUiData?: (data: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphHighlightedNodeData {
|
export interface GraphHighlightedNodeData {
|
||||||
@@ -121,6 +122,10 @@ 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 {
|
||||||
@@ -218,6 +223,8 @@ 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 = {
|
||||||
@@ -237,6 +244,9 @@ 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
|
||||||
@@ -284,41 +294,27 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
|
|||||||
this.setGremlinParams();
|
this.setGremlinParams();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* TODO Make this Knockout-free ! */
|
const selectedNode = this.state.highlightedNode;
|
||||||
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"]
|
||||||
@@ -408,7 +404,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.updateGraphData(this.originalGraphData, this.state.igraphConfig);
|
||||||
})
|
})
|
||||||
.then(
|
.then(
|
||||||
() => {
|
() => {
|
||||||
@@ -446,7 +442,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.updateGraphData(graphData, this.state.igraphConfig);
|
||||||
this.setState({ highlightedNode: null });
|
this.setState({ highlightedNode: null });
|
||||||
|
|
||||||
// Remove from root map
|
// Remove from root map
|
||||||
@@ -582,7 +578,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.updateGraphData(graphData, this.state.igraphConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
vertex._outEdgeIds = vertex._outEdgeIds || [];
|
vertex._outEdgeIds = vertex._outEdgeIds || [];
|
||||||
@@ -788,7 +784,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.updateGraphData(graphData, this.state.igraphConfig);
|
||||||
},
|
},
|
||||||
(error: string) => {
|
(error: string) => {
|
||||||
GraphExplorer.reportToConsole(
|
GraphExplorer.reportToConsole(
|
||||||
@@ -809,7 +805,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.updateGraphData(graphData, this.state.igraphConfig);
|
||||||
},
|
},
|
||||||
(error: string) => {
|
(error: string) => {
|
||||||
GraphExplorer.reportToConsole(
|
GraphExplorer.reportToConsole(
|
||||||
@@ -858,7 +854,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.updateGraphData(new GraphData.GraphData(), this.state.igraphConfig);
|
||||||
this.setState({ highlightedNode: null });
|
this.setState({ highlightedNode: null });
|
||||||
GraphExplorer.reportToConsole(ConsoleDataType.Info, "Query result is empty");
|
GraphExplorer.reportToConsole(ConsoleDataType.Info, "Query result is empty");
|
||||||
}
|
}
|
||||||
@@ -940,7 +936,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.updateGraphData(graphData, this.state.igraphConfig);
|
||||||
this.collectNodeProperties(this.originalGraphData.vertices);
|
this.collectNodeProperties(this.originalGraphData.vertices);
|
||||||
|
|
||||||
// Keep new vertex selected
|
// Keep new vertex selected
|
||||||
@@ -1121,8 +1117,13 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
|
|||||||
return rootMap[id];
|
return rootMap[id];
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
if (this.props.graphConfigUiData.nodeProperties().indexOf(GraphExplorer.DISPLAY_DEFAULT_PROPERTY_KEY) !== -1) {
|
if (this.state.igraphConfigUiData.nodeProperties.indexOf(GraphExplorer.DISPLAY_DEFAULT_PROPERTY_KEY) !== -1) {
|
||||||
this.props.graphConfigUiData.nodeCaptionChoice(GraphExplorer.DISPLAY_DEFAULT_PROPERTY_KEY);
|
this.setState({
|
||||||
|
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
|
||||||
@@ -1139,7 +1140,12 @@ 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.props.graphConfig.nodeIconKey(null);
|
this.setState({
|
||||||
|
igraphConfig: {
|
||||||
|
...this.state.igraphConfig,
|
||||||
|
nodeIconKey: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1163,8 +1169,13 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update graph configuration
|
// Update graph configuration
|
||||||
this.props.graphConfig.iconsMap(newIconsMap);
|
this.setState({
|
||||||
this.props.graphConfig.nodeIconKey(nodeProp);
|
igraphConfig: {
|
||||||
|
...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}`);
|
||||||
@@ -1209,7 +1220,7 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getPossibleRootNodes(): LeftPane.CaptionId[] {
|
private getPossibleRootNodes(): LeftPane.CaptionId[] {
|
||||||
const key = this.props.graphConfigUiData.nodeCaptionChoice();
|
const key = this.state.igraphConfig.nodeCaption;
|
||||||
return $.map(
|
return $.map(
|
||||||
this.state.rootMap,
|
this.state.rootMap,
|
||||||
(value: any, index: number): LeftPane.CaptionId => {
|
(value: any, index: number): LeftPane.CaptionId => {
|
||||||
@@ -1320,7 +1331,7 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeCaption = this.props.graphConfigUiData.nodeCaptionChoice();
|
const nodeCaption = this.state.igraphConfigUiData.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;
|
||||||
}
|
}
|
||||||
@@ -1410,7 +1421,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.props.graphConfigUiData.nodeCaptionChoice() || "id"
|
this.state.igraphConfigUiData.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[]) => {
|
||||||
@@ -1539,9 +1550,14 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
|
|||||||
});
|
});
|
||||||
|
|
||||||
const values = Object.keys(props);
|
const values = Object.keys(props);
|
||||||
this.props.graphConfigUiData.nodeProperties(values);
|
this.setState({
|
||||||
// TODO This should move out of GraphExplorer
|
igraphConfigUiData: {
|
||||||
this.props.graphConfigUiData.nodePropertiesWithNone([GraphExplorer.NONE_CHOICE].concat(values));
|
...this.state.igraphConfigUiData,
|
||||||
|
nodeProperties: values,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.props.setIConfigUiData(values);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1566,9 +1582,8 @@ 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.props.graphConfigUiData.nodeCaptionChoice();
|
let nodeCaption = this.state.igraphConfigUiData.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,
|
||||||
@@ -1615,7 +1630,12 @@ 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({ name: caption, id: neighborId, edgeId: edge.id, edgeLabel: p });
|
sources.push({
|
||||||
|
name: caption,
|
||||||
|
id: neighborId,
|
||||||
|
edgeId: edge.id,
|
||||||
|
edgeLabel: p,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1629,7 +1649,12 @@ 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({ name: caption, id: neighborId, edgeId: edge.id, edgeLabel: p });
|
targets.push({
|
||||||
|
name: caption,
|
||||||
|
id: neighborId,
|
||||||
|
edgeId: edge.id,
|
||||||
|
edgeLabel: p,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1678,14 +1703,17 @@ 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(graphData: GraphData.GraphData<GraphData.GremlinVertex, GraphData.GremlinEdge>) {
|
private updateGraphData(
|
||||||
|
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);
|
this.d3ForceGraph.updateGraph(gd, igraphConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
public onMiddlePaneInitialized(instance: D3ForceGraph.GraphRenderer): void {
|
public onMiddlePaneInitialized(instance: D3ForceGraph.GraphRenderer): void {
|
||||||
@@ -1694,10 +1722,12 @@ export class GraphExplorer extends React.Component<GraphExplorerProps, GraphExpl
|
|||||||
|
|
||||||
private renderMiddlePane(): JSX.Element {
|
private renderMiddlePane(): JSX.Element {
|
||||||
const forceGraphParams: D3ForceGraph.D3ForceGraphParameters = {
|
const forceGraphParams: D3ForceGraph.D3ForceGraphParameters = {
|
||||||
graphConfig: this.props.graphConfig,
|
igraphConfig: this.state.igraphConfig,
|
||||||
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 => this.onMiddlePaneInitialized(instance),
|
onInitialized: (instance: D3ForceGraph.GraphRenderer): void => {
|
||||||
|
this.onMiddlePaneInitialized(instance);
|
||||||
|
},
|
||||||
onGraphUpdated: this.onGraphUpdated.bind(this),
|
onGraphUpdated: this.onGraphUpdated.bind(this),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
131
src/Explorer/Graph/GraphStyleComponent/GraphStyleComponent.tsx
Normal file
131
src/Explorer/Graph/GraphStyleComponent/GraphStyleComponent.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`Graph Style Component should render default property 1`] = `[Function]`;
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -132,6 +132,7 @@ 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>
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ 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,
|
||||||
|
|||||||
@@ -126,7 +126,6 @@ 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: {
|
||||||
@@ -221,7 +220,6 @@ 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: {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ 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";
|
||||||
@@ -261,13 +262,12 @@ function createOpenSynapseLinkDialogButton(container: Explorer): CommandButtonCo
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createNewDatabase(container: Explorer): CommandButtonComponentProps {
|
function createNewDatabase(container: Explorer): CommandButtonComponentProps {
|
||||||
const label = container.addDatabaseText();
|
const label = "New " + getDatabaseName();
|
||||||
return {
|
return {
|
||||||
iconSrc: AddDatabaseIcon,
|
iconSrc: AddDatabaseIcon,
|
||||||
iconAlt: label,
|
iconAlt: label,
|
||||||
onCommandClick: () => {
|
onCommandClick: () => {
|
||||||
container.addDatabasePane.open();
|
container.openAddDatabasePane();
|
||||||
document.getElementById("linkAddDatabase").focus();
|
|
||||||
},
|
},
|
||||||
commandButtonLabel: label,
|
commandButtonLabel: label,
|
||||||
ariaLabel: label,
|
ariaLabel: label,
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// 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}`;
|
||||||
|
}
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
import * as React from "react";
|
|
||||||
|
|
||||||
import { ReactAdapter } from "../../../Bindings/ReactBindingHandler";
|
|
||||||
import { NotebookClientV2 } from "../NotebookClientV2";
|
|
||||||
|
|
||||||
// Vendor modules
|
// Vendor modules
|
||||||
import { actions, createContentRef, createKernelRef, selectors } from "@nteract/core";
|
import { actions, createContentRef, createKernelRef, selectors } from "@nteract/core";
|
||||||
import VirtualCommandBarComponent from "./VirtualCommandBarComponent";
|
import * as React from "react";
|
||||||
|
import { ReactAdapter } from "../../../Bindings/ReactBindingHandler";
|
||||||
|
import { NotebookClientV2 } from "../NotebookClientV2";
|
||||||
import { NotebookContentItem } from "../NotebookContentItem";
|
import { NotebookContentItem } from "../NotebookContentItem";
|
||||||
import { NotebookComponentBootstrapper } from "./NotebookComponentBootstrapper";
|
import { NotebookComponentBootstrapper } from "./NotebookComponentBootstrapper";
|
||||||
import { CdbAppState } from "./types";
|
import VirtualCommandBarComponent from "./VirtualCommandBarComponent";
|
||||||
|
|
||||||
export interface NotebookComponentAdapterOptions {
|
export interface NotebookComponentAdapterOptions {
|
||||||
contentItem: NotebookContentItem;
|
contentItem: NotebookContentItem;
|
||||||
@@ -19,7 +16,6 @@ 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) {
|
||||||
@@ -46,11 +42,6 @@ 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 => {
|
||||||
|
|||||||
@@ -1,35 +1,29 @@
|
|||||||
import * as React from "react";
|
import { CellId, CellType, ImmutableNotebook } from "@nteract/commutable";
|
||||||
|
|
||||||
import { NotebookComponent } from "./NotebookComponent";
|
|
||||||
import { NotebookClientV2 } from "../NotebookClientV2";
|
|
||||||
import { NotebookUtil } from "../NotebookUtil";
|
|
||||||
|
|
||||||
// Vendor modules
|
// Vendor modules
|
||||||
import {
|
import {
|
||||||
actions,
|
actions,
|
||||||
AppState,
|
AppState,
|
||||||
createKernelRef,
|
|
||||||
DocumentRecordProps,
|
|
||||||
ContentRef,
|
ContentRef,
|
||||||
|
DocumentRecordProps,
|
||||||
KernelRef,
|
KernelRef,
|
||||||
NotebookContentRecord,
|
NotebookContentRecord,
|
||||||
selectors,
|
selectors,
|
||||||
} from "@nteract/core";
|
} from "@nteract/core";
|
||||||
import * as Immutable from "immutable";
|
|
||||||
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/lib/codemirror.css";
|
|
||||||
import "@nteract/styles/editor-overrides.css";
|
import "@nteract/styles/editor-overrides.css";
|
||||||
import "@nteract/styles/global-variables.css";
|
import "@nteract/styles/global-variables.css";
|
||||||
|
import "codemirror/addon/hint/show-hint.css";
|
||||||
|
import "codemirror/lib/codemirror.css";
|
||||||
|
import * as Immutable from "immutable";
|
||||||
|
import * as React from "react";
|
||||||
|
import { Provider } from "react-redux";
|
||||||
import "react-table/react-table.css";
|
import "react-table/react-table.css";
|
||||||
|
import { AnyAction, Store } from "redux";
|
||||||
import * as CdbActions from "./actions";
|
import { NotebookClientV2 } from "../NotebookClientV2";
|
||||||
|
import { NotebookUtil } from "../NotebookUtil";
|
||||||
import * as NteractUtil from "../NTeractUtil";
|
import * as NteractUtil from "../NTeractUtil";
|
||||||
|
import * as CdbActions from "./actions";
|
||||||
|
import { NotebookComponent } from "./NotebookComponent";
|
||||||
|
import "./NotebookComponent.less";
|
||||||
|
|
||||||
export interface NotebookComponentBootstrapperOptions {
|
export interface NotebookComponentBootstrapperOptions {
|
||||||
notebookClient: NotebookClientV2;
|
notebookClient: NotebookClientV2;
|
||||||
@@ -37,7 +31,7 @@ export interface NotebookComponentBootstrapperOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class NotebookComponentBootstrapper {
|
export class NotebookComponentBootstrapper {
|
||||||
protected contentRef: ContentRef;
|
public contentRef: ContentRef;
|
||||||
protected renderExtraComponent: () => JSX.Element;
|
protected renderExtraComponent: () => JSX.Element;
|
||||||
|
|
||||||
private notebookClient: NotebookClientV2;
|
private notebookClient: NotebookClientV2;
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { ServerConfig, IContentProvider, FileType, IContent, IGetParams } from "@nteract/core";
|
import { FileType, IContent, IContentProvider, IGetParams, ServerConfig } 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(private gitHubContentProvider: GitHubContentProvider, private jupyterContentProvider: IContentProvider) {}
|
constructor(
|
||||||
|
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);
|
||||||
@@ -60,6 +66,10 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 {
|
||||||
@@ -85,21 +86,68 @@ export const traceNotebookTelemetry = (payload: {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UPDATE_NOTEBOOK_PARENT_DOM_ELTS = "UPDATE_NOTEBOOK_PARENT_DOM_ELTS";
|
export const STORE_CELL_OUTPUT_SNAPSHOT = "STORE_CELL_OUTPUT_SNAPSHOT";
|
||||||
export interface UpdateNotebookParentDomEltAction {
|
export interface StoreCellOutputSnapshotAction {
|
||||||
type: "UPDATE_NOTEBOOK_PARENT_DOM_ELTS";
|
type: "STORE_CELL_OUTPUT_SNAPSHOT";
|
||||||
payload: {
|
payload: {
|
||||||
contentRef: ContentRef;
|
cellId: string;
|
||||||
parentElt: HTMLElement;
|
snapshot: SnapshotFragment;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UpdateNotebookParentDomElt = (payload: {
|
export const storeCellOutputSnapshot = (payload: {
|
||||||
contentRef: ContentRef;
|
cellId: string;
|
||||||
parentElt: HTMLElement;
|
snapshot: SnapshotFragment;
|
||||||
}): UpdateNotebookParentDomEltAction => {
|
}): StoreCellOutputSnapshotAction => {
|
||||||
return {
|
return {
|
||||||
type: UPDATE_NOTEBOOK_PARENT_DOM_ELTS,
|
type: STORE_CELL_OUTPUT_SNAPSHOT,
|
||||||
|
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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -70,17 +70,32 @@ export const cdbReducer = (state: CdbRecord, action: Action) => {
|
|||||||
return state.set("hoveredCellId", typedAction.payload.cellId);
|
return state.set("hoveredCellId", typedAction.payload.cellId);
|
||||||
}
|
}
|
||||||
|
|
||||||
case cdbActions.UPDATE_NOTEBOOK_PARENT_DOM_ELTS: {
|
case cdbActions.STORE_CELL_OUTPUT_SNAPSHOT: {
|
||||||
const typedAction = action as cdbActions.UpdateNotebookParentDomEltAction;
|
const typedAction = action as cdbActions.StoreCellOutputSnapshotAction;
|
||||||
var parentEltsMap = state.get("currentNotebookParentElements");
|
state.cellOutputSnapshots.set(typedAction.payload.cellId, typedAction.payload.snapshot);
|
||||||
const contentRef = typedAction.payload.contentRef;
|
// TODO Simpler datastructure to instantiate new Map?
|
||||||
const parentElt = typedAction.payload.parentElt;
|
return state.set("cellOutputSnapshots", new Map(state.cellOutputSnapshots));
|
||||||
if (parentElt) {
|
}
|
||||||
parentEltsMap.set(contentRef, parentElt);
|
|
||||||
} else {
|
case cdbActions.STORE_NOTEBOOK_SNAPSHOT: {
|
||||||
parentEltsMap.delete(contentRef);
|
const typedAction = action as cdbActions.StoreNotebookSnapshotAction;
|
||||||
}
|
// Clear pending request
|
||||||
return state.set("currentNotebookParentElements", parentEltsMap);
|
return state.set("notebookSnapshot", typedAction.payload).set("pendingSnapshotRequest", undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|||||||
@@ -1,15 +1,40 @@
|
|||||||
import * as Immutable from "immutable";
|
|
||||||
import { AppState, ContentRef } from "@nteract/core";
|
|
||||||
|
|
||||||
import { Notebook } from "../../../Common/Constants";
|
|
||||||
import { CellId } from "@nteract/commutable";
|
import { CellId } from "@nteract/commutable";
|
||||||
|
import { AppState } from "@nteract/core";
|
||||||
|
import * as Immutable from "immutable";
|
||||||
|
import { Notebook } from "../../../Common/Constants";
|
||||||
|
|
||||||
|
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;
|
||||||
currentNotebookParentElements: Map<ContentRef, HTMLElement>;
|
cellOutputSnapshots: Map<string, SnapshotFragment>;
|
||||||
|
notebookSnapshot?: { imageSrc: string; requestId: string };
|
||||||
|
pendingSnapshotRequest?: SnapshotRequest;
|
||||||
|
notebookSnapshotError?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CdbRecord = Immutable.RecordOf<CdbRecordProps>;
|
export type CdbRecord = Immutable.RecordOf<CdbRecordProps>;
|
||||||
@@ -23,5 +48,8 @@ export const makeCdbRecord = Immutable.Record<CdbRecordProps>({
|
|||||||
defaultExperience: undefined,
|
defaultExperience: undefined,
|
||||||
kernelRestartDelayMs: Notebook.kernelRestartInitialDelayMs,
|
kernelRestartDelayMs: Notebook.kernelRestartInitialDelayMs,
|
||||||
hoveredCellId: undefined,
|
hoveredCellId: undefined,
|
||||||
currentNotebookParentElements: new Map<ContentRef, HTMLElement>(),
|
cellOutputSnapshots: new Map(),
|
||||||
|
notebookSnapshot: undefined,
|
||||||
|
pendingSnapshotRequest: undefined,
|
||||||
|
notebookSnapshotError: undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,12 +22,14 @@ 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;
|
||||||
|
|
||||||
@@ -49,6 +51,7 @@ 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;
|
||||||
@@ -62,12 +65,20 @@ 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
|
||||||
);
|
);
|
||||||
@@ -112,11 +123,13 @@ export default class NotebookManager {
|
|||||||
public async openPublishNotebookPane(
|
public async openPublishNotebookPane(
|
||||||
name: string,
|
name: string,
|
||||||
content: NotebookPaneContent,
|
content: NotebookPaneContent,
|
||||||
parentDomElement: HTMLElement
|
notebookContentRef: string,
|
||||||
|
onTakeSnapshot: (request: SnapshotRequest) => void,
|
||||||
|
onClosePanel: () => void
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const explorer = this.params.container;
|
const explorer = this.params.container;
|
||||||
explorer.openSidePanel(
|
explorer.openSidePanel(
|
||||||
"New Collection",
|
"Publish Notebook",
|
||||||
<PublishNotebookPane
|
<PublishNotebookPane
|
||||||
explorer={this.params.container}
|
explorer={this.params.container}
|
||||||
junoClient={this.junoClient}
|
junoClient={this.junoClient}
|
||||||
@@ -125,8 +138,10 @@ export default class NotebookManager {
|
|||||||
name={name}
|
name={name}
|
||||||
author={getFullName()}
|
author={getFullName()}
|
||||||
notebookContent={content}
|
notebookContent={content}
|
||||||
parentDomElement={parentDomElement}
|
notebookContentRef={notebookContentRef}
|
||||||
/>
|
onTakeSnapshot={onTakeSnapshot}
|
||||||
|
/>,
|
||||||
|
onClosePanel
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 } from "@nteract/core";
|
import { actions, ContentRef, selectors } 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,6 +12,8 @@ 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";
|
||||||
@@ -32,10 +34,18 @@ export interface NotebookRendererBaseProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface NotebookRendererDispatchProps {
|
interface NotebookRendererDispatchProps {
|
||||||
updateNotebookParentDomElt: (contentRef: ContentRef, parentElt: HTMLElement) => void;
|
storeNotebookSnapshot: (imageSrc: string, requestId: string) => void;
|
||||||
|
notebookSnapshotError: (error: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type NotebookRendererProps = NotebookRendererBaseProps & NotebookRendererDispatchProps;
|
interface StateProps {
|
||||||
|
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 = () => (
|
||||||
@@ -60,27 +70,37 @@ 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate() {
|
async componentDidUpdate(): Promise<void> {
|
||||||
this.props.updateNotebookParentDomElt(this.props.contentRef, this.notebookRendererRef.current);
|
// Take a snapshot if there's a pending request and all the outputs are also saved
|
||||||
}
|
if (
|
||||||
|
this.props.pendingSnapshotRequest &&
|
||||||
componentWillUnmount() {
|
this.props.pendingSnapshotRequest.type === "notebook" &&
|
||||||
this.props.updateNotebookParentDomElt(this.props.contentRef, undefined);
|
this.props.pendingSnapshotRequest.notebookContentRef === this.props.contentRef &&
|
||||||
|
(!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 {
|
||||||
@@ -156,28 +176,40 @@ 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 }) =>
|
||||||
return dispatch(
|
dispatch(
|
||||||
actions.addTransform({
|
actions.addTransform({
|
||||||
mediaType: transform.MIMETYPE,
|
mediaType: transform.MIMETYPE,
|
||||||
component: transform,
|
component: transform,
|
||||||
})
|
})
|
||||||
);
|
),
|
||||||
},
|
storeNotebookSnapshot: (imageSrc: string, requestId: string) =>
|
||||||
updateNotebookParentDomElt: (contentRef: ContentRef, parentElt: HTMLElement) => {
|
dispatch(cdbActions.storeNotebookSnapshot({ imageSrc, requestId })),
|
||||||
return dispatch(
|
notebookSnapshotError: (error: string) => dispatch(cdbActions.notebookSnapshotError({ error })),
|
||||||
cdbActions.UpdateNotebookParentDomElt({
|
|
||||||
contentRef,
|
|
||||||
parentElt,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
return mapDispatchToProps;
|
return mapDispatchToProps;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default connect(null, makeMapDispatchToProps)(BaseNotebookRenderer);
|
export default connect(makeMapStateToProps, makeMapDispatchToProps)(BaseNotebookRenderer);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ContextualMenuItemType, DirectionalHint, IconButton, IContextualMenuItem } from "@fluentui/react";
|
import { ContextualMenuItemType, DirectionalHint, IconButton, IContextualMenuItem } from "@fluentui/react";
|
||||||
import { CellId, CellType } from "@nteract/commutable";
|
import { CellId, CellType, ImmutableCodeCell } 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,6 +10,8 @@ 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;
|
||||||
@@ -26,12 +28,14 @@ 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> {
|
||||||
@@ -58,11 +62,29 @@ 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([
|
||||||
@@ -183,12 +205,13 @@ 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 cellType = selectors.cell.cellFromState(state, { id: ownProps.id, contentRef: ownProps.contentRef })
|
const cell = selectors.cell.cellFromState(state, { id: ownProps.id, contentRef: ownProps.contentRef });
|
||||||
.cell_type;
|
const cellType = cell.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);
|
||||||
@@ -199,6 +222,7 @@ 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;
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
|
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?
|
||||||
@@ -29,7 +28,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>);
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ 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 } from "../../../../CellOutputViewer/CellOutputViewer";
|
import { CellOutputViewerProps, SnapshotResponse } 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>
|
||||||
@@ -24,27 +26,47 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SandboxOutputs extends React.PureComponent<ComponentProps & StateProps & DispatchProps> {
|
type SandboxOutputsProps = 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 (
|
return this.props.outputs && this.props.outputs.size > 0 ? (
|
||||||
<IframeResizer
|
<div ref={this.nodeRef}>
|
||||||
checkOrigin={false}
|
<IframeResizer
|
||||||
loading="lazy"
|
checkOrigin={false}
|
||||||
heightCalculationMethod="taggedElement"
|
loading="lazy"
|
||||||
onLoad={(event) => this.handleFrameLoad(event)}
|
heightCalculationMethod="taggedElement"
|
||||||
src="./cellOutputViewer.html"
|
onLoad={(event) => this.handleFrameLoad(event)}
|
||||||
style={{ height: "1px", width: "1px", minWidth: "100%", border: "none" }}
|
src="./cellOutputViewer.html"
|
||||||
sandbox="allow-downloads allow-popups allow-forms allow-pointer-lock allow-scripts allow-popups-to-escape-sandbox"
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,8 +98,48 @@ export class SandboxOutputs extends React.PureComponent<ComponentProps & StatePr
|
|||||||
this.sendPropsToFrame();
|
this.sendPropsToFrame();
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(): void {
|
async componentDidUpdate(prevProps: SandboxOutputsProps): Promise<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +147,7 @@ export const makeMapStateToProps = (
|
|||||||
initialState: AppState,
|
initialState: AppState,
|
||||||
ownProps: ComponentProps
|
ownProps: ComponentProps
|
||||||
): ((state: AppState) => StateProps) => {
|
): ((state: AppState) => StateProps) => {
|
||||||
const mapStateToProps = (state: AppState): StateProps => {
|
const mapStateToProps = (state: CdbAppState): StateProps => {
|
||||||
let outputs = Immutable.List();
|
let outputs = Immutable.List();
|
||||||
let hidden = false;
|
let hidden = false;
|
||||||
let expanded = false;
|
let expanded = false;
|
||||||
@@ -102,7 +164,17 @@ export const makeMapStateToProps = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { outputs, hidden, expanded };
|
// Determine whether to take a snapshot or not
|
||||||
|
let pendingSnapshotRequest = state.cdb.pendingSnapshotRequest;
|
||||||
|
if (
|
||||||
|
pendingSnapshotRequest &&
|
||||||
|
pendingSnapshotRequest.type === "celloutput" &&
|
||||||
|
pendingSnapshotRequest.cellId !== id
|
||||||
|
) {
|
||||||
|
pendingSnapshotRequest = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { outputs, hidden, expanded, pendingSnapshotRequest };
|
||||||
};
|
};
|
||||||
return mapStateToProps;
|
return mapStateToProps;
|
||||||
};
|
};
|
||||||
@@ -125,6 +197,11 @@ 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;
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { NotebookUtil } from "./NotebookUtil";
|
|
||||||
import * as GitHubUtils from "../../Utils/GitHubUtils";
|
|
||||||
import {
|
import {
|
||||||
ImmutableNotebook,
|
|
||||||
MediaBundle,
|
|
||||||
CodeCellParams,
|
CodeCellParams,
|
||||||
MarkdownCellParams,
|
ImmutableNotebook,
|
||||||
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.findFirstCodeCellWithDisplay(notebookObject)).toEqual(1);
|
expect(NotebookUtil.findCodeCellWithDisplay(notebookObject)[0]).toEqual("1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
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 { ImmutableNotebook, ImmutableCodeCell } from "@nteract/commutable";
|
|
||||||
import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem";
|
|
||||||
import * as StringUtils from "../../Utils/StringUtils";
|
|
||||||
import * as GitHubUtils from "../../Utils/GitHubUtils";
|
import * as GitHubUtils from "../../Utils/GitHubUtils";
|
||||||
|
import * as StringUtils from "../../Utils/StringUtils";
|
||||||
|
import { SnapshotFragment } from "./NotebookComponent/types";
|
||||||
|
import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem";
|
||||||
|
|
||||||
// Must match rx-jupyter' FileType
|
// Must match rx-jupyter' FileType
|
||||||
export type FileType = "directory" | "file" | "notebook";
|
export type FileType = "directory" | "file" | "notebook";
|
||||||
@@ -141,23 +144,175 @@ export class NotebookUtil {
|
|||||||
return `${basePath}${newName}`;
|
return `${basePath}${newName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static findFirstCodeCellWithDisplay(notebookObject: ImmutableNotebook): number {
|
public static hasCodeCellOutput(cell: ImmutableCodeCell): boolean {
|
||||||
let codeCellIndex = 0;
|
return !!cell?.outputs?.find(
|
||||||
for (let i = 0; i < notebookObject.cellOrder.size; i++) {
|
(output) =>
|
||||||
const cellId = notebookObject.cellOrder.get(i);
|
output.output_type === "display_data" ||
|
||||||
if (cellId) {
|
output.output_type === "execute_result" ||
|
||||||
const cell = notebookObject.cellMap.get(cellId);
|
output.output_type === "stream"
|
||||||
if (cell?.cell_type === "code") {
|
);
|
||||||
const displayOutput = (cell as ImmutableCodeCell)?.outputs?.find(
|
}
|
||||||
(output) => output.output_type === "display_data" || output.output_type === "execute_result"
|
|
||||||
);
|
/**
|
||||||
if (displayOutput) {
|
* Find code cells with display
|
||||||
return codeCellIndex;
|
* @param notebookObject
|
||||||
}
|
* @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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.schemaAnalyzerComponent {
|
.schemaAnalyzer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -1,22 +1,26 @@
|
|||||||
import { FontIcon, PrimaryButton, Spinner, SpinnerSize, Stack, Text, TextField } from "@fluentui/react";
|
import { Spinner, SpinnerSize, Stack } from "@fluentui/react";
|
||||||
import { ImmutableOutput } from "@nteract/commutable";
|
import { ImmutableExecuteResult, 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 "./SchemaAnalyzerComponent.less";
|
import "./SchemaAnalyzer.less";
|
||||||
|
import { DefaultFilter, DefaultSampleSize, SchemaAnalyzerHeader } from "./SchemaAnalyzerHeader";
|
||||||
|
import { SchemaAnalyzerSplashScreen } from "./SchemaAnalyzerSplashScreen";
|
||||||
|
|
||||||
interface SchemaAnalyzerComponentPureProps {
|
interface SchemaAnalyzerPureProps {
|
||||||
contentRef: ContentRef;
|
contentRef: ContentRef;
|
||||||
kernelRef: KernelRef;
|
kernelRef: KernelRef;
|
||||||
databaseId: string;
|
databaseId: string;
|
||||||
collectionId: string;
|
collectionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SchemaAnalyzerComponentDispatchProps {
|
interface SchemaAnalyzerDispatchProps {
|
||||||
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;
|
||||||
@@ -24,25 +28,23 @@ interface SchemaAnalyzerComponentDispatchProps {
|
|||||||
|
|
||||||
type OutputType = "rich" | "json";
|
type OutputType = "rich" | "json";
|
||||||
|
|
||||||
interface SchemaAnalyzerComponentState {
|
interface SchemaAnalyzerState {
|
||||||
outputType: OutputType;
|
outputType: OutputType;
|
||||||
filter?: string;
|
|
||||||
isFiltering: boolean;
|
isFiltering: boolean;
|
||||||
|
sampleSize: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SchemaAnalyzerComponentProps = SchemaAnalyzerComponentPureProps &
|
type SchemaAnalyzerProps = SchemaAnalyzerPureProps & StateProps & SchemaAnalyzerDispatchProps;
|
||||||
StateProps &
|
|
||||||
SchemaAnalyzerComponentDispatchProps;
|
|
||||||
|
|
||||||
export class SchemaAnalyzerComponent extends React.Component<
|
export class SchemaAnalyzer extends React.Component<SchemaAnalyzerProps, SchemaAnalyzerState> {
|
||||||
SchemaAnalyzerComponentProps,
|
private clickAnalyzeTelemetryStartKey: number;
|
||||||
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,34 +52,59 @@ export class SchemaAnalyzerComponent extends React.Component<
|
|||||||
loadTransform(this.props);
|
loadTransform(this.props);
|
||||||
}
|
}
|
||||||
|
|
||||||
private onFilterTextFieldChange = (
|
private onAnalyzeButtonClick = (filter: string = DefaultFilter, sampleSize: string = this.state.sampleSize) => {
|
||||||
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: this.state.filter,
|
filter,
|
||||||
|
sampleSize,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.state.filter) {
|
this.setState({
|
||||||
this.setState({
|
isFiltering: true,
|
||||||
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) {
|
||||||
@@ -86,31 +113,22 @@ export class SchemaAnalyzerComponent extends React.Component<
|
|||||||
|
|
||||||
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="schemaAnalyzerComponent">
|
<div className="schemaAnalyzer">
|
||||||
<Stack horizontalAlign="center" tokens={{ childrenGap: 20, padding: 20 }}>
|
<Stack tokens={{ childrenGap: 20, padding: 20 }}>
|
||||||
<Stack.Item grow styles={{ root: { display: "contents" } }}>
|
<SchemaAnalyzerHeader
|
||||||
<Stack horizontal tokens={{ childrenGap: 20 }} styles={{ root: { width: "100%" } }}>
|
isKernelIdle={isKernelIdle}
|
||||||
<Stack.Item grow align="end">
|
isKernelBusy={isKernelBusy}
|
||||||
<TextField
|
onSampleSizeUpdated={(sampleSize) => this.setState({ sampleSize })}
|
||||||
value={this.state.filter}
|
onAnalyzeButtonClick={this.onAnalyzeButtonClick}
|
||||||
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
|
||||||
@@ -120,32 +138,13 @@ export class SchemaAnalyzerComponent extends React.Component<
|
|||||||
outputClassName="schema-analyzer-cell-output"
|
outputClassName="schema-analyzer-cell-output"
|
||||||
/>
|
/>
|
||||||
) : this.state.isFiltering ? (
|
) : this.state.isFiltering ? (
|
||||||
<Stack.Item>
|
<Spinner styles={{ root: { marginTop: 40 } }} size={SpinnerSize.large} />
|
||||||
{isKernelBusy && <Spinner styles={{ root: { marginTop: 40 } }} size={SpinnerSize.large} />}
|
|
||||||
</Stack.Item>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<SchemaAnalyzerSplashScreen
|
||||||
<Stack.Item>
|
isKernelIdle={isKernelIdle}
|
||||||
<FontIcon iconName="Chart" style={{ fontSize: 100, color: "#43B1E5", marginTop: 40 }} />
|
isKernelBusy={isKernelBusy}
|
||||||
</Stack.Item>
|
onAnalyzeButtonClick={this.onAnalyzeButtonClick}
|
||||||
<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>
|
||||||
@@ -229,4 +228,4 @@ const makeMapDispatchToProps = () => {
|
|||||||
return mapDispatchToProps;
|
return mapDispatchToProps;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default connect(makeMapStateToProps, makeMapDispatchToProps)(SchemaAnalyzerComponent);
|
export default connect(makeMapStateToProps, makeMapDispatchToProps)(SchemaAnalyzer);
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/Explorer/Notebook/SchemaAnalyzer/SchemaAnalyzerHeader.tsx
Normal file
101
src/Explorer/Notebook/SchemaAnalyzer/SchemaAnalyzerHeader.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
44
src/Explorer/Notebook/SchemaAnalyzer/SchemaAnalyzerUtils.ts
Normal file
44
src/Explorer/Notebook/SchemaAnalyzer/SchemaAnalyzerUtils.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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",
|
||||||
|
};
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,6 @@ 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"),
|
||||||
|
|||||||
@@ -140,19 +140,16 @@ 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,602 +0,0 @@
|
|||||||
<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: isEnableMongoCapabilityEnabled()">
|
|
||||||
<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>
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
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
@@ -25,7 +25,7 @@ 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 } from "../../Utils/CapabilityUtils";
|
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";
|
||||||
@@ -182,7 +182,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!this.isServerlessAccount() && (
|
{!isServerlessAccount() && (
|
||||||
<Stack horizontal>
|
<Stack horizontal>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label={`Share throughput across ${getCollectionName(true).toLocaleLowerCase()}`}
|
label={`Share throughput across ${getCollectionName(true).toLocaleLowerCase()}`}
|
||||||
@@ -207,7 +207,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!this.isServerlessAccount() && this.state.isSharedThroughputChecked && (
|
{!isServerlessAccount() && this.state.isSharedThroughputChecked && (
|
||||||
<ThroughputInput
|
<ThroughputInput
|
||||||
showFreeTierExceedThroughputTooltip={
|
showFreeTierExceedThroughputTooltip={
|
||||||
this.isFreeTierAccount() && !this.props.explorer.isFirstResourceCreated()
|
this.isFreeTierAccount() && !this.props.explorer.isFirstResourceCreated()
|
||||||
@@ -396,7 +396,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 +408,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!this.isServerlessAccount() && !this.state.createNewDatabase && this.isSelectedDatabaseSharedThroughput() && (
|
{!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()}`}
|
||||||
@@ -751,14 +751,8 @@ 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 && !this.isServerlessAccount();
|
return userContext.subscriptionType !== SubscriptionType.EA && !isServerlessAccount();
|
||||||
}
|
}
|
||||||
|
|
||||||
private getFreeTierIndexingText(): string {
|
private getFreeTierIndexingText(): string {
|
||||||
@@ -796,7 +790,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
|||||||
}
|
}
|
||||||
|
|
||||||
private shouldShowCollectionThroughputInput(): boolean {
|
private shouldShowCollectionThroughputInput(): boolean {
|
||||||
if (this.isServerlessAccount()) {
|
if (isServerlessAccount()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -826,7 +820,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isServerlessAccount()) {
|
if (isServerlessAccount()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -917,6 +911,10 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,452 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
239
src/Explorer/Panes/AddDatabasePanel/AddDatabasePanel.tsx
Normal file
239
src/Explorer/Panes/AddDatabasePanel/AddDatabasePanel.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// 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>
|
||||||
|
`;
|
||||||
@@ -9,10 +9,7 @@ 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 {
|
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
|
||||||
GenericRightPaneComponent,
|
|
||||||
GenericRightPaneProps,
|
|
||||||
} from "../GenericRightPaneComponent/GenericRightPaneComponent";
|
|
||||||
import { CopyNotebookPaneComponent, CopyNotebookPaneProps } from "./CopyNotebookPaneComponent";
|
import { CopyNotebookPaneComponent, CopyNotebookPaneProps } from "./CopyNotebookPaneComponent";
|
||||||
|
|
||||||
interface Location {
|
interface Location {
|
||||||
@@ -42,7 +39,6 @@ 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>();
|
||||||
|
|
||||||
@@ -92,7 +88,6 @@ 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();
|
||||||
@@ -130,14 +125,10 @@ export const CopyNotebookPane: FunctionComponent<CopyNotebookPanelProps> = ({
|
|||||||
setSelectedLocation(option?.data);
|
setSelectedLocation(option?.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
const genericPaneProps: GenericRightPaneProps = {
|
const props: RightPaneFormProps = {
|
||||||
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(),
|
||||||
};
|
};
|
||||||
@@ -149,8 +140,8 @@ export const CopyNotebookPane: FunctionComponent<CopyNotebookPanelProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GenericRightPaneComponent {...genericPaneProps}>
|
<RightPaneForm {...props}>
|
||||||
<CopyNotebookPaneComponent {...copyNotebookPaneProps} />
|
<CopyNotebookPaneComponent {...copyNotebookPaneProps} />
|
||||||
</GenericRightPaneComponent>
|
</RightPaneForm>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -130,8 +130,8 @@ describe("Delete Collection Confirmation Pane", () => {
|
|||||||
.hostNodes()
|
.hostNodes()
|
||||||
.simulate("change", { target: { value: selectedCollectionId } });
|
.simulate("change", { target: { value: selectedCollectionId } });
|
||||||
|
|
||||||
expect(wrapper.exists(".genericPaneSubmitBtn")).toBe(true);
|
expect(wrapper.exists("#sidePanelOkButton")).toBe(true);
|
||||||
wrapper.find(".genericPaneSubmitBtn").hostNodes().simulate("click");
|
wrapper.find("#sidePanelOkButton").hostNodes().simulate("submit");
|
||||||
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(".genericPaneSubmitBtn")).toBe(true);
|
expect(wrapper.exists("#sidePanelOkButton")).toBe(true);
|
||||||
wrapper.find(".genericPaneSubmitBtn").hostNodes().simulate("click");
|
wrapper.find("#sidePanelOkButton").hostNodes().simulate("submit");
|
||||||
expect(deleteCollection).toHaveBeenCalledWith(databaseId, selectedCollectionId);
|
expect(deleteCollection).toHaveBeenCalledWith(databaseId, selectedCollectionId);
|
||||||
|
|
||||||
const deleteFeedback = new DeleteFeedback(
|
const deleteFeedback = new DeleteFeedback(
|
||||||
|
|||||||
@@ -12,10 +12,7 @@ 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 {
|
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
|
||||||
GenericRightPaneComponent,
|
|
||||||
GenericRightPaneProps,
|
|
||||||
} from "../GenericRightPaneComponent/GenericRightPaneComponent";
|
|
||||||
export interface DeleteCollectionConfirmationPaneProps {
|
export interface DeleteCollectionConfirmationPaneProps {
|
||||||
explorer: Explorer;
|
explorer: Explorer;
|
||||||
closePanel: () => void;
|
closePanel: () => void;
|
||||||
@@ -35,7 +32,7 @@ export const DeleteCollectionConfirmationPane: FunctionComponent<DeleteCollectio
|
|||||||
};
|
};
|
||||||
const collectionName = getCollectionName().toLocaleLowerCase();
|
const collectionName = getCollectionName().toLocaleLowerCase();
|
||||||
const paneTitle = "Delete " + collectionName;
|
const paneTitle = "Delete " + collectionName;
|
||||||
const submit = async (): Promise<void> => {
|
const onSubmit = 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;
|
||||||
@@ -100,19 +97,15 @@ export const DeleteCollectionConfirmationPane: FunctionComponent<DeleteCollectio
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const genericPaneProps: GenericRightPaneProps = {
|
const props: RightPaneFormProps = {
|
||||||
formError: formError,
|
formError: formError,
|
||||||
formErrorDetail: formError,
|
|
||||||
id: "deleteCollectionpane",
|
|
||||||
isExecuting,
|
isExecuting,
|
||||||
title: paneTitle,
|
|
||||||
submitButtonText: "OK",
|
submitButtonText: "OK",
|
||||||
onClose: closePanel,
|
onSubmit,
|
||||||
onSubmit: submit,
|
|
||||||
expandConsole: () => explorer.expandConsole(),
|
expandConsole: () => explorer.expandConsole(),
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<GenericRightPaneComponent {...genericPaneProps}>
|
<RightPaneForm {...props}>
|
||||||
<div className="panelFormWrapper">
|
<div className="panelFormWrapper">
|
||||||
<div className="panelMainContent">
|
<div className="panelMainContent">
|
||||||
<div className="confirmDeleteInput">
|
<div className="confirmDeleteInput">
|
||||||
@@ -150,6 +143,6 @@ export const DeleteCollectionConfirmationPane: FunctionComponent<DeleteCollectio
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</GenericRightPaneComponent>
|
</RightPaneForm>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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("#sidePanelOkButton")).toBe(true);
|
expect(wrapper.exists("button")).toBe(true);
|
||||||
wrapper.find("#sidePanelOkButton").hostNodes().simulate("submit");
|
wrapper.find("button").hostNodes().simulate("submit");
|
||||||
expect(deleteDatabase).toHaveBeenCalledWith(selectedDatabaseId);
|
expect(deleteDatabase).toHaveBeenCalledWith(selectedDatabaseId);
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useBoolean } from "@fluentui/react-hooks";
|
|
||||||
import { Text, TextField } from "@fluentui/react";
|
import { Text, TextField } from "@fluentui/react";
|
||||||
|
import { useBoolean } from "@fluentui/react-hooks";
|
||||||
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,9 +12,8 @@ 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 { PanelLoadingScreen } from "./PanelLoadingScreen";
|
import { RightPaneForm, RightPaneFormProps } from "./RightPaneForm/RightPaneForm";
|
||||||
|
|
||||||
interface DeleteDatabaseConfirmationPanelProps {
|
interface DeleteDatabaseConfirmationPanelProps {
|
||||||
explorer: Explorer;
|
explorer: Explorer;
|
||||||
@@ -23,36 +22,19 @@ interface DeleteDatabaseConfirmationPanelProps {
|
|||||||
selectedDatabase: Database;
|
selectedDatabase: Database;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseConfirmationPanelProps> = (
|
export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseConfirmationPanelProps> = ({
|
||||||
props: DeleteDatabaseConfirmationPanelProps
|
explorer,
|
||||||
): JSX.Element => {
|
openNotificationConsole,
|
||||||
|
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 getPanelErrorProps = (): PanelInfoErrorProps => {
|
const submit = async (): Promise<void> => {
|
||||||
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()}`);
|
||||||
@@ -69,7 +51,7 @@ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseCo
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteDatabase(selectedDatabase.id());
|
await deleteDatabase(selectedDatabase.id());
|
||||||
props.closePanel();
|
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);
|
||||||
@@ -121,13 +103,27 @@ 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 (
|
||||||
<form className="panelFormWrapper" onSubmit={submit}>
|
<RightPaneForm {...props}>
|
||||||
<PanelInfoErrorComponent {...getPanelErrorProps()} />
|
{!formError && <PanelInfoErrorComponent {...errorProps} />}
|
||||||
<div className="panelMainContent">
|
<div className="panelMainContent">
|
||||||
<div className="confirmDeleteInput">
|
<div className="confirmDeleteInput">
|
||||||
<span className="mandatoryStar">* </span>
|
<span className="mandatoryStar">* </span>
|
||||||
@@ -161,8 +157,6 @@ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseCo
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<PanelFooterComponent buttonLabel="OK" />
|
</RightPaneForm>
|
||||||
{isLoading && <PanelLoadingScreen />}
|
|
||||||
</form>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { useBoolean } from "@fluentui/react-hooks";
|
|
||||||
import { IDropdownOption, IImageProps, Image, Stack, Text } from "@fluentui/react";
|
import { IDropdownOption, IImageProps, Image, Stack, Text } from "@fluentui/react";
|
||||||
|
import { useBoolean } from "@fluentui/react-hooks";
|
||||||
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 {
|
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
|
||||||
GenericRightPaneComponent,
|
|
||||||
GenericRightPaneProps,
|
|
||||||
} from "../GenericRightPaneComponent/GenericRightPaneComponent";
|
|
||||||
import { InputParameter } from "./InputParameter";
|
import { InputParameter } from "./InputParameter";
|
||||||
|
|
||||||
interface ExecuteSprocParamsPaneProps {
|
interface ExecuteSprocParamsPaneProps {
|
||||||
@@ -35,24 +33,11 @@ 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++) {
|
||||||
@@ -66,7 +51,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}`);
|
||||||
setFormErrorsDetails(`Invalid param specified: ${invalidParam} is not a valid literal value`);
|
logConsoleError(`Invalid param specified: ${invalidParam} is not a valid literal value`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = (): void => {
|
const submit = (): void => {
|
||||||
@@ -128,8 +113,16 @@ export const ExecuteSprocParamsPane: FunctionComponent<ExecuteSprocParamsPanePro
|
|||||||
setParamKeyValues(cloneParamKeyValue);
|
setParamKeyValues(cloneParamKeyValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const props: RightPaneFormProps = {
|
||||||
|
expandConsole,
|
||||||
|
formError: formError,
|
||||||
|
isExecuting: isLoading,
|
||||||
|
submitButtonText: "Execute",
|
||||||
|
onSubmit: () => submit(),
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GenericRightPaneComponent {...genericPaneProps}>
|
<RightPaneForm {...props}>
|
||||||
<div className="panelFormWrapper">
|
<div className="panelFormWrapper">
|
||||||
<div className="panelMainContent">
|
<div className="panelMainContent">
|
||||||
<InputParameter
|
<InputParameter
|
||||||
@@ -169,6 +162,6 @@ export const ExecuteSprocParamsPane: FunctionComponent<ExecuteSprocParamsPanePro
|
|||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</GenericRightPaneComponent>
|
</RightPaneForm>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,130 +0,0 @@
|
|||||||
import { IconButton, PrimaryButton } from "@fluentui/react";
|
|
||||||
import React, { FunctionComponent, ReactNode } from "react";
|
|
||||||
import ErrorRedIcon from "../../../../images/error_red.svg";
|
|
||||||
import LoadingIndicatorIcon from "../../../../images/LoadingIndicator_3Squares.gif";
|
|
||||||
import { KeyCodes } from "../../../Common/Constants";
|
|
||||||
|
|
||||||
export interface GenericRightPaneProps {
|
|
||||||
expandConsole: () => void;
|
|
||||||
formError: string;
|
|
||||||
formErrorDetail: string;
|
|
||||||
id: string;
|
|
||||||
isExecuting: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSubmit: () => void;
|
|
||||||
submitButtonText: string;
|
|
||||||
title: string;
|
|
||||||
isSubmitButtonHidden?: boolean;
|
|
||||||
children?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GenericRightPaneState {
|
|
||||||
panelHeight: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GenericRightPaneComponent: FunctionComponent<GenericRightPaneProps> = ({
|
|
||||||
expandConsole,
|
|
||||||
formError,
|
|
||||||
formErrorDetail,
|
|
||||||
id,
|
|
||||||
isExecuting,
|
|
||||||
onClose,
|
|
||||||
onSubmit,
|
|
||||||
submitButtonText,
|
|
||||||
title,
|
|
||||||
isSubmitButtonHidden,
|
|
||||||
children,
|
|
||||||
}: GenericRightPaneProps) => {
|
|
||||||
const getPanelHeight = (): number => {
|
|
||||||
const notificationConsoleElement: HTMLElement = document.getElementById("explorerNotificationConsole");
|
|
||||||
return window.innerHeight - $(notificationConsoleElement).height();
|
|
||||||
};
|
|
||||||
|
|
||||||
const panelHeight: number = getPanelHeight();
|
|
||||||
|
|
||||||
const renderPanelHeader = (): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<div className="firstdivbg headerline">
|
|
||||||
<span id="databaseTitle" role="heading" aria-level={2}>
|
|
||||||
{title}
|
|
||||||
</span>
|
|
||||||
<IconButton
|
|
||||||
ariaLabel="Close pane"
|
|
||||||
title="Close pane"
|
|
||||||
onClick={onClose}
|
|
||||||
tabIndex={0}
|
|
||||||
className="closePaneBtn"
|
|
||||||
iconProps={{ iconName: "Cancel" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderErrorSection = (): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<div className="warningErrorContainer" aria-live="assertive" hidden={!formError}>
|
|
||||||
<div className="warningErrorContent">
|
|
||||||
<span>
|
|
||||||
<img className="paneErrorIcon" src={ErrorRedIcon} alt="Error" />
|
|
||||||
</span>
|
|
||||||
<span className="warningErrorDetailsLinkContainer">
|
|
||||||
<span className="formErrors" title={formError}>
|
|
||||||
{formError}
|
|
||||||
</span>
|
|
||||||
<a className="errorLink" role="link" hidden={!formErrorDetail} onClick={expandConsole}>
|
|
||||||
More details
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderPanelFooter = (): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<div className="paneFooter">
|
|
||||||
<div className="leftpanel-okbut">
|
|
||||||
<PrimaryButton
|
|
||||||
style={{ visibility: isSubmitButtonHidden ? "hidden" : "visible" }}
|
|
||||||
ariaLabel="Submit"
|
|
||||||
title="Submit"
|
|
||||||
onClick={onSubmit}
|
|
||||||
tabIndex={0}
|
|
||||||
className="genericPaneSubmitBtn"
|
|
||||||
text={submitButtonText}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderLoadingScreen = (): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<div className="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" hidden={!isExecuting}>
|
|
||||||
<img className="dataExplorerLoader" src={LoadingIndicatorIcon} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
|
|
||||||
if (event.keyCode === KeyCodes.Escape) {
|
|
||||||
onClose();
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div tabIndex={-1} onKeyDown={onKeyDown}>
|
|
||||||
<div className="contextual-pane-out" onClick={onClose}></div>
|
|
||||||
<div className="contextual-pane" id={id} style={{ height: panelHeight }} onKeyDown={onKeyDown}>
|
|
||||||
<div className="panelContentWrapper">
|
|
||||||
{renderPanelHeader()}
|
|
||||||
{renderErrorSection()}
|
|
||||||
{children}
|
|
||||||
{renderPanelFooter()}
|
|
||||||
</div>
|
|
||||||
{renderLoadingScreen()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user