Added the Self Serve Data Model (#367)
* added recursion and inition decorators * working version * added todo comment and removed console.log * Added Recursive add * removed type requirement * proper resolution of promises * added custom element and base class * Made selfServe standalone page * Added custom renderer as async type * Added overall defaults * added inital open from data explorer * removed landingpage * added feature for self serve type * renamed sqlx->example and added invalid type * Added comments for Example * removed unnecessary changes * Resolved PR comments Added tests Moved onSubmt and initialize inside base class Moved testExplorer to separate folder made fields of SelfServe Class non static * fixed lint errors * fixed compilation errors * Removed reactbinding changes * renamed dropdown -> choice * Added SelfServeComponent * Addressed PR comments * merged master * added selfservetype.none for emulator and hosted experience * fixed formatting errors * Removed "any" type * undid package.json changes
This commit is contained in:
parent
2b2de7c645
commit
c1937ca464
|
@ -1,3 +1,4 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
presets: [["@babel/preset-env", { targets: { node: "current" } }], "@babel/preset-react", "@babel/preset-typescript"]
|
presets: [["@babel/preset-env", { targets: { node: "current" } }], "@babel/preset-react", "@babel/preset-typescript"],
|
||||||
|
plugins: [["@babel/plugin-proposal-decorators", { legacy: true }]]
|
||||||
};
|
};
|
||||||
|
|
|
@ -156,9 +156,9 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"qs": {
|
"qs": {
|
||||||
"version": "6.9.4",
|
"version": "6.9.6",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.9.6.tgz",
|
||||||
"integrity": "sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ=="
|
"integrity": "sha512-TIRk4aqYLNoJUbd+g2lEdz5kLWIuTMRagAXxl78Q0RiVjAOugHmeKNGdd3cwo/ktpf9aL9epCfFqWDEKysUlLQ=="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -403,7 +403,6 @@
|
||||||
"version": "7.12.1",
|
"version": "7.12.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.12.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.12.1.tgz",
|
||||||
"integrity": "sha512-hkL++rWeta/OVOBTRJc9a5Azh5mt5WgZUGAKMD8JM141YsE08K//bp1unBBieO6rUKkIPyUE0USQ30jAy3Sk1w==",
|
"integrity": "sha512-hkL++rWeta/OVOBTRJc9a5Azh5mt5WgZUGAKMD8JM141YsE08K//bp1unBBieO6rUKkIPyUE0USQ30jAy3Sk1w==",
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"@babel/helper-function-name": "^7.10.4",
|
"@babel/helper-function-name": "^7.10.4",
|
||||||
"@babel/helper-member-expression-to-functions": "^7.12.1",
|
"@babel/helper-member-expression-to-functions": "^7.12.1",
|
||||||
|
@ -630,6 +629,25 @@
|
||||||
"@babel/plugin-syntax-async-generators": "^7.8.0"
|
"@babel/plugin-syntax-async-generators": "^7.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@babel/plugin-proposal-class-properties": {
|
||||||
|
"version": "7.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.12.1.tgz",
|
||||||
|
"integrity": "sha512-cKp3dlQsFsEs5CWKnN7BnSHOd0EOW8EKpEjkoz1pO2E5KzIDNV9Ros1b0CnmbVgAGXJubOYVBOGCT1OmJwOI7w==",
|
||||||
|
"requires": {
|
||||||
|
"@babel/helper-create-class-features-plugin": "^7.12.1",
|
||||||
|
"@babel/helper-plugin-utils": "^7.10.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@babel/plugin-proposal-decorators": {
|
||||||
|
"version": "7.12.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.12.12.tgz",
|
||||||
|
"integrity": "sha512-fhkE9lJYpw2mjHelBpM2zCbaA11aov2GJs7q4cFaXNrWx0H3bW58H9Esy2rdtYOghFBEYUDRIpvlgi+ZD+AvvQ==",
|
||||||
|
"requires": {
|
||||||
|
"@babel/helper-create-class-features-plugin": "^7.12.1",
|
||||||
|
"@babel/helper-plugin-utils": "^7.10.4",
|
||||||
|
"@babel/plugin-syntax-decorators": "^7.12.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@babel/plugin-proposal-dynamic-import": {
|
"@babel/plugin-proposal-dynamic-import": {
|
||||||
"version": "7.12.1",
|
"version": "7.12.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.12.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.12.1.tgz",
|
||||||
|
@ -739,6 +757,14 @@
|
||||||
"@babel/helper-plugin-utils": "^7.10.4"
|
"@babel/helper-plugin-utils": "^7.10.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@babel/plugin-syntax-decorators": {
|
||||||
|
"version": "7.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.12.1.tgz",
|
||||||
|
"integrity": "sha512-ir9YW5daRrTYiy9UJ2TzdNIJEZu8KclVzDcfSt4iEmOtwQ4llPtWInNKJyKnVXp1vE4bbVd5S31M/im3mYMO1w==",
|
||||||
|
"requires": {
|
||||||
|
"@babel/helper-plugin-utils": "^7.10.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@babel/plugin-syntax-dynamic-import": {
|
"@babel/plugin-syntax-dynamic-import": {
|
||||||
"version": "7.8.3",
|
"version": "7.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz",
|
||||||
|
@ -18467,6 +18493,11 @@
|
||||||
"resolved": "https://registry.npmjs.org/redux-observable/-/redux-observable-2.0.0-alpha.0.tgz",
|
"resolved": "https://registry.npmjs.org/redux-observable/-/redux-observable-2.0.0-alpha.0.tgz",
|
||||||
"integrity": "sha512-w0RsVGprIFiYi1AhFCOATiv3ld2AtuobvbcVsLvX19p8eAwLowWl2OrKYcCq/QEeEpmSHTXutXfVfcBnzaWmdw=="
|
"integrity": "sha512-w0RsVGprIFiYi1AhFCOATiv3ld2AtuobvbcVsLvX19p8eAwLowWl2OrKYcCq/QEeEpmSHTXutXfVfcBnzaWmdw=="
|
||||||
},
|
},
|
||||||
|
"reflect-metadata": {
|
||||||
|
"version": "0.1.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz",
|
||||||
|
"integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg=="
|
||||||
|
},
|
||||||
"reflect.ownkeys": {
|
"reflect.ownkeys": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz",
|
||||||
|
|
|
@ -8,6 +8,8 @@
|
||||||
"@azure/cosmos": "3.9.0",
|
"@azure/cosmos": "3.9.0",
|
||||||
"@azure/cosmos-language-service": "0.0.5",
|
"@azure/cosmos-language-service": "0.0.5",
|
||||||
"@azure/identity": "1.2.1",
|
"@azure/identity": "1.2.1",
|
||||||
|
"@babel/plugin-proposal-class-properties": "7.12.1",
|
||||||
|
"@babel/plugin-proposal-decorators": "7.12.12",
|
||||||
"@jupyterlab/services": "6.0.0-rc.2",
|
"@jupyterlab/services": "6.0.0-rc.2",
|
||||||
"@jupyterlab/terminal": "3.0.0-rc.2",
|
"@jupyterlab/terminal": "3.0.0-rc.2",
|
||||||
"@microsoft/applicationinsights-web": "2.5.9",
|
"@microsoft/applicationinsights-web": "2.5.9",
|
||||||
|
@ -87,6 +89,7 @@
|
||||||
"react-notification-system": "0.2.17",
|
"react-notification-system": "0.2.17",
|
||||||
"react-redux": "7.1.3",
|
"react-redux": "7.1.3",
|
||||||
"redux": "4.0.4",
|
"redux": "4.0.4",
|
||||||
|
"reflect-metadata": "0.1.13",
|
||||||
"rx-jupyter": "5.5.12",
|
"rx-jupyter": "5.5.12",
|
||||||
"rxjs": "6.6.3",
|
"rxjs": "6.6.3",
|
||||||
"styled-components": "4.3.2",
|
"styled-components": "4.3.2",
|
||||||
|
|
|
@ -119,6 +119,7 @@ export class Features {
|
||||||
public static readonly enableSchema = "enableschema";
|
public static readonly enableSchema = "enableschema";
|
||||||
public static readonly enableSDKoperations = "enablesdkoperations";
|
public static readonly enableSDKoperations = "enablesdkoperations";
|
||||||
public static readonly showMinRUSurvey = "showminrusurvey";
|
public static readonly showMinRUSurvey = "showminrusurvey";
|
||||||
|
public static readonly selfServeType = "selfservetype";
|
||||||
}
|
}
|
||||||
|
|
||||||
// flight names returned from the portal are always lowercase
|
// flight names returned from the portal are always lowercase
|
||||||
|
|
|
@ -15,6 +15,7 @@ import DocumentId from "../Explorer/Tree/DocumentId";
|
||||||
import StoredProcedure from "../Explorer/Tree/StoredProcedure";
|
import StoredProcedure from "../Explorer/Tree/StoredProcedure";
|
||||||
import Trigger from "../Explorer/Tree/Trigger";
|
import Trigger from "../Explorer/Tree/Trigger";
|
||||||
import UserDefinedFunction from "../Explorer/Tree/UserDefinedFunction";
|
import UserDefinedFunction from "../Explorer/Tree/UserDefinedFunction";
|
||||||
|
import { SelfServeType } from "../SelfServe/SelfServeUtils";
|
||||||
import { UploadDetails } from "../workers/upload/definitions";
|
import { UploadDetails } from "../workers/upload/definitions";
|
||||||
import * as DataModels from "./DataModels";
|
import * as DataModels from "./DataModels";
|
||||||
import { SubscriptionType } from "./SubscriptionType";
|
import { SubscriptionType } from "./SubscriptionType";
|
||||||
|
@ -395,6 +396,7 @@ export interface DataExplorerInputsFrame {
|
||||||
isAuthWithresourceToken?: boolean;
|
isAuthWithresourceToken?: boolean;
|
||||||
defaultCollectionThroughput?: CollectionCreationDefaults;
|
defaultCollectionThroughput?: CollectionCreationDefaults;
|
||||||
flights?: readonly string[];
|
flights?: readonly string[];
|
||||||
|
selfServeType?: SelfServeType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CollectionCreationDefaults {
|
export interface CollectionCreationDefaults {
|
||||||
|
|
|
@ -48,6 +48,7 @@ export const FeaturePanelComponent: React.FunctionComponent = () => {
|
||||||
{ key: "feature.hosteddataexplorerenabled", label: "Hosted Data Explorer (deprecated?)", value: "true" },
|
{ key: "feature.hosteddataexplorerenabled", label: "Hosted Data Explorer (deprecated?)", value: "true" },
|
||||||
{ key: "feature.enablettl", label: "Enable TTL", value: "true" },
|
{ key: "feature.enablettl", label: "Enable TTL", value: "true" },
|
||||||
{ key: "feature.enablegallerypublish", label: "Enable Notebook Gallery Publishing", value: "true" },
|
{ key: "feature.enablegallerypublish", label: "Enable Notebook Gallery Publishing", value: "true" },
|
||||||
|
{ key: "feature.selfServeType", label: "Self serve feature", value: "sample" },
|
||||||
{
|
{
|
||||||
key: "feature.enableLinkInjection",
|
key: "feature.enableLinkInjection",
|
||||||
label: "Enable Injecting Notebook Viewer Link into the first cell",
|
label: "Enable Injecting Notebook Viewer Link into the first cell",
|
||||||
|
|
|
@ -157,14 +157,14 @@ exports[`Feature panel renders all flags 1`] = `
|
||||||
/>
|
/>
|
||||||
<StyledCheckboxBase
|
<StyledCheckboxBase
|
||||||
checked={false}
|
checked={false}
|
||||||
key="feature.enableLinkInjection"
|
key="feature.selfServeType"
|
||||||
label="Enable Injecting Notebook Viewer Link into the first cell"
|
label="Self serve feature"
|
||||||
onChange={[Function]}
|
onChange={[Function]}
|
||||||
/>
|
/>
|
||||||
<StyledCheckboxBase
|
<StyledCheckboxBase
|
||||||
checked={false}
|
checked={false}
|
||||||
key="feature.canexceedmaximumvalue"
|
key="feature.enableLinkInjection"
|
||||||
label="Can exceed max value"
|
label="Enable Injecting Notebook Viewer Link into the first cell"
|
||||||
onChange={[Function]}
|
onChange={[Function]}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
@ -172,6 +172,12 @@ exports[`Feature panel renders all flags 1`] = `
|
||||||
className="checkboxRow"
|
className="checkboxRow"
|
||||||
horizontalAlign="space-between"
|
horizontalAlign="space-between"
|
||||||
>
|
>
|
||||||
|
<StyledCheckboxBase
|
||||||
|
checked={false}
|
||||||
|
key="feature.canexceedmaximumvalue"
|
||||||
|
label="Can exceed max value"
|
||||||
|
onChange={[Function]}
|
||||||
|
/>
|
||||||
<StyledCheckboxBase
|
<StyledCheckboxBase
|
||||||
checked={false}
|
checked={false}
|
||||||
key="feature.enablefixedcollectionwithsharedthroughput"
|
key="feature.enablefixedcollectionwithsharedthroughput"
|
||||||
|
|
|
@ -1026,6 +1026,7 @@ exports[`SettingsComponent renders 1`] = `
|
||||||
"notificationConsoleData": [Function],
|
"notificationConsoleData": [Function],
|
||||||
"onRefreshDatabasesKeyPress": [Function],
|
"onRefreshDatabasesKeyPress": [Function],
|
||||||
"onRefreshResourcesClick": [Function],
|
"onRefreshResourcesClick": [Function],
|
||||||
|
"onSwitchToConnectionString": [Function],
|
||||||
"onToggleKeyDown": [Function],
|
"onToggleKeyDown": [Function],
|
||||||
"provideFeedbackEmail": [Function],
|
"provideFeedbackEmail": [Function],
|
||||||
"queriesClient": QueriesClient {
|
"queriesClient": QueriesClient {
|
||||||
|
@ -1071,6 +1072,8 @@ exports[`SettingsComponent renders 1`] = `
|
||||||
"title": [Function],
|
"title": [Function],
|
||||||
"visible": [Function],
|
"visible": [Function],
|
||||||
},
|
},
|
||||||
|
"renewToken": [Function],
|
||||||
|
"renewTokenError": [Function],
|
||||||
"resourceTokenCollection": [Function],
|
"resourceTokenCollection": [Function],
|
||||||
"resourceTokenCollectionId": [Function],
|
"resourceTokenCollectionId": [Function],
|
||||||
"resourceTokenDatabaseId": [Function],
|
"resourceTokenDatabaseId": [Function],
|
||||||
|
@ -1117,6 +1120,14 @@ exports[`SettingsComponent renders 1`] = `
|
||||||
},
|
},
|
||||||
"selectedDatabaseId": [Function],
|
"selectedDatabaseId": [Function],
|
||||||
"selectedNode": [Function],
|
"selectedNode": [Function],
|
||||||
|
"selfServeComponentAdapter": SelfServeComponentAdapter {
|
||||||
|
"container": [Circular],
|
||||||
|
"parameters": [Function],
|
||||||
|
},
|
||||||
|
"selfServeLoadingComponentAdapter": SelfServeLoadingComponentAdapter {
|
||||||
|
"parameters": [Function],
|
||||||
|
},
|
||||||
|
"selfServeType": [Function],
|
||||||
"serverId": [Function],
|
"serverId": [Function],
|
||||||
"settingsPane": SettingsPane {
|
"settingsPane": SettingsPane {
|
||||||
"container": [Circular],
|
"container": [Circular],
|
||||||
|
@ -1163,6 +1174,7 @@ exports[`SettingsComponent renders 1`] = `
|
||||||
"shouldShowContextSwitchPrompt": [Function],
|
"shouldShowContextSwitchPrompt": [Function],
|
||||||
"shouldShowDataAccessExpiryDialog": [Function],
|
"shouldShowDataAccessExpiryDialog": [Function],
|
||||||
"shouldShowShareDialogContents": [Function],
|
"shouldShowShareDialogContents": [Function],
|
||||||
|
"signInAad": [Function],
|
||||||
"sparkClusterConnectionInfo": [Function],
|
"sparkClusterConnectionInfo": [Function],
|
||||||
"splashScreenAdapter": SplashScreenComponentAdapter {
|
"splashScreenAdapter": SplashScreenComponentAdapter {
|
||||||
"clearMostRecent": [Function],
|
"clearMostRecent": [Function],
|
||||||
|
@ -1229,6 +1241,7 @@ exports[`SettingsComponent renders 1`] = `
|
||||||
"toggleLeftPaneExpandedKeyPress": [Function],
|
"toggleLeftPaneExpandedKeyPress": [Function],
|
||||||
"toggleRead": [Function],
|
"toggleRead": [Function],
|
||||||
"toggleReadWrite": [Function],
|
"toggleReadWrite": [Function],
|
||||||
|
"tokenForRenewal": [Function],
|
||||||
"uploadFilePane": UploadFilePane {
|
"uploadFilePane": UploadFilePane {
|
||||||
"container": [Circular],
|
"container": [Circular],
|
||||||
"extensions": [Function],
|
"extensions": [Function],
|
||||||
|
@ -2296,6 +2309,7 @@ exports[`SettingsComponent renders 1`] = `
|
||||||
"notificationConsoleData": [Function],
|
"notificationConsoleData": [Function],
|
||||||
"onRefreshDatabasesKeyPress": [Function],
|
"onRefreshDatabasesKeyPress": [Function],
|
||||||
"onRefreshResourcesClick": [Function],
|
"onRefreshResourcesClick": [Function],
|
||||||
|
"onSwitchToConnectionString": [Function],
|
||||||
"onToggleKeyDown": [Function],
|
"onToggleKeyDown": [Function],
|
||||||
"provideFeedbackEmail": [Function],
|
"provideFeedbackEmail": [Function],
|
||||||
"queriesClient": QueriesClient {
|
"queriesClient": QueriesClient {
|
||||||
|
@ -2341,6 +2355,8 @@ exports[`SettingsComponent renders 1`] = `
|
||||||
"title": [Function],
|
"title": [Function],
|
||||||
"visible": [Function],
|
"visible": [Function],
|
||||||
},
|
},
|
||||||
|
"renewToken": [Function],
|
||||||
|
"renewTokenError": [Function],
|
||||||
"resourceTokenCollection": [Function],
|
"resourceTokenCollection": [Function],
|
||||||
"resourceTokenCollectionId": [Function],
|
"resourceTokenCollectionId": [Function],
|
||||||
"resourceTokenDatabaseId": [Function],
|
"resourceTokenDatabaseId": [Function],
|
||||||
|
@ -2387,6 +2403,14 @@ exports[`SettingsComponent renders 1`] = `
|
||||||
},
|
},
|
||||||
"selectedDatabaseId": [Function],
|
"selectedDatabaseId": [Function],
|
||||||
"selectedNode": [Function],
|
"selectedNode": [Function],
|
||||||
|
"selfServeComponentAdapter": SelfServeComponentAdapter {
|
||||||
|
"container": [Circular],
|
||||||
|
"parameters": [Function],
|
||||||
|
},
|
||||||
|
"selfServeLoadingComponentAdapter": SelfServeLoadingComponentAdapter {
|
||||||
|
"parameters": [Function],
|
||||||
|
},
|
||||||
|
"selfServeType": [Function],
|
||||||
"serverId": [Function],
|
"serverId": [Function],
|
||||||
"settingsPane": SettingsPane {
|
"settingsPane": SettingsPane {
|
||||||
"container": [Circular],
|
"container": [Circular],
|
||||||
|
@ -2433,6 +2457,7 @@ exports[`SettingsComponent renders 1`] = `
|
||||||
"shouldShowContextSwitchPrompt": [Function],
|
"shouldShowContextSwitchPrompt": [Function],
|
||||||
"shouldShowDataAccessExpiryDialog": [Function],
|
"shouldShowDataAccessExpiryDialog": [Function],
|
||||||
"shouldShowShareDialogContents": [Function],
|
"shouldShowShareDialogContents": [Function],
|
||||||
|
"signInAad": [Function],
|
||||||
"sparkClusterConnectionInfo": [Function],
|
"sparkClusterConnectionInfo": [Function],
|
||||||
"splashScreenAdapter": SplashScreenComponentAdapter {
|
"splashScreenAdapter": SplashScreenComponentAdapter {
|
||||||
"clearMostRecent": [Function],
|
"clearMostRecent": [Function],
|
||||||
|
@ -2499,6 +2524,7 @@ exports[`SettingsComponent renders 1`] = `
|
||||||
"toggleLeftPaneExpandedKeyPress": [Function],
|
"toggleLeftPaneExpandedKeyPress": [Function],
|
||||||
"toggleRead": [Function],
|
"toggleRead": [Function],
|
||||||
"toggleReadWrite": [Function],
|
"toggleReadWrite": [Function],
|
||||||
|
"tokenForRenewal": [Function],
|
||||||
"uploadFilePane": UploadFilePane {
|
"uploadFilePane": UploadFilePane {
|
||||||
"container": [Circular],
|
"container": [Circular],
|
||||||
"extensions": [Function],
|
"extensions": [Function],
|
||||||
|
@ -3579,6 +3605,7 @@ exports[`SettingsComponent renders 1`] = `
|
||||||
"notificationConsoleData": [Function],
|
"notificationConsoleData": [Function],
|
||||||
"onRefreshDatabasesKeyPress": [Function],
|
"onRefreshDatabasesKeyPress": [Function],
|
||||||
"onRefreshResourcesClick": [Function],
|
"onRefreshResourcesClick": [Function],
|
||||||
|
"onSwitchToConnectionString": [Function],
|
||||||
"onToggleKeyDown": [Function],
|
"onToggleKeyDown": [Function],
|
||||||
"provideFeedbackEmail": [Function],
|
"provideFeedbackEmail": [Function],
|
||||||
"queriesClient": QueriesClient {
|
"queriesClient": QueriesClient {
|
||||||
|
@ -3624,6 +3651,8 @@ exports[`SettingsComponent renders 1`] = `
|
||||||
"title": [Function],
|
"title": [Function],
|
||||||
"visible": [Function],
|
"visible": [Function],
|
||||||
},
|
},
|
||||||
|
"renewToken": [Function],
|
||||||
|
"renewTokenError": [Function],
|
||||||
"resourceTokenCollection": [Function],
|
"resourceTokenCollection": [Function],
|
||||||
"resourceTokenCollectionId": [Function],
|
"resourceTokenCollectionId": [Function],
|
||||||
"resourceTokenDatabaseId": [Function],
|
"resourceTokenDatabaseId": [Function],
|
||||||
|
@ -3670,6 +3699,14 @@ exports[`SettingsComponent renders 1`] = `
|
||||||
},
|
},
|
||||||
"selectedDatabaseId": [Function],
|
"selectedDatabaseId": [Function],
|
||||||
"selectedNode": [Function],
|
"selectedNode": [Function],
|
||||||
|
"selfServeComponentAdapter": SelfServeComponentAdapter {
|
||||||
|
"container": [Circular],
|
||||||
|
"parameters": [Function],
|
||||||
|
},
|
||||||
|
"selfServeLoadingComponentAdapter": SelfServeLoadingComponentAdapter {
|
||||||
|
"parameters": [Function],
|
||||||
|
},
|
||||||
|
"selfServeType": [Function],
|
||||||
"serverId": [Function],
|
"serverId": [Function],
|
||||||
"settingsPane": SettingsPane {
|
"settingsPane": SettingsPane {
|
||||||
"container": [Circular],
|
"container": [Circular],
|
||||||
|
@ -3716,6 +3753,7 @@ exports[`SettingsComponent renders 1`] = `
|
||||||
"shouldShowContextSwitchPrompt": [Function],
|
"shouldShowContextSwitchPrompt": [Function],
|
||||||
"shouldShowDataAccessExpiryDialog": [Function],
|
"shouldShowDataAccessExpiryDialog": [Function],
|
||||||
"shouldShowShareDialogContents": [Function],
|
"shouldShowShareDialogContents": [Function],
|
||||||
|
"signInAad": [Function],
|
||||||
"sparkClusterConnectionInfo": [Function],
|
"sparkClusterConnectionInfo": [Function],
|
||||||
"splashScreenAdapter": SplashScreenComponentAdapter {
|
"splashScreenAdapter": SplashScreenComponentAdapter {
|
||||||
"clearMostRecent": [Function],
|
"clearMostRecent": [Function],
|
||||||
|
@ -3782,6 +3820,7 @@ exports[`SettingsComponent renders 1`] = `
|
||||||
"toggleLeftPaneExpandedKeyPress": [Function],
|
"toggleLeftPaneExpandedKeyPress": [Function],
|
||||||
"toggleRead": [Function],
|
"toggleRead": [Function],
|
||||||
"toggleReadWrite": [Function],
|
"toggleReadWrite": [Function],
|
||||||
|
"tokenForRenewal": [Function],
|
||||||
"uploadFilePane": UploadFilePane {
|
"uploadFilePane": UploadFilePane {
|
||||||
"container": [Circular],
|
"container": [Circular],
|
||||||
"extensions": [Function],
|
"extensions": [Function],
|
||||||
|
@ -4849,6 +4888,7 @@ exports[`SettingsComponent renders 1`] = `
|
||||||
"notificationConsoleData": [Function],
|
"notificationConsoleData": [Function],
|
||||||
"onRefreshDatabasesKeyPress": [Function],
|
"onRefreshDatabasesKeyPress": [Function],
|
||||||
"onRefreshResourcesClick": [Function],
|
"onRefreshResourcesClick": [Function],
|
||||||
|
"onSwitchToConnectionString": [Function],
|
||||||
"onToggleKeyDown": [Function],
|
"onToggleKeyDown": [Function],
|
||||||
"provideFeedbackEmail": [Function],
|
"provideFeedbackEmail": [Function],
|
||||||
"queriesClient": QueriesClient {
|
"queriesClient": QueriesClient {
|
||||||
|
@ -4894,6 +4934,8 @@ exports[`SettingsComponent renders 1`] = `
|
||||||
"title": [Function],
|
"title": [Function],
|
||||||
"visible": [Function],
|
"visible": [Function],
|
||||||
},
|
},
|
||||||
|
"renewToken": [Function],
|
||||||
|
"renewTokenError": [Function],
|
||||||
"resourceTokenCollection": [Function],
|
"resourceTokenCollection": [Function],
|
||||||
"resourceTokenCollectionId": [Function],
|
"resourceTokenCollectionId": [Function],
|
||||||
"resourceTokenDatabaseId": [Function],
|
"resourceTokenDatabaseId": [Function],
|
||||||
|
@ -4940,6 +4982,14 @@ exports[`SettingsComponent renders 1`] = `
|
||||||
},
|
},
|
||||||
"selectedDatabaseId": [Function],
|
"selectedDatabaseId": [Function],
|
||||||
"selectedNode": [Function],
|
"selectedNode": [Function],
|
||||||
|
"selfServeComponentAdapter": SelfServeComponentAdapter {
|
||||||
|
"container": [Circular],
|
||||||
|
"parameters": [Function],
|
||||||
|
},
|
||||||
|
"selfServeLoadingComponentAdapter": SelfServeLoadingComponentAdapter {
|
||||||
|
"parameters": [Function],
|
||||||
|
},
|
||||||
|
"selfServeType": [Function],
|
||||||
"serverId": [Function],
|
"serverId": [Function],
|
||||||
"settingsPane": SettingsPane {
|
"settingsPane": SettingsPane {
|
||||||
"container": [Circular],
|
"container": [Circular],
|
||||||
|
@ -4986,6 +5036,7 @@ exports[`SettingsComponent renders 1`] = `
|
||||||
"shouldShowContextSwitchPrompt": [Function],
|
"shouldShowContextSwitchPrompt": [Function],
|
||||||
"shouldShowDataAccessExpiryDialog": [Function],
|
"shouldShowDataAccessExpiryDialog": [Function],
|
||||||
"shouldShowShareDialogContents": [Function],
|
"shouldShowShareDialogContents": [Function],
|
||||||
|
"signInAad": [Function],
|
||||||
"sparkClusterConnectionInfo": [Function],
|
"sparkClusterConnectionInfo": [Function],
|
||||||
"splashScreenAdapter": SplashScreenComponentAdapter {
|
"splashScreenAdapter": SplashScreenComponentAdapter {
|
||||||
"clearMostRecent": [Function],
|
"clearMostRecent": [Function],
|
||||||
|
@ -5052,6 +5103,7 @@ exports[`SettingsComponent renders 1`] = `
|
||||||
"toggleLeftPaneExpandedKeyPress": [Function],
|
"toggleLeftPaneExpandedKeyPress": [Function],
|
||||||
"toggleRead": [Function],
|
"toggleRead": [Function],
|
||||||
"toggleReadWrite": [Function],
|
"toggleReadWrite": [Function],
|
||||||
|
"tokenForRenewal": [Function],
|
||||||
"uploadFilePane": UploadFilePane {
|
"uploadFilePane": UploadFilePane {
|
||||||
"container": [Circular],
|
"container": [Circular],
|
||||||
"extensions": [Function],
|
"extensions": [Function],
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { shallow } from "enzyme";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Descriptor, SmartUiComponent } from "./SmartUiComponent";
|
import { shallow } from "enzyme";
|
||||||
|
import { SmartUiComponent, SmartUiDescriptor, UiType } from "./SmartUiComponent";
|
||||||
|
|
||||||
describe("SmartUiComponent", () => {
|
describe("SmartUiComponent", () => {
|
||||||
const exampleData: Descriptor = {
|
const exampleData: SmartUiDescriptor = {
|
||||||
root: {
|
root: {
|
||||||
id: "root",
|
id: "root",
|
||||||
info: {
|
info: {
|
||||||
|
@ -24,7 +24,7 @@ describe("SmartUiComponent", () => {
|
||||||
max: 500,
|
max: 500,
|
||||||
step: 10,
|
step: 10,
|
||||||
defaultValue: 400,
|
defaultValue: 400,
|
||||||
inputType: "spin"
|
uiType: UiType.Spinner
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -37,7 +37,21 @@ describe("SmartUiComponent", () => {
|
||||||
max: 500,
|
max: 500,
|
||||||
step: 10,
|
step: 10,
|
||||||
defaultValue: 400,
|
defaultValue: 400,
|
||||||
inputType: "slider"
|
uiType: UiType.Slider
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "throughput3",
|
||||||
|
input: {
|
||||||
|
label: "Throughput (invalid)",
|
||||||
|
dataFieldName: "throughput3",
|
||||||
|
type: "boolean",
|
||||||
|
min: 400,
|
||||||
|
max: 500,
|
||||||
|
step: 10,
|
||||||
|
defaultValue: 400,
|
||||||
|
uiType: UiType.Spinner,
|
||||||
|
errorMessage: "label, truelabel and falselabel are required for boolean input 'throughput3'"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -64,11 +78,11 @@ describe("SmartUiComponent", () => {
|
||||||
input: {
|
input: {
|
||||||
label: "Database",
|
label: "Database",
|
||||||
dataFieldName: "database",
|
dataFieldName: "database",
|
||||||
type: "enum",
|
type: "object",
|
||||||
choices: [
|
choices: [
|
||||||
{ label: "Database 1", key: "db1", value: "database1" },
|
{ label: "Database 1", key: "db1" },
|
||||||
{ label: "Database 2", key: "db2", value: "database2" },
|
{ label: "Database 2", key: "db2" },
|
||||||
{ label: "Database 3", key: "db3", value: "database3" }
|
{ label: "Database 3", key: "db3" }
|
||||||
],
|
],
|
||||||
defaultKey: "db2"
|
defaultKey: "db2"
|
||||||
}
|
}
|
||||||
|
@ -77,10 +91,11 @@ describe("SmartUiComponent", () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const exampleCallbacks = (): void => undefined;
|
it("should render", async () => {
|
||||||
|
const wrapper = shallow(
|
||||||
it("should render", () => {
|
<SmartUiComponent descriptor={exampleData} currentValues={new Map()} onInputChange={undefined} />
|
||||||
const wrapper = shallow(<SmartUiComponent descriptor={exampleData} onChange={exampleCallbacks} />);
|
);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
expect(wrapper).toMatchSnapshot();
|
expect(wrapper).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -5,11 +5,9 @@ import { SpinButton } from "office-ui-fabric-react/lib/SpinButton";
|
||||||
import { Dropdown, IDropdownOption } from "office-ui-fabric-react/lib/Dropdown";
|
import { Dropdown, IDropdownOption } from "office-ui-fabric-react/lib/Dropdown";
|
||||||
import { TextField } from "office-ui-fabric-react/lib/TextField";
|
import { TextField } from "office-ui-fabric-react/lib/TextField";
|
||||||
import { Text } from "office-ui-fabric-react/lib/Text";
|
import { Text } from "office-ui-fabric-react/lib/Text";
|
||||||
import { InputType } from "../../Tables/Constants";
|
|
||||||
import { RadioSwitchComponent } from "../RadioSwitchComponent/RadioSwitchComponent";
|
import { RadioSwitchComponent } from "../RadioSwitchComponent/RadioSwitchComponent";
|
||||||
import { Stack, IStackTokens } from "office-ui-fabric-react/lib/Stack";
|
import { Stack, IStackTokens } from "office-ui-fabric-react/lib/Stack";
|
||||||
import { Link, MessageBar, MessageBarType } from "office-ui-fabric-react";
|
import { Link, MessageBar, MessageBarType } from "office-ui-fabric-react";
|
||||||
|
|
||||||
import * as InputUtils from "./InputUtils";
|
import * as InputUtils from "./InputUtils";
|
||||||
import "./SmartUiComponent.less";
|
import "./SmartUiComponent.less";
|
||||||
|
|
||||||
|
@ -21,45 +19,16 @@ import "./SmartUiComponent.less";
|
||||||
* - a descriptor of the UX.
|
* - a descriptor of the UX.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type InputTypeValue = "number" | "string" | "boolean" | "enum";
|
export type InputTypeValue = "number" | "string" | "boolean" | "object";
|
||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
export enum UiType {
|
||||||
export type EnumItem = { label: string; key: string; value: any };
|
Spinner = "Spinner",
|
||||||
|
Slider = "Slider"
|
||||||
export type InputType = number | string | boolean | EnumItem;
|
|
||||||
|
|
||||||
interface BaseInput {
|
|
||||||
label: string;
|
|
||||||
dataFieldName: string;
|
|
||||||
type: InputTypeValue;
|
|
||||||
placeholder?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export type ChoiceItem = { label: string; key: string };
|
||||||
* For now, this only supports integers
|
|
||||||
*/
|
|
||||||
export interface NumberInput extends BaseInput {
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
step: number;
|
|
||||||
defaultValue: number;
|
|
||||||
inputType: "spin" | "slider";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BooleanInput extends BaseInput {
|
export type InputType = number | string | boolean | ChoiceItem;
|
||||||
trueLabel: string;
|
|
||||||
falseLabel: string;
|
|
||||||
defaultValue: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StringInput extends BaseInput {
|
|
||||||
defaultValue?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EnumInput extends BaseInput {
|
|
||||||
choices: EnumItem[];
|
|
||||||
defaultKey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Info {
|
export interface Info {
|
||||||
message: string;
|
message: string;
|
||||||
|
@ -69,28 +38,62 @@ export interface Info {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AnyInput = NumberInput | BooleanInput | StringInput | EnumInput;
|
interface BaseInput {
|
||||||
|
label: string;
|
||||||
|
dataFieldName: string;
|
||||||
|
type: InputTypeValue;
|
||||||
|
placeholder?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Node {
|
/**
|
||||||
|
* For now, this only supports integers
|
||||||
|
*/
|
||||||
|
interface NumberInput extends BaseInput {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step: number;
|
||||||
|
defaultValue?: number;
|
||||||
|
uiType: UiType;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BooleanInput extends BaseInput {
|
||||||
|
trueLabel: string;
|
||||||
|
falseLabel: string;
|
||||||
|
defaultValue?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StringInput extends BaseInput {
|
||||||
|
defaultValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChoiceInput extends BaseInput {
|
||||||
|
choices: ChoiceItem[];
|
||||||
|
defaultKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnyInput = NumberInput | BooleanInput | StringInput | ChoiceInput;
|
||||||
|
|
||||||
|
interface Node {
|
||||||
id: string;
|
id: string;
|
||||||
info?: Info;
|
info?: Info;
|
||||||
input?: AnyInput;
|
input?: AnyInput;
|
||||||
children?: Node[];
|
children?: Node[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Descriptor {
|
export interface SmartUiDescriptor {
|
||||||
root: Node;
|
root: Node;
|
||||||
}
|
}
|
||||||
|
|
||||||
/************************** Component implementation starts here ************************************* */
|
/************************** Component implementation starts here ************************************* */
|
||||||
|
|
||||||
export interface SmartUiComponentProps {
|
export interface SmartUiComponentProps {
|
||||||
descriptor: Descriptor;
|
descriptor: SmartUiDescriptor;
|
||||||
onChange: (newValues: Map<string, InputType>) => void;
|
currentValues: Map<string, InputType>;
|
||||||
|
onInputChange: (input: AnyInput, newValue: InputType) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SmartUiComponentState {
|
interface SmartUiComponentState {
|
||||||
currentValues: Map<string, InputType>;
|
|
||||||
errors: Map<string, string>;
|
errors: Map<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -104,7 +107,6 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
|
||||||
constructor(props: SmartUiComponentProps) {
|
constructor(props: SmartUiComponentProps) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
currentValues: new Map(),
|
|
||||||
errors: new Map()
|
errors: new Map()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -113,30 +115,26 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
|
||||||
return (
|
return (
|
||||||
<MessageBar>
|
<MessageBar>
|
||||||
{info.message}
|
{info.message}
|
||||||
|
{info.link && (
|
||||||
<Link href={info.link.href} target="_blank">
|
<Link href={info.link.href} target="_blank">
|
||||||
{info.link.text}
|
{info.link.text}
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</MessageBar>
|
</MessageBar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private onInputChange = (newValue: string | number | boolean, dataFieldName: string) => {
|
private renderTextInput(input: StringInput): JSX.Element {
|
||||||
const { currentValues } = this.state;
|
const value = this.props.currentValues.get(input.dataFieldName) as string;
|
||||||
currentValues.set(dataFieldName, newValue);
|
|
||||||
this.setState({ currentValues }, () => this.props.onChange(this.state.currentValues));
|
|
||||||
};
|
|
||||||
|
|
||||||
private renderStringInput(input: StringInput): JSX.Element {
|
|
||||||
return (
|
return (
|
||||||
<div className="stringInputContainer">
|
<div className="stringInputContainer">
|
||||||
<div>
|
|
||||||
<TextField
|
<TextField
|
||||||
id={`${input.dataFieldName}-input`}
|
id={`${input.dataFieldName}-textBox-input`}
|
||||||
label={input.label}
|
label={input.label}
|
||||||
type="text"
|
type="text"
|
||||||
value={input.defaultValue}
|
value={value}
|
||||||
placeholder={input.placeholder}
|
placeholder={input.placeholder}
|
||||||
onChange={(_, newValue) => this.onInputChange(newValue, input.dataFieldName)}
|
onChange={(_, newValue) => this.props.onInputChange(input, newValue)}
|
||||||
styles={{
|
styles={{
|
||||||
subComponentStyles: {
|
subComponentStyles: {
|
||||||
label: {
|
label: {
|
||||||
|
@ -149,7 +147,6 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -159,10 +156,11 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
|
||||||
this.setState({ errors });
|
this.setState({ errors });
|
||||||
}
|
}
|
||||||
|
|
||||||
private onValidate = (value: string, min: number, max: number, dataFieldName: string): string => {
|
private onValidate = (input: AnyInput, value: string, min: number, max: number): string => {
|
||||||
const newValue = InputUtils.onValidateValueChange(value, min, max);
|
const newValue = InputUtils.onValidateValueChange(value, min, max);
|
||||||
|
const dataFieldName = input.dataFieldName;
|
||||||
if (newValue) {
|
if (newValue) {
|
||||||
this.onInputChange(newValue, dataFieldName);
|
this.props.onInputChange(input, newValue);
|
||||||
this.clearError(dataFieldName);
|
this.clearError(dataFieldName);
|
||||||
return newValue.toString();
|
return newValue.toString();
|
||||||
} else {
|
} else {
|
||||||
|
@ -173,20 +171,22 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
private onIncrement = (value: string, step: number, max: number, dataFieldName: string): string => {
|
private onIncrement = (input: AnyInput, value: string, step: number, max: number): string => {
|
||||||
const newValue = InputUtils.onIncrementValue(value, step, max);
|
const newValue = InputUtils.onIncrementValue(value, step, max);
|
||||||
|
const dataFieldName = input.dataFieldName;
|
||||||
if (newValue) {
|
if (newValue) {
|
||||||
this.onInputChange(newValue, dataFieldName);
|
this.props.onInputChange(input, newValue);
|
||||||
this.clearError(dataFieldName);
|
this.clearError(dataFieldName);
|
||||||
return newValue.toString();
|
return newValue.toString();
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
private onDecrement = (value: string, step: number, min: number, dataFieldName: string): string => {
|
private onDecrement = (input: AnyInput, value: string, step: number, min: number): string => {
|
||||||
const newValue = InputUtils.onDecrementValue(value, step, min);
|
const newValue = InputUtils.onDecrementValue(value, step, min);
|
||||||
|
const dataFieldName = input.dataFieldName;
|
||||||
if (newValue) {
|
if (newValue) {
|
||||||
this.onInputChange(newValue, dataFieldName);
|
this.props.onInputChange(input, newValue);
|
||||||
this.clearError(dataFieldName);
|
this.clearError(dataFieldName);
|
||||||
return newValue.toString();
|
return newValue.toString();
|
||||||
}
|
}
|
||||||
|
@ -194,18 +194,26 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
|
||||||
};
|
};
|
||||||
|
|
||||||
private renderNumberInput(input: NumberInput): JSX.Element {
|
private renderNumberInput(input: NumberInput): JSX.Element {
|
||||||
const { label, min, max, defaultValue, dataFieldName, step } = input;
|
const { label, min, max, dataFieldName, step } = input;
|
||||||
const props = { label, min, max, ariaLabel: label, step };
|
const props = {
|
||||||
|
label: label,
|
||||||
|
min: min,
|
||||||
|
max: max,
|
||||||
|
ariaLabel: label,
|
||||||
|
step: step
|
||||||
|
};
|
||||||
|
|
||||||
if (input.inputType === "spin") {
|
const value = this.props.currentValues.get(dataFieldName) as number;
|
||||||
|
if (input.uiType === UiType.Spinner) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<SpinButton
|
<SpinButton
|
||||||
{...props}
|
{...props}
|
||||||
defaultValue={defaultValue.toString()}
|
id={`${input.dataFieldName}-spinner-input`}
|
||||||
onValidate={newValue => this.onValidate(newValue, min, max, dataFieldName)}
|
value={value?.toString()}
|
||||||
onIncrement={newValue => this.onIncrement(newValue, step, max, dataFieldName)}
|
onValidate={newValue => this.onValidate(input, newValue, props.min, props.max)}
|
||||||
onDecrement={newValue => this.onDecrement(newValue, step, min, dataFieldName)}
|
onIncrement={newValue => this.onIncrement(input, newValue, props.step, props.max)}
|
||||||
|
onDecrement={newValue => this.onDecrement(input, newValue, props.step, props.min)}
|
||||||
labelPosition={Position.top}
|
labelPosition={Position.top}
|
||||||
styles={{
|
styles={{
|
||||||
label: {
|
label: {
|
||||||
|
@ -217,16 +225,15 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
|
||||||
{this.state.errors.has(dataFieldName) && (
|
{this.state.errors.has(dataFieldName) && (
|
||||||
<MessageBar messageBarType={MessageBarType.error}>Error: {this.state.errors.get(dataFieldName)}</MessageBar>
|
<MessageBar messageBarType={MessageBarType.error}>Error: {this.state.errors.get(dataFieldName)}</MessageBar>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
} else if (input.inputType === "slider") {
|
} else if (input.uiType === UiType.Slider) {
|
||||||
return (
|
return (
|
||||||
|
<div id={`${input.dataFieldName}-slider-input`}>
|
||||||
<Slider
|
<Slider
|
||||||
// showValue={true}
|
|
||||||
// valueFormat={}
|
|
||||||
{...props}
|
{...props}
|
||||||
defaultValue={defaultValue}
|
value={value}
|
||||||
onChange={newValue => this.onInputChange(newValue, dataFieldName)}
|
onChange={newValue => this.props.onInputChange(input, newValue)}
|
||||||
styles={{
|
styles={{
|
||||||
titleLabel: {
|
titleLabel: {
|
||||||
...SmartUiComponent.labelStyle,
|
...SmartUiComponent.labelStyle,
|
||||||
|
@ -235,16 +242,18 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
|
||||||
valueLabel: SmartUiComponent.labelStyle
|
valueLabel: SmartUiComponent.labelStyle
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return <>Unsupported number input type {input.inputType}</>;
|
return <>Unsupported number UI type {input.uiType}</>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderBooleanInput(input: BooleanInput): JSX.Element {
|
private renderBooleanInput(input: BooleanInput): JSX.Element {
|
||||||
const { dataFieldName } = input;
|
const value = this.props.currentValues.get(input.dataFieldName) as boolean;
|
||||||
|
const selectedKey = value || input.defaultValue ? "true" : "false";
|
||||||
return (
|
return (
|
||||||
<div>
|
<div id={`${input.dataFieldName}-radioSwitch-input`}>
|
||||||
<div className="inputLabelContainer">
|
<div className="inputLabelContainer">
|
||||||
<Text variant="small" nowrap className="inputLabel">
|
<Text variant="small" nowrap className="inputLabel">
|
||||||
{input.label}
|
{input.label}
|
||||||
|
@ -255,41 +264,33 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
|
||||||
{
|
{
|
||||||
label: input.falseLabel,
|
label: input.falseLabel,
|
||||||
key: "false",
|
key: "false",
|
||||||
onSelect: () => this.onInputChange(false, dataFieldName)
|
onSelect: () => this.props.onInputChange(input, false)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: input.trueLabel,
|
label: input.trueLabel,
|
||||||
key: "true",
|
key: "true",
|
||||||
onSelect: () => this.onInputChange(true, dataFieldName)
|
onSelect: () => this.props.onInputChange(input, true)
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
selectedKey={
|
selectedKey={selectedKey}
|
||||||
(this.state.currentValues.has(dataFieldName)
|
|
||||||
? (this.state.currentValues.get(dataFieldName) as boolean)
|
|
||||||
: input.defaultValue)
|
|
||||||
? "true"
|
|
||||||
: "false"
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderEnumInput(input: EnumInput): JSX.Element {
|
private renderChoiceInput(input: ChoiceInput): JSX.Element {
|
||||||
const { label, defaultKey, dataFieldName, choices, placeholder } = input;
|
const { label, defaultKey: defaultKey, dataFieldName, choices, placeholder } = input;
|
||||||
|
const value = this.props.currentValues.get(dataFieldName) as string;
|
||||||
return (
|
return (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
id={`${input.dataFieldName}-dropown-input`}
|
||||||
label={label}
|
label={label}
|
||||||
selectedKey={
|
selectedKey={value ? value : defaultKey}
|
||||||
this.state.currentValues.has(dataFieldName)
|
onChange={(_, item: IDropdownOption) => this.props.onInputChange(input, item.key.toString())}
|
||||||
? (this.state.currentValues.get(dataFieldName) as string)
|
|
||||||
: defaultKey
|
|
||||||
}
|
|
||||||
onChange={(_, item: IDropdownOption) => this.onInputChange(item.key.toString(), dataFieldName)}
|
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
options={choices.map(c => ({
|
options={choices.map(c => ({
|
||||||
key: c.key,
|
key: c.key,
|
||||||
text: c.value
|
text: c.label
|
||||||
}))}
|
}))}
|
||||||
styles={{
|
styles={{
|
||||||
label: {
|
label: {
|
||||||
|
@ -302,34 +303,48 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private renderError(input: AnyInput): JSX.Element {
|
||||||
|
return <MessageBar messageBarType={MessageBarType.error}>Error: {input.errorMessage}</MessageBar>;
|
||||||
|
}
|
||||||
|
|
||||||
private renderInput(input: AnyInput): JSX.Element {
|
private renderInput(input: AnyInput): JSX.Element {
|
||||||
|
if (input.errorMessage) {
|
||||||
|
return this.renderError(input);
|
||||||
|
}
|
||||||
switch (input.type) {
|
switch (input.type) {
|
||||||
case "string":
|
case "string":
|
||||||
return this.renderStringInput(input as StringInput);
|
return this.renderTextInput(input as StringInput);
|
||||||
case "number":
|
case "number":
|
||||||
return this.renderNumberInput(input as NumberInput);
|
return this.renderNumberInput(input as NumberInput);
|
||||||
case "boolean":
|
case "boolean":
|
||||||
return this.renderBooleanInput(input as BooleanInput);
|
return this.renderBooleanInput(input as BooleanInput);
|
||||||
case "enum":
|
case "object":
|
||||||
return this.renderEnumInput(input as EnumInput);
|
return this.renderChoiceInput(input as ChoiceInput);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown input type: ${input.type}`);
|
throw new Error(`Unknown input type: ${input.type}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderNode(node: Node): JSX.Element {
|
private renderNode(node: Node): JSX.Element {
|
||||||
const containerStackTokens: IStackTokens = { childrenGap: 10 };
|
const containerStackTokens: IStackTokens = { childrenGap: 15 };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack tokens={containerStackTokens} className="widgetRendererContainer">
|
<Stack tokens={containerStackTokens} className="widgetRendererContainer">
|
||||||
{node.info && this.renderInfo(node.info)}
|
<Stack.Item>
|
||||||
|
{node.info && this.renderInfo(node.info as Info)}
|
||||||
{node.input && this.renderInput(node.input)}
|
{node.input && this.renderInput(node.input)}
|
||||||
|
</Stack.Item>
|
||||||
{node.children && node.children.map(child => <div key={child.id}>{this.renderNode(child)}</div>)}
|
{node.children && node.children.map(child => <div key={child.id}>{this.renderNode(child)}</div>)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
render(): JSX.Element {
|
render(): JSX.Element {
|
||||||
return <>{this.renderNode(this.props.descriptor.root)}</>;
|
const containerStackTokens: IStackTokens = { childrenGap: 20 };
|
||||||
|
return (
|
||||||
|
<Stack tokens={containerStackTokens} styles={{ root: { width: 400, padding: 10 } }}>
|
||||||
|
{this.renderNode(this.props.descriptor.root)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +1,30 @@
|
||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
exports[`SmartUiComponent should render 1`] = `
|
exports[`SmartUiComponent should render 1`] = `
|
||||||
<Fragment>
|
<Stack
|
||||||
|
styles={
|
||||||
|
Object {
|
||||||
|
"root": Object {
|
||||||
|
"padding": 10,
|
||||||
|
"width": 400,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokens={
|
||||||
|
Object {
|
||||||
|
"childrenGap": 20,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
<Stack
|
<Stack
|
||||||
className="widgetRendererContainer"
|
className="widgetRendererContainer"
|
||||||
tokens={
|
tokens={
|
||||||
Object {
|
Object {
|
||||||
"childrenGap": 10,
|
"childrenGap": 15,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<StackItem>
|
||||||
<StyledMessageBarBase>
|
<StyledMessageBarBase>
|
||||||
Start at $24/mo per database
|
Start at $24/mo per database
|
||||||
<StyledLinkBase
|
<StyledLinkBase
|
||||||
|
@ -19,6 +34,7 @@ exports[`SmartUiComponent should render 1`] = `
|
||||||
More Details
|
More Details
|
||||||
</StyledLinkBase>
|
</StyledLinkBase>
|
||||||
</StyledMessageBarBase>
|
</StyledMessageBarBase>
|
||||||
|
</StackItem>
|
||||||
<div
|
<div
|
||||||
key="throughput"
|
key="throughput"
|
||||||
>
|
>
|
||||||
|
@ -26,11 +42,11 @@ exports[`SmartUiComponent should render 1`] = `
|
||||||
className="widgetRendererContainer"
|
className="widgetRendererContainer"
|
||||||
tokens={
|
tokens={
|
||||||
Object {
|
Object {
|
||||||
"childrenGap": 10,
|
"childrenGap": 15,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div>
|
<StackItem>
|
||||||
<CustomizedSpinButton
|
<CustomizedSpinButton
|
||||||
ariaLabel="Throughput (input)"
|
ariaLabel="Throughput (input)"
|
||||||
decrementButtonIcon={
|
decrementButtonIcon={
|
||||||
|
@ -38,8 +54,8 @@ exports[`SmartUiComponent should render 1`] = `
|
||||||
"iconName": "ChevronDownSmall",
|
"iconName": "ChevronDownSmall",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
defaultValue="400"
|
|
||||||
disabled={false}
|
disabled={false}
|
||||||
|
id="throughput-spinner-input"
|
||||||
incrementButtonIcon={
|
incrementButtonIcon={
|
||||||
Object {
|
Object {
|
||||||
"iconName": "ChevronUpSmall",
|
"iconName": "ChevronUpSmall",
|
||||||
|
@ -64,7 +80,7 @@ exports[`SmartUiComponent should render 1`] = `
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</StackItem>
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -74,13 +90,16 @@ exports[`SmartUiComponent should render 1`] = `
|
||||||
className="widgetRendererContainer"
|
className="widgetRendererContainer"
|
||||||
tokens={
|
tokens={
|
||||||
Object {
|
Object {
|
||||||
"childrenGap": 10,
|
"childrenGap": 15,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
>
|
||||||
|
<StackItem>
|
||||||
|
<div
|
||||||
|
id="throughput2-slider-input"
|
||||||
>
|
>
|
||||||
<StyledSliderBase
|
<StyledSliderBase
|
||||||
ariaLabel="Throughput (Slider)"
|
ariaLabel="Throughput (Slider)"
|
||||||
defaultValue={400}
|
|
||||||
label="Throughput (Slider)"
|
label="Throughput (Slider)"
|
||||||
max={500}
|
max={500}
|
||||||
min={400}
|
min={400}
|
||||||
|
@ -102,6 +121,29 @@ exports[`SmartUiComponent should render 1`] = `
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</StackItem>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
key="throughput3"
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
className="widgetRendererContainer"
|
||||||
|
tokens={
|
||||||
|
Object {
|
||||||
|
"childrenGap": 15,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<StackItem>
|
||||||
|
<StyledMessageBarBase
|
||||||
|
messageBarType={1}
|
||||||
|
>
|
||||||
|
Error:
|
||||||
|
label, truelabel and falselabel are required for boolean input 'throughput3'
|
||||||
|
</StyledMessageBarBase>
|
||||||
|
</StackItem>
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -111,16 +153,16 @@ exports[`SmartUiComponent should render 1`] = `
|
||||||
className="widgetRendererContainer"
|
className="widgetRendererContainer"
|
||||||
tokens={
|
tokens={
|
||||||
Object {
|
Object {
|
||||||
"childrenGap": 10,
|
"childrenGap": 15,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<StackItem>
|
||||||
<div
|
<div
|
||||||
className="stringInputContainer"
|
className="stringInputContainer"
|
||||||
>
|
>
|
||||||
<div>
|
|
||||||
<StyledTextFieldBase
|
<StyledTextFieldBase
|
||||||
id="containerId-input"
|
id="containerId-textBox-input"
|
||||||
label="Container id"
|
label="Container id"
|
||||||
onChange={[Function]}
|
onChange={[Function]}
|
||||||
styles={
|
styles={
|
||||||
|
@ -140,7 +182,7 @@ exports[`SmartUiComponent should render 1`] = `
|
||||||
type="text"
|
type="text"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</StackItem>
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -150,11 +192,14 @@ exports[`SmartUiComponent should render 1`] = `
|
||||||
className="widgetRendererContainer"
|
className="widgetRendererContainer"
|
||||||
tokens={
|
tokens={
|
||||||
Object {
|
Object {
|
||||||
"childrenGap": 10,
|
"childrenGap": 15,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div>
|
<StackItem>
|
||||||
|
<div
|
||||||
|
id="analyticalStore-radioSwitch-input"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="inputLabelContainer"
|
className="inputLabelContainer"
|
||||||
>
|
>
|
||||||
|
@ -184,6 +229,7 @@ exports[`SmartUiComponent should render 1`] = `
|
||||||
selectedKey="true"
|
selectedKey="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</StackItem>
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -193,26 +239,28 @@ exports[`SmartUiComponent should render 1`] = `
|
||||||
className="widgetRendererContainer"
|
className="widgetRendererContainer"
|
||||||
tokens={
|
tokens={
|
||||||
Object {
|
Object {
|
||||||
"childrenGap": 10,
|
"childrenGap": 15,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<StackItem>
|
||||||
<StyledWithResponsiveMode
|
<StyledWithResponsiveMode
|
||||||
|
id="database-dropown-input"
|
||||||
label="Database"
|
label="Database"
|
||||||
onChange={[Function]}
|
onChange={[Function]}
|
||||||
options={
|
options={
|
||||||
Array [
|
Array [
|
||||||
Object {
|
Object {
|
||||||
"key": "db1",
|
"key": "db1",
|
||||||
"text": "database1",
|
"text": "Database 1",
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
"key": "db2",
|
"key": "db2",
|
||||||
"text": "database2",
|
"text": "Database 2",
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
"key": "db3",
|
"key": "db3",
|
||||||
"text": "database3",
|
"text": "Database 3",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -233,8 +281,9 @@ exports[`SmartUiComponent should render 1`] = `
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</StackItem>
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Fragment>
|
</Stack>
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -88,6 +88,9 @@ import { stringToBlob } from "../Utils/BlobUtils";
|
||||||
import { IChoiceGroupProps } from "office-ui-fabric-react";
|
import { IChoiceGroupProps } from "office-ui-fabric-react";
|
||||||
import { getErrorMessage, handleError, getErrorStack } from "../Common/ErrorHandlingUtils";
|
import { getErrorMessage, handleError, getErrorStack } from "../Common/ErrorHandlingUtils";
|
||||||
import { SubscriptionType } from "../Contracts/SubscriptionType";
|
import { SubscriptionType } from "../Contracts/SubscriptionType";
|
||||||
|
import { SelfServeLoadingComponentAdapter } from "../SelfServe/SelfServeLoadingComponentAdapter";
|
||||||
|
import { SelfServeType } from "../SelfServe/SelfServeUtils";
|
||||||
|
import { SelfServeComponentAdapter } from "../SelfServe/SelfServeComponentAdapter";
|
||||||
|
|
||||||
BindingHandlersRegisterer.registerBindingHandlers();
|
BindingHandlersRegisterer.registerBindingHandlers();
|
||||||
// Hold a reference to ComponentRegisterer to prevent transpiler to ignore import
|
// Hold a reference to ComponentRegisterer to prevent transpiler to ignore import
|
||||||
|
@ -131,6 +134,7 @@ export default class Explorer {
|
||||||
public isEnableMongoCapabilityPresent: ko.Computed<boolean>;
|
public isEnableMongoCapabilityPresent: ko.Computed<boolean>;
|
||||||
public isServerlessEnabled: ko.Computed<boolean>;
|
public isServerlessEnabled: ko.Computed<boolean>;
|
||||||
public isAccountReady: ko.Observable<boolean>;
|
public isAccountReady: ko.Observable<boolean>;
|
||||||
|
public selfServeType: ko.Observable<SelfServeType>;
|
||||||
public canSaveQueries: ko.Computed<boolean>;
|
public canSaveQueries: ko.Computed<boolean>;
|
||||||
public features: ko.Observable<any>;
|
public features: ko.Observable<any>;
|
||||||
public serverId: ko.Observable<string>;
|
public serverId: ko.Observable<string>;
|
||||||
|
@ -156,6 +160,7 @@ export default class Explorer {
|
||||||
public selectedNode: ko.Observable<ViewModels.TreeNode>;
|
public selectedNode: ko.Observable<ViewModels.TreeNode>;
|
||||||
public isRefreshingExplorer: ko.Observable<boolean>;
|
public isRefreshingExplorer: ko.Observable<boolean>;
|
||||||
private resourceTree: ResourceTreeAdapter;
|
private resourceTree: ResourceTreeAdapter;
|
||||||
|
private selfServeComponentAdapter: SelfServeComponentAdapter;
|
||||||
|
|
||||||
// Resource Token
|
// Resource Token
|
||||||
public resourceTokenDatabaseId: ko.Observable<string>;
|
public resourceTokenDatabaseId: ko.Observable<string>;
|
||||||
|
@ -214,6 +219,8 @@ export default class Explorer {
|
||||||
public shouldShowShareDialogContents: ko.Observable<boolean>;
|
public shouldShowShareDialogContents: ko.Observable<boolean>;
|
||||||
public shareAccessData: ko.Observable<AdHocAccessData>;
|
public shareAccessData: ko.Observable<AdHocAccessData>;
|
||||||
public renewExplorerShareAccess: (explorer: Explorer, token: string) => Q.Promise<void>;
|
public renewExplorerShareAccess: (explorer: Explorer, token: string) => Q.Promise<void>;
|
||||||
|
public renewTokenError: ko.Observable<string>;
|
||||||
|
public tokenForRenewal: ko.Observable<string>;
|
||||||
public shareAccessToggleState: ko.Observable<ShareAccessToggleState>;
|
public shareAccessToggleState: ko.Observable<ShareAccessToggleState>;
|
||||||
public shareAccessUrl: ko.Observable<string>;
|
public shareAccessUrl: ko.Observable<string>;
|
||||||
public shareUrlCopyHelperText: ko.Observable<string>;
|
public shareUrlCopyHelperText: ko.Observable<string>;
|
||||||
|
@ -257,6 +264,7 @@ export default class Explorer {
|
||||||
private _dialogProps: ko.Observable<DialogProps>;
|
private _dialogProps: ko.Observable<DialogProps>;
|
||||||
private addSynapseLinkDialog: DialogComponentAdapter;
|
private addSynapseLinkDialog: DialogComponentAdapter;
|
||||||
private _addSynapseLinkDialogProps: ko.Observable<DialogProps>;
|
private _addSynapseLinkDialogProps: ko.Observable<DialogProps>;
|
||||||
|
private selfServeLoadingComponentAdapter: SelfServeLoadingComponentAdapter;
|
||||||
|
|
||||||
private static readonly MaxNbDatabasesToAutoExpand = 5;
|
private static readonly MaxNbDatabasesToAutoExpand = 5;
|
||||||
|
|
||||||
|
@ -292,6 +300,7 @@ export default class Explorer {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.isAccountReady = ko.observable<boolean>(false);
|
this.isAccountReady = ko.observable<boolean>(false);
|
||||||
|
this.selfServeType = ko.observable<SelfServeType>(undefined);
|
||||||
this._isInitializingNotebooks = false;
|
this._isInitializingNotebooks = false;
|
||||||
this._isInitializingSparkConnectionInfo = false;
|
this._isInitializingSparkConnectionInfo = false;
|
||||||
this.arcadiaToken = ko.observable<string>();
|
this.arcadiaToken = ko.observable<string>();
|
||||||
|
@ -312,7 +321,7 @@ export default class Explorer {
|
||||||
this.isSynapseLinkUpdating = ko.observable<boolean>(false);
|
this.isSynapseLinkUpdating = ko.observable<boolean>(false);
|
||||||
this.isAccountReady.subscribe(async (isAccountReady: boolean) => {
|
this.isAccountReady.subscribe(async (isAccountReady: boolean) => {
|
||||||
if (isAccountReady) {
|
if (isAccountReady) {
|
||||||
this.isAuthWithResourceToken() ? await this.refreshDatabaseForResourceToken() : this.refreshAllDatabases(true);
|
this.isAuthWithResourceToken() ? this.refreshDatabaseForResourceToken() : this.refreshAllDatabases(true);
|
||||||
RouteHandler.getInstance().initHandler();
|
RouteHandler.getInstance().initHandler();
|
||||||
this.notebookWorkspaceManager = new NotebookWorkspaceManager();
|
this.notebookWorkspaceManager = new NotebookWorkspaceManager();
|
||||||
this.arcadiaWorkspaces = ko.observableArray();
|
this.arcadiaWorkspaces = ko.observableArray();
|
||||||
|
@ -379,6 +388,8 @@ export default class Explorer {
|
||||||
readWriteUrl: undefined,
|
readWriteUrl: undefined,
|
||||||
readUrl: undefined
|
readUrl: undefined
|
||||||
});
|
});
|
||||||
|
this.tokenForRenewal = ko.observable<string>("");
|
||||||
|
this.renewTokenError = ko.observable<string>("");
|
||||||
this.shareAccessUrl = ko.observable<string>();
|
this.shareAccessUrl = ko.observable<string>();
|
||||||
this.shareUrlCopyHelperText = ko.observable<string>("Click to copy");
|
this.shareUrlCopyHelperText = ko.observable<string>("Click to copy");
|
||||||
this.shareTokenCopyHelperText = ko.observable<string>("Click to copy");
|
this.shareTokenCopyHelperText = ko.observable<string>("Click to copy");
|
||||||
|
@ -410,6 +421,7 @@ export default class Explorer {
|
||||||
|
|
||||||
this.isSchemaEnabled = ko.computed<boolean>(() => this.isFeatureEnabled(Constants.Features.enableSchema));
|
this.isSchemaEnabled = ko.computed<boolean>(() => this.isFeatureEnabled(Constants.Features.enableSchema));
|
||||||
this.isNotificationConsoleExpanded = ko.observable<boolean>(false);
|
this.isNotificationConsoleExpanded = ko.observable<boolean>(false);
|
||||||
|
|
||||||
this.isAutoscaleDefaultEnabled = ko.observable<boolean>(false);
|
this.isAutoscaleDefaultEnabled = ko.observable<boolean>(false);
|
||||||
|
|
||||||
this.databases = ko.observableArray<ViewModels.Database>();
|
this.databases = ko.observableArray<ViewModels.Database>();
|
||||||
|
@ -693,6 +705,7 @@ export default class Explorer {
|
||||||
});
|
});
|
||||||
|
|
||||||
this.uploadItemsPaneAdapter = new UploadItemsPaneAdapter(this);
|
this.uploadItemsPaneAdapter = new UploadItemsPaneAdapter(this);
|
||||||
|
this.selfServeComponentAdapter = new SelfServeComponentAdapter(this);
|
||||||
|
|
||||||
this.loadQueryPane = new LoadQueryPane({
|
this.loadQueryPane = new LoadQueryPane({
|
||||||
id: "loadquerypane",
|
id: "loadquerypane",
|
||||||
|
@ -868,6 +881,7 @@ export default class Explorer {
|
||||||
});
|
});
|
||||||
|
|
||||||
this.commandBarComponentAdapter = new CommandBarComponentAdapter(this);
|
this.commandBarComponentAdapter = new CommandBarComponentAdapter(this);
|
||||||
|
this.selfServeLoadingComponentAdapter = new SelfServeLoadingComponentAdapter();
|
||||||
this.notificationConsoleComponentAdapter = new NotificationConsoleComponentAdapter(this);
|
this.notificationConsoleComponentAdapter = new NotificationConsoleComponentAdapter(this);
|
||||||
|
|
||||||
this._initSettings();
|
this._initSettings();
|
||||||
|
@ -1090,6 +1104,25 @@ export default class Explorer {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public renewToken = (): void => {
|
||||||
|
TelemetryProcessor.trace(Action.ConnectEncryptionToken);
|
||||||
|
this.renewTokenError("");
|
||||||
|
const id: string = NotificationConsoleUtils.logConsoleMessage(
|
||||||
|
ConsoleDataType.InProgress,
|
||||||
|
"Initiating connection to account"
|
||||||
|
);
|
||||||
|
this.renewExplorerShareAccess(this, this.tokenForRenewal())
|
||||||
|
.fail((error: any) => {
|
||||||
|
const stringifiedError: string = getErrorMessage(error);
|
||||||
|
this.renewTokenError("Invalid connection string specified");
|
||||||
|
NotificationConsoleUtils.logConsoleMessage(
|
||||||
|
ConsoleDataType.Error,
|
||||||
|
`Failed to initiate connection to account: ${stringifiedError}`
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => NotificationConsoleUtils.clearInProgressMessageWithId(id));
|
||||||
|
};
|
||||||
|
|
||||||
public generateSharedAccessData(): void {
|
public generateSharedAccessData(): void {
|
||||||
const id: string = NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.InProgress, "Generating share url");
|
const id: string = NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.InProgress, "Generating share url");
|
||||||
AuthHeadersUtil.generateEncryptedToken().then(
|
AuthHeadersUtil.generateEncryptedToken().then(
|
||||||
|
@ -1241,6 +1274,16 @@ export default class Explorer {
|
||||||
$("#contextSwitchPrompt").dialog("open");
|
$("#contextSwitchPrompt").dialog("open");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public displayConnectExplorerForm(): void {
|
||||||
|
$("#divExplorer").hide();
|
||||||
|
$("#connectExplorer").css("display", "flex");
|
||||||
|
}
|
||||||
|
|
||||||
|
public hideConnectExplorerForm(): void {
|
||||||
|
$("#connectExplorer").hide();
|
||||||
|
$("#divExplorer").show();
|
||||||
|
}
|
||||||
|
|
||||||
public isReadWriteToggled: () => boolean = (): boolean => {
|
public isReadWriteToggled: () => boolean = (): boolean => {
|
||||||
return this.shareAccessToggleState() === ShareAccessToggleState.ReadWrite;
|
return this.shareAccessToggleState() === ShareAccessToggleState.ReadWrite;
|
||||||
};
|
};
|
||||||
|
@ -1330,17 +1373,21 @@ export default class Explorer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async refreshDatabaseForResourceToken(): Promise<any> {
|
public refreshDatabaseForResourceToken(): Q.Promise<any> {
|
||||||
const databaseId = this.resourceTokenDatabaseId();
|
const databaseId = this.resourceTokenDatabaseId();
|
||||||
const collectionId = this.resourceTokenCollectionId();
|
const collectionId = this.resourceTokenCollectionId();
|
||||||
if (!databaseId || !collectionId) {
|
if (!databaseId || !collectionId) {
|
||||||
throw new Error("No collection ID or database ID for resource token");
|
return Q.reject();
|
||||||
}
|
}
|
||||||
|
|
||||||
return readCollection(databaseId, collectionId).then((collection: DataModels.Collection) => {
|
const deferred: Q.Deferred<void> = Q.defer();
|
||||||
|
readCollection(databaseId, collectionId).then((collection: DataModels.Collection) => {
|
||||||
this.resourceTokenCollection(new ResourceTokenCollection(this, databaseId, collection));
|
this.resourceTokenCollection(new ResourceTokenCollection(this, databaseId, collection));
|
||||||
this.selectedNode(this.resourceTokenCollection());
|
this.selectedNode(this.resourceTokenCollection());
|
||||||
|
deferred.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return deferred.promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
public refreshAllDatabases(isInitialLoad?: boolean): Q.Promise<any> {
|
public refreshAllDatabases(isInitialLoad?: boolean): Q.Promise<any> {
|
||||||
|
@ -1806,11 +1853,25 @@ export default class Explorer {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setSelfServeType(inputs: ViewModels.DataExplorerInputsFrame): void {
|
||||||
|
const selfServeFeature = inputs.features[Constants.Features.selfServeType];
|
||||||
|
if (selfServeFeature) {
|
||||||
|
// self serve type received from query string
|
||||||
|
const selfServeType = SelfServeType[selfServeFeature?.toLowerCase() as keyof typeof SelfServeType];
|
||||||
|
this.selfServeType(selfServeType ? selfServeType : SelfServeType.invalid);
|
||||||
|
} else if (inputs.selfServeType) {
|
||||||
|
// self serve type received from portal
|
||||||
|
this.selfServeType(inputs.selfServeType);
|
||||||
|
} else {
|
||||||
|
this.selfServeType(SelfServeType.none);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public initDataExplorerWithFrameInputs(inputs: ViewModels.DataExplorerInputsFrame): void {
|
public initDataExplorerWithFrameInputs(inputs: ViewModels.DataExplorerInputsFrame): void {
|
||||||
if (inputs != null) {
|
if (inputs != null) {
|
||||||
// In development mode, save the iframe message from the portal in session storage.
|
// In development mode, save the iframe message from the portal in session storage.
|
||||||
// This allows webpack hot reload to funciton properly
|
// This allows webpack hot reload to funciton properly
|
||||||
if (process.env.NODE_ENV === "development" && configContext.platform === Platform.Portal) {
|
if (process.env.NODE_ENV === "development") {
|
||||||
sessionStorage.setItem("portalDataExplorerInitMessage", JSON.stringify(inputs));
|
sessionStorage.setItem("portalDataExplorerInitMessage", JSON.stringify(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1829,6 +1890,7 @@ export default class Explorer {
|
||||||
this.isTryCosmosDBSubscription(inputs.isTryCosmosDBSubscription);
|
this.isTryCosmosDBSubscription(inputs.isTryCosmosDBSubscription);
|
||||||
this.isAuthWithResourceToken(inputs.isAuthWithresourceToken);
|
this.isAuthWithResourceToken(inputs.isAuthWithresourceToken);
|
||||||
this.setFeatureFlagsFromFlights(inputs.flights);
|
this.setFeatureFlagsFromFlights(inputs.flights);
|
||||||
|
this.setSelfServeType(inputs);
|
||||||
this._importExplorerConfigComplete = true;
|
this._importExplorerConfigComplete = true;
|
||||||
|
|
||||||
updateConfigContext({
|
updateConfigContext({
|
||||||
|
@ -1845,6 +1907,16 @@ export default class Explorer {
|
||||||
subscriptionType: inputs.subscriptionType,
|
subscriptionType: inputs.subscriptionType,
|
||||||
quotaId: inputs.quotaId
|
quotaId: inputs.quotaId
|
||||||
});
|
});
|
||||||
|
TelemetryProcessor.traceSuccess(
|
||||||
|
Action.LoadDatabaseAccount,
|
||||||
|
{
|
||||||
|
resourceId: this.databaseAccount && this.databaseAccount().id,
|
||||||
|
dataExplorerArea: Constants.Areas.ResourceTree,
|
||||||
|
databaseAccount: this.databaseAccount && this.databaseAccount()
|
||||||
|
},
|
||||||
|
inputs.loadDatabaseAccountTimestamp
|
||||||
|
);
|
||||||
|
|
||||||
this.isAccountReady(true);
|
this.isAccountReady(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1926,6 +1998,18 @@ export default class Explorer {
|
||||||
this.commandBarComponentAdapter.onUpdateTabsButtons(buttons);
|
this.commandBarComponentAdapter.onUpdateTabsButtons(buttons);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public signInAad = () => {
|
||||||
|
TelemetryProcessor.trace(Action.SignInAad, undefined, { area: "Explorer" });
|
||||||
|
sendMessage({
|
||||||
|
type: MessageTypes.AadSignIn
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
public onSwitchToConnectionString = () => {
|
||||||
|
$("#connectWithAad").hide();
|
||||||
|
$("#connectWithConnectionString").show();
|
||||||
|
};
|
||||||
|
|
||||||
public clickHostedAccountSwitch = () => {
|
public clickHostedAccountSwitch = () => {
|
||||||
sendMessage({
|
sendMessage({
|
||||||
type: MessageTypes.UpdateAccountSwitch,
|
type: MessageTypes.UpdateAccountSwitch,
|
||||||
|
|
19
src/Main.tsx
19
src/Main.tsx
|
@ -81,6 +81,7 @@ import { DefaultExperienceUtility } from "./Shared/DefaultExperienceUtility";
|
||||||
import { parseResourceTokenConnectionString } from "./Platform/Hosted/Helpers/ResourceTokenUtils";
|
import { parseResourceTokenConnectionString } from "./Platform/Hosted/Helpers/ResourceTokenUtils";
|
||||||
import { AccountKind, DefaultAccountExperience, ServerIds } from "./Common/Constants";
|
import { AccountKind, DefaultAccountExperience, ServerIds } from "./Common/Constants";
|
||||||
import { listKeys } from "./Utils/arm/generatedClients/2020-04-01/databaseAccounts";
|
import { listKeys } from "./Utils/arm/generatedClients/2020-04-01/databaseAccounts";
|
||||||
|
import { SelfServeType } from "./SelfServe/SelfServeUtils";
|
||||||
|
|
||||||
const App: React.FunctionComponent = () => {
|
const App: React.FunctionComponent = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -89,6 +90,7 @@ const App: React.FunctionComponent = () => {
|
||||||
if (config.platform === Platform.Hosted) {
|
if (config.platform === Platform.Hosted) {
|
||||||
const win = (window as unknown) as HostedExplorerChildFrame;
|
const win = (window as unknown) as HostedExplorerChildFrame;
|
||||||
explorer = new Explorer();
|
explorer = new Explorer();
|
||||||
|
explorer.selfServeType(SelfServeType.none);
|
||||||
if (win.hostedConfig.authType === AuthType.EncryptedToken) {
|
if (win.hostedConfig.authType === AuthType.EncryptedToken) {
|
||||||
// TODO: Remove window.authType
|
// TODO: Remove window.authType
|
||||||
window.authType = AuthType.EncryptedToken;
|
window.authType = AuthType.EncryptedToken;
|
||||||
|
@ -236,6 +238,7 @@ const App: React.FunctionComponent = () => {
|
||||||
} else if (config.platform === Platform.Emulator) {
|
} else if (config.platform === Platform.Emulator) {
|
||||||
window.authType = AuthType.MasterKey;
|
window.authType = AuthType.MasterKey;
|
||||||
explorer = new Explorer();
|
explorer = new Explorer();
|
||||||
|
explorer.selfServeType(SelfServeType.none);
|
||||||
explorer.databaseAccount(emulatorAccount);
|
explorer.databaseAccount(emulatorAccount);
|
||||||
explorer.isAccountReady(true);
|
explorer.isAccountReady(true);
|
||||||
} else if (config.platform === Platform.Portal) {
|
} else if (config.platform === Platform.Portal) {
|
||||||
|
@ -261,7 +264,17 @@ const App: React.FunctionComponent = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flexContainer">
|
<div className="flexContainer">
|
||||||
<div id="divExplorer" className="flexContainer hideOverflows" style={{ display: "none" }}>
|
<div
|
||||||
|
id="divSelfServe"
|
||||||
|
className="flexContainer"
|
||||||
|
data-bind="visible: selfServeType() && selfServeType() !== 'none', react: selfServeComponentAdapter"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
id="divExplorer"
|
||||||
|
data-bind="if: selfServeType() === 'none'"
|
||||||
|
className="flexContainer hideOverflows"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
>
|
||||||
{/* Main Command Bar - Start */}
|
{/* Main Command Bar - Start */}
|
||||||
<div data-bind="react: commandBarComponentAdapter" />
|
<div data-bind="react: commandBarComponentAdapter" />
|
||||||
{/* Main Command Bar - End */}
|
{/* Main Command Bar - End */}
|
||||||
|
@ -453,8 +466,11 @@ const App: React.FunctionComponent = () => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Global loader - Start */}
|
{/* Global loader - Start */}
|
||||||
|
|
||||||
<div className="splashLoaderContainer" data-bind="visible: isRefreshingExplorer">
|
<div className="splashLoaderContainer" data-bind="visible: isRefreshingExplorer">
|
||||||
<div className="splashLoaderContentContainer">
|
<div className="splashLoaderContentContainer">
|
||||||
|
<div data-bind="visible: selfServeType() === undefined, react: selfServeLoadingComponentAdapter"></div>
|
||||||
|
<div data-bind="if: selfServeType() === 'none'" style={{ display: "none" }}>
|
||||||
<p className="connectExplorerContent">
|
<p className="connectExplorerContent">
|
||||||
<img src={hdeConnectImage} alt="Azure Cosmos DB" />
|
<img src={hdeConnectImage} alt="Azure Cosmos DB" />
|
||||||
</p>
|
</p>
|
||||||
|
@ -466,6 +482,7 @@ const App: React.FunctionComponent = () => {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/* Global loader - End */}
|
{/* Global loader - End */}
|
||||||
<div data-bind="react:uploadItemsPaneAdapter" />
|
<div data-bind="react:uploadItemsPaneAdapter" />
|
||||||
<div data-bind='component: { name: "add-database-pane", params: {data: addDatabasePane} }' />
|
<div data-bind='component: { name: "add-database-pane", params: {data: addDatabasePane} }' />
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { Info } from "../Explorer/Controls/SmartUi/SmartUiComponent";
|
||||||
|
import { addPropertyToMap, buildSmartUiDescriptor } from "./SelfServeUtils";
|
||||||
|
|
||||||
|
export const IsDisplayable = (): ClassDecorator => {
|
||||||
|
return target => {
|
||||||
|
buildSmartUiDescriptor(target.name, target.prototype);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ClassInfo = (info: (() => Promise<Info>) | Info): ClassDecorator => {
|
||||||
|
return target => {
|
||||||
|
addPropertyToMap(target.prototype, "root", target.name, "info", info);
|
||||||
|
};
|
||||||
|
};
|
|
@ -0,0 +1,167 @@
|
||||||
|
import { PropertyInfo, OnChange, Values } from "../PropertyDecorators";
|
||||||
|
import { ClassInfo, IsDisplayable } from "../ClassDecorators";
|
||||||
|
import { SelfServeBaseClass } from "../SelfServeUtils";
|
||||||
|
import { ChoiceItem, Info, InputType, UiType } from "../../Explorer/Controls/SmartUi/SmartUiComponent";
|
||||||
|
import { SessionStorageUtility } from "../../Shared/StorageUtility";
|
||||||
|
|
||||||
|
export enum Regions {
|
||||||
|
NorthCentralUS = "NCUS",
|
||||||
|
WestUS = "WUS",
|
||||||
|
EastUS2 = "EUS2"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const regionDropdownItems: ChoiceItem[] = [
|
||||||
|
{ label: "North Central US", key: Regions.NorthCentralUS },
|
||||||
|
{ label: "West US", key: Regions.WestUS },
|
||||||
|
{ label: "East US 2", key: Regions.EastUS2 }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const selfServeExampleInfo: Info = {
|
||||||
|
message: "This is a self serve class"
|
||||||
|
};
|
||||||
|
|
||||||
|
export const regionDropdownInfo: Info = {
|
||||||
|
message: "More regions can be added in the future."
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDbThroughputChange = (currentState: Map<string, InputType>, newValue: InputType): Map<string, InputType> => {
|
||||||
|
currentState.set("dbThroughput", newValue);
|
||||||
|
currentState.set("collectionThroughput", newValue);
|
||||||
|
return currentState;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initializeMaxThroughput = async (): Promise<number> => {
|
||||||
|
return 10000;
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
This is an example self serve class that auto generates UI components for your feature.
|
||||||
|
|
||||||
|
Each self serve class
|
||||||
|
- Needs to extends the SelfServeBase class.
|
||||||
|
- Needs to have the @IsDisplayable() decorator to tell the compiler that UI needs to be generated from this class.
|
||||||
|
- Needs to define an onSubmit() function, a callback for when the submit button is clicked.
|
||||||
|
- Needs to define an initialize() function, to set default values for the inputs.
|
||||||
|
|
||||||
|
You can test this self serve UI by using the featureflag '?feature.selfServeType=example'
|
||||||
|
and plumb in similar feature flags for your own self serve class.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
@IsDisplayable()
|
||||||
|
- role: Indicates to the compiler that UI should be generated from this class.
|
||||||
|
*/
|
||||||
|
@IsDisplayable()
|
||||||
|
/*
|
||||||
|
@ClassInfo()
|
||||||
|
- optional
|
||||||
|
- input: Info | () => Promise<Info>
|
||||||
|
- role: Display an Info bar as the first element of the UI.
|
||||||
|
*/
|
||||||
|
@ClassInfo(selfServeExampleInfo)
|
||||||
|
export default class SelfServeExample extends SelfServeBaseClass {
|
||||||
|
/*
|
||||||
|
onSubmit()
|
||||||
|
- input: (currentValues: Map<string, InputType>) => Promise<void>
|
||||||
|
- role: Callback that is triggerred when the submit button is clicked. You should perform your rest API
|
||||||
|
calls here using the data from the different inputs passed as a Map to this callback function.
|
||||||
|
|
||||||
|
In this example, the onSubmit callback simply sets the value for keys corresponding to the field name
|
||||||
|
in the SessionStorage.
|
||||||
|
*/
|
||||||
|
public onSubmit = async (currentValues: Map<string, InputType>): Promise<void> => {
|
||||||
|
SessionStorageUtility.setEntry("regions", currentValues.get("regions")?.toString());
|
||||||
|
SessionStorageUtility.setEntry("enableLogging", currentValues.get("enableLogging")?.toString());
|
||||||
|
SessionStorageUtility.setEntry("accountName", currentValues.get("accountName")?.toString());
|
||||||
|
SessionStorageUtility.setEntry("dbThroughput", currentValues.get("dbThroughput")?.toString());
|
||||||
|
SessionStorageUtility.setEntry("collectionThroughput", currentValues.get("collectionThroughput")?.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
initialize()
|
||||||
|
- input: () => Promise<Map<string, InputType>>
|
||||||
|
- role: Set default values for the properties of this class.
|
||||||
|
|
||||||
|
The properties of this class (namely regions, enableLogging, accountName, dbThroughput, collectionThroughput),
|
||||||
|
having the @Values decorator, will each correspond to an UI element. Their values can be of 'InputType'. Their
|
||||||
|
defaults can be set by setting values in a Map corresponding to the field's name.
|
||||||
|
|
||||||
|
Typically, you can make rest calls in the async initialize function, to fetch the initial values for
|
||||||
|
these fields. This is called after the onSubmit callback, to reinitialize the defaults.
|
||||||
|
|
||||||
|
In this example, the initialize function simply reads the SessionStorage to fetch the default values
|
||||||
|
for these fields. These are then set when the changes are submitted.
|
||||||
|
*/
|
||||||
|
public initialize = async (): Promise<Map<string, InputType>> => {
|
||||||
|
const defaults = new Map<string, InputType>();
|
||||||
|
defaults.set("regions", SessionStorageUtility.getEntry("regions"));
|
||||||
|
defaults.set("enableLogging", SessionStorageUtility.getEntry("enableLogging") === "true");
|
||||||
|
const stringInput = SessionStorageUtility.getEntry("accountName");
|
||||||
|
defaults.set("accountName", stringInput ? stringInput : "");
|
||||||
|
const numberSliderInput = parseInt(SessionStorageUtility.getEntry("dbThroughput"));
|
||||||
|
defaults.set("dbThroughput", isNaN(numberSliderInput) ? 1 : numberSliderInput);
|
||||||
|
const numberSpinnerInput = parseInt(SessionStorageUtility.getEntry("collectionThroughput"));
|
||||||
|
defaults.set("collectionThroughput", isNaN(numberSpinnerInput) ? 1 : numberSpinnerInput);
|
||||||
|
return defaults;
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
@PropertyInfo()
|
||||||
|
- optional
|
||||||
|
- input: Info | () => Promise<Info>
|
||||||
|
- role: Display an Info bar above the UI element for this property.
|
||||||
|
*/
|
||||||
|
@PropertyInfo(regionDropdownInfo)
|
||||||
|
|
||||||
|
/*
|
||||||
|
@Values() :
|
||||||
|
- input: NumberInputOptions | StringInputOptions | BooleanInputOptions | ChoiceInputOptions
|
||||||
|
- role: Specifies the required options to display the property as TextBox, Number Spinner/Slider, Radio buton or Dropdown.
|
||||||
|
*/
|
||||||
|
@Values({ label: "Regions", choices: regionDropdownItems })
|
||||||
|
regions: ChoiceItem;
|
||||||
|
|
||||||
|
@Values({
|
||||||
|
label: "Enable Logging",
|
||||||
|
trueLabel: "Enable",
|
||||||
|
falseLabel: "Disable"
|
||||||
|
})
|
||||||
|
enableLogging: boolean;
|
||||||
|
|
||||||
|
@Values({
|
||||||
|
label: "Account Name",
|
||||||
|
placeholder: "Enter the account name"
|
||||||
|
})
|
||||||
|
accountName: string;
|
||||||
|
|
||||||
|
/*
|
||||||
|
@OnChange()
|
||||||
|
- optional
|
||||||
|
- input: (currentValues: Map<string, InputType>, newValue: InputType) => Map<string, InputType>
|
||||||
|
- role: Takes a Map of current values and the newValue for this property as inputs. This is called when a property
|
||||||
|
changes its value in the UI. This can be used to change other input values based on some other input.
|
||||||
|
|
||||||
|
The new Map of propertyName -> value is returned.
|
||||||
|
|
||||||
|
In this example, the onDbThroughputChange function sets the collectionThroughput to the same value as the dbThroughput
|
||||||
|
when the slider in moved in the UI.
|
||||||
|
*/
|
||||||
|
@OnChange(onDbThroughputChange)
|
||||||
|
@Values({
|
||||||
|
label: "Database Throughput",
|
||||||
|
min: 400,
|
||||||
|
max: initializeMaxThroughput,
|
||||||
|
step: 100,
|
||||||
|
uiType: UiType.Slider
|
||||||
|
})
|
||||||
|
dbThroughput: number;
|
||||||
|
|
||||||
|
@Values({
|
||||||
|
label: "Collection Throughput",
|
||||||
|
min: 400,
|
||||||
|
max: initializeMaxThroughput,
|
||||||
|
step: 100,
|
||||||
|
uiType: UiType.Spinner
|
||||||
|
})
|
||||||
|
collectionThroughput: number;
|
||||||
|
}
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { ChoiceItem, Info, InputType, UiType } from "../Explorer/Controls/SmartUi/SmartUiComponent";
|
||||||
|
import { addPropertyToMap, CommonInputTypes } from "./SelfServeUtils";
|
||||||
|
|
||||||
|
type ValueOf<T> = T[keyof T];
|
||||||
|
interface Decorator {
|
||||||
|
name: keyof CommonInputTypes;
|
||||||
|
value: ValueOf<CommonInputTypes>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InputOptionsBase {
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberInputOptions extends InputOptionsBase {
|
||||||
|
min: (() => Promise<number>) | number;
|
||||||
|
max: (() => Promise<number>) | number;
|
||||||
|
step: (() => Promise<number>) | number;
|
||||||
|
uiType: UiType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StringInputOptions extends InputOptionsBase {
|
||||||
|
placeholder?: (() => Promise<string>) | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BooleanInputOptions extends InputOptionsBase {
|
||||||
|
trueLabel: (() => Promise<string>) | string;
|
||||||
|
falseLabel: (() => Promise<string>) | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChoiceInputOptions extends InputOptionsBase {
|
||||||
|
choices: (() => Promise<ChoiceItem[]>) | ChoiceItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type InputOptions = NumberInputOptions | StringInputOptions | BooleanInputOptions | ChoiceInputOptions;
|
||||||
|
|
||||||
|
const isNumberInputOptions = (inputOptions: InputOptions): inputOptions is NumberInputOptions => {
|
||||||
|
return "min" in inputOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isBooleanInputOptions = (inputOptions: InputOptions): inputOptions is BooleanInputOptions => {
|
||||||
|
return "trueLabel" in inputOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isChoiceInputOptions = (inputOptions: InputOptions): inputOptions is ChoiceInputOptions => {
|
||||||
|
return "choices" in inputOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addToMap = (...decorators: Decorator[]): PropertyDecorator => {
|
||||||
|
return (target, property) => {
|
||||||
|
let className = target.constructor.name;
|
||||||
|
const propertyName = property.toString();
|
||||||
|
if (className === "Function") {
|
||||||
|
//eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
|
className = (target as Function).name;
|
||||||
|
throw new Error(`Property '${propertyName}' in class '${className}'should be not be static.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const propertyType = (Reflect.getMetadata("design:type", target, property)?.name as string)?.toLowerCase();
|
||||||
|
addPropertyToMap(target, propertyName, className, "type", propertyType);
|
||||||
|
addPropertyToMap(target, propertyName, className, "dataFieldName", propertyName);
|
||||||
|
|
||||||
|
decorators.map((decorator: Decorator) =>
|
||||||
|
addPropertyToMap(target, propertyName, className, decorator.name, decorator.value)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OnChange = (
|
||||||
|
onChange: (currentState: Map<string, InputType>, newValue: InputType) => Map<string, InputType>
|
||||||
|
): PropertyDecorator => {
|
||||||
|
return addToMap({ name: "onChange", value: onChange });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PropertyInfo = (info: (() => Promise<Info>) | Info): PropertyDecorator => {
|
||||||
|
return addToMap({ name: "info", value: info });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Values = (inputOptions: InputOptions): PropertyDecorator => {
|
||||||
|
if (isNumberInputOptions(inputOptions)) {
|
||||||
|
return addToMap(
|
||||||
|
{ name: "label", value: inputOptions.label },
|
||||||
|
{ name: "min", value: inputOptions.min },
|
||||||
|
{ name: "max", value: inputOptions.max },
|
||||||
|
{ name: "step", value: inputOptions.step },
|
||||||
|
{ name: "uiType", value: inputOptions.uiType }
|
||||||
|
);
|
||||||
|
} else if (isBooleanInputOptions(inputOptions)) {
|
||||||
|
return addToMap(
|
||||||
|
{ name: "label", value: inputOptions.label },
|
||||||
|
{ name: "trueLabel", value: inputOptions.trueLabel },
|
||||||
|
{ name: "falseLabel", value: inputOptions.falseLabel }
|
||||||
|
);
|
||||||
|
} else if (isChoiceInputOptions(inputOptions)) {
|
||||||
|
return addToMap({ name: "label", value: inputOptions.label }, { name: "choices", value: inputOptions.choices });
|
||||||
|
} else {
|
||||||
|
return addToMap(
|
||||||
|
{ name: "label", value: inputOptions.label },
|
||||||
|
{ name: "placeholder", value: inputOptions.placeholder }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
|
@ -0,0 +1,104 @@
|
||||||
|
import React from "react";
|
||||||
|
import { shallow } from "enzyme";
|
||||||
|
import { SelfServeDescriptor, SelfServeComponent, SelfServeComponentState } from "./SelfServeComponent";
|
||||||
|
import { InputType, UiType } from "../Explorer/Controls/SmartUi/SmartUiComponent";
|
||||||
|
|
||||||
|
describe("SelfServeComponent", () => {
|
||||||
|
const defaultValues = new Map<string, InputType>([
|
||||||
|
["throughput", "450"],
|
||||||
|
["analyticalStore", "false"],
|
||||||
|
["database", "db2"]
|
||||||
|
]);
|
||||||
|
const initializeMock = jest.fn(async () => defaultValues);
|
||||||
|
const onSubmitMock = jest.fn(async () => {
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
|
||||||
|
const exampleData: SelfServeDescriptor = {
|
||||||
|
initialize: initializeMock,
|
||||||
|
onSubmit: onSubmitMock,
|
||||||
|
inputNames: ["throughput", "containerId", "analyticalStore", "database"],
|
||||||
|
root: {
|
||||||
|
id: "root",
|
||||||
|
info: {
|
||||||
|
message: "Start at $24/mo per database",
|
||||||
|
link: {
|
||||||
|
href: "https://aka.ms/azure-cosmos-db-pricing",
|
||||||
|
text: "More Details"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: "throughput",
|
||||||
|
input: {
|
||||||
|
label: "Throughput (input)",
|
||||||
|
dataFieldName: "throughput",
|
||||||
|
type: "number",
|
||||||
|
min: 400,
|
||||||
|
max: 500,
|
||||||
|
step: 10,
|
||||||
|
defaultValue: 400,
|
||||||
|
uiType: UiType.Spinner
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "containerId",
|
||||||
|
input: {
|
||||||
|
label: "Container id",
|
||||||
|
dataFieldName: "containerId",
|
||||||
|
type: "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "analyticalStore",
|
||||||
|
input: {
|
||||||
|
label: "Analytical Store",
|
||||||
|
trueLabel: "Enabled",
|
||||||
|
falseLabel: "Disabled",
|
||||||
|
defaultValue: true,
|
||||||
|
dataFieldName: "analyticalStore",
|
||||||
|
type: "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "database",
|
||||||
|
input: {
|
||||||
|
label: "Database",
|
||||||
|
dataFieldName: "database",
|
||||||
|
type: "object",
|
||||||
|
choices: [
|
||||||
|
{ label: "Database 1", key: "db1" },
|
||||||
|
{ label: "Database 2", key: "db2" },
|
||||||
|
{ label: "Database 3", key: "db3" }
|
||||||
|
],
|
||||||
|
defaultKey: "db2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyDefaultsSet = (currentValues: Map<string, InputType>): void => {
|
||||||
|
for (const key of currentValues.keys()) {
|
||||||
|
if (defaultValues.has(key)) {
|
||||||
|
expect(defaultValues.get(key)).toEqual(currentValues.get(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should render", async () => {
|
||||||
|
const wrapper = shallow(<SelfServeComponent descriptor={exampleData} />);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
expect(wrapper).toMatchSnapshot();
|
||||||
|
|
||||||
|
// initialize() should be called and defaults should be set when component is mounted
|
||||||
|
expect(initializeMock).toHaveBeenCalled();
|
||||||
|
const state = wrapper.state() as SelfServeComponentState;
|
||||||
|
verifyDefaultsSet(state.currentValues);
|
||||||
|
|
||||||
|
// onSubmit() must be called when submit button is clicked
|
||||||
|
const submitButton = wrapper.find("#submitButton");
|
||||||
|
submitButton.simulate("click");
|
||||||
|
expect(onSubmitMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,218 @@
|
||||||
|
import React from "react";
|
||||||
|
import { IStackTokens, PrimaryButton, Spinner, SpinnerSize, Stack } from "office-ui-fabric-react";
|
||||||
|
import {
|
||||||
|
ChoiceItem,
|
||||||
|
InputType,
|
||||||
|
InputTypeValue,
|
||||||
|
SmartUiComponent,
|
||||||
|
UiType,
|
||||||
|
SmartUiDescriptor,
|
||||||
|
Info
|
||||||
|
} from "../Explorer/Controls/SmartUi/SmartUiComponent";
|
||||||
|
|
||||||
|
export interface BaseInput {
|
||||||
|
label: (() => Promise<string>) | string;
|
||||||
|
dataFieldName: string;
|
||||||
|
type: InputTypeValue;
|
||||||
|
onChange?: (currentState: Map<string, InputType>, newValue: InputType) => Map<string, InputType>;
|
||||||
|
placeholder?: (() => Promise<string>) | string;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberInput extends BaseInput {
|
||||||
|
min: (() => Promise<number>) | number;
|
||||||
|
max: (() => Promise<number>) | number;
|
||||||
|
step: (() => Promise<number>) | number;
|
||||||
|
defaultValue?: number;
|
||||||
|
uiType: UiType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BooleanInput extends BaseInput {
|
||||||
|
trueLabel: (() => Promise<string>) | string;
|
||||||
|
falseLabel: (() => Promise<string>) | string;
|
||||||
|
defaultValue?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StringInput extends BaseInput {
|
||||||
|
defaultValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChoiceInput extends BaseInput {
|
||||||
|
choices: (() => Promise<ChoiceItem[]>) | ChoiceItem[];
|
||||||
|
defaultKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Node {
|
||||||
|
id: string;
|
||||||
|
info?: (() => Promise<Info>) | Info;
|
||||||
|
input?: AnyInput;
|
||||||
|
children?: Node[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelfServeDescriptor {
|
||||||
|
root: Node;
|
||||||
|
initialize?: () => Promise<Map<string, InputType>>;
|
||||||
|
onSubmit?: (currentValues: Map<string, InputType>) => Promise<void>;
|
||||||
|
inputNames?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnyInput = NumberInput | BooleanInput | StringInput | ChoiceInput;
|
||||||
|
|
||||||
|
export interface SelfServeComponentProps {
|
||||||
|
descriptor: SelfServeDescriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelfServeComponentState {
|
||||||
|
root: SelfServeDescriptor;
|
||||||
|
currentValues: Map<string, InputType>;
|
||||||
|
baselineValues: Map<string, InputType>;
|
||||||
|
isRefreshing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SelfServeComponent extends React.Component<SelfServeComponentProps, SelfServeComponentState> {
|
||||||
|
componentDidMount(): void {
|
||||||
|
this.initializeSmartUiComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(props: SelfServeComponentProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
root: this.props.descriptor,
|
||||||
|
currentValues: new Map(),
|
||||||
|
baselineValues: new Map(),
|
||||||
|
isRefreshing: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeSmartUiComponent = async (): Promise<void> => {
|
||||||
|
this.setState({ isRefreshing: true });
|
||||||
|
await this.initializeSmartUiNode(this.props.descriptor.root);
|
||||||
|
await this.setDefaults();
|
||||||
|
this.setState({ isRefreshing: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
private setDefaults = async (): Promise<void> => {
|
||||||
|
this.setState({ isRefreshing: true });
|
||||||
|
let { currentValues, baselineValues } = this.state;
|
||||||
|
|
||||||
|
const initialValues = await this.props.descriptor.initialize();
|
||||||
|
for (const key of initialValues.keys()) {
|
||||||
|
if (this.props.descriptor.inputNames.indexOf(key) === -1) {
|
||||||
|
this.setState({ isRefreshing: false });
|
||||||
|
throw new Error(`${key} is not an input property of this class.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentValues = currentValues.set(key, initialValues.get(key));
|
||||||
|
baselineValues = baselineValues.set(key, initialValues.get(key));
|
||||||
|
}
|
||||||
|
this.setState({ currentValues, baselineValues, isRefreshing: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
public discard = (): void => {
|
||||||
|
let { currentValues } = this.state;
|
||||||
|
const { baselineValues } = this.state;
|
||||||
|
for (const key of baselineValues.keys()) {
|
||||||
|
currentValues = currentValues.set(key, baselineValues.get(key));
|
||||||
|
}
|
||||||
|
this.setState({ currentValues });
|
||||||
|
};
|
||||||
|
|
||||||
|
private initializeSmartUiNode = async (currentNode: Node): Promise<void> => {
|
||||||
|
currentNode.info = await this.getResolvedValue(currentNode.info);
|
||||||
|
|
||||||
|
if (currentNode.input) {
|
||||||
|
currentNode.input = await this.getResolvedInput(currentNode.input);
|
||||||
|
}
|
||||||
|
|
||||||
|
const promises = currentNode.children?.map(async (child: Node) => await this.initializeSmartUiNode(child));
|
||||||
|
if (promises) {
|
||||||
|
await Promise.all(promises);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private getResolvedInput = async (input: AnyInput): Promise<AnyInput> => {
|
||||||
|
input.label = await this.getResolvedValue(input.label);
|
||||||
|
input.placeholder = await this.getResolvedValue(input.placeholder);
|
||||||
|
|
||||||
|
switch (input.type) {
|
||||||
|
case "string": {
|
||||||
|
return input as StringInput;
|
||||||
|
}
|
||||||
|
case "number": {
|
||||||
|
const numberInput = input as NumberInput;
|
||||||
|
numberInput.min = await this.getResolvedValue(numberInput.min);
|
||||||
|
numberInput.max = await this.getResolvedValue(numberInput.max);
|
||||||
|
numberInput.step = await this.getResolvedValue(numberInput.step);
|
||||||
|
return numberInput;
|
||||||
|
}
|
||||||
|
case "boolean": {
|
||||||
|
const booleanInput = input as BooleanInput;
|
||||||
|
booleanInput.trueLabel = await this.getResolvedValue(booleanInput.trueLabel);
|
||||||
|
booleanInput.falseLabel = await this.getResolvedValue(booleanInput.falseLabel);
|
||||||
|
return booleanInput;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const choiceInput = input as ChoiceInput;
|
||||||
|
choiceInput.choices = await this.getResolvedValue(choiceInput.choices);
|
||||||
|
return choiceInput;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public async getResolvedValue<T>(value: T | (() => Promise<T>)): Promise<T> {
|
||||||
|
if (value instanceof Function) {
|
||||||
|
return value();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onInputChange = (input: AnyInput, newValue: InputType) => {
|
||||||
|
if (input.onChange) {
|
||||||
|
const newValues = input.onChange(this.state.currentValues, newValue);
|
||||||
|
this.setState({ currentValues: newValues });
|
||||||
|
} else {
|
||||||
|
const dataFieldName = input.dataFieldName;
|
||||||
|
const { currentValues } = this.state;
|
||||||
|
currentValues.set(dataFieldName, newValue);
|
||||||
|
this.setState({ currentValues });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public render(): JSX.Element {
|
||||||
|
const containerStackTokens: IStackTokens = { childrenGap: 20 };
|
||||||
|
return !this.state.isRefreshing ? (
|
||||||
|
<div style={{ overflowX: "auto" }}>
|
||||||
|
<Stack tokens={containerStackTokens} styles={{ root: { width: 400, padding: 10 } }}>
|
||||||
|
<SmartUiComponent
|
||||||
|
descriptor={this.state.root as SmartUiDescriptor}
|
||||||
|
currentValues={this.state.currentValues}
|
||||||
|
onInputChange={this.onInputChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack horizontal tokens={{ childrenGap: 10 }}>
|
||||||
|
<PrimaryButton
|
||||||
|
id="submitButton"
|
||||||
|
styles={{ root: { width: 100 } }}
|
||||||
|
text="submit"
|
||||||
|
onClick={async () => {
|
||||||
|
await this.props.descriptor.onSubmit(this.state.currentValues);
|
||||||
|
this.setDefaults();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PrimaryButton
|
||||||
|
id="discardButton"
|
||||||
|
styles={{ root: { width: 100 } }}
|
||||||
|
text="discard"
|
||||||
|
onClick={() => this.discard()}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Spinner
|
||||||
|
size={SpinnerSize.large}
|
||||||
|
styles={{ root: { textAlign: "center", justifyContent: "center", width: "100%", height: "100%" } }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,51 @@
|
||||||
|
/**
|
||||||
|
* This adapter is responsible to render the React component
|
||||||
|
* If the component signals a change through the callback passed in the properties, it must render the React component when appropriate
|
||||||
|
* and update any knockout observables passed from the parent.
|
||||||
|
*/
|
||||||
|
import * as ko from "knockout";
|
||||||
|
import * as React from "react";
|
||||||
|
import { ReactAdapter } from "../Bindings/ReactBindingHandler";
|
||||||
|
import Explorer from "../Explorer/Explorer";
|
||||||
|
import { SelfServeDescriptor, SelfServeComponent } from "./SelfServeComponent";
|
||||||
|
import { SelfServeType } from "./SelfServeUtils";
|
||||||
|
|
||||||
|
export class SelfServeComponentAdapter implements ReactAdapter {
|
||||||
|
public parameters: ko.Observable<SelfServeDescriptor>;
|
||||||
|
public container: Explorer;
|
||||||
|
|
||||||
|
constructor(container: Explorer) {
|
||||||
|
this.container = container;
|
||||||
|
this.parameters = ko.observable(undefined);
|
||||||
|
this.container.selfServeType.subscribe(() => {
|
||||||
|
this.triggerRender();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getDescriptor = async (selfServeType: SelfServeType): Promise<SelfServeDescriptor> => {
|
||||||
|
switch (selfServeType) {
|
||||||
|
case SelfServeType.example: {
|
||||||
|
const SelfServeExample = await import(/* webpackChunkName: "SelfServeExample" */ "./Example/SelfServeExample");
|
||||||
|
return new SelfServeExample.default().toSelfServeDescriptor();
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public renderComponent(): JSX.Element {
|
||||||
|
if (this.container.selfServeType() === SelfServeType.invalid) {
|
||||||
|
return <h1>Invalid self serve type!</h1>;
|
||||||
|
}
|
||||||
|
const smartUiDescriptor = this.parameters();
|
||||||
|
return smartUiDescriptor ? <SelfServeComponent descriptor={smartUiDescriptor} /> : <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private triggerRender() {
|
||||||
|
window.requestAnimationFrame(async () => {
|
||||||
|
const selfServeType = this.container.selfServeType();
|
||||||
|
const smartUiDescriptor = await SelfServeComponentAdapter.getDescriptor(selfServeType);
|
||||||
|
this.parameters(smartUiDescriptor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
/**
|
||||||
|
* This adapter is responsible to render the React component
|
||||||
|
* If the component signals a change through the callback passed in the properties, it must render the React component when appropriate
|
||||||
|
* and update any knockout observables passed from the parent.
|
||||||
|
*/
|
||||||
|
import * as ko from "knockout";
|
||||||
|
import { Spinner, SpinnerSize } from "office-ui-fabric-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { ReactAdapter } from "../Bindings/ReactBindingHandler";
|
||||||
|
|
||||||
|
export class SelfServeLoadingComponentAdapter implements ReactAdapter {
|
||||||
|
public parameters: ko.Observable<number>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.parameters = ko.observable(Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderComponent(): JSX.Element {
|
||||||
|
return <Spinner size={SpinnerSize.large} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
private triggerRender() {
|
||||||
|
window.requestAnimationFrame(() => this.renderComponent());
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,277 @@
|
||||||
|
import {
|
||||||
|
CommonInputTypes,
|
||||||
|
mapToSmartUiDescriptor,
|
||||||
|
SelfServeBaseClass,
|
||||||
|
updateContextWithDecorator
|
||||||
|
} from "./SelfServeUtils";
|
||||||
|
import { InputType, UiType } from "./../Explorer/Controls/SmartUi/SmartUiComponent";
|
||||||
|
|
||||||
|
describe("SelfServeUtils", () => {
|
||||||
|
it("initialize should be declared for self serve classes", () => {
|
||||||
|
class Test extends SelfServeBaseClass {
|
||||||
|
public onSubmit = async (): Promise<void> => {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
public initialize: () => Promise<Map<string, InputType>>;
|
||||||
|
}
|
||||||
|
expect(() => new Test().toSelfServeDescriptor()).toThrow("initialize() was not declared for the class 'Test'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("onSubmit should be declared for self serve classes", () => {
|
||||||
|
class Test extends SelfServeBaseClass {
|
||||||
|
public onSubmit: () => Promise<void>;
|
||||||
|
public initialize = async (): Promise<Map<string, InputType>> => {
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
expect(() => new Test().toSelfServeDescriptor()).toThrow("onSubmit() was not declared for the class 'Test'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("@SmartUi decorator must be present for self serve classes", () => {
|
||||||
|
class Test extends SelfServeBaseClass {
|
||||||
|
public onSubmit = async (): Promise<void> => {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
public initialize = async (): Promise<Map<string, InputType>> => {
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
expect(() => new Test().toSelfServeDescriptor()).toThrow(
|
||||||
|
"@SmartUi decorator was not declared for the class 'Test'"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateContextWithDecorator", () => {
|
||||||
|
const context = new Map<string, CommonInputTypes>();
|
||||||
|
updateContextWithDecorator(context, "dbThroughput", "testClass", "max", 1);
|
||||||
|
updateContextWithDecorator(context, "dbThroughput", "testClass", "min", 2);
|
||||||
|
updateContextWithDecorator(context, "collThroughput", "testClass", "max", 5);
|
||||||
|
expect(context.size).toEqual(2);
|
||||||
|
expect(context.get("dbThroughput")).toEqual({ id: "dbThroughput", max: 1, min: 2 });
|
||||||
|
expect(context.get("collThroughput")).toEqual({ id: "collThroughput", max: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mapToSmartUiDescriptor", () => {
|
||||||
|
const context: Map<string, CommonInputTypes> = new Map([
|
||||||
|
[
|
||||||
|
"dbThroughput",
|
||||||
|
{
|
||||||
|
id: "dbThroughput",
|
||||||
|
dataFieldName: "dbThroughput",
|
||||||
|
type: "number",
|
||||||
|
label: "Database Throughput",
|
||||||
|
min: 1,
|
||||||
|
max: 5,
|
||||||
|
step: 1,
|
||||||
|
uiType: UiType.Slider
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"collThroughput",
|
||||||
|
{
|
||||||
|
id: "collThroughput",
|
||||||
|
dataFieldName: "collThroughput",
|
||||||
|
type: "number",
|
||||||
|
label: "Coll Throughput",
|
||||||
|
min: 1,
|
||||||
|
max: 5,
|
||||||
|
step: 1,
|
||||||
|
uiType: UiType.Spinner
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"invalidThroughput",
|
||||||
|
{
|
||||||
|
id: "invalidThroughput",
|
||||||
|
dataFieldName: "invalidThroughput",
|
||||||
|
type: "boolean",
|
||||||
|
label: "Invalid Coll Throughput",
|
||||||
|
min: 1,
|
||||||
|
max: 5,
|
||||||
|
step: 1,
|
||||||
|
uiType: UiType.Spinner,
|
||||||
|
errorMessage: "label, truelabel and falselabel are required for boolean input"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"collName",
|
||||||
|
{
|
||||||
|
id: "collName",
|
||||||
|
dataFieldName: "collName",
|
||||||
|
type: "string",
|
||||||
|
label: "Coll Name",
|
||||||
|
placeholder: "placeholder text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"enableLogging",
|
||||||
|
{
|
||||||
|
id: "enableLogging",
|
||||||
|
dataFieldName: "enableLogging",
|
||||||
|
type: "boolean",
|
||||||
|
label: "Enable Logging",
|
||||||
|
trueLabel: "Enable",
|
||||||
|
falseLabel: "Disable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"invalidEnableLogging",
|
||||||
|
{
|
||||||
|
id: "invalidEnableLogging",
|
||||||
|
dataFieldName: "invalidEnableLogging",
|
||||||
|
type: "boolean",
|
||||||
|
label: "Invalid Enable Logging",
|
||||||
|
placeholder: "placeholder text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"regions",
|
||||||
|
{
|
||||||
|
id: "regions",
|
||||||
|
dataFieldName: "regions",
|
||||||
|
type: "object",
|
||||||
|
label: "Regions",
|
||||||
|
choices: [
|
||||||
|
{ label: "South West US", key: "SWUS" },
|
||||||
|
{ label: "North Central US", key: "NCUS" },
|
||||||
|
{ label: "East US 2", key: "EUS2" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"invalidRegions",
|
||||||
|
{
|
||||||
|
id: "invalidRegions",
|
||||||
|
dataFieldName: "invalidRegions",
|
||||||
|
type: "object",
|
||||||
|
label: "Invalid Regions",
|
||||||
|
placeholder: "placeholder text"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
const expectedDescriptor = {
|
||||||
|
root: {
|
||||||
|
id: "root",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: "dbThroughput",
|
||||||
|
input: {
|
||||||
|
id: "dbThroughput",
|
||||||
|
dataFieldName: "dbThroughput",
|
||||||
|
type: "number",
|
||||||
|
label: "Database Throughput",
|
||||||
|
min: 1,
|
||||||
|
max: 5,
|
||||||
|
step: 1,
|
||||||
|
uiType: "Slider"
|
||||||
|
},
|
||||||
|
children: [] as Node[]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "collThroughput",
|
||||||
|
input: {
|
||||||
|
id: "collThroughput",
|
||||||
|
dataFieldName: "collThroughput",
|
||||||
|
type: "number",
|
||||||
|
label: "Coll Throughput",
|
||||||
|
min: 1,
|
||||||
|
max: 5,
|
||||||
|
step: 1,
|
||||||
|
uiType: "Spinner"
|
||||||
|
},
|
||||||
|
children: [] as Node[]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "invalidThroughput",
|
||||||
|
input: {
|
||||||
|
id: "invalidThroughput",
|
||||||
|
dataFieldName: "invalidThroughput",
|
||||||
|
type: "boolean",
|
||||||
|
label: "Invalid Coll Throughput",
|
||||||
|
min: 1,
|
||||||
|
max: 5,
|
||||||
|
step: 1,
|
||||||
|
uiType: "Spinner",
|
||||||
|
errorMessage: "label, truelabel and falselabel are required for boolean input 'invalidThroughput'."
|
||||||
|
},
|
||||||
|
children: [] as Node[]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "collName",
|
||||||
|
input: {
|
||||||
|
id: "collName",
|
||||||
|
dataFieldName: "collName",
|
||||||
|
type: "string",
|
||||||
|
label: "Coll Name",
|
||||||
|
placeholder: "placeholder text"
|
||||||
|
},
|
||||||
|
children: [] as Node[]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "enableLogging",
|
||||||
|
input: {
|
||||||
|
id: "enableLogging",
|
||||||
|
dataFieldName: "enableLogging",
|
||||||
|
type: "boolean",
|
||||||
|
label: "Enable Logging",
|
||||||
|
trueLabel: "Enable",
|
||||||
|
falseLabel: "Disable"
|
||||||
|
},
|
||||||
|
children: [] as Node[]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "invalidEnableLogging",
|
||||||
|
input: {
|
||||||
|
id: "invalidEnableLogging",
|
||||||
|
dataFieldName: "invalidEnableLogging",
|
||||||
|
type: "boolean",
|
||||||
|
label: "Invalid Enable Logging",
|
||||||
|
placeholder: "placeholder text",
|
||||||
|
errorMessage: "label, truelabel and falselabel are required for boolean input 'invalidEnableLogging'."
|
||||||
|
},
|
||||||
|
children: [] as Node[]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "regions",
|
||||||
|
input: {
|
||||||
|
id: "regions",
|
||||||
|
dataFieldName: "regions",
|
||||||
|
type: "object",
|
||||||
|
label: "Regions",
|
||||||
|
choices: [
|
||||||
|
{ label: "South West US", key: "SWUS" },
|
||||||
|
{ label: "North Central US", key: "NCUS" },
|
||||||
|
{ label: "East US 2", key: "EUS2" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
children: [] as Node[]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "invalidRegions",
|
||||||
|
input: {
|
||||||
|
id: "invalidRegions",
|
||||||
|
dataFieldName: "invalidRegions",
|
||||||
|
type: "object",
|
||||||
|
label: "Invalid Regions",
|
||||||
|
placeholder: "placeholder text",
|
||||||
|
errorMessage: "label and choices are required for Choice input 'invalidRegions'."
|
||||||
|
},
|
||||||
|
children: [] as Node[]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
inputNames: [
|
||||||
|
"dbThroughput",
|
||||||
|
"collThroughput",
|
||||||
|
"invalidThroughput",
|
||||||
|
"collName",
|
||||||
|
"enableLogging",
|
||||||
|
"invalidEnableLogging",
|
||||||
|
"regions",
|
||||||
|
"invalidRegions"
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const descriptor = mapToSmartUiDescriptor(context);
|
||||||
|
expect(descriptor).toEqual(expectedDescriptor);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,183 @@
|
||||||
|
import "reflect-metadata";
|
||||||
|
import { ChoiceItem, Info, InputTypeValue, InputType } from "../Explorer/Controls/SmartUi/SmartUiComponent";
|
||||||
|
import {
|
||||||
|
BooleanInput,
|
||||||
|
ChoiceInput,
|
||||||
|
SelfServeDescriptor,
|
||||||
|
NumberInput,
|
||||||
|
StringInput,
|
||||||
|
Node,
|
||||||
|
AnyInput
|
||||||
|
} from "./SelfServeComponent";
|
||||||
|
|
||||||
|
export enum SelfServeType {
|
||||||
|
// No self serve type passed, launch explorer
|
||||||
|
none = "none",
|
||||||
|
// Unsupported self serve type passed as feature flag
|
||||||
|
invalid = "invalid",
|
||||||
|
// Add your self serve types here
|
||||||
|
example = "example"
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class SelfServeBaseClass {
|
||||||
|
public abstract onSubmit: (currentValues: Map<string, InputType>) => Promise<void>;
|
||||||
|
public abstract initialize: () => Promise<Map<string, InputType>>;
|
||||||
|
|
||||||
|
public toSelfServeDescriptor(): SelfServeDescriptor {
|
||||||
|
const className = this.constructor.name;
|
||||||
|
const smartUiDescriptor = Reflect.getMetadata(className, this) as SelfServeDescriptor;
|
||||||
|
|
||||||
|
if (!this.initialize) {
|
||||||
|
throw new Error(`initialize() was not declared for the class '${className}'`);
|
||||||
|
}
|
||||||
|
if (!this.onSubmit) {
|
||||||
|
throw new Error(`onSubmit() was not declared for the class '${className}'`);
|
||||||
|
}
|
||||||
|
if (!smartUiDescriptor?.root) {
|
||||||
|
throw new Error(`@SmartUi decorator was not declared for the class '${className}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
smartUiDescriptor.initialize = this.initialize;
|
||||||
|
smartUiDescriptor.onSubmit = this.onSubmit;
|
||||||
|
return smartUiDescriptor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommonInputTypes {
|
||||||
|
id: string;
|
||||||
|
info?: (() => Promise<Info>) | Info;
|
||||||
|
type?: InputTypeValue;
|
||||||
|
label?: (() => Promise<string>) | string;
|
||||||
|
placeholder?: (() => Promise<string>) | string;
|
||||||
|
dataFieldName?: string;
|
||||||
|
min?: (() => Promise<number>) | number;
|
||||||
|
max?: (() => Promise<number>) | number;
|
||||||
|
step?: (() => Promise<number>) | number;
|
||||||
|
trueLabel?: (() => Promise<string>) | string;
|
||||||
|
falseLabel?: (() => Promise<string>) | string;
|
||||||
|
choices?: (() => Promise<ChoiceItem[]>) | ChoiceItem[];
|
||||||
|
uiType?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
onChange?: (currentState: Map<string, InputType>, newValue: InputType) => Map<string, InputType>;
|
||||||
|
onSubmit?: (currentValues: Map<string, InputType>) => Promise<void>;
|
||||||
|
initialize?: () => Promise<Map<string, InputType>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const setValue = <T extends keyof CommonInputTypes, K extends CommonInputTypes[T]>(
|
||||||
|
name: T,
|
||||||
|
value: K,
|
||||||
|
fieldObject: CommonInputTypes
|
||||||
|
): void => {
|
||||||
|
fieldObject[name] = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getValue = <T extends keyof CommonInputTypes>(name: T, fieldObject: CommonInputTypes): unknown => {
|
||||||
|
return fieldObject[name];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addPropertyToMap = <T extends keyof CommonInputTypes, K extends CommonInputTypes[T]>(
|
||||||
|
target: unknown,
|
||||||
|
propertyName: string,
|
||||||
|
className: string,
|
||||||
|
descriptorName: keyof CommonInputTypes,
|
||||||
|
descriptorValue: K
|
||||||
|
): void => {
|
||||||
|
const context =
|
||||||
|
(Reflect.getMetadata(className, target) as Map<string, CommonInputTypes>) ?? new Map<string, CommonInputTypes>();
|
||||||
|
updateContextWithDecorator(context, propertyName, className, descriptorName, descriptorValue);
|
||||||
|
Reflect.defineMetadata(className, context, target);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateContextWithDecorator = <T extends keyof CommonInputTypes, K extends CommonInputTypes[T]>(
|
||||||
|
context: Map<string, CommonInputTypes>,
|
||||||
|
propertyName: string,
|
||||||
|
className: string,
|
||||||
|
descriptorName: keyof CommonInputTypes,
|
||||||
|
descriptorValue: K
|
||||||
|
): void => {
|
||||||
|
if (!(context instanceof Map)) {
|
||||||
|
throw new Error(`@SmartUi should be the first decorator for the class '${className}'.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const propertyObject = context.get(propertyName) ?? { id: propertyName };
|
||||||
|
|
||||||
|
if (getValue(descriptorName, propertyObject) && descriptorName !== "type" && descriptorName !== "dataFieldName") {
|
||||||
|
throw new Error(
|
||||||
|
`Duplicate value passed for '${descriptorName}' on property '${propertyName}' of class '${className}'`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(descriptorName, descriptorValue, propertyObject);
|
||||||
|
context.set(propertyName, propertyObject);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildSmartUiDescriptor = (className: string, target: unknown): void => {
|
||||||
|
const context = Reflect.getMetadata(className, target) as Map<string, CommonInputTypes>;
|
||||||
|
const smartUiDescriptor = mapToSmartUiDescriptor(context);
|
||||||
|
Reflect.defineMetadata(className, smartUiDescriptor, target);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapToSmartUiDescriptor = (context: Map<string, CommonInputTypes>): SelfServeDescriptor => {
|
||||||
|
const root = context.get("root");
|
||||||
|
context.delete("root");
|
||||||
|
const inputNames: string[] = [];
|
||||||
|
|
||||||
|
const smartUiDescriptor: SelfServeDescriptor = {
|
||||||
|
root: {
|
||||||
|
id: "root",
|
||||||
|
info: root?.info,
|
||||||
|
children: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
while (context.size > 0) {
|
||||||
|
const key = context.keys().next().value;
|
||||||
|
addToDescriptor(context, smartUiDescriptor.root, key, inputNames);
|
||||||
|
}
|
||||||
|
smartUiDescriptor.inputNames = inputNames;
|
||||||
|
|
||||||
|
return smartUiDescriptor;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addToDescriptor = (
|
||||||
|
context: Map<string, CommonInputTypes>,
|
||||||
|
root: Node,
|
||||||
|
key: string,
|
||||||
|
inputNames: string[]
|
||||||
|
): void => {
|
||||||
|
const value = context.get(key);
|
||||||
|
inputNames.push(value.id);
|
||||||
|
const element = {
|
||||||
|
id: value.id,
|
||||||
|
info: value.info,
|
||||||
|
input: getInput(value),
|
||||||
|
children: []
|
||||||
|
} as Node;
|
||||||
|
context.delete(key);
|
||||||
|
root.children.push(element);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInput = (value: CommonInputTypes): AnyInput => {
|
||||||
|
switch (value.type) {
|
||||||
|
case "number":
|
||||||
|
if (!value.label || !value.step || !value.uiType || !value.min || !value.max) {
|
||||||
|
value.errorMessage = `label, step, min, max and uiType are required for number input '${value.id}'.`;
|
||||||
|
}
|
||||||
|
return value as NumberInput;
|
||||||
|
case "string":
|
||||||
|
if (!value.label) {
|
||||||
|
value.errorMessage = `label is required for string input '${value.id}'.`;
|
||||||
|
}
|
||||||
|
return value as StringInput;
|
||||||
|
case "boolean":
|
||||||
|
if (!value.label || !value.trueLabel || !value.falseLabel) {
|
||||||
|
value.errorMessage = `label, truelabel and falselabel are required for boolean input '${value.id}'.`;
|
||||||
|
}
|
||||||
|
return value as BooleanInput;
|
||||||
|
default:
|
||||||
|
if (!value.label || !value.choices) {
|
||||||
|
value.errorMessage = `label and choices are required for Choice input '${value.id}'.`;
|
||||||
|
}
|
||||||
|
return value as ChoiceInput;
|
||||||
|
}
|
||||||
|
};
|
|
@ -0,0 +1,168 @@
|
||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`SelfServeComponent should render 1`] = `
|
||||||
|
<div
|
||||||
|
style={
|
||||||
|
Object {
|
||||||
|
"overflowX": "auto",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
styles={
|
||||||
|
Object {
|
||||||
|
"root": Object {
|
||||||
|
"padding": 10,
|
||||||
|
"width": 400,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokens={
|
||||||
|
Object {
|
||||||
|
"childrenGap": 20,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SmartUiComponent
|
||||||
|
currentValues={
|
||||||
|
Map {
|
||||||
|
"throughput" => "450",
|
||||||
|
"analyticalStore" => "false",
|
||||||
|
"database" => "db2",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
descriptor={
|
||||||
|
Object {
|
||||||
|
"initialize": [MockFunction] {
|
||||||
|
"calls": Array [
|
||||||
|
Array [],
|
||||||
|
],
|
||||||
|
"results": Array [
|
||||||
|
Object {
|
||||||
|
"type": "return",
|
||||||
|
"value": Promise {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"inputNames": Array [
|
||||||
|
"throughput",
|
||||||
|
"containerId",
|
||||||
|
"analyticalStore",
|
||||||
|
"database",
|
||||||
|
],
|
||||||
|
"onSubmit": [MockFunction],
|
||||||
|
"root": Object {
|
||||||
|
"children": Array [
|
||||||
|
Object {
|
||||||
|
"id": "throughput",
|
||||||
|
"info": undefined,
|
||||||
|
"input": Object {
|
||||||
|
"dataFieldName": "throughput",
|
||||||
|
"defaultValue": 400,
|
||||||
|
"label": "Throughput (input)",
|
||||||
|
"max": 500,
|
||||||
|
"min": 400,
|
||||||
|
"placeholder": undefined,
|
||||||
|
"step": 10,
|
||||||
|
"type": "number",
|
||||||
|
"uiType": "Spinner",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"id": "containerId",
|
||||||
|
"info": undefined,
|
||||||
|
"input": Object {
|
||||||
|
"dataFieldName": "containerId",
|
||||||
|
"label": "Container id",
|
||||||
|
"placeholder": undefined,
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"id": "analyticalStore",
|
||||||
|
"info": undefined,
|
||||||
|
"input": Object {
|
||||||
|
"dataFieldName": "analyticalStore",
|
||||||
|
"defaultValue": true,
|
||||||
|
"falseLabel": "Disabled",
|
||||||
|
"label": "Analytical Store",
|
||||||
|
"placeholder": undefined,
|
||||||
|
"trueLabel": "Enabled",
|
||||||
|
"type": "boolean",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"id": "database",
|
||||||
|
"info": undefined,
|
||||||
|
"input": Object {
|
||||||
|
"choices": Array [
|
||||||
|
Object {
|
||||||
|
"key": "db1",
|
||||||
|
"label": "Database 1",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"key": "db2",
|
||||||
|
"label": "Database 2",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"key": "db3",
|
||||||
|
"label": "Database 3",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"dataFieldName": "database",
|
||||||
|
"defaultKey": "db2",
|
||||||
|
"label": "Database",
|
||||||
|
"placeholder": undefined,
|
||||||
|
"type": "object",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"id": "root",
|
||||||
|
"info": Object {
|
||||||
|
"link": Object {
|
||||||
|
"href": "https://aka.ms/azure-cosmos-db-pricing",
|
||||||
|
"text": "More Details",
|
||||||
|
},
|
||||||
|
"message": "Start at $24/mo per database",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onInputChange={[Function]}
|
||||||
|
/>
|
||||||
|
<Stack
|
||||||
|
horizontal={true}
|
||||||
|
tokens={
|
||||||
|
Object {
|
||||||
|
"childrenGap": 10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CustomizedPrimaryButton
|
||||||
|
id="submitButton"
|
||||||
|
onClick={[Function]}
|
||||||
|
styles={
|
||||||
|
Object {
|
||||||
|
"root": Object {
|
||||||
|
"width": 100,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text="submit"
|
||||||
|
/>
|
||||||
|
<CustomizedPrimaryButton
|
||||||
|
id="discardButton"
|
||||||
|
onClick={[Function]}
|
||||||
|
styles={
|
||||||
|
Object {
|
||||||
|
"root": Object {
|
||||||
|
"width": 100,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text="discard"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
`;
|
|
@ -1,59 +1,9 @@
|
||||||
import { ElementHandle, Frame } from "puppeteer";
|
import { ElementHandle, Frame } from "puppeteer";
|
||||||
import { TestExplorerParams } from "./testExplorer/TestExplorerParams";
|
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
|
||||||
export const NOTEBOOK_OPERATION_DELAY = 5000;
|
export const NOTEBOOK_OPERATION_DELAY = 5000;
|
||||||
export const RENDER_DELAY = 2500;
|
export const RENDER_DELAY = 2500;
|
||||||
|
|
||||||
let testExplorerFrame: Frame;
|
|
||||||
export const getTestExplorerFrame = async (): Promise<Frame> => {
|
|
||||||
if (testExplorerFrame) {
|
|
||||||
return testExplorerFrame;
|
|
||||||
}
|
|
||||||
|
|
||||||
const notebooksTestRunnerTenantId = process.env.NOTEBOOKS_TEST_RUNNER_TENANT_ID;
|
|
||||||
const notebooksTestRunnerClientId = process.env.NOTEBOOKS_TEST_RUNNER_CLIENT_ID;
|
|
||||||
const notebooksTestRunnerClientSecret = process.env.NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET;
|
|
||||||
const portalRunnerDatabaseAccount = process.env.PORTAL_RUNNER_DATABASE_ACCOUNT;
|
|
||||||
const portalRunnerDatabaseAccountKey = process.env.PORTAL_RUNNER_DATABASE_ACCOUNT_KEY;
|
|
||||||
const portalRunnerSubscripton = process.env.PORTAL_RUNNER_SUBSCRIPTION;
|
|
||||||
const portalRunnerResourceGroup = process.env.PORTAL_RUNNER_RESOURCE_GROUP;
|
|
||||||
|
|
||||||
const testExplorerUrl = new URL("testExplorer.html", "https://localhost:1234");
|
|
||||||
testExplorerUrl.searchParams.append(
|
|
||||||
TestExplorerParams.notebooksTestRunnerTenantId,
|
|
||||||
encodeURI(notebooksTestRunnerTenantId)
|
|
||||||
);
|
|
||||||
testExplorerUrl.searchParams.append(
|
|
||||||
TestExplorerParams.notebooksTestRunnerClientId,
|
|
||||||
encodeURI(notebooksTestRunnerClientId)
|
|
||||||
);
|
|
||||||
testExplorerUrl.searchParams.append(
|
|
||||||
TestExplorerParams.notebooksTestRunnerClientSecret,
|
|
||||||
encodeURI(notebooksTestRunnerClientSecret)
|
|
||||||
);
|
|
||||||
testExplorerUrl.searchParams.append(
|
|
||||||
TestExplorerParams.portalRunnerDatabaseAccount,
|
|
||||||
encodeURI(portalRunnerDatabaseAccount)
|
|
||||||
);
|
|
||||||
testExplorerUrl.searchParams.append(
|
|
||||||
TestExplorerParams.portalRunnerDatabaseAccountKey,
|
|
||||||
encodeURI(portalRunnerDatabaseAccountKey)
|
|
||||||
);
|
|
||||||
testExplorerUrl.searchParams.append(TestExplorerParams.portalRunnerSubscripton, encodeURI(portalRunnerSubscripton));
|
|
||||||
testExplorerUrl.searchParams.append(
|
|
||||||
TestExplorerParams.portalRunnerResourceGroup,
|
|
||||||
encodeURI(portalRunnerResourceGroup)
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.goto(testExplorerUrl.toString());
|
|
||||||
|
|
||||||
const handle = await page.waitForSelector("iframe");
|
|
||||||
testExplorerFrame = await handle.contentFrame();
|
|
||||||
await testExplorerFrame.waitForSelector(".galleryHeader");
|
|
||||||
return testExplorerFrame;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const uploadNotebookIfNotExist = async (frame: Frame, notebookName: string): Promise<ElementHandle<Element>> => {
|
export const uploadNotebookIfNotExist = async (frame: Frame, notebookName: string): Promise<ElementHandle<Element>> => {
|
||||||
const notebookNode = await getNotebookNode(frame, notebookName);
|
const notebookNode = await getNotebookNode(frame, notebookName);
|
||||||
if (notebookNode) {
|
if (notebookNode) {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import "expect-puppeteer";
|
import { uploadNotebookIfNotExist } from "./notebookTestUtils";
|
||||||
import { getTestExplorerFrame, uploadNotebookIfNotExist } from "./notebookTestUtils";
|
|
||||||
import { ElementHandle, Frame } from "puppeteer";
|
import { ElementHandle, Frame } from "puppeteer";
|
||||||
|
import { getTestExplorerFrame } from "../testExplorer/TestExplorerUtils";
|
||||||
|
|
||||||
jest.setTimeout(300000);
|
jest.setTimeout(300000);
|
||||||
|
|
||||||
|
@ -12,6 +12,7 @@ describe("Notebook UI tests", () => {
|
||||||
it("Upload, Open and Delete Notebook", async () => {
|
it("Upload, Open and Delete Notebook", async () => {
|
||||||
try {
|
try {
|
||||||
frame = await getTestExplorerFrame();
|
frame = await getTestExplorerFrame();
|
||||||
|
await frame.waitForSelector(".galleryHeader");
|
||||||
uploadedNotebookNode = await uploadNotebookIfNotExist(frame, notebookName);
|
uploadedNotebookNode = await uploadNotebookIfNotExist(frame, notebookName);
|
||||||
await uploadedNotebookNode.click();
|
await uploadedNotebookNode.click();
|
||||||
await frame.waitForSelector(".tabNavText");
|
await frame.waitForSelector(".tabNavText");
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { Frame } from "puppeteer";
|
||||||
|
import { TestExplorerParams } from "../testExplorer/TestExplorerParams";
|
||||||
|
import { getTestExplorerFrame } from "../testExplorer/TestExplorerUtils";
|
||||||
|
import { SelfServeType } from "../../src/SelfServe/SelfServeUtils";
|
||||||
|
|
||||||
|
jest.setTimeout(300000);
|
||||||
|
|
||||||
|
let frame: Frame;
|
||||||
|
describe("Self Serve", () => {
|
||||||
|
it("Launch Self Serve Example", async () => {
|
||||||
|
try {
|
||||||
|
frame = await getTestExplorerFrame(
|
||||||
|
new Map<string, string>([[TestExplorerParams.selfServeType, SelfServeType.example]])
|
||||||
|
);
|
||||||
|
await frame.waitForSelector("#regions-dropown-input");
|
||||||
|
await frame.waitForSelector("#enableLogging-radioSwitch-input");
|
||||||
|
await frame.waitForSelector("#accountName-textBox-input");
|
||||||
|
await frame.waitForSelector("#dbThroughput-slider-input");
|
||||||
|
await frame.waitForSelector("#collectionThroughput-spinner-input");
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const testName = (expect as any).getState().currentTestName;
|
||||||
|
await page.screenshot({ path: `Test Failed ${testName}.jpg` });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,11 +1,11 @@
|
||||||
import { MessageTypes } from "../../../src/Contracts/ExplorerContracts";
|
import { MessageTypes } from "../../src/Contracts/ExplorerContracts";
|
||||||
import "../../../less/hostedexplorer.less";
|
import "../../less/hostedexplorer.less";
|
||||||
import { TestExplorerParams } from "./TestExplorerParams";
|
import { TestExplorerParams } from "./TestExplorerParams";
|
||||||
import { ClientSecretCredential } from "@azure/identity";
|
import { ClientSecretCredential } from "@azure/identity";
|
||||||
import { DatabaseAccountsGetResponse } from "@azure/arm-cosmosdb/esm/models";
|
import { DatabaseAccountsGetResponse } from "@azure/arm-cosmosdb/esm/models";
|
||||||
import { CosmosDBManagementClient } from "@azure/arm-cosmosdb";
|
import { CosmosDBManagementClient } from "@azure/arm-cosmosdb";
|
||||||
import * as msRest from "@azure/ms-rest-js";
|
import * as msRest from "@azure/ms-rest-js";
|
||||||
import * as ViewModels from "../../../src/Contracts/ViewModels";
|
import * as ViewModels from "../../src/Contracts/ViewModels";
|
||||||
|
|
||||||
class CustomSigner implements msRest.ServiceClientCredentials {
|
class CustomSigner implements msRest.ServiceClientCredentials {
|
||||||
private token: string;
|
private token: string;
|
||||||
|
@ -87,6 +87,7 @@ const initTestExplorer = async (): Promise<void> => {
|
||||||
const portalRunnerResourceGroup = decodeURIComponent(
|
const portalRunnerResourceGroup = decodeURIComponent(
|
||||||
urlSearchParams.get(TestExplorerParams.portalRunnerResourceGroup)
|
urlSearchParams.get(TestExplorerParams.portalRunnerResourceGroup)
|
||||||
);
|
);
|
||||||
|
const selfServeType = urlSearchParams.get(TestExplorerParams.selfServeType);
|
||||||
|
|
||||||
const token = await AADLogin(
|
const token = await AADLogin(
|
||||||
notebooksTestRunnerTenantId,
|
notebooksTestRunnerTenantId,
|
||||||
|
@ -128,7 +129,8 @@ const initTestExplorer = async (): Promise<void> => {
|
||||||
throughput: { fixed: 400, unlimited: 400, unlimitedmax: 100000, unlimitedmin: 400, shared: 400 }
|
throughput: { fixed: 400, unlimited: 400, unlimitedmax: 100000, unlimitedmin: 400, shared: 400 }
|
||||||
},
|
},
|
||||||
// add UI test only when feature is not dependent on flights anymore
|
// add UI test only when feature is not dependent on flights anymore
|
||||||
flights: []
|
flights: [],
|
||||||
|
selfServeType: selfServeType
|
||||||
} as ViewModels.DataExplorerInputsFrame
|
} as ViewModels.DataExplorerInputsFrame
|
||||||
};
|
};
|
||||||
|
|
|
@ -5,5 +5,6 @@ export enum TestExplorerParams {
|
||||||
portalRunnerDatabaseAccount = "portalRunnerDatabaseAccount",
|
portalRunnerDatabaseAccount = "portalRunnerDatabaseAccount",
|
||||||
portalRunnerDatabaseAccountKey = "portalRunnerDatabaseAccountKey",
|
portalRunnerDatabaseAccountKey = "portalRunnerDatabaseAccountKey",
|
||||||
portalRunnerSubscripton = "portalRunnerSubscripton",
|
portalRunnerSubscripton = "portalRunnerSubscripton",
|
||||||
portalRunnerResourceGroup = "portalRunnerResourceGroup"
|
portalRunnerResourceGroup = "portalRunnerResourceGroup",
|
||||||
|
selfServeType = "selfServeType"
|
||||||
}
|
}
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { Frame } from "puppeteer";
|
||||||
|
import { TestExplorerParams } from "./TestExplorerParams";
|
||||||
|
|
||||||
|
let testExplorerFrame: Frame;
|
||||||
|
export const getTestExplorerFrame = async (params?: Map<string, string>): Promise<Frame> => {
|
||||||
|
if (testExplorerFrame) {
|
||||||
|
return testExplorerFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notebooksTestRunnerTenantId = process.env.NOTEBOOKS_TEST_RUNNER_TENANT_ID;
|
||||||
|
const notebooksTestRunnerClientId = process.env.NOTEBOOKS_TEST_RUNNER_CLIENT_ID;
|
||||||
|
const notebooksTestRunnerClientSecret = process.env.NOTEBOOKS_TEST_RUNNER_CLIENT_SECRET;
|
||||||
|
const portalRunnerDatabaseAccount = process.env.PORTAL_RUNNER_DATABASE_ACCOUNT;
|
||||||
|
const portalRunnerDatabaseAccountKey = process.env.PORTAL_RUNNER_DATABASE_ACCOUNT_KEY;
|
||||||
|
const portalRunnerSubscripton = process.env.PORTAL_RUNNER_SUBSCRIPTION;
|
||||||
|
const portalRunnerResourceGroup = process.env.PORTAL_RUNNER_RESOURCE_GROUP;
|
||||||
|
|
||||||
|
const testExplorerUrl = new URL("testExplorer.html", "https://localhost:1234");
|
||||||
|
testExplorerUrl.searchParams.append(
|
||||||
|
TestExplorerParams.notebooksTestRunnerTenantId,
|
||||||
|
encodeURI(notebooksTestRunnerTenantId)
|
||||||
|
);
|
||||||
|
testExplorerUrl.searchParams.append(
|
||||||
|
TestExplorerParams.notebooksTestRunnerClientId,
|
||||||
|
encodeURI(notebooksTestRunnerClientId)
|
||||||
|
);
|
||||||
|
testExplorerUrl.searchParams.append(
|
||||||
|
TestExplorerParams.notebooksTestRunnerClientSecret,
|
||||||
|
encodeURI(notebooksTestRunnerClientSecret)
|
||||||
|
);
|
||||||
|
testExplorerUrl.searchParams.append(
|
||||||
|
TestExplorerParams.portalRunnerDatabaseAccount,
|
||||||
|
encodeURI(portalRunnerDatabaseAccount)
|
||||||
|
);
|
||||||
|
testExplorerUrl.searchParams.append(
|
||||||
|
TestExplorerParams.portalRunnerDatabaseAccountKey,
|
||||||
|
encodeURI(portalRunnerDatabaseAccountKey)
|
||||||
|
);
|
||||||
|
testExplorerUrl.searchParams.append(TestExplorerParams.portalRunnerSubscripton, encodeURI(portalRunnerSubscripton));
|
||||||
|
testExplorerUrl.searchParams.append(
|
||||||
|
TestExplorerParams.portalRunnerResourceGroup,
|
||||||
|
encodeURI(portalRunnerResourceGroup)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (params) {
|
||||||
|
for (const key of params.keys()) {
|
||||||
|
testExplorerUrl.searchParams.append(key, encodeURI(params.get(key)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto(testExplorerUrl.toString());
|
||||||
|
const handle = await page.waitForSelector("iframe");
|
||||||
|
return await handle.contentFrame();
|
||||||
|
};
|
|
@ -12,6 +12,8 @@
|
||||||
"downlevelIteration": true,
|
"downlevelIteration": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"target": "es5",
|
"target": "es5",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
"lib": ["es5", "es6", "dom", "webworker.importscripts"],
|
"lib": ["es5", "es6", "dom", "webworker.importscripts"],
|
||||||
"jsx": "react",
|
"jsx": "react",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
|
@ -19,6 +21,6 @@
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": ["jest"]
|
"types": ["jest"]
|
||||||
},
|
},
|
||||||
"include": ["./src/**/*", "./test/notebooks/testExplorer/**/*"],
|
"include": ["./src/**/*", "./test/testExplorer/TestExplorer.ts"],
|
||||||
"exclude": ["./src/**/__mocks__/**/*"]
|
"exclude": ["./src/**/__mocks__/**/*"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -143,7 +143,7 @@ module.exports = function(env = {}, argv = {}) {
|
||||||
}),
|
}),
|
||||||
new HtmlWebpackPlugin({
|
new HtmlWebpackPlugin({
|
||||||
filename: "testExplorer.html",
|
filename: "testExplorer.html",
|
||||||
template: "test/notebooks/testExplorer/testExplorer.html",
|
template: "test/testExplorer/testExplorer.html",
|
||||||
chunks: ["testExplorer"]
|
chunks: ["testExplorer"]
|
||||||
}),
|
}),
|
||||||
new HtmlWebpackPlugin({
|
new HtmlWebpackPlugin({
|
||||||
|
@ -184,7 +184,7 @@ module.exports = function(env = {}, argv = {}) {
|
||||||
index: "./src/Index.ts",
|
index: "./src/Index.ts",
|
||||||
quickstart: "./src/quickstart.ts",
|
quickstart: "./src/quickstart.ts",
|
||||||
hostedExplorer: "./src/HostedExplorer.tsx",
|
hostedExplorer: "./src/HostedExplorer.tsx",
|
||||||
testExplorer: "./test/notebooks/testExplorer/TestExplorer.ts",
|
testExplorer: "./test/testExplorer/TestExplorer.ts",
|
||||||
heatmap: "./src/Controls/Heatmap/Heatmap.ts",
|
heatmap: "./src/Controls/Heatmap/Heatmap.ts",
|
||||||
terminal: "./src/Terminal/index.ts",
|
terminal: "./src/Terminal/index.ts",
|
||||||
notebookViewer: "./src/NotebookViewer/NotebookViewer.tsx",
|
notebookViewer: "./src/NotebookViewer/NotebookViewer.tsx",
|
||||||
|
|
Loading…
Reference in New Issue