Compare commits

...

8 Commits

Author SHA1 Message Date
Laurent Nguyen
acdd05a79b New MongoQueryTab and component running nteract 2021-01-27 18:38:12 +05:30
Laurent Nguyen
86a483c3a4 Fix notebook cell selection bug (#402)
This fixes a bug that prevents getting focus to a text cell (effectively preventing editing) when the window height is small after double-clicking on a neighboring code cell.
The issue is that selecting a text cell is broken likely because there's a behavior change in MonacoEditor that keeps the focus on the code cell. The selection issue will probably be resolved when migrating the text cell to Monaco (which will acquire and keep focus the same way), but for now, this will disable the faulty code which doesn't appear to work anymore (presumably auto-scrolling to the cell).
2021-01-27 09:09:54 +00:00
Tanuj Mittal
263262a040 Update Juno endpoints to pass subscriptionId (#339)
Corresponding [server side change](https://msdata.visualstudio.com/CosmosDB/_git/CosmosDB-portal/pullrequest/464443?_a=overview) has been deployed to Prod so now we can go ahead with DE side changes.
2021-01-27 08:08:58 +00:00
victor-meng
bd4d8da065 Move notification console to react (#400) 2021-01-26 15:32:37 -08:00
Steve Faulkner
59ec18cd9b Add basic static code metrics (#396) 2021-01-26 13:13:13 -06:00
Srinath Narayanan
49bf8c60db Added more Self Serve functionalities (#401)
* 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

* added toggle, visibility, text display,commandbar

* added sqlx example

* added onRefrssh

* formatting changes

* rmoved radioswitch display

* updated smartui tests

* Added more tests

* onSubmit -> onSave

* Resolved PR comments
2021-01-26 09:44:14 -08:00
Steve Faulkner
b0b973b21a Refactor explorer config into useKnockoutExplorer hook (#397)
Co-authored-by: Steve Faulkner <stfaul@microsoft.com>
2021-01-25 13:56:15 -06:00
Chris-MS-896
3529e80f0d no message (#398) 2021-01-22 10:02:35 -06:00
58 changed files with 3754 additions and 1322 deletions

View File

@@ -9,6 +9,20 @@ on:
branches:
- master
jobs:
codemetrics:
runs-on: ubuntu-latest
name: "Log Code Metrics"
if: github.ref == 'refs/heads/master'
steps:
- uses: actions/checkout@v2
- name: Use Node.js 12.x
uses: actions/setup-node@v1
with:
node-version: 12.x
- run: npm ci
- run: node utils/codeMetrics.js
env:
CODE_METRICS_APP_ID: ${{ secrets.CODE_METRICS_APP_ID }}
compile:
runs-on: ubuntu-latest
name: "Compile TypeScript"

View File

@@ -1694,6 +1694,7 @@ input::-webkit-calendar-picker-indicator {
display: flex;
flex-direction: column;
height: 100%;
max-height: 100vh;
}
.contextual-pane .paneErrorDetailsContainer {
@@ -2083,7 +2084,7 @@ a:link {
display: flex;
flex: 1 1 auto;
overflow-x: auto;
overflow-y: hidden;
overflow-y: auto;
height: 100%;
}

34
package-lock.json generated
View File

@@ -2738,25 +2738,25 @@
"integrity": "sha512-flupibACquaWTo51o8YnNwTlPKoEayVQ2kLoAlKlLWbHSzA38C2hAnYztuR+2dmkpKl2tAXJbUWPdwdiCboyVw=="
},
"@nodelib/fs.scandir": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz",
"integrity": "sha512-eGmwYQn3gxo4r7jdQnkrrN6bY478C3P+a/y72IJukF8LjB6ZHeB3c+Ehacj3sYeSmUXGlnA67/PmbM9CVwL7Dw==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz",
"integrity": "sha512-33g3pMJk3bg5nXbL/+CY6I2eJDzZAni49PfJnL5fghPTggPvBd/pFNSgJsdAgWptuFu7qq/ERvOYFlhvsLTCKA==",
"requires": {
"@nodelib/fs.stat": "2.0.3",
"@nodelib/fs.stat": "2.0.4",
"run-parallel": "^1.1.9"
}
},
"@nodelib/fs.stat": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.3.tgz",
"integrity": "sha512-bQBFruR2TAwoevBEd/NWMoAAtNGzTRgdrqnYCc7dhzfoNvqPzLyqlEQnzZ3kVnNrSp25iyxE00/3h2fqGAGArA=="
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.4.tgz",
"integrity": "sha512-IYlHJA0clt2+Vg7bccq+TzRdJvv19c2INqBSsoOLp1je7xjtr7J26+WXR72MCdvU9q1qTzIWDfhMf+DRvQJK4Q=="
},
"@nodelib/fs.walk": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.4.tgz",
"integrity": "sha512-1V9XOY4rDW0rehzbrcqAmHnz8e7SKvX27gh8Gt2WgB0+pdzdiLV83p72kZPU+jvMbS1qU5mauP2iOvO8rhmurQ==",
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.6.tgz",
"integrity": "sha512-8Broas6vTtW4GIXTAHDoE32hnN2M5ykgCpWGbuXHQ15vEMqr23pB76e/GZcYsZCHALv50ktd24qhEyKr6wBtow==",
"requires": {
"@nodelib/fs.scandir": "2.1.3",
"@nodelib/fs.scandir": "2.1.4",
"fastq": "^1.6.0"
}
},
@@ -10457,9 +10457,9 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"fast-glob": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.4.tgz",
"integrity": "sha512-kr/Oo6PX51265qeuCYsyGypiO5uJFgBS0jksyG7FUeCyQzNwYnzrNIMR1NXfkZXsMYXYLRAHgISHBz8gQcxKHQ==",
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.5.tgz",
"integrity": "sha512-2DtFcgT68wiTTiwZ2hNdJfcHNke9XOfnwmBRWXhmeKM8rF0TGwmC/Qto3S7RoZKp5cilZbxzO5iTNTQsJ+EeDg==",
"requires": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
@@ -10537,9 +10537,9 @@
"dev": true
},
"fastq": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.9.0.tgz",
"integrity": "sha512-i7FVWL8HhVY+CTkwFxkN2mk3h+787ixS5S63eb78diVRc1MCssarHq3W5cj0av7YDSwmaV928RNag+U1etRQ7w==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.10.0.tgz",
"integrity": "sha512-NL2Qc5L3iQEsyYzweq7qfgy5OtXCmGzGvhElGEd/SoFWEMOEczNh5s5ocaF01HDetxz+p8ecjNPA6cZxxIHmzA==",
"requires": {
"reusify": "^1.0.4"
}

View File

@@ -151,6 +151,7 @@
"eslint-plugin-prefer-arrow": "1.2.2",
"eslint-plugin-react-hooks": "4.2.0",
"expose-loader": "0.7.5",
"fast-glob": "3.2.5",
"file-loader": "2.0.0",
"fs-extra": "7.0.0",
"html-loader": "0.5.5",

View File

@@ -4,7 +4,7 @@ export enum Platform {
Emulator = "Emulator",
}
interface ConfigContext {
export interface ConfigContext {
platform: Platform;
allowedParentFrameOrigins: string[];
gitSha?: string;

View File

@@ -33,7 +33,6 @@ export enum MessageTypes {
CreateWorkspace,
CreateSparkPool,
RefreshDatabaseAccount,
InitTestExplorer,
}
export { Versions, ActionContracts, Diagnostics };

View File

@@ -26,7 +26,7 @@ ko.components.register("throughput-input-autopilot-v3", ThroughputInputComponent
ko.components.register("tabs-manager", TabsManagerKOComponent());
// Collection Tabs
ko.components.register("documents-tab", new TabComponents.DocumentsTab());
ko.components.register("documents-tab", new TabComponents.MongoDocumentsTabV2());
ko.components.register("mongo-documents-tab", new TabComponents.MongoDocumentsTab());
ko.components.register("stored-procedure-tab", new TabComponents.StoredProcedureTab());
ko.components.register("trigger-tab", new TabComponents.TriggerTab());

View File

@@ -958,7 +958,6 @@ exports[`SettingsComponent renders 1`] = `
"isMongoIndexingEnabled": [Function],
"isNotebookEnabled": [Function],
"isNotebooksEnabledForAccount": [Function],
"isNotificationConsoleExpanded": [Function],
"isPreferredApiCassandra": [Function],
"isPreferredApiDocumentDB": [Function],
"isPreferredApiGraph": [Function],
@@ -1018,12 +1017,6 @@ exports[`SettingsComponent renders 1`] = `
"nonSystemDatabases": [Function],
"notebookBasePath": [Function],
"notebookServerInfo": [Function],
"notificationConsoleComponentAdapter": NotificationConsoleComponentAdapter {
"consoleData": [Function],
"container": [Circular],
"parameters": [Function],
},
"notificationConsoleData": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"onSwitchToConnectionString": [Function],
@@ -1129,6 +1122,9 @@ exports[`SettingsComponent renders 1`] = `
},
"selfServeType": [Function],
"serverId": [Function],
"setInProgressConsoleDataIdToBeDeleted": undefined,
"setIsNotificationConsoleExpanded": undefined,
"setNotificationConsoleData": undefined,
"settingsPane": SettingsPane {
"container": [Circular],
"crossPartitionQueryEnabled": [Function],
@@ -2241,7 +2237,6 @@ exports[`SettingsComponent renders 1`] = `
"isMongoIndexingEnabled": [Function],
"isNotebookEnabled": [Function],
"isNotebooksEnabledForAccount": [Function],
"isNotificationConsoleExpanded": [Function],
"isPreferredApiCassandra": [Function],
"isPreferredApiDocumentDB": [Function],
"isPreferredApiGraph": [Function],
@@ -2301,12 +2296,6 @@ exports[`SettingsComponent renders 1`] = `
"nonSystemDatabases": [Function],
"notebookBasePath": [Function],
"notebookServerInfo": [Function],
"notificationConsoleComponentAdapter": NotificationConsoleComponentAdapter {
"consoleData": [Function],
"container": [Circular],
"parameters": [Function],
},
"notificationConsoleData": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"onSwitchToConnectionString": [Function],
@@ -2412,6 +2401,9 @@ exports[`SettingsComponent renders 1`] = `
},
"selfServeType": [Function],
"serverId": [Function],
"setInProgressConsoleDataIdToBeDeleted": undefined,
"setIsNotificationConsoleExpanded": undefined,
"setNotificationConsoleData": undefined,
"settingsPane": SettingsPane {
"container": [Circular],
"crossPartitionQueryEnabled": [Function],
@@ -3537,7 +3529,6 @@ exports[`SettingsComponent renders 1`] = `
"isMongoIndexingEnabled": [Function],
"isNotebookEnabled": [Function],
"isNotebooksEnabledForAccount": [Function],
"isNotificationConsoleExpanded": [Function],
"isPreferredApiCassandra": [Function],
"isPreferredApiDocumentDB": [Function],
"isPreferredApiGraph": [Function],
@@ -3597,12 +3588,6 @@ exports[`SettingsComponent renders 1`] = `
"nonSystemDatabases": [Function],
"notebookBasePath": [Function],
"notebookServerInfo": [Function],
"notificationConsoleComponentAdapter": NotificationConsoleComponentAdapter {
"consoleData": [Function],
"container": [Circular],
"parameters": [Function],
},
"notificationConsoleData": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"onSwitchToConnectionString": [Function],
@@ -3708,6 +3693,9 @@ exports[`SettingsComponent renders 1`] = `
},
"selfServeType": [Function],
"serverId": [Function],
"setInProgressConsoleDataIdToBeDeleted": undefined,
"setIsNotificationConsoleExpanded": undefined,
"setNotificationConsoleData": undefined,
"settingsPane": SettingsPane {
"container": [Circular],
"crossPartitionQueryEnabled": [Function],
@@ -4820,7 +4808,6 @@ exports[`SettingsComponent renders 1`] = `
"isMongoIndexingEnabled": [Function],
"isNotebookEnabled": [Function],
"isNotebooksEnabledForAccount": [Function],
"isNotificationConsoleExpanded": [Function],
"isPreferredApiCassandra": [Function],
"isPreferredApiDocumentDB": [Function],
"isPreferredApiGraph": [Function],
@@ -4880,12 +4867,6 @@ exports[`SettingsComponent renders 1`] = `
"nonSystemDatabases": [Function],
"notebookBasePath": [Function],
"notebookServerInfo": [Function],
"notificationConsoleComponentAdapter": NotificationConsoleComponentAdapter {
"consoleData": [Function],
"container": [Circular],
"parameters": [Function],
},
"notificationConsoleData": [Function],
"onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function],
"onSwitchToConnectionString": [Function],
@@ -4991,6 +4972,9 @@ exports[`SettingsComponent renders 1`] = `
},
"selfServeType": [Function],
"serverId": [Function],
"setInProgressConsoleDataIdToBeDeleted": undefined,
"setIsNotificationConsoleExpanded": undefined,
"setNotificationConsoleData": undefined,
"settingsPane": SettingsPane {
"container": [Circular],
"crossPartitionQueryEnabled": [Function],

View File

@@ -1,6 +1,7 @@
import React from "react";
import { shallow } from "enzyme";
import { SmartUiComponent, SmartUiDescriptor, UiType } from "./SmartUiComponent";
import { SmartUiComponent, SmartUiDescriptor } from "./SmartUiComponent";
import { NumberUiType, SmartUiInput } from "../../../SelfServe/SelfServeTypes";
describe("SmartUiComponent", () => {
const exampleData: SmartUiDescriptor = {
@@ -14,6 +15,20 @@ describe("SmartUiComponent", () => {
},
},
children: [
{
id: "description",
input: {
dataFieldName: "description",
type: "string",
description: {
text: "this is an example description text.",
link: {
href: "https://docs.microsoft.com/en-us/azure/cosmos-db/introduction",
text: "Click here for more information.",
},
},
},
},
{
id: "throughput",
input: {
@@ -24,7 +39,7 @@ describe("SmartUiComponent", () => {
max: 500,
step: 10,
defaultValue: 400,
uiType: UiType.Spinner,
uiType: NumberUiType.Spinner,
},
},
{
@@ -37,7 +52,7 @@ describe("SmartUiComponent", () => {
max: 500,
step: 10,
defaultValue: 400,
uiType: UiType.Slider,
uiType: NumberUiType.Slider,
},
},
{
@@ -50,7 +65,7 @@ describe("SmartUiComponent", () => {
max: 500,
step: 10,
defaultValue: 400,
uiType: UiType.Spinner,
uiType: NumberUiType.Spinner,
errorMessage: "label, truelabel and falselabel are required for boolean input 'throughput3'",
},
},
@@ -91,11 +106,58 @@ describe("SmartUiComponent", () => {
},
};
it("should render", async () => {
it("should render and honor input's hidden, disabled state", async () => {
const currentValues = new Map<string, SmartUiInput>();
const wrapper = shallow(
<SmartUiComponent descriptor={exampleData} currentValues={new Map()} onInputChange={undefined} />
<SmartUiComponent
disabled={false}
descriptor={exampleData}
currentValues={currentValues}
onInputChange={jest.fn()}
onError={() => {
return;
}}
/>
);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(wrapper).toMatchSnapshot();
expect(wrapper.exists("#containerId-textField-input")).toBeTruthy();
currentValues.set("containerId", { value: "container1", hidden: true });
wrapper.setProps({ currentValues });
wrapper.update();
expect(wrapper.exists("#containerId-textField-input")).toBeFalsy();
currentValues.set("containerId", { value: "container1", hidden: false, disabled: true });
wrapper.setProps({ currentValues });
wrapper.update();
const containerIdTextField = wrapper.find("#containerId-textField-input");
expect(containerIdTextField.props().disabled).toBeTruthy();
});
it("disable all inputs", async () => {
const wrapper = shallow(
<SmartUiComponent
disabled={true}
descriptor={exampleData}
currentValues={new Map()}
onInputChange={jest.fn()}
onError={() => {
return;
}}
/>
);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(wrapper).toMatchSnapshot();
const throughputSpinner = wrapper.find("#throughput-spinner-input");
expect(throughputSpinner.props().disabled).toBeTruthy();
const throughput2Slider = wrapper.find("#throughput2-slider-input").childAt(0);
expect(throughput2Slider.props().disabled).toBeTruthy();
const containerIdTextField = wrapper.find("#containerId-textField-input");
expect(containerIdTextField.props().disabled).toBeTruthy();
const analyticalStoreToggle = wrapper.find("#analyticalStore-toggle-input");
expect(analyticalStoreToggle.props().disabled).toBeTruthy();
const databaseDropdown = wrapper.find("#database-dropdown-input");
expect(databaseDropdown.props().disabled).toBeTruthy();
});
});

View File

@@ -5,11 +5,19 @@ import { SpinButton } from "office-ui-fabric-react/lib/SpinButton";
import { Dropdown, IDropdownOption } from "office-ui-fabric-react/lib/Dropdown";
import { TextField } from "office-ui-fabric-react/lib/TextField";
import { Text } from "office-ui-fabric-react/lib/Text";
import { RadioSwitchComponent } from "../RadioSwitchComponent/RadioSwitchComponent";
import { Stack, IStackTokens } from "office-ui-fabric-react/lib/Stack";
import { Link, MessageBar, MessageBarType } from "office-ui-fabric-react";
import { Link, MessageBar, MessageBarType, Toggle } from "office-ui-fabric-react";
import * as InputUtils from "./InputUtils";
import "./SmartUiComponent.less";
import {
ChoiceItem,
Description,
Info,
InputType,
InputTypeValue,
NumberUiType,
SmartUiInput,
} from "../../../SelfServe/SelfServeTypes";
/**
* Generic UX renderer
@@ -19,29 +27,14 @@ import "./SmartUiComponent.less";
* - a descriptor of the UX.
*/
export type InputTypeValue = "number" | "string" | "boolean" | "object";
export enum UiType {
Spinner = "Spinner",
Slider = "Slider",
}
export type ChoiceItem = { label: string; key: string };
export type InputType = number | string | boolean | ChoiceItem;
export interface Info {
message: string;
link?: {
href: string;
text: string;
};
}
interface BaseInput {
label: string;
interface BaseDisplay {
dataFieldName: string;
errorMessage?: string;
type: InputTypeValue;
}
interface BaseInput extends BaseDisplay {
label: string;
placeholder?: string;
errorMessage?: string;
}
@@ -54,7 +47,7 @@ interface NumberInput extends BaseInput {
max: number;
step: number;
defaultValue?: number;
uiType: UiType;
uiType: NumberUiType;
}
interface BooleanInput extends BaseInput {
@@ -72,12 +65,16 @@ interface ChoiceInput extends BaseInput {
defaultKey?: string;
}
type AnyInput = NumberInput | BooleanInput | StringInput | ChoiceInput;
interface DescriptionDisplay extends BaseDisplay {
description: Description;
}
type AnyDisplay = NumberInput | BooleanInput | StringInput | ChoiceInput | DescriptionDisplay;
interface Node {
id: string;
info?: Info;
input?: AnyInput;
input?: AnyDisplay;
children?: Node[];
}
@@ -86,11 +83,12 @@ export interface SmartUiDescriptor {
}
/************************** Component implementation starts here ************************************* */
export interface SmartUiComponentProps {
descriptor: SmartUiDescriptor;
currentValues: Map<string, InputType>;
onInputChange: (input: AnyInput, newValue: InputType) => void;
currentValues: Map<string, SmartUiInput>;
onInputChange: (input: AnyDisplay, newValue: InputType) => void;
onError: (hasError: boolean) => void;
disabled: boolean;
}
interface SmartUiComponentState {
@@ -98,12 +96,22 @@ interface SmartUiComponentState {
}
export class SmartUiComponent extends React.Component<SmartUiComponentProps, SmartUiComponentState> {
private shouldCheckErrors = true;
private static readonly labelStyle = {
color: "#393939",
fontFamily: "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
fontSize: 12,
};
componentDidUpdate(): void {
if (!this.shouldCheckErrors) {
this.shouldCheckErrors = true;
return;
}
this.props.onError(this.state.errors.size > 0);
this.shouldCheckErrors = false;
}
constructor(props: SmartUiComponentProps) {
super(props);
this.state = {
@@ -113,7 +121,7 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
private renderInfo(info: Info): JSX.Element {
return (
<MessageBar>
<MessageBar styles={{ root: { width: 400 } }}>
{info.message}
{info.link && (
<Link href={info.link.href} target="_blank">
@@ -125,17 +133,20 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
}
private renderTextInput(input: StringInput): JSX.Element {
const value = this.props.currentValues.get(input.dataFieldName) as string;
const value = this.props.currentValues.get(input.dataFieldName)?.value as string;
const disabled = this.props.disabled || this.props.currentValues.get(input.dataFieldName)?.disabled;
return (
<div className="stringInputContainer">
<TextField
id={`${input.dataFieldName}-textBox-input`}
id={`${input.dataFieldName}-textField-input`}
label={input.label}
type="text"
value={value}
value={value || ""}
placeholder={input.placeholder}
disabled={disabled}
onChange={(_, newValue) => this.props.onInputChange(input, newValue)}
styles={{
root: { width: 400 },
subComponentStyles: {
label: {
root: {
@@ -150,13 +161,27 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
);
}
private renderDescription(input: DescriptionDisplay): JSX.Element {
const description = input.description;
return (
<Text id={`${input.dataFieldName}-text-display`}>
{input.description.text}{" "}
{description.link && (
<Link target="_blank" href={input.description.link.href}>
{input.description.link.text}
</Link>
)}
</Text>
);
}
private clearError(dataFieldName: string): void {
const { errors } = this.state;
errors.delete(dataFieldName);
this.setState({ errors });
}
private onValidate = (input: AnyInput, value: string, min: number, max: number): string => {
private onValidate = (input: NumberInput, value: string, min: number, max: number): string => {
const newValue = InputUtils.onValidateValueChange(value, min, max);
const dataFieldName = input.dataFieldName;
if (newValue) {
@@ -165,13 +190,13 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
return newValue.toString();
} else {
const { errors } = this.state;
errors.set(dataFieldName, `Invalid value ${value}: must be between ${min} and ${max}`);
errors.set(dataFieldName, `Invalid value '${value}'. It must be between ${min} and ${max}`);
this.setState({ errors });
}
return undefined;
};
private onIncrement = (input: AnyInput, value: string, step: number, max: number): string => {
private onIncrement = (input: NumberInput, value: string, step: number, max: number): string => {
const newValue = InputUtils.onIncrementValue(value, step, max);
const dataFieldName = input.dataFieldName;
if (newValue) {
@@ -182,7 +207,7 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
return undefined;
};
private onDecrement = (input: AnyInput, value: string, step: number, min: number): string => {
private onDecrement = (input: NumberInput, value: string, step: number, min: number): string => {
const newValue = InputUtils.onDecrementValue(value, step, min);
const dataFieldName = input.dataFieldName;
if (newValue) {
@@ -203,10 +228,11 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
step: step,
};
const value = this.props.currentValues.get(dataFieldName) as number;
if (input.uiType === UiType.Spinner) {
const value = this.props.currentValues.get(dataFieldName)?.value as number;
const disabled = this.props.disabled || this.props.currentValues.get(dataFieldName)?.disabled;
if (input.uiType === NumberUiType.Spinner) {
return (
<>
<Stack styles={{ root: { width: 400 } }} tokens={{ childrenGap: 2 }}>
<SpinButton
{...props}
id={`${input.dataFieldName}-spinner-input`}
@@ -215,6 +241,7 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
onIncrement={(newValue) => this.onIncrement(input, newValue, props.step, props.max)}
onDecrement={(newValue) => this.onDecrement(input, newValue, props.step, props.min)}
labelPosition={Position.top}
disabled={disabled}
styles={{
label: {
...SmartUiComponent.labelStyle,
@@ -225,16 +252,18 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
{this.state.errors.has(dataFieldName) && (
<MessageBar messageBarType={MessageBarType.error}>Error: {this.state.errors.get(dataFieldName)}</MessageBar>
)}
</>
</Stack>
);
} else if (input.uiType === UiType.Slider) {
} else if (input.uiType === NumberUiType.Slider) {
return (
<div id={`${input.dataFieldName}-slider-input`}>
<Slider
{...props}
value={value}
disabled={disabled}
onChange={(newValue) => this.props.onInputChange(input, newValue)}
styles={{
root: { width: 400 },
titleLabel: {
...SmartUiComponent.labelStyle,
fontWeight: 600,
@@ -250,49 +279,44 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
}
private renderBooleanInput(input: BooleanInput): JSX.Element {
const value = this.props.currentValues.get(input.dataFieldName) as boolean;
const selectedKey = value || input.defaultValue ? "true" : "false";
const value = this.props.currentValues.get(input.dataFieldName)?.value as boolean;
const disabled = this.props.disabled || this.props.currentValues.get(input.dataFieldName)?.disabled;
return (
<div id={`${input.dataFieldName}-radioSwitch-input`}>
<div className="inputLabelContainer">
<Text variant="small" nowrap className="inputLabel">
{input.label}
</Text>
</div>
<RadioSwitchComponent
choices={[
{
label: input.falseLabel,
key: "false",
onSelect: () => this.props.onInputChange(input, false),
},
{
label: input.trueLabel,
key: "true",
onSelect: () => this.props.onInputChange(input, true),
},
]}
selectedKey={selectedKey}
/>
</div>
<Toggle
id={`${input.dataFieldName}-toggle-input`}
label={input.label}
checked={value || false}
onText={input.trueLabel}
offText={input.falseLabel}
disabled={disabled}
onChange={(event, checked: boolean) => this.props.onInputChange(input, checked)}
styles={{ root: { width: 400 } }}
/>
);
}
private renderChoiceInput(input: ChoiceInput): JSX.Element {
const { label, defaultKey: defaultKey, dataFieldName, choices, placeholder } = input;
const value = this.props.currentValues.get(dataFieldName) as string;
const value = this.props.currentValues.get(dataFieldName)?.value as string;
const disabled = this.props.disabled || this.props.currentValues.get(dataFieldName)?.disabled;
let selectedKey = value ? value : defaultKey;
if (!selectedKey) {
selectedKey = "";
}
return (
<Dropdown
id={`${input.dataFieldName}-dropown-input`}
id={`${input.dataFieldName}-dropdown-input`}
label={label}
selectedKey={value ? value : defaultKey}
selectedKey={selectedKey}
onChange={(_, item: IDropdownOption) => this.props.onInputChange(input, item.key.toString())}
placeholder={placeholder}
disabled={disabled}
options={choices.map((c) => ({
key: c.key,
text: c.label,
}))}
styles={{
root: { width: 400 },
label: {
...SmartUiComponent.labelStyle,
fontWeight: 600,
@@ -303,16 +327,23 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
);
}
private renderError(input: AnyInput): JSX.Element {
private renderError(input: AnyDisplay): JSX.Element {
return <MessageBar messageBarType={MessageBarType.error}>Error: {input.errorMessage}</MessageBar>;
}
private renderInput(input: AnyInput): JSX.Element {
private renderDisplay(input: AnyDisplay): JSX.Element {
if (input.errorMessage) {
return this.renderError(input);
}
const inputHidden = this.props.currentValues.get(input.dataFieldName)?.hidden;
if (inputHidden) {
return <></>;
}
switch (input.type) {
case "string":
if ("description" in input) {
return this.renderDescription(input as DescriptionDisplay);
}
return this.renderTextInput(input as StringInput);
case "number":
return this.renderNumberInput(input as NumberInput);
@@ -326,13 +357,13 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
}
private renderNode(node: Node): JSX.Element {
const containerStackTokens: IStackTokens = { childrenGap: 15 };
const containerStackTokens: IStackTokens = { childrenGap: 10 };
return (
<Stack tokens={containerStackTokens} className="widgetRendererContainer">
<Stack.Item>
{node.info && this.renderInfo(node.info as Info)}
{node.input && this.renderInput(node.input)}
{node.input && this.renderDisplay(node.input)}
</Stack.Item>
{node.children && node.children.map((child) => <div key={child.id}>{this.renderNode(child)}</div>)}
</Stack>
@@ -340,11 +371,6 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
}
render(): JSX.Element {
const containerStackTokens: IStackTokens = { childrenGap: 20 };
return (
<Stack tokens={containerStackTokens} styles={{ root: { width: 400, padding: 10 } }}>
{this.renderNode(this.props.descriptor.root)}
</Stack>
);
return this.renderNode(this.props.descriptor.root);
}
}

View File

@@ -1,52 +1,405 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SmartUiComponent should render 1`] = `
exports[`SmartUiComponent disable all inputs 1`] = `
<Stack
styles={
Object {
"root": Object {
"padding": 10,
"width": 400,
},
}
}
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 20,
"childrenGap": 10,
}
}
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 15,
}
}
>
<StackItem>
<StyledMessageBarBase>
Start at $24/mo per database
<StyledLinkBase
href="https://aka.ms/azure-cosmos-db-pricing"
target="_blank"
>
More Details
</StyledLinkBase>
</StyledMessageBarBase>
</StackItem>
<div
key="throughput"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 15,
}
<StackItem>
<StyledMessageBarBase
styles={
Object {
"root": Object {
"width": 400,
},
}
}
>
Start at $24/mo per database
<StyledLinkBase
href="https://aka.ms/azure-cosmos-db-pricing"
target="_blank"
>
<StackItem>
More Details
</StyledLinkBase>
</StyledMessageBarBase>
</StackItem>
<div
key="description"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
>
<StackItem>
<Text
id="description-text-display"
>
this is an example description text.
<StyledLinkBase
href="https://docs.microsoft.com/en-us/azure/cosmos-db/introduction"
target="_blank"
>
Click here for more information.
</StyledLinkBase>
</Text>
</StackItem>
</Stack>
</div>
<div
key="throughput"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
>
<StackItem>
<Stack
styles={
Object {
"root": Object {
"width": 400,
},
}
}
tokens={
Object {
"childrenGap": 2,
}
}
>
<CustomizedSpinButton
ariaLabel="Throughput (input)"
decrementButtonIcon={
Object {
"iconName": "ChevronDownSmall",
}
}
disabled={true}
id="throughput-spinner-input"
incrementButtonIcon={
Object {
"iconName": "ChevronUpSmall",
}
}
label="Throughput (input)"
labelPosition={0}
max={500}
min={400}
onDecrement={[Function]}
onIncrement={[Function]}
onValidate={[Function]}
step={10}
styles={
Object {
"label": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
"fontWeight": 600,
},
}
}
/>
</Stack>
</StackItem>
</Stack>
</div>
<div
key="throughput2"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
>
<StackItem>
<div
id="throughput2-slider-input"
>
<StyledSliderBase
ariaLabel="Throughput (Slider)"
disabled={true}
label="Throughput (Slider)"
max={500}
min={400}
onChange={[Function]}
step={10}
styles={
Object {
"root": Object {
"width": 400,
},
"titleLabel": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
"fontWeight": 600,
},
"valueLabel": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
},
}
}
/>
</div>
</StackItem>
</Stack>
</div>
<div
key="throughput3"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
>
<StackItem>
<StyledMessageBarBase
messageBarType={1}
>
Error:
label, truelabel and falselabel are required for boolean input 'throughput3'
</StyledMessageBarBase>
</StackItem>
</Stack>
</div>
<div
key="containerId"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
>
<StackItem>
<div
className="stringInputContainer"
>
<StyledTextFieldBase
disabled={true}
id="containerId-textField-input"
label="Container id"
onChange={[Function]}
styles={
Object {
"root": Object {
"width": 400,
},
"subComponentStyles": Object {
"label": Object {
"root": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
"fontWeight": 600,
},
},
},
}
}
type="text"
value=""
/>
</div>
</StackItem>
</Stack>
</div>
<div
key="analyticalStore"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
>
<StackItem>
<StyledToggleBase
checked={false}
disabled={true}
id="analyticalStore-toggle-input"
label="Analytical Store"
offText="Disabled"
onChange={[Function]}
onText="Enabled"
styles={
Object {
"root": Object {
"width": 400,
},
}
}
/>
</StackItem>
</Stack>
</div>
<div
key="database"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
>
<StackItem>
<StyledWithResponsiveMode
disabled={true}
id="database-dropdown-input"
label="Database"
onChange={[Function]}
options={
Array [
Object {
"key": "db1",
"text": "Database 1",
},
Object {
"key": "db2",
"text": "Database 2",
},
Object {
"key": "db3",
"text": "Database 3",
},
]
}
selectedKey="db2"
styles={
Object {
"dropdown": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
},
"label": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
"fontWeight": 600,
},
"root": Object {
"width": 400,
},
}
}
/>
</StackItem>
</Stack>
</div>
</Stack>
`;
exports[`SmartUiComponent should render and honor input's hidden, disabled state 1`] = `
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
>
<StackItem>
<StyledMessageBarBase
styles={
Object {
"root": Object {
"width": 400,
},
}
}
>
Start at $24/mo per database
<StyledLinkBase
href="https://aka.ms/azure-cosmos-db-pricing"
target="_blank"
>
More Details
</StyledLinkBase>
</StyledMessageBarBase>
</StackItem>
<div
key="description"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
>
<StackItem>
<Text
id="description-text-display"
>
this is an example description text.
<StyledLinkBase
href="https://docs.microsoft.com/en-us/azure/cosmos-db/introduction"
target="_blank"
>
Click here for more information.
</StyledLinkBase>
</Text>
</StackItem>
</Stack>
</div>
<div
key="throughput"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
>
<StackItem>
<Stack
styles={
Object {
"root": Object {
"width": 400,
},
}
}
tokens={
Object {
"childrenGap": 2,
}
}
>
<CustomizedSpinButton
ariaLabel="Throughput (input)"
decrementButtonIcon={
@@ -80,210 +433,203 @@ exports[`SmartUiComponent should render 1`] = `
}
}
/>
</StackItem>
</Stack>
</div>
<div
key="throughput2"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 15,
}
</Stack>
</StackItem>
</Stack>
</div>
<div
key="throughput2"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
>
<StackItem>
<div
id="throughput2-slider-input"
>
<StyledSliderBase
ariaLabel="Throughput (Slider)"
label="Throughput (Slider)"
max={500}
min={400}
onChange={[Function]}
step={10}
styles={
Object {
"titleLabel": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
"fontWeight": 600,
},
"valueLabel": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
},
}
}
/>
</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>
</div>
<div
key="containerId"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 15,
}
}
>
<StackItem>
<div
className="stringInputContainer"
>
<StyledTextFieldBase
id="containerId-textBox-input"
label="Container id"
onChange={[Function]}
styles={
Object {
"subComponentStyles": Object {
"label": Object {
"root": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
"fontWeight": 600,
},
},
},
}
}
type="text"
/>
</div>
</StackItem>
</Stack>
</div>
<div
key="analyticalStore"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 15,
}
}
>
<StackItem>
<div
id="analyticalStore-radioSwitch-input"
>
<div
className="inputLabelContainer"
>
<Text
className="inputLabel"
nowrap={true}
variant="small"
>
Analytical Store
</Text>
</div>
<RadioSwitchComponent
choices={
Array [
Object {
"key": "false",
"label": "Disabled",
"onSelect": [Function],
},
Object {
"key": "true",
"label": "Enabled",
"onSelect": [Function],
},
]
}
selectedKey="true"
/>
</div>
</StackItem>
</Stack>
</div>
<div
key="database"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 15,
}
}
>
<StackItem>
<StyledWithResponsiveMode
id="database-dropown-input"
label="Database"
<StackItem>
<div
id="throughput2-slider-input"
>
<StyledSliderBase
ariaLabel="Throughput (Slider)"
label="Throughput (Slider)"
max={500}
min={400}
onChange={[Function]}
options={
Array [
Object {
"key": "db1",
"text": "Database 1",
},
Object {
"key": "db2",
"text": "Database 2",
},
Object {
"key": "db3",
"text": "Database 3",
},
]
}
selectedKey="db2"
step={10}
styles={
Object {
"dropdown": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
"root": Object {
"width": 400,
},
"label": Object {
"titleLabel": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
"fontWeight": 600,
},
"valueLabel": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
},
}
}
/>
</StackItem>
</Stack>
</div>
</Stack>
</div>
</StackItem>
</Stack>
</div>
<div
key="throughput3"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
>
<StackItem>
<StyledMessageBarBase
messageBarType={1}
>
Error:
label, truelabel and falselabel are required for boolean input 'throughput3'
</StyledMessageBarBase>
</StackItem>
</Stack>
</div>
<div
key="containerId"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
>
<StackItem>
<div
className="stringInputContainer"
>
<StyledTextFieldBase
id="containerId-textField-input"
label="Container id"
onChange={[Function]}
styles={
Object {
"root": Object {
"width": 400,
},
"subComponentStyles": Object {
"label": Object {
"root": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
"fontWeight": 600,
},
},
},
}
}
type="text"
value=""
/>
</div>
</StackItem>
</Stack>
</div>
<div
key="analyticalStore"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
>
<StackItem>
<StyledToggleBase
checked={false}
id="analyticalStore-toggle-input"
label="Analytical Store"
offText="Disabled"
onChange={[Function]}
onText="Enabled"
styles={
Object {
"root": Object {
"width": 400,
},
}
}
/>
</StackItem>
</Stack>
</div>
<div
key="database"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
>
<StackItem>
<StyledWithResponsiveMode
id="database-dropdown-input"
label="Database"
onChange={[Function]}
options={
Array [
Object {
"key": "db1",
"text": "Database 1",
},
Object {
"key": "db2",
"text": "Database 2",
},
Object {
"key": "db3",
"text": "Database 3",
},
]
}
selectedKey="db2"
styles={
Object {
"dropdown": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
},
"label": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
"fontWeight": 600,
},
"root": Object {
"width": 400,
},
}
}
/>
</StackItem>
</Stack>
</div>
</Stack>
`;

View File

@@ -55,7 +55,6 @@ import { sendMessage, sendCachedDataMessage, handleCachedDataMessage } from "../
import { NotebookContentItem, NotebookContentItemType } from "./Notebook/NotebookContentItem";
import { NotebookUtil } from "./Notebook/NotebookUtil";
import { NotebookWorkspaceManager } from "../NotebookWorkspaceManager/NotebookWorkspaceManager";
import { NotificationConsoleComponentAdapter } from "./Menus/NotificationConsole/NotificationConsoleComponentAdapter";
import * as NotificationConsoleUtils from "../Utils/NotificationConsoleUtils";
import { QueriesClient } from "../Common/QueriesClient";
import { QuerySelectPane } from "./Panes/Tables/QuerySelectPane";
@@ -107,6 +106,12 @@ interface AdHocAccessData {
readUrl: string;
}
export interface ExplorerParams {
setIsNotificationConsoleExpanded: (isExpanded: boolean) => void;
setNotificationConsoleData: (consoleData: ConsoleData) => void;
setInProgressConsoleDataIdToBeDeleted: (id: string) => void;
}
export default class Explorer {
public flight: ko.Observable<string> = ko.observable<string>(
SharedConstants.CollectionCreation.DefaultAddCollectionDefaultFlight
@@ -146,8 +151,9 @@ export default class Explorer {
public mostRecentActivity: MostRecentActivity.MostRecentActivity;
// Notification Console
public notificationConsoleData: ko.ObservableArray<ConsoleData>;
public isNotificationConsoleExpanded: ko.Observable<boolean>;
private setIsNotificationConsoleExpanded: (isExpanded: boolean) => void;
private setNotificationConsoleData: (consoleData: ConsoleData) => void;
private setInProgressConsoleDataIdToBeDeleted: (id: string) => void;
// Panes
public contextPanes: ContextualPaneBase[];
@@ -260,7 +266,6 @@ export default class Explorer {
// React adapters
private commandBarComponentAdapter: CommandBarComponentAdapter;
private splashScreenAdapter: SplashScreenComponentAdapter;
private notificationConsoleComponentAdapter: NotificationConsoleComponentAdapter;
private dialogComponentAdapter: DialogComponentAdapter;
private _dialogProps: ko.Observable<DialogProps>;
private addSynapseLinkDialog: DialogComponentAdapter;
@@ -269,7 +274,11 @@ export default class Explorer {
private static readonly MaxNbDatabasesToAutoExpand = 5;
constructor() {
constructor(params?: ExplorerParams) {
this.setIsNotificationConsoleExpanded = params?.setIsNotificationConsoleExpanded;
this.setNotificationConsoleData = params?.setNotificationConsoleData;
this.setInProgressConsoleDataIdToBeDeleted = params?.setInProgressConsoleDataIdToBeDeleted;
const startKey: number = TelemetryProcessor.traceStart(Action.InitializeDataExplorer, {
dataExplorerArea: Constants.Areas.ResourceTree,
});
@@ -430,7 +439,6 @@ export default class Explorer {
);
this.isSchemaEnabled = ko.computed<boolean>(() => this.isFeatureEnabled(Constants.Features.enableSchema));
this.isNotificationConsoleExpanded = ko.observable<boolean>(false);
this.isAutoscaleDefaultEnabled = ko.observable<boolean>(false);
@@ -478,7 +486,6 @@ export default class Explorer {
bounds: splitterBounds,
direction: SplitterDirection.Vertical,
});
this.notificationConsoleData = ko.observableArray<ConsoleData>([]);
this.defaultExperience = ko.observable<string>();
this.databaseAccount.subscribe((databaseAccount) => {
const defaultExperience: string = DefaultExperienceUtility.getDefaultExperienceFromDatabaseAccount(
@@ -892,7 +899,6 @@ export default class Explorer {
this.commandBarComponentAdapter = new CommandBarComponentAdapter(this);
this.selfServeLoadingComponentAdapter = new SelfServeLoadingComponentAdapter();
this.notificationConsoleComponentAdapter = new NotificationConsoleComponentAdapter(this);
this._initSettings();
@@ -1349,23 +1355,19 @@ export default class Explorer {
}
public logConsoleData(consoleData: ConsoleData): void {
this.notificationConsoleData.splice(0, 0, consoleData);
this.setNotificationConsoleData(consoleData);
}
public deleteInProgressConsoleDataWithId(id: string): void {
const updatedConsoleData = _.reject(
this.notificationConsoleData(),
(data: ConsoleData) => data.type === ConsoleDataType.InProgress && data.id === id
);
this.notificationConsoleData(updatedConsoleData);
this.setInProgressConsoleDataIdToBeDeleted(id);
}
public expandConsole(): void {
this.isNotificationConsoleExpanded(true);
this.setIsNotificationConsoleExpanded(true);
}
public collapseConsole(): void {
this.isNotificationConsoleExpanded(false);
this.setIsNotificationConsoleExpanded(false);
}
public toggleLeftPaneExpanded() {
@@ -1718,58 +1720,7 @@ export default class Explorer {
this._addSynapseLinkDialogProps.valueHasMutated();
};
private _shouldProcessMessage(event: MessageEvent): boolean {
if (typeof event.data !== "object") {
return false;
}
if (event.data["signature"] !== "pcIframe") {
return false;
}
if (!("data" in event.data)) {
return false;
}
if (typeof event.data["data"] !== "object") {
return false;
}
// before initialization completed give exception
const message = event.data.data;
if (!this._importExplorerConfigComplete && message && message.type) {
const messageType = message.type;
switch (messageType) {
case MessageTypes.SendNotification:
case MessageTypes.ClearNotification:
case MessageTypes.LoadingStatus:
case MessageTypes.InitTestExplorer:
return true;
}
}
if (!("inputs" in event.data["data"]) && !this._importExplorerConfigComplete) {
return false;
}
return true;
}
public handleMessage(event: MessageEvent) {
if (isInvalidParentFrameOrigin(event)) {
return;
}
if (!this._shouldProcessMessage(event)) {
return;
}
const message: any = event.data.data;
const inputs: ViewModels.DataExplorerInputsFrame = message.inputs;
const isRunningInPortal = configContext.platform === Platform.Portal;
const isRunningInDevMode = process.env.NODE_ENV === "development";
if (inputs && configContext.BACKEND_ENDPOINT && isRunningInPortal && isRunningInDevMode) {
inputs.extensionEndpoint = configContext.PROXY_PATH;
}
this.initDataExplorerWithFrameInputs(inputs);
public handleMessage(message: any) {
const openAction: ActionContracts.DataExplorerAction = message.openAction;
if (!!openAction) {
if (this.isRefreshingExplorer()) {
@@ -1874,7 +1825,7 @@ export default class Explorer {
}
}
public initDataExplorerWithFrameInputs(inputs: ViewModels.DataExplorerInputsFrame): void {
public configure(inputs: ViewModels.DataExplorerInputsFrame): void {
if (inputs != null) {
// In development mode, save the iframe message from the portal in session storage.
// This allows webpack hot reload to funciton properly
@@ -2395,13 +2346,11 @@ export default class Explorer {
this.tabsManager.activateTab(notebookTab);
} else {
const options: NotebookTabOptions = {
account: userContext.databaseAccount,
tabKind: ViewModels.CollectionTabKind.NotebookV2,
node: null,
title: notebookContentItem.name,
tabPath: notebookContentItem.path,
collection: null,
masterKey: userContext.masterKey || "",
hashLocation: "notebooks",
isActive: ko.observable(false),
isTabsContentExpanded: ko.observable(true),

View File

@@ -2,7 +2,6 @@ import React from "react";
import { shallow } from "enzyme";
import {
NotificationConsoleComponentProps,
ConsoleData,
NotificationConsoleComponent,
ConsoleDataType,
} from "./NotificationConsoleComponent";
@@ -10,38 +9,40 @@ import {
describe("NotificationConsoleComponent", () => {
const createBlankProps = (): NotificationConsoleComponentProps => {
return {
consoleData: [],
isConsoleExpanded: true,
onConsoleDataChange: (consoleData: ConsoleData[]) => {},
onConsoleExpandedChange: (isExpanded: boolean) => {},
consoleData: undefined,
isConsoleExpanded: false,
inProgressConsoleDataIdToBeDeleted: "",
setIsConsoleExpanded: (isExpanded: boolean): void => {},
};
};
it("renders the console (expanded)", () => {
it("renders the console", () => {
const props = createBlankProps();
props.consoleData.push({
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
expect(wrapper).toMatchSnapshot();
props.consoleData = {
type: ConsoleDataType.Info,
date: "date",
message: "message",
});
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
};
wrapper.setProps(props);
expect(wrapper).toMatchSnapshot();
});
it("shows proper progress count", () => {
const count = 100;
const props = createBlankProps();
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
for (let i = 0; i < count; i++) {
props.consoleData.push({
props.consoleData = {
type: ConsoleDataType.InProgress,
date: "date",
date: "date" + i,
message: "message",
});
};
wrapper.setProps(props);
}
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
expect(wrapper.find(".notificationConsoleHeader .numInProgress").text()).toEqual(count.toString());
expect(wrapper.find(".notificationConsoleHeader .numErroredItems").text()).toEqual("0");
expect(wrapper.find(".notificationConsoleHeader .numInfoItems").text()).toEqual("0");
@@ -50,16 +51,17 @@ describe("NotificationConsoleComponent", () => {
it("shows proper error count", () => {
const count = 100;
const props = createBlankProps();
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
for (let i = 0; i < count; i++) {
props.consoleData.push({
props.consoleData = {
type: ConsoleDataType.Error,
date: "date",
date: "date" + i,
message: "message",
});
};
wrapper.setProps(props);
}
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
expect(wrapper.find(".notificationConsoleHeader .numInProgress").text()).toEqual("0");
expect(wrapper.find(".notificationConsoleHeader .numErroredItems").text()).toEqual(count.toString());
expect(wrapper.find(".notificationConsoleHeader .numInfoItems").text()).toEqual("0");
@@ -68,31 +70,34 @@ describe("NotificationConsoleComponent", () => {
it("shows proper info count", () => {
const count = 100;
const props = createBlankProps();
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
for (let i = 0; i < count; i++) {
props.consoleData.push({
props.consoleData = {
type: ConsoleDataType.Info,
date: "date",
date: "date" + i,
message: "message",
});
};
wrapper.setProps(props);
}
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
expect(wrapper.find(".notificationConsoleHeader .numInProgress").text()).toEqual("0");
expect(wrapper.find(".notificationConsoleHeader .numErroredItems").text()).toEqual("0");
expect(wrapper.find(".notificationConsoleHeader .numInfoItems").text()).toEqual(count.toString());
});
const testRenderNotification = (date: string, msg: string, type: ConsoleDataType, iconClassName: string) => {
const testRenderNotification = (date: string, message: string, type: ConsoleDataType, iconClassName: string) => {
const props = createBlankProps();
props.consoleData.push({
date: date,
message: msg,
type: type,
});
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
props.consoleData = {
type,
date,
message,
};
wrapper.setProps(props);
expect(wrapper.find(".notificationConsoleData .date").text()).toEqual(date);
expect(wrapper.find(".notificationConsoleData .message").text()).toEqual(msg);
expect(wrapper.find(".notificationConsoleData .message").text()).toEqual(message);
expect(wrapper.exists(`.notificationConsoleData .${iconClassName}`));
};
@@ -110,55 +115,78 @@ describe("NotificationConsoleComponent", () => {
it("clears notifications", () => {
const props = createBlankProps();
props.consoleData.push({
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
props.consoleData = {
type: ConsoleDataType.InProgress,
date: "date",
message: "message1",
});
props.consoleData.push({
};
wrapper.setProps(props);
props.consoleData = {
type: ConsoleDataType.Error,
date: "date",
message: "message2",
});
props.consoleData.push({
};
wrapper.setProps(props);
props.consoleData = {
type: ConsoleDataType.Info,
date: "date",
message: "message3",
});
};
wrapper.setProps(props);
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
wrapper.find(".clearNotificationsButton").simulate("click");
expect(!wrapper.exists(".notificationConsoleData"));
});
it("collapses and hide content", () => {
const props = createBlankProps();
props.consoleData.push({
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
props.consoleData = {
type: ConsoleDataType.Info,
date: "date",
message: "message",
type: ConsoleDataType.Info,
});
};
props.isConsoleExpanded = true;
wrapper.setProps(props);
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
wrapper.find(".notificationConsoleHeader").simulate("click");
expect(!wrapper.exists(".notificationConsoleContent"));
});
it("display latest data in header", () => {
const latestData = "latest data";
const props1 = createBlankProps();
const props2 = createBlankProps();
props2.consoleData.push({
const props = createBlankProps();
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
props.consoleData = {
type: ConsoleDataType.Info,
date: "date",
message: latestData,
type: ConsoleDataType.Info,
});
props2.isConsoleExpanded = true;
};
props.isConsoleExpanded = true;
wrapper.setProps(props);
const wrapper = shallow(<NotificationConsoleComponent {...props1} />);
wrapper.setProps(props2);
expect(wrapper.find(".headerStatusEllipsis").text()).toEqual(latestData);
});
it("delete in progress message", () => {
const props = createBlankProps();
props.consoleData = {
type: ConsoleDataType.InProgress,
date: "date",
message: "message",
id: "1",
};
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
expect(wrapper.find(".notificationConsoleHeader .numInProgress").text()).toEqual("1");
props.inProgressConsoleDataIdToBeDeleted = "1";
wrapper.setProps(props);
expect(wrapper.find(".notificationConsoleHeader .numInProgress").text()).toEqual("0");
});
});

View File

@@ -37,15 +37,15 @@ export interface ConsoleData {
export interface NotificationConsoleComponentProps {
isConsoleExpanded: boolean;
onConsoleExpandedChange: (isExpanded: boolean) => void;
consoleData: ConsoleData[];
onConsoleDataChange: (consoleData: ConsoleData[]) => void;
consoleData: ConsoleData;
inProgressConsoleDataIdToBeDeleted: string;
setIsConsoleExpanded: (isExpanded: boolean) => void;
}
interface NotificationConsoleComponentState {
headerStatus: string;
selectedFilter: string;
isExpanded: boolean;
allConsoleData: ConsoleData[];
}
export class NotificationConsoleComponent extends React.Component<
@@ -60,28 +60,28 @@ export class NotificationConsoleComponent extends React.Component<
{ key: "Error", text: "Error" },
];
private headerTimeoutId?: number;
private prevHeaderStatus: string | null;
private prevHeaderStatus: string;
private consoleHeaderElement?: HTMLElement;
constructor(props: NotificationConsoleComponentProps) {
super(props);
this.state = {
headerStatus: "",
selectedFilter: NotificationConsoleComponent.FilterOptions[0].key || "",
isExpanded: props.isConsoleExpanded,
headerStatus: undefined,
selectedFilter: NotificationConsoleComponent.FilterOptions[0].key,
allConsoleData: props.consoleData ? [props.consoleData] : [],
};
this.prevHeaderStatus = null;
this.prevHeaderStatus = undefined;
}
public componentDidUpdate(
prevProps: NotificationConsoleComponentProps,
prevState: NotificationConsoleComponentState
) {
const currentHeaderStatus = NotificationConsoleComponent.extractHeaderStatus(this.props);
const currentHeaderStatus = NotificationConsoleComponent.extractHeaderStatus(this.props.consoleData);
if (
this.prevHeaderStatus !== currentHeaderStatus &&
currentHeaderStatus !== null &&
currentHeaderStatus !== undefined &&
prevState.headerStatus !== currentHeaderStatus
) {
this.setHeaderStatus(currentHeaderStatus);
@@ -92,10 +92,8 @@ export class NotificationConsoleComponent extends React.Component<
// updates: currentHeaderStatus -> "" -> currentHeaderStatus -> "" etc.
this.prevHeaderStatus = currentHeaderStatus;
if (prevProps.isConsoleExpanded !== this.props.isConsoleExpanded) {
// Sync state and props
// TODO react anti-pattern: remove isExpanded from state which duplicates prop's isConsoleExpanded
this.setState({ isExpanded: this.props.isConsoleExpanded });
if (this.props.consoleData || this.props.inProgressConsoleDataIdToBeDeleted) {
this.updateConsoleData(prevProps);
}
}
@@ -104,12 +102,14 @@ export class NotificationConsoleComponent extends React.Component<
};
public render(): JSX.Element {
const numInProgress = this.props.consoleData.filter((data: ConsoleData) => data.type === ConsoleDataType.InProgress)
const numInProgress = this.state.allConsoleData.filter(
(data: ConsoleData) => data.type === ConsoleDataType.InProgress
).length;
const numErroredItems = this.state.allConsoleData.filter((data: ConsoleData) => data.type === ConsoleDataType.Error)
.length;
const numErroredItems = this.props.consoleData.filter((data: ConsoleData) => data.type === ConsoleDataType.Error)
.length;
const numInfoItems = this.props.consoleData.filter((data: ConsoleData) => data.type === ConsoleDataType.Info)
const numInfoItems = this.state.allConsoleData.filter((data: ConsoleData) => data.type === ConsoleDataType.Info)
.length;
return (
<div className="notificationConsoleContainer">
<div
@@ -143,18 +143,18 @@ export class NotificationConsoleComponent extends React.Component<
className="expandCollapseButton"
role="button"
tabIndex={0}
aria-label={"console button" + (this.state.isExpanded ? " collapsed" : " expanded")}
aria-expanded={!this.state.isExpanded}
aria-label={"console button" + (this.props.isConsoleExpanded ? " collapsed" : " expanded")}
aria-expanded={!this.props.isConsoleExpanded}
>
<img
src={this.state.isExpanded ? ChevronDownIcon : ChevronUpIcon}
alt={this.state.isExpanded ? "ChevronDownIcon" : "ChevronUpIcon"}
src={this.props.isConsoleExpanded ? ChevronDownIcon : ChevronUpIcon}
alt={this.props.isConsoleExpanded ? "ChevronDownIcon" : "ChevronUpIcon"}
/>
</div>
</div>
<AnimateHeight
duration={NotificationConsoleComponent.transitionDurationMs}
height={this.state.isExpanded ? "auto" : 0}
height={this.props.isConsoleExpanded ? "auto" : 0}
onAnimationEnd={this.onConsoleWasExpanded}
>
<div className="notificationConsoleContents">
@@ -189,7 +189,7 @@ export class NotificationConsoleComponent extends React.Component<
);
}
private expandCollapseConsole() {
this.setState({ isExpanded: !this.state.isExpanded });
this.props.setIsConsoleExpanded(!this.props.isConsoleExpanded);
}
private onExpandCollapseKeyPress = (event: React.KeyboardEvent<HTMLDivElement>): void => {
@@ -209,7 +209,7 @@ export class NotificationConsoleComponent extends React.Component<
};
private clearNotifications(): void {
this.props.onConsoleDataChange([]);
this.setState({ allConsoleData: [] });
}
private renderAllFilteredConsoleData(rowData: ConsoleData[]): JSX.Element[] {
@@ -229,12 +229,9 @@ export class NotificationConsoleComponent extends React.Component<
};
private getFilteredConsoleData(): ConsoleData[] {
let filterType: ConsoleDataType | null = null;
let filterType: ConsoleDataType;
switch (this.state.selectedFilter) {
case "All":
filterType = null;
break;
case "In Progress":
filterType = ConsoleDataType.InProgress;
break;
@@ -245,12 +242,12 @@ export class NotificationConsoleComponent extends React.Component<
filterType = ConsoleDataType.Error;
break;
default:
filterType = null;
filterType = undefined;
}
return filterType == null
? this.props.consoleData
: this.props.consoleData.filter((data: ConsoleData) => data.type === filterType);
return filterType
? this.state.allConsoleData.filter((data: ConsoleData) => data.type === filterType)
: this.state.allConsoleData;
}
private setHeaderStatus(statusMessage: string): void {
@@ -266,18 +263,43 @@ export class NotificationConsoleComponent extends React.Component<
);
}
private static extractHeaderStatus(props: NotificationConsoleComponentProps) {
if (props.consoleData && props.consoleData.length > 0) {
return props.consoleData[0].message.split(":\n")[0];
} else {
return null;
}
private static extractHeaderStatus(consoleData: ConsoleData) {
return consoleData?.message.split(":\n")[0];
}
private onConsoleWasExpanded = (): void => {
this.props.onConsoleExpandedChange(this.state.isExpanded);
if (this.state.isExpanded && this.consoleHeaderElement) {
if (this.props.isConsoleExpanded && this.consoleHeaderElement) {
this.consoleHeaderElement.focus();
}
};
private updateConsoleData = (prevProps: NotificationConsoleComponentProps): void => {
if (!this.areConsoleDataEqual(this.props.consoleData, prevProps.consoleData)) {
this.setState({ allConsoleData: [this.props.consoleData, ...this.state.allConsoleData] });
}
if (
this.props.inProgressConsoleDataIdToBeDeleted &&
prevProps.inProgressConsoleDataIdToBeDeleted !== this.props.inProgressConsoleDataIdToBeDeleted
) {
const allConsoleData = this.state.allConsoleData.filter(
(data: ConsoleData) =>
!(data.type === ConsoleDataType.InProgress && data.id === this.props.inProgressConsoleDataIdToBeDeleted)
);
this.setState({ allConsoleData });
}
};
private areConsoleDataEqual = (currentData: ConsoleData, prevData: ConsoleData): boolean => {
if (!currentData || !prevData) {
return !currentData && !prevData;
}
return (
currentData.date === prevData.date &&
currentData.message === prevData.message &&
currentData.type === prevData.type &&
currentData.id === prevData.id
);
};
}

View File

@@ -1,47 +0,0 @@
import * as ko from "knockout";
import * as React from "react";
import { ReactAdapter } from "../../../Bindings/ReactBindingHandler";
import * as ViewModels from "../../../Contracts/ViewModels";
import { NotificationConsoleComponent } from "./NotificationConsoleComponent";
import { ConsoleData } from "./NotificationConsoleComponent";
import Explorer from "../../Explorer";
export class NotificationConsoleComponentAdapter implements ReactAdapter {
public parameters: ko.Observable<number>;
public container: Explorer;
private consoleData: ko.ObservableArray<ConsoleData>;
constructor(container: Explorer) {
this.container = container;
this.consoleData = container.notificationConsoleData;
this.consoleData.subscribe((newValue: ConsoleData[]) => this.triggerRender());
container.isNotificationConsoleExpanded.subscribe(() => this.triggerRender());
this.parameters = ko.observable(Date.now());
}
private onConsoleExpandedChange(isExpanded: boolean): void {
isExpanded ? this.container.expandConsole() : this.container.collapseConsole();
this.triggerRender();
}
private onConsoleDataChange(consoleData: ConsoleData[]): void {
this.consoleData(consoleData);
this.triggerRender();
}
public renderComponent(): JSX.Element {
return (
<NotificationConsoleComponent
isConsoleExpanded={this.container.isNotificationConsoleExpanded()}
onConsoleExpandedChange={this.onConsoleExpandedChange.bind(this)}
consoleData={this.consoleData()}
onConsoleDataChange={this.onConsoleDataChange.bind(this)}
/>
);
}
private triggerRender() {
window.requestAnimationFrame(() => this.parameters(Date.now()));
}
}

View File

@@ -1,6 +1,169 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`NotificationConsoleComponent renders the console (expanded) 1`] = `
exports[`NotificationConsoleComponent renders the console 1`] = `
<div
className="notificationConsoleContainer"
>
<div
className="notificationConsoleHeader"
onClick={[Function]}
onKeyDown={[Function]}
tabIndex={0}
>
<div
className="statusBar"
>
<span
className="dataTypeIcons"
>
<span
className="notificationConsoleHeaderIconWithData"
>
<img
alt="in progress items"
src=""
/>
<span
className="numInProgress"
>
0
</span>
</span>
<span
className="notificationConsoleHeaderIconWithData"
>
<img
alt="error items"
src=""
/>
<span
className="numErroredItems"
>
0
</span>
</span>
<span
className="notificationConsoleHeaderIconWithData"
>
<img
alt="info items"
src=""
/>
<span
className="numInfoItems"
>
0
</span>
</span>
</span>
<span
className="consoleSplitter"
/>
<span
className="headerStatus"
>
<span
className="headerStatusEllipsis"
/>
</span>
</div>
<div
aria-expanded={true}
aria-label="console button expanded"
className="expandCollapseButton"
role="button"
tabIndex={0}
>
<img
alt="ChevronUpIcon"
src=""
/>
</div>
</div>
<AnimateHeight
animateOpacity={false}
animationStateClasses={
Object {
"animating": "rah-animating",
"animatingDown": "rah-animating--down",
"animatingToHeightAuto": "rah-animating--to-height-auto",
"animatingToHeightSpecific": "rah-animating--to-height-specific",
"animatingToHeightZero": "rah-animating--to-height-zero",
"animatingUp": "rah-animating--up",
"static": "rah-static",
"staticHeightAuto": "rah-static--height-auto",
"staticHeightSpecific": "rah-static--height-specific",
"staticHeightZero": "rah-static--height-zero",
}
}
applyInlineTransitions={true}
delay={0}
duration={200}
easing="ease"
height={0}
onAnimationEnd={[Function]}
style={Object {}}
>
<div
className="notificationConsoleContents"
>
<div
className="notificationConsoleControls"
>
<StyledWithResponsiveMode
aria-label="All"
aria-labelledby="consoleFilterLabel"
label="Filter:"
onChange={[Function]}
options={
Array [
Object {
"key": "All",
"text": "All",
},
Object {
"key": "In Progress",
"text": "In progress",
},
Object {
"key": "Info",
"text": "Info",
},
Object {
"key": "Error",
"text": "Error",
},
]
}
role="combobox"
selectedKey="All"
/>
<span
className="consoleSplitter"
/>
<span
className="clearNotificationsButton"
onClick={[Function]}
onKeyDown={[Function]}
role="button"
tabIndex={0}
>
<img
alt="clear notifications image"
src=""
/>
Clear Notifications
</span>
</div>
<div
className="notificationConsoleData"
/>
</div>
</AnimateHeight>
</div>
`;
exports[`NotificationConsoleComponent renders the console 2`] = `
<div
className="notificationConsoleContainer"
>
@@ -64,18 +227,20 @@ exports[`NotificationConsoleComponent renders the console (expanded) 1`] = `
>
<span
className="headerStatusEllipsis"
/>
>
message
</span>
</span>
</div>
<div
aria-expanded={false}
aria-label="console button collapsed"
aria-expanded={true}
aria-label="console button expanded"
className="expandCollapseButton"
role="button"
tabIndex={0}
>
<img
alt="ChevronDownIcon"
alt="ChevronUpIcon"
src=""
/>
</div>
@@ -100,7 +265,7 @@ exports[`NotificationConsoleComponent renders the console (expanded) 1`] = `
delay={0}
duration={200}
easing="ease"
height="auto"
height={0}
onAnimationEnd={[Function]}
style={Object {}}
>

View File

@@ -0,0 +1,26 @@
.mongoQueryComponent {
margin-left: 10px;
height: 100%;
overflow-y: auto;
width: 100%;
input {
margin-top: 0;
}
label {
padding: 0;
margin-bottom: 0;
}
label:before {
top: 2px;
left: 2px;
height: 16px;
width: 16px;
}
.queryInput {
border: 1px solid black;
margin: 5px;
}
}

View File

@@ -0,0 +1,185 @@
import * as React from "react";
import { Dispatch } from "redux";
import MonacoEditor from "@nteract/monaco-editor";
import { PrimaryButton } from "office-ui-fabric-react";
import { ChoiceGroup, IChoiceGroupOption } from "office-ui-fabric-react/lib/ChoiceGroup";
import Outputs from "@nteract/stateful-components/lib/outputs";
import { KernelOutputError, StreamText } from "@nteract/outputs";
import TransformMedia from "@nteract/stateful-components/lib/outputs/transform-media";
import { actions, selectors, AppState, ContentRef, KernelRef } from "@nteract/core";
import loadTransform from "../NotebookComponent/loadTransform";
import { connect } from "react-redux";
import "./MongoQueryComponent.less";
interface MongoQueryComponentPureProps {
contentRef: ContentRef;
kernelRef: KernelRef;
databaseId: string;
collectionId: string;
}
interface MongoQueryComponentDispatchProps {
runCell: (contentRef: ContentRef, cellId: string) => void;
addTransform: (transform: React.ComponentType & { MIMETYPE: string }) => void;
updateCell: (text: string, id: string, contentRef: ContentRef) => void;
}
type OutputType = "rich" | "json";
interface MongoQueryComponentState {
query: string;
outputType: OutputType;
}
const options: IChoiceGroupOption[] = [
{ key: "rich", text: "Rich Output" },
{ key: "json", text: "Json Output" },
];
type MongoQueryComponentProps = MongoQueryComponentPureProps & StateProps & MongoQueryComponentDispatchProps;
export class MongoQueryComponent extends React.Component<MongoQueryComponentProps, MongoQueryComponentState> {
constructor(props: MongoQueryComponentProps) {
super(props);
this.state = {
query: this.props.inputValue,
outputType: "rich",
};
}
componentDidMount(): void {
loadTransform(this.props);
}
private onExecute = () => {
const query = JSON.parse(this.state.query);
query["database"] = this.props.databaseId;
query["collection"] = this.props.collectionId;
query["outputType"] = this.state.outputType;
this.props.updateCell(JSON.stringify(query), this.props.firstCellId, this.props.contentRef);
this.props.runCell(this.props.contentRef, this.props.firstCellId);
};
private onOutputTypeChange = (
e: React.FormEvent<HTMLElement | HTMLInputElement>,
option: IChoiceGroupOption
): void => {
const outputType = option.key as OutputType;
this.setState({ outputType }, () => this.onInputChange(this.props.inputValue));
};
private onInputChange = (text: string) => {
this.setState({ query: text });
};
render(): JSX.Element {
const { firstCellId: id, contentRef } = this.props;
if (!id) {
return <></>;
}
return (
<div className="mongoQueryComponent">
<div className="queryInput">
<MonacoEditor
id={this.props.firstCellId}
contentRef={this.props.contentRef}
theme={""}
language="json"
onChange={this.onInputChange}
value={this.state.query}
/>
</div>
<PrimaryButton text="Run" onClick={this.onExecute} disabled={!this.props.firstCellId} />
<ChoiceGroup
selectedKey={this.state.outputType}
options={options}
onChange={this.onOutputTypeChange}
label="Output Type"
styles={{ root: { marginTop: 0 } }}
/>
<hr />
<Outputs id={id} contentRef={contentRef}>
<TransformMedia output_type={"display_data"} id={id} contentRef={contentRef} />
<TransformMedia output_type={"execute_result"} id={id} contentRef={contentRef} />
<KernelOutputError />
<StreamText />
</Outputs>
</div>
);
}
}
interface StateProps {
firstCellId: string;
inputValue: string;
}
interface InitialProps {
contentRef: string;
}
// Redux
const makeMapStateToProps = (state: AppState, initialProps: InitialProps) => {
const { contentRef } = initialProps;
const mapStateToProps = (state: AppState) => {
let firstCellId;
let inputValue = "";
const content = selectors.content(state, { contentRef });
if (content?.type === "notebook") {
const cellOrder = selectors.notebook.cellOrder(content.model);
if (cellOrder.size > 0) {
firstCellId = cellOrder.first() as string;
const cell = selectors.notebook.cellById(content.model, { id: firstCellId });
// Parse to extract filter and output type
const cellValue = cell.get("source", "");
if (cellValue) {
try {
const filterValue = JSON.parse(cellValue).filter;
if (filterValue) {
inputValue = filterValue;
}
} catch (e) {
console.error("Could not parse", e);
}
}
}
}
return {
firstCellId,
inputValue,
};
};
return mapStateToProps;
};
const makeMapDispatchToProps = (initialDispatch: Dispatch, initialProps: MongoQueryComponentProps) => {
const mapDispatchToProps = (dispatch: Dispatch) => {
return {
addTransform: (transform: React.ComponentType & { MIMETYPE: string }) => {
return dispatch(
actions.addTransform({
mediaType: transform.MIMETYPE,
component: transform,
})
);
},
runCell: (contentRef: ContentRef, cellId: string) => {
return dispatch(
actions.executeCell({
contentRef,
id: cellId,
})
);
},
updateCell: (text: string, id: string, contentRef: ContentRef) => {
dispatch(actions.updateCellSource({ id, contentRef, value: text }));
},
};
};
return mapDispatchToProps;
};
export default connect(makeMapStateToProps, makeMapDispatchToProps)(MongoQueryComponent);

View File

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

View File

@@ -45,17 +45,18 @@ type NotebookRendererProps = NotebookRendererBaseProps & NotebookRendererDispatc
const decorate = (id: string, contentRef: ContentRef, cell_type: CellType, children: React.ReactNode) => {
const Cell = () => (
<DraggableCell id={id} contentRef={contentRef}>
<HijackScroll id={id} contentRef={contentRef}>
<CellCreator id={id} contentRef={contentRef}>
<CellLabeler id={id} contentRef={contentRef}>
<HoverableCell id={id} contentRef={contentRef}>
{children}
</HoverableCell>
</CellLabeler>
</CellCreator>
</HijackScroll>
</DraggableCell>
// TODO Draggable and HijackScroll not working anymore. Fix or remove when reworking MarkdownCell.
// <DraggableCell id={id} contentRef={contentRef}>
// <HijackScroll id={id} contentRef={contentRef}>
<CellCreator id={id} contentRef={contentRef}>
<CellLabeler id={id} contentRef={contentRef}>
<HoverableCell id={id} contentRef={contentRef}>
{children}
</HoverableCell>
</CellLabeler>
</CellCreator>
// </HijackScroll>
// </DraggableCell>
);
Cell.defaultProps = { cell_type };

View File

@@ -29,10 +29,6 @@ export abstract class ContextualPaneBase extends WaitsForTemplateViewModel {
this.title = ko.observable<string>();
this.formErrorsDetails = ko.observable<string>();
this.isExecuting = ko.observable<boolean>(false);
this.container.isNotificationConsoleExpanded.subscribe((isExpanded: boolean) => {
this.resizePane();
});
this.container.isNotificationConsoleExpanded.extend({ rateLimit: 10 });
}
public cancel() {

View File

@@ -57,7 +57,6 @@ describe("Delete Collection Confirmation Pane", () => {
describe("shouldRecordFeedback()", () => {
it("should return true if last collection and database does not have shared throughput else false", () => {
let fakeExplorer = new Explorer();
fakeExplorer.isNotificationConsoleExpanded = ko.observable<boolean>(false);
fakeExplorer.refreshAllDatabases = () => Q.resolve();
let pane = new DeleteCollectionConfirmationPane({
@@ -101,7 +100,6 @@ describe("Delete Collection Confirmation Pane", () => {
rid: "test",
} as ViewModels.Collection;
};
fakeExplorer.isNotificationConsoleExpanded = ko.observable<boolean>(false);
fakeExplorer.selectedCollectionId = ko.computed<string>(() => selectedCollectionId);
fakeExplorer.isSelectedDatabaseShared = () => false;
const SubscriptionId = "testId";

View File

@@ -55,7 +55,6 @@ describe("Delete Database Confirmation Pane", () => {
describe("shouldRecordFeedback()", () => {
it("should return true if last non empty database or is last database that has shared throughput, else false", () => {
let fakeExplorer = {} as Explorer;
fakeExplorer.isNotificationConsoleExpanded = ko.observable<boolean>(false);
let pane = new DeleteDatabaseConfirmationPane({
id: "deletedatabaseconfirmationpane",
@@ -92,7 +91,6 @@ describe("Delete Database Confirmation Pane", () => {
} as ViewModels.Database;
};
fakeExplorer.refreshAllDatabases = () => Q.resolve();
fakeExplorer.isNotificationConsoleExpanded = ko.observable<boolean>(false);
fakeExplorer.selectedDatabaseId = ko.computed<string>(() => selectedDatabaseId);
fakeExplorer.isSelectedDatabaseShared = () => false;
const SubscriptionId = "testId";

View File

@@ -34,13 +34,6 @@ export class GenericRightPaneComponent extends React.Component<GenericRightPaneP
};
}
public componentDidMount(): void {
this.notificationConsoleSubscription = this.props.container.isNotificationConsoleExpanded.subscribe(() => {
this.setState({ panelHeight: this.getPanelHeight() });
});
this.props.container.isNotificationConsoleExpanded.extend({ rateLimit: 10 });
}
public componentWillUnmount(): void {
this.notificationConsoleSubscription && this.notificationConsoleSubscription.dispose();
}

View File

@@ -240,10 +240,7 @@ function updateTableScrollableRegionHeight(): void {
var dataTablesScrollBodyPosY = $(tabElement).find(Constants.htmlSelectors.dataTableScrollBodySelector).offset().top;
var dataTablesInfoElem = $(tabElement).find(".dataTables_info");
var dataTablesPaginateElem = $(tabElement).find(".dataTables_paginate");
const explorer = window.dataExplorer;
const notificationConsoleHeight = explorer.isNotificationConsoleExpanded()
? 252 /** 32px(header) + 220px(content height) **/
: 32; /** Header height **/
const notificationConsoleHeight = 32; /** Header height **/
var scrollHeight =
bodyHeight -

View File

@@ -0,0 +1 @@
<div data-bind="react:mongoQueryComponentAdapter" style="height: 100%"></div>

View File

@@ -0,0 +1,49 @@
import * as Q from "q";
import NotebookTabBase, { NotebookTabBaseOptions } from "./NotebookTabBase";
import { MongoQueryComponentAdapter } from "../Notebook/MongoQueryComponent/MongoQueryComponentAdapter";
export default class MongoDocumentsTabV2 extends NotebookTabBase {
private mongoQueryComponentAdapter: MongoQueryComponentAdapter;
constructor(options: NotebookTabBaseOptions) {
super(options);
this.mongoQueryComponentAdapter = new MongoQueryComponentAdapter(
{
contentRef: undefined,
notebookClient: NotebookTabBase.clientManager,
},
options.collection?.databaseId,
options.collection?.id()
);
}
public onCloseTabButtonClick(): Q.Promise<void> {
super.onCloseTabButtonClick();
// const cleanup = () => {
// this.notebookComponentAdapter.notebookShutdown();
// this.isActive(false);
// super.onCloseTabButtonClick();
// };
// if (this.notebookComponentAdapter.isContentDirty()) {
// this.container.showOkCancelModalDialog(
// "Close without saving?",
// `File has unsaved changes, close without saving?`,
// "Close",
// cleanup,
// "Cancel",
// undefined
// );
// return Q.resolve(null);
// } else {
// cleanup();
// return Q.resolve(null);
// }
return undefined;
}
protected buildCommandBarOptions(): void {
this.updateNavbarWithTabsButtons();
}
}

View File

@@ -0,0 +1,50 @@
import * as ViewModels from "../../Contracts/ViewModels";
import TabsBase from "./TabsBase";
import { NotebookClientV2 } from "../Notebook/NotebookClientV2";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import Explorer from "../Explorer";
import { ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import { Areas } from "../../Common/Constants";
export interface NotebookTabBaseOptions extends ViewModels.TabOptions {
container: Explorer;
}
/**
* Every notebook-based tab inherits from this class. It holds the static reference to a notebook client (singleton)
*/
export default class NotebookTabBase extends TabsBase {
protected static clientManager: NotebookClientV2;
protected container: Explorer;
constructor(options: NotebookTabBaseOptions) {
super(options);
this.container = options.container;
if (!NotebookTabBase.clientManager) {
NotebookTabBase.clientManager = new NotebookClientV2({
connectionInfo: this.container.notebookServerInfo(),
databaseAccountName: this.container.databaseAccount().name,
defaultExperience: this.container.defaultExperience(),
contentProvider: this.container.notebookManager?.notebookContentProvider,
});
}
}
/**
* Override base behavior
*/
protected getContainer(): Explorer {
return this.container;
}
protected traceTelemetry(actionType: number): void {
TelemetryProcessor.trace(actionType, ActionModifiers.Mark, {
databaseAccountName: this.container.databaseAccount() && this.container.databaseAccount().name,
defaultExperience: this.container.defaultExperience && this.container.defaultExperience(),
dataExplorerArea: Areas.Notebook,
});
}
}

View File

@@ -2,9 +2,7 @@ import * as _ from "underscore";
import * as Q from "q";
import * as ko from "knockout";
import * as ViewModels from "../../Contracts/ViewModels";
import * as DataModels from "../../Contracts/DataModels";
import TabsBase from "./TabsBase";
import NewCellIcon from "../../../images/notebook/Notebook-insert-cell.svg";
import CutIcon from "../../../images/notebook/Notebook-cut.svg";
@@ -17,33 +15,27 @@ import SaveIcon from "../../../images/save-cosmos.svg";
import ClearAllOutputsIcon from "../../../images/notebook/Notebook-clear-all-outputs.svg";
import InterruptKernelIcon from "../../../images/notebook/Notebook-stop.svg";
import KillKernelIcon from "../../../images/notebook/Notebook-stop.svg";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import { Areas, ArmApiVersions } from "../../Common/Constants";
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
import { ArmApiVersions } from "../../Common/Constants";
import { CommandBarComponentButtonFactory } from "../Menus/CommandBar/CommandBarComponentButtonFactory";
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils";
import { NotebookComponentAdapter } from "../Notebook/NotebookComponent/NotebookComponentAdapter";
import { NotebookConfigurationUtils } from "../../Utils/NotebookConfigurationUtils";
import { KernelSpecsDisplay, NotebookClientV2 } from "../Notebook/NotebookClientV2";
import { KernelSpecsDisplay } from "../Notebook/NotebookClientV2";
import { configContext } from "../../ConfigContext";
import Explorer from "../Explorer";
import { NotebookContentItem } from "../Notebook/NotebookContentItem";
import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent";
import { toJS, stringifyNotebook } from "@nteract/commutable";
import { appInsights } from "../../Shared/appInsights";
import { userContext } from "../../UserContext";
import NotebookTabBase, { NotebookTabBaseOptions } from "./NotebookTabBase";
export interface NotebookTabOptions extends ViewModels.TabOptions {
account: DataModels.DatabaseAccount;
masterKey: string;
container: Explorer;
export interface NotebookTabOptions extends NotebookTabBaseOptions {
notebookContentItem: NotebookContentItem;
}
export default class NotebookTabV2 extends TabsBase {
private static clientManager: NotebookClientV2;
private container: Explorer;
export default class NotebookTabV2 extends NotebookTabBase {
public notebookPath: ko.Observable<string>;
private selectedSparkPool: ko.Observable<string>;
private notebookComponentAdapter: NotebookComponentAdapter;
@@ -52,16 +44,6 @@ export default class NotebookTabV2 extends TabsBase {
super(options);
this.container = options.container;
if (!NotebookTabV2.clientManager) {
NotebookTabV2.clientManager = new NotebookClientV2({
connectionInfo: this.container.notebookServerInfo(),
databaseAccountName: this.container.databaseAccount().name,
defaultExperience: this.container.defaultExperience(),
contentProvider: this.container.notebookManager?.notebookContentProvider,
});
}
this.notebookPath = ko.observable(options.notebookContentItem.path);
this.container.notebookServerInfo.subscribe((newValue: DataModels.NotebookWorkspaceConnectionInfo) => {
@@ -71,7 +53,7 @@ export default class NotebookTabV2 extends TabsBase {
this.notebookComponentAdapter = new NotebookComponentAdapter({
contentItem: options.notebookContentItem,
notebooksBasePath: this.container.getNotebookBasePath(),
notebookClient: NotebookTabV2.clientManager,
notebookClient: NotebookTabBase.clientManager,
onUpdateKernelInfo: this.onKernelUpdate,
});
@@ -117,10 +99,6 @@ export default class NotebookTabV2 extends TabsBase {
return await this.configureServiceEndpoints(this.notebookComponentAdapter.getCurrentKernelName());
}
protected getContainer(): Explorer {
return this.container;
}
protected getTabsButtons(): CommandButtonComponentProps[] {
const availableKernels = NotebookTabV2.clientManager.getAvailableKernelSpecs();
@@ -504,12 +482,4 @@ export default class NotebookTabV2 extends TabsBase {
this.container.copyNotebook(notebookContent.name, content);
};
private traceTelemetry(actionType: number) {
TelemetryProcessor.trace(actionType, ActionModifiers.Mark, {
databaseAccountName: this.container.databaseAccount() && this.container.databaseAccount().name,
defaultExperience: this.container.defaultExperience && this.container.defaultExperience(),
dataExplorerArea: Areas.Notebook,
});
}
}

View File

@@ -5,6 +5,7 @@ import SparkMasterTabTemplate from "./SparkMasterTab.html";
import NotebookV2TabTemplate from "./NotebookV2Tab.html";
import TerminalTabTemplate from "./TerminalTab.html";
import MongoDocumentsTabTemplate from "./MongoDocumentsTab.html";
import MongoDocumentsTabV2Template from "./MongoDocumentsTabV2.html";
import MongoQueryTabTemplate from "./MongoQueryTab.html";
import MongoShellTabTemplate from "./MongoShellTab.html";
import QueryTabTemplate from "./QueryTab.html";
@@ -105,6 +106,15 @@ export class MongoQueryTab {
}
}
export class MongoDocumentsTabV2 {
constructor() {
return {
viewModel: TabComponent,
template: MongoDocumentsTabV2Template,
};
}
}
export class MongoShellTab {
constructor() {
return {

View File

@@ -23,6 +23,7 @@ import ConflictsTab from "../Tabs/ConflictsTab";
import DocumentsTab from "../Tabs/DocumentsTab";
import GraphTab from "../Tabs/GraphTab";
import MongoDocumentsTab from "../Tabs/MongoDocumentsTab";
import MongoDocumentsTabV2 from "../Tabs/MongoDocumentsTabV2";
import MongoQueryTab from "../Tabs/MongoQueryTab";
import MongoShellTab from "../Tabs/MongoShellTab";
import QueryTab from "../Tabs/QueryTab";
@@ -496,11 +497,11 @@ export default class Collection implements ViewModels.Collection {
dataExplorerArea: Constants.Areas.ResourceTree,
});
const mongoDocumentsTabs: MongoDocumentsTab[] = this.container.tabsManager.getTabs(
const mongoDocumentsTabs: MongoDocumentsTabV2[] = this.container.tabsManager.getTabs(
ViewModels.CollectionTabKind.Documents,
(tab) => tab.collection && tab.collection.databaseId === this.databaseId && tab.collection.id() === this.id()
) as MongoDocumentsTab[];
let mongoDocumentsTab: MongoDocumentsTab = mongoDocumentsTabs && mongoDocumentsTabs[0];
) as MongoDocumentsTabV2[];
let mongoDocumentsTab: MongoDocumentsTabV2 = mongoDocumentsTabs && mongoDocumentsTabs[0];
if (mongoDocumentsTab) {
this.container.tabsManager.activateTab(mongoDocumentsTab);
@@ -515,9 +516,8 @@ export default class Collection implements ViewModels.Collection {
});
this.documentIds([]);
mongoDocumentsTab = new MongoDocumentsTab({
partitionKey: this.partitionKey,
documentIds: this.documentIds,
mongoDocumentsTab = new MongoDocumentsTabV2({
container: this.container,
tabKind: ViewModels.CollectionTabKind.Documents,
title: "Documents",
tabPath: "",

View File

@@ -1,17 +1,18 @@
import { AuthType } from "./AuthType";
import { AccessInputMetadata, DatabaseAccount } from "./Contracts/DataModels";
type HostedConfig = AAD | ConnectionString | EncryptedToken | ResourceToken;
export interface HostedExplorerChildFrame extends Window {
hostedConfig: AAD | ConnectionString | EncryptedToken | ResourceToken;
hostedConfig: HostedConfig;
}
interface AAD {
export interface AAD {
authType: AuthType.AAD;
databaseAccount: DatabaseAccount;
authorizationToken: string;
}
interface ConnectionString {
export interface ConnectionString {
authType: AuthType.ConnectionString;
// Connection string uses still use encrypted token for Cassandra/Mongo APIs as they us the portal backend proxy
encryptedToken: string;
@@ -20,13 +21,13 @@ interface ConnectionString {
masterKey?: string;
}
interface EncryptedToken {
export interface EncryptedToken {
authType: AuthType.EncryptedToken;
encryptedToken: string;
encryptedTokenMetadata: AccessInputMetadata;
}
interface ResourceToken {
export interface ResourceToken {
authType: AuthType.ResourceToken;
resourceToken: string;
}

View File

@@ -248,7 +248,7 @@ describe("Gallery", () => {
const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(
`${configContext.JUNO_ENDPOINT}/api/notebooks/${sampleDatabaseAccount.name}/gallery/${id}/favorite`,
`${configContext.JUNO_ENDPOINT}/api/notebooks/${sampleSubscriptionId}/${sampleDatabaseAccount.name}/gallery/${id}/favorite`,
{
method: "PATCH",
headers: {

View File

@@ -80,7 +80,7 @@ export class JunoClient {
}
public async getPinnedRepos(scope: string): Promise<IJunoResponse<IPinnedRepo[]>> {
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/github/pinnedrepos`, {
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/github/pinnedrepos`, {
headers: JunoClient.getHeaders(),
});
@@ -103,7 +103,7 @@ export class JunoClient {
}
public async updatePinnedRepos(repos: IPinnedRepo[]): Promise<IJunoResponse<undefined>> {
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/github/pinnedrepos`, {
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/github/pinnedrepos`, {
method: "PUT",
body: JSON.stringify(repos),
headers: JunoClient.getHeaders(),
@@ -120,7 +120,7 @@ export class JunoClient {
}
public async deleteGitHubInfo(): Promise<IJunoResponse<undefined>> {
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/github`, {
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/github`, {
method: "DELETE",
headers: JunoClient.getHeaders(),
});
@@ -135,9 +135,12 @@ export class JunoClient {
const githubParams = JunoClient.getGitHubClientParams();
githubParams.append("code", code);
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/github/token?${githubParams.toString()}`, {
headers: JunoClient.getHeaders(),
});
const response = await window.fetch(
`${this.getNotebooksSubscriptionIdAccountUrl()}/github/token?${githubParams.toString()}`,
{
headers: JunoClient.getHeaders(),
}
);
let data: IGitHubOAuthToken;
const body = await response.text();
@@ -159,10 +162,13 @@ export class JunoClient {
const githubParams = JunoClient.getGitHubClientParams();
githubParams.append("access_token", token);
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/github/token?${githubParams.toString()}`, {
method: "DELETE",
headers: JunoClient.getHeaders(),
});
const response = await window.fetch(
`${this.getNotebooksSubscriptionIdAccountUrl()}/github/token?${githubParams.toString()}`,
{
method: "DELETE",
headers: JunoClient.getHeaders(),
}
);
return {
status: response.status,
@@ -179,7 +185,7 @@ export class JunoClient {
}
public async getPublicGalleryData(): Promise<IJunoResponse<IPublicGalleryData>> {
const url = `${this.getNotebooksAccountUrl()}/gallery/public`;
const url = `${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/public`;
const response = await window.fetch(url, {
method: "PATCH",
headers: JunoClient.getHeaders(),
@@ -197,7 +203,7 @@ export class JunoClient {
}
public async acceptCodeOfConduct(): Promise<IJunoResponse<boolean>> {
const url = `${this.getNotebooksAccountUrl()}/gallery/acceptCodeOfConduct`;
const url = `${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/acceptCodeOfConduct`;
const response = await window.fetch(url, {
method: "PATCH",
headers: JunoClient.getHeaders(),
@@ -215,7 +221,7 @@ export class JunoClient {
}
public async isCodeOfConductAccepted(): Promise<IJunoResponse<boolean>> {
const url = `${this.getNotebooksAccountUrl()}/gallery/isCodeOfConductAccepted`;
const url = `${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/isCodeOfConductAccepted`;
const response = await window.fetch(url, {
method: "PATCH",
headers: JunoClient.getHeaders(),
@@ -294,7 +300,7 @@ export class JunoClient {
}
public async favoriteNotebook(id: string): Promise<IJunoResponse<IGalleryItem>> {
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/gallery/${id}/favorite`, {
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/${id}/favorite`, {
method: "PATCH",
headers: JunoClient.getHeaders(),
});
@@ -365,22 +371,19 @@ export class JunoClient {
content: string,
isLinkInjectionEnabled: boolean
): Promise<IJunoResponse<IGalleryItem>> {
const response = await window.fetch(
`${this.getNotebooksUrl()}/${this.getSubscriptionId()}/${this.getAccount()}/gallery`,
{
method: "PUT",
headers: JunoClient.getHeaders(),
body: JSON.stringify({
name,
description,
tags,
author,
thumbnailUrl,
content: JSON.parse(content),
addLinkToNotebookViewer: isLinkInjectionEnabled,
} as IPublishNotebookRequest),
}
);
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery`, {
method: "PUT",
headers: JunoClient.getHeaders(),
body: JSON.stringify({
name,
description,
tags,
author,
thumbnailUrl,
content: JSON.parse(content),
addLinkToNotebookViewer: isLinkInjectionEnabled,
} as IPublishNotebookRequest),
});
let data: IGalleryItem;
if (response.status === HttpStatusCodes.OK) {
@@ -502,6 +505,10 @@ export class JunoClient {
return `${this.getNotebooksUrl()}/${this.getAccount()}`;
}
private getNotebooksSubscriptionIdAccountUrl(): string {
return `${this.getNotebooksUrl()}/${this.getSubscriptionId()}/${this.getAccount()}`;
}
private getAnalyticsUrl(): string {
return `${configContext.JUNO_ENDPOINT}/api/analytics`;
}

View File

@@ -53,215 +53,33 @@ import "object.entries/auto";
import "./Libs/is-integer-polyfill";
import "url-polyfill/url-polyfill.min";
initializeIcons();
import { AuthType } from "./AuthType";
import { initializeIcons } from "office-ui-fabric-react/lib/Icons";
import { applyExplorerBindings } from "./applyExplorerBindings";
import { configContext, initializeConfiguration, Platform } from "./ConfigContext";
import Explorer from "./Explorer/Explorer";
import React, { useEffect } from "react";
import { ExplorerParams } from "./Explorer/Explorer";
import React, { useState } from "react";
import ReactDOM from "react-dom";
import copyImage from "../images/Copy.svg";
import hdeConnectImage from "../images/HdeConnectCosmosDB.svg";
import refreshImg from "../images/refresh-cosmos.svg";
import arrowLeftImg from "../images/imgarrowlefticon.svg";
import { KOCommentEnd, KOCommentIfStart } from "./koComment";
import { updateUserContext } from "./UserContext";
import { CollectionCreation } from "./Shared/Constants";
import { extractFeatures } from "./Platform/Hosted/extractFeatures";
import { emulatorAccount } from "./Platform/Emulator/emulatorAccount";
import { HostedExplorerChildFrame } from "./HostedExplorerChildFrame";
import {
getDatabaseAccountKindFromExperience,
getDatabaseAccountPropertiesFromMetadata,
} from "./Platform/Hosted/HostedUtils";
import { DefaultExperienceUtility } from "./Shared/DefaultExperienceUtility";
import { parseResourceTokenConnectionString } from "./Platform/Hosted/Helpers/ResourceTokenUtils";
import { AccountKind, DefaultAccountExperience, ServerIds } from "./Common/Constants";
import { listKeys } from "./Utils/arm/generatedClients/2020-04-01/databaseAccounts";
import { SelfServeType } from "./SelfServe/SelfServeUtils";
import { useConfig } from "./hooks/useConfig";
import { useKnockoutExplorer } from "./hooks/useKnockoutExplorer";
import { NotificationConsoleComponent } from "./Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
initializeIcons();
const App: React.FunctionComponent = () => {
useEffect(() => {
initializeConfiguration().then(async (config) => {
let explorer: Explorer;
if (config.platform === Platform.Hosted) {
const win = (window as unknown) as HostedExplorerChildFrame;
explorer = new Explorer();
explorer.selfServeType(SelfServeType.none);
if (win.hostedConfig.authType === AuthType.EncryptedToken) {
// TODO: Remove window.authType
window.authType = AuthType.EncryptedToken;
// Impossible to tell if this is a try cosmos sub using an encrypted token
explorer.isTryCosmosDBSubscription(false);
updateUserContext({
accessToken: encodeURIComponent(win.hostedConfig.encryptedToken),
});
const apiExperience: string = DefaultExperienceUtility.getDefaultExperienceFromApiKind(
win.hostedConfig.encryptedTokenMetadata.apiKind
);
explorer.initDataExplorerWithFrameInputs({
databaseAccount: {
id: "",
// id: Main._databaseAccountId,
name: win.hostedConfig.encryptedTokenMetadata.accountName,
kind: getDatabaseAccountKindFromExperience(apiExperience),
properties: getDatabaseAccountPropertiesFromMetadata(win.hostedConfig.encryptedTokenMetadata),
tags: [],
},
subscriptionId: undefined,
resourceGroup: undefined,
masterKey: undefined,
hasWriteAccess: true, // TODO: we should embed this information in the token ideally
authorizationToken: undefined,
features: extractFeatures(),
csmEndpoint: undefined,
dnsSuffix: undefined,
serverId: ServerIds.productionPortal,
extensionEndpoint: configContext.BACKEND_ENDPOINT,
subscriptionType: CollectionCreation.DefaultSubscriptionType,
quotaId: undefined,
addCollectionDefaultFlight: explorer.flight(),
isTryCosmosDBSubscription: explorer.isTryCosmosDBSubscription(),
});
explorer.isAccountReady(true);
} else if (win.hostedConfig.authType === AuthType.ResourceToken) {
window.authType = AuthType.ResourceToken;
// Resource tokens can only be used with SQL API
const apiExperience: string = DefaultAccountExperience.DocumentDB;
const parsedResourceToken = parseResourceTokenConnectionString(win.hostedConfig.resourceToken);
updateUserContext({
resourceToken: parsedResourceToken.resourceToken,
endpoint: parsedResourceToken.accountEndpoint,
});
explorer.resourceTokenDatabaseId(parsedResourceToken.databaseId);
explorer.resourceTokenCollectionId(parsedResourceToken.collectionId);
if (parsedResourceToken.partitionKey) {
explorer.resourceTokenPartitionKey(parsedResourceToken.partitionKey);
}
explorer.initDataExplorerWithFrameInputs({
databaseAccount: {
id: "",
name: parsedResourceToken.accountEndpoint,
kind: AccountKind.GlobalDocumentDB,
properties: { documentEndpoint: parsedResourceToken.accountEndpoint },
tags: { defaultExperience: apiExperience },
},
subscriptionId: undefined,
resourceGroup: undefined,
masterKey: undefined,
hasWriteAccess: true, // TODO: we should embed this information in the token ideally
authorizationToken: undefined,
features: extractFeatures(),
csmEndpoint: undefined,
dnsSuffix: undefined,
serverId: ServerIds.productionPortal,
extensionEndpoint: configContext.BACKEND_ENDPOINT,
subscriptionType: CollectionCreation.DefaultSubscriptionType,
quotaId: undefined,
addCollectionDefaultFlight: explorer.flight(),
isTryCosmosDBSubscription: explorer.isTryCosmosDBSubscription(),
isAuthWithresourceToken: true,
});
explorer.isAccountReady(true);
explorer.isRefreshingExplorer(false);
} else if (win.hostedConfig.authType === AuthType.ConnectionString) {
// For legacy reasons lots of code expects a connection string login to look and act like an encrypted token login
window.authType = AuthType.EncryptedToken;
// Impossible to tell if this is a try cosmos sub using an encrypted token
explorer.isTryCosmosDBSubscription(false);
updateUserContext({
accessToken: encodeURIComponent(win.hostedConfig.encryptedToken),
});
const apiExperience: string = DefaultExperienceUtility.getDefaultExperienceFromApiKind(
win.hostedConfig.encryptedTokenMetadata.apiKind
);
explorer.initDataExplorerWithFrameInputs({
databaseAccount: {
id: "",
// id: Main._databaseAccountId,
name: win.hostedConfig.encryptedTokenMetadata.accountName,
kind: getDatabaseAccountKindFromExperience(apiExperience),
properties: getDatabaseAccountPropertiesFromMetadata(win.hostedConfig.encryptedTokenMetadata),
tags: [],
},
subscriptionId: undefined,
resourceGroup: undefined,
masterKey: win.hostedConfig.masterKey,
hasWriteAccess: true, // TODO: we should embed this information in the token ideally
authorizationToken: undefined,
features: extractFeatures(),
csmEndpoint: undefined,
dnsSuffix: undefined,
serverId: ServerIds.productionPortal,
extensionEndpoint: configContext.BACKEND_ENDPOINT,
subscriptionType: CollectionCreation.DefaultSubscriptionType,
quotaId: undefined,
addCollectionDefaultFlight: explorer.flight(),
isTryCosmosDBSubscription: explorer.isTryCosmosDBSubscription(),
});
explorer.isAccountReady(true);
} else if (win.hostedConfig.authType === AuthType.AAD) {
window.authType = AuthType.AAD;
const account = win.hostedConfig.databaseAccount;
const accountResourceId = account.id;
const subscriptionId = accountResourceId && accountResourceId.split("subscriptions/")[1].split("/")[0];
const resourceGroup = accountResourceId && accountResourceId.split("resourceGroups/")[1].split("/")[0];
updateUserContext({
authorizationToken: `Bearer ${win.hostedConfig.authorizationToken}`,
databaseAccount: win.hostedConfig.databaseAccount,
});
const keys = await listKeys(subscriptionId, resourceGroup, account.name);
explorer.initDataExplorerWithFrameInputs({
databaseAccount: account,
subscriptionId,
resourceGroup,
masterKey: keys.primaryMasterKey,
hasWriteAccess: true, //TODO: 425017 - support read access
authorizationToken: `Bearer ${win.hostedConfig.authorizationToken}`,
features: extractFeatures(),
csmEndpoint: undefined,
dnsSuffix: undefined,
serverId: ServerIds.productionPortal,
extensionEndpoint: configContext.BACKEND_ENDPOINT,
subscriptionType: CollectionCreation.DefaultSubscriptionType,
quotaId: undefined,
addCollectionDefaultFlight: explorer.flight(),
isTryCosmosDBSubscription: explorer.isTryCosmosDBSubscription(),
});
explorer.isAccountReady(true);
}
} else if (config.platform === Platform.Emulator) {
window.authType = AuthType.MasterKey;
explorer = new Explorer();
explorer.selfServeType(SelfServeType.none);
explorer.databaseAccount(emulatorAccount);
explorer.isAccountReady(true);
} else if (config.platform === Platform.Portal) {
window.authType = AuthType.AAD;
explorer = new Explorer();
// In development mode, try to load the iframe message from session storage.
// This allows webpack hot reload to funciton properly
if (process.env.NODE_ENV === "development") {
const initMessage = sessionStorage.getItem("portalDataExplorerInitMessage");
if (initMessage) {
const message = JSON.parse(initMessage);
console.warn("Loaded cached portal iframe message from session storage");
console.dir(message);
explorer.initDataExplorerWithFrameInputs(message);
}
}
window.addEventListener("message", explorer.handleMessage.bind(explorer), false);
}
applyExplorerBindings(explorer);
});
}, []);
const [isNotificationConsoleExpanded, setIsNotificationConsoleExpanded] = useState(false);
const [notificationConsoleData, setNotificationConsoleData] = useState(undefined);
//TODO: Refactor so we don't need to pass the id to remove a console data
const [inProgressConsoleDataIdToBeDeleted, setInProgressConsoleDataIdToBeDeleted] = useState("");
const explorerParams: ExplorerParams = {
setIsNotificationConsoleExpanded,
setNotificationConsoleData,
setInProgressConsoleDataIdToBeDeleted,
};
const config = useConfig();
useKnockoutExplorer(config, explorerParams);
return (
<div className="flexContainer">
@@ -463,8 +281,14 @@ const App: React.FunctionComponent = () => {
role="contentinfo"
aria-label="Notification console"
id="explorerNotificationConsole"
data-bind="react: notificationConsoleComponentAdapter"
/>
>
<NotificationConsoleComponent
isConsoleExpanded={isNotificationConsoleExpanded}
consoleData={notificationConsoleData}
inProgressConsoleDataIdToBeDeleted={inProgressConsoleDataIdToBeDeleted}
setIsConsoleExpanded={setIsNotificationConsoleExpanded}
/>
</div>
</div>
{/* Global loader - Start */}

View File

@@ -1,14 +0,0 @@
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);
};
};

View File

@@ -1,10 +1,10 @@
import { ChoiceItem, Info, InputType, UiType } from "../Explorer/Controls/SmartUi/SmartUiComponent";
import { addPropertyToMap, CommonInputTypes } from "./SelfServeUtils";
import { ChoiceItem, Description, Info, InputType, NumberUiType, SmartUiInput } from "./SelfServeTypes";
import { addPropertyToMap, DecoratorProperties, buildSmartUiDescriptor } from "./SelfServeUtils";
type ValueOf<T> = T[keyof T];
interface Decorator {
name: keyof CommonInputTypes;
value: ValueOf<CommonInputTypes>;
name: keyof DecoratorProperties;
value: ValueOf<DecoratorProperties>;
}
interface InputOptionsBase {
@@ -15,7 +15,7 @@ export interface NumberInputOptions extends InputOptionsBase {
min: (() => Promise<number>) | number;
max: (() => Promise<number>) | number;
step: (() => Promise<number>) | number;
uiType: UiType;
uiType: NumberUiType;
}
export interface StringInputOptions extends InputOptionsBase {
@@ -29,9 +29,19 @@ export interface BooleanInputOptions extends InputOptionsBase {
export interface ChoiceInputOptions extends InputOptionsBase {
choices: (() => Promise<ChoiceItem[]>) | ChoiceItem[];
placeholder?: (() => Promise<string>) | string;
}
type InputOptions = NumberInputOptions | StringInputOptions | BooleanInputOptions | ChoiceInputOptions;
export interface DescriptionDisplayOptions {
description?: (() => Promise<Description>) | Description;
}
type InputOptions =
| NumberInputOptions
| StringInputOptions
| BooleanInputOptions
| ChoiceInputOptions
| DescriptionDisplayOptions;
const isNumberInputOptions = (inputOptions: InputOptions): inputOptions is NumberInputOptions => {
return "min" in inputOptions;
@@ -45,6 +55,10 @@ const isChoiceInputOptions = (inputOptions: InputOptions): inputOptions is Choic
return "choices" in inputOptions;
};
const isDescriptionDisplayOptions = (inputOptions: InputOptions): inputOptions is DescriptionDisplayOptions => {
return "description" in inputOptions;
};
const addToMap = (...decorators: Decorator[]): PropertyDecorator => {
return (target, property) => {
let className = target.constructor.name;
@@ -66,7 +80,7 @@ const addToMap = (...decorators: Decorator[]): PropertyDecorator => {
};
export const OnChange = (
onChange: (currentState: Map<string, InputType>, newValue: InputType) => Map<string, InputType>
onChange: (currentState: Map<string, SmartUiInput>, newValue: InputType) => Map<string, SmartUiInput>
): PropertyDecorator => {
return addToMap({ name: "onChange", value: onChange });
};
@@ -91,7 +105,13 @@ export const Values = (inputOptions: InputOptions): PropertyDecorator => {
{ name: "falseLabel", value: inputOptions.falseLabel }
);
} else if (isChoiceInputOptions(inputOptions)) {
return addToMap({ name: "label", value: inputOptions.label }, { name: "choices", value: inputOptions.choices });
return addToMap(
{ name: "label", value: inputOptions.label },
{ name: "placeholder", value: inputOptions.placeholder },
{ name: "choices", value: inputOptions.choices }
);
} else if (isDescriptionDisplayOptions(inputOptions)) {
return addToMap({ name: "description", value: inputOptions.description });
} else {
return addToMap(
{ name: "label", value: inputOptions.label },
@@ -99,3 +119,15 @@ export const Values = (inputOptions: InputOptions): PropertyDecorator => {
);
}
};
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);
};
};

View File

@@ -0,0 +1,64 @@
import { get } from "../../Utils/arm/generatedClients/2020-04-01/databaseAccounts";
import { userContext } from "../../UserContext";
import { SessionStorageUtility } from "../../Shared/StorageUtility";
import { RefreshResult } from "../SelfServeTypes";
export enum Regions {
NorthCentralUS = "NorthCentralUS",
WestUS = "WestUS",
EastUS2 = "EastUS2",
}
export interface InitializeResponse {
regions: Regions;
enableLogging: boolean;
accountName: string;
collectionThroughput: number;
dbThroughput: number;
}
export const getMaxThroughput = async (): Promise<number> => {
return 10000;
};
export const update = async (
regions: Regions,
enableLogging: boolean,
accountName: string,
collectionThroughput: number,
dbThoughput: number
): Promise<void> => {
SessionStorageUtility.setEntry("regions", regions);
SessionStorageUtility.setEntry("enableLogging", enableLogging?.toString());
SessionStorageUtility.setEntry("accountName", accountName);
SessionStorageUtility.setEntry("collectionThroughput", collectionThroughput?.toString());
SessionStorageUtility.setEntry("dbThroughput", dbThoughput?.toString());
};
export const initialize = async (): Promise<InitializeResponse> => {
const regions = Regions[SessionStorageUtility.getEntry("regions") as keyof typeof Regions];
const enableLogging = SessionStorageUtility.getEntry("enableLogging") === "true";
const accountName = SessionStorageUtility.getEntry("accountName");
let collectionThroughput = parseInt(SessionStorageUtility.getEntry("collectionThroughput"));
collectionThroughput = isNaN(collectionThroughput) ? undefined : collectionThroughput;
let dbThroughput = parseInt(SessionStorageUtility.getEntry("dbThroughput"));
dbThroughput = isNaN(dbThroughput) ? undefined : dbThroughput;
return {
regions: regions,
enableLogging: enableLogging,
accountName: accountName,
collectionThroughput: collectionThroughput,
dbThroughput: dbThroughput,
};
};
export const onRefreshSelfServeExample = async (): Promise<RefreshResult> => {
const subscriptionId = userContext.subscriptionId;
const resourceGroup = userContext.resourceGroup;
const databaseAccountName = userContext.databaseAccount.name;
const databaseAccountGetResults = await get(subscriptionId, resourceGroup, databaseAccountName);
const isUpdateInProgress = databaseAccountGetResults.properties.provisioningState !== "Succeeded";
return {
isUpdateInProgress: isUpdateInProgress,
notificationMessage: "Self Serve Example successfully refreshing",
};
};

View File

@@ -1,37 +1,57 @@
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";
import { PropertyInfo, OnChange, Values, IsDisplayable, ClassInfo } from "../Decorators";
import {
ChoiceItem,
Info,
InputType,
NumberUiType,
RefreshResult,
SelfServeBaseClass,
SelfServeNotification,
SelfServeNotificationType,
SmartUiInput,
} from "../SelfServeTypes";
import { onRefreshSelfServeExample, getMaxThroughput, Regions, update, initialize } from "./SelfServeExample.rp";
export enum Regions {
NorthCentralUS = "NCUS",
WestUS = "WUS",
EastUS2 = "EUS2",
}
export const regionDropdownItems: ChoiceItem[] = [
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 = {
const selfServeExampleInfo: Info = {
message: "This is a self serve class",
};
export const regionDropdownInfo: Info = {
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);
const onRegionsChange = (currentState: Map<string, SmartUiInput>, newValue: InputType): Map<string, SmartUiInput> => {
currentState.set("regions", { value: newValue });
const currentEnableLogging = currentState.get("enableLogging");
if (newValue === Regions.NorthCentralUS) {
currentState.set("enableLogging", { value: false, disabled: true });
} else {
currentState.set("enableLogging", { value: currentEnableLogging.value, disabled: false });
}
return currentState;
};
const initializeMaxThroughput = async (): Promise<number> => {
return 10000;
const onEnableDbLevelThroughputChange = (
currentState: Map<string, SmartUiInput>,
newValue: InputType
): Map<string, SmartUiInput> => {
currentState.set("enableDbLevelThroughput", { value: newValue });
const currentDbThroughput = currentState.get("dbThroughput");
const isDbThroughputHidden = newValue === undefined || !(newValue as boolean);
currentState.set("dbThroughput", { value: currentDbThroughput.value, hidden: isDbThroughputHidden });
return currentState;
};
const validate = (currentvalues: Map<string, SmartUiInput>): void => {
if (!currentvalues.get("regions").value || !currentvalues.get("accountName").value) {
throw new Error("Regions and AccountName should not be empty.");
}
};
/*
@@ -40,8 +60,9 @@ const initializeMaxThroughput = async (): Promise<number> => {
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 onSave() function, a callback for when the submit button is clicked.
- Needs to define an initialize() function, to set default values for the inputs.
- Needs to define an onRefresh() function, a callback for when the refresh button is clicked.
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.
@@ -61,25 +82,46 @@ const initializeMaxThroughput = async (): Promise<number> => {
@ClassInfo(selfServeExampleInfo)
export default class SelfServeExample extends SelfServeBaseClass {
/*
onSubmit()
onRefresh()
- role : Callback that is triggerrd when the refresh button is clicked. You should perform the your rest API
call to check if the update action is completed.
- returns:
RefreshResult -
isComponentUpdating: Indicated if the state is still being updated
notificationMessage: Notification message to be shown in case the component is still being updated
i.e, isComponentUpdating is true
*/
public onRefresh = async (): Promise<RefreshResult> => {
return onRefreshSelfServeExample();
};
/*
onSave()
- 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 this example, the onSave callback simply sets the value for keys corresponding to the field name
in the SessionStorage.
- returns: SelfServeNotification -
message: The message to be displayed in the message bar after the onSave is completed
type: The type of message bar to be used (info, warning, error)
*/
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());
public onSave = async (currentValues: Map<string, SmartUiInput>): Promise<SelfServeNotification> => {
validate(currentValues);
const regions = Regions[currentValues.get("regions")?.value as keyof typeof Regions];
const enableLogging = currentValues.get("enableLogging")?.value as boolean;
const accountName = currentValues.get("accountName")?.value as string;
const collectionThroughput = currentValues.get("collectionThroughput")?.value as number;
const enableDbLevelThroughput = currentValues.get("enableDbLevelThroughput")?.value as boolean;
let dbThroughput = currentValues.get("dbThroughput")?.value as number;
dbThroughput = enableDbLevelThroughput ? dbThroughput : undefined;
await update(regions, enableLogging, accountName, collectionThroughput, dbThroughput);
return { message: "submitted successfully", type: SelfServeNotificationType.info };
};
/*
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),
@@ -87,24 +129,46 @@ export default class SelfServeExample extends SelfServeBaseClass {
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.
these fields. This is called after the onSave 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.
- returns: () => Promise<Map<string, InputType>>
*/
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);
public initialize = async (): Promise<Map<string, SmartUiInput>> => {
const initializeResponse = await initialize();
const defaults = new Map<string, SmartUiInput>();
defaults.set("regions", { value: initializeResponse.regions });
defaults.set("enableLogging", { value: initializeResponse.enableLogging });
const accountName = initializeResponse.accountName;
defaults.set("accountName", { value: accountName ? accountName : "" });
defaults.set("collectionThroughput", { value: initializeResponse.collectionThroughput });
const enableDbLevelThroughput = !!initializeResponse.dbThroughput;
defaults.set("enableDbLevelThroughput", { value: enableDbLevelThroughput });
defaults.set("dbThroughput", { value: initializeResponse.dbThroughput, hidden: !enableDbLevelThroughput });
return defaults;
};
/*
@Values() :
- input: NumberInputOptions | StringInputOptions | BooleanInputOptions | ChoiceInputOptions | DescriptionDisplay
- role: Specifies the required options to display the property as
a) TextBox for text input
b) Spinner/Slider for number input
c) Radio buton/Toggle for boolean input
d) Dropdown for choice input
e) Text (with optional hyperlink) for descriptions
*/
@Values({
description: {
text: "This class sets collection and database throughput.",
link: {
href: "https://docs.microsoft.com/en-us/azure/cosmos-db/introduction",
text: "Click here for more information",
},
},
})
description: string;
/*
@PropertyInfo()
- optional
@@ -114,11 +178,22 @@ export default class SelfServeExample extends SelfServeBaseClass {
@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.
@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,
say prop1, changes its value in the UI. This can be used to
a) Change the value (and reflect it in the UI) for prop2 based on prop1.
b) Change the visibility for prop2 in the UI, based on prop1
The new Map of propertyName -> value is returned.
In this example, the onRegionsChange function sets the enableLogging property to false (and disables
the corresponsing toggle UI) when "regions" is set to "North Central US", and enables the toggle for
any other value of "regions"
*/
@Values({ label: "Regions", choices: regionDropdownItems })
@OnChange(onRegionsChange)
@Values({ label: "Regions", choices: regionDropdownItems, placeholder: "Select a region" })
regions: ChoiceItem;
@Values({
@@ -134,34 +209,33 @@ export default class SelfServeExample extends SelfServeBaseClass {
})
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,
max: getMaxThroughput,
step: 100,
uiType: UiType.Spinner,
uiType: NumberUiType.Spinner,
})
collectionThroughput: number;
/*
In this example, the onEnableDbLevelThroughputChange function makes the dbThroughput property visible when
enableDbLevelThroughput, a boolean, is set to true and hides dbThroughput property when it is set to false.
*/
@OnChange(onEnableDbLevelThroughputChange)
@Values({
label: "Enable DB level throughput",
trueLabel: "Enable",
falseLabel: "Disable",
})
enableDbLevelThroughput: boolean;
@Values({
label: "Database Throughput",
min: 400,
max: getMaxThroughput,
step: 100,
uiType: NumberUiType.Slider,
})
dbThroughput: number;
}

View File

@@ -1,23 +1,36 @@
import React from "react";
import { shallow } from "enzyme";
import { SelfServeDescriptor, SelfServeComponent, SelfServeComponentState } from "./SelfServeComponent";
import { InputType, UiType } from "../Explorer/Controls/SmartUi/SmartUiComponent";
import { SelfServeComponent, SelfServeComponentState } from "./SelfServeComponent";
import { NumberUiType, SelfServeDescriptor, SelfServeNotificationType, SmartUiInput } from "./SelfServeTypes";
describe("SelfServeComponent", () => {
const defaultValues = new Map<string, InputType>([
["throughput", "450"],
["analyticalStore", "false"],
["database", "db2"],
const defaultValues = new Map<string, SmartUiInput>([
["throughput", { value: 450 }],
["analyticalStore", { value: false }],
["database", { value: "db2" }],
]);
const initializeMock = jest.fn(async () => defaultValues);
const onSubmitMock = jest.fn(async () => {
return;
const updatedValues = new Map<string, SmartUiInput>([
["throughput", { value: 460 }],
["analyticalStore", { value: true }],
["database", { value: "db2" }],
]);
const initializeMock = jest.fn(async () => new Map(defaultValues));
const onSaveMock = jest.fn(async () => {
return { message: "submitted successfully", type: SelfServeNotificationType.info };
});
const onRefreshMock = jest.fn(async () => {
return { isUpdateInProgress: false, notificationMessage: "refresh performed successfully" };
});
const onRefreshIsUpdatingMock = jest.fn(async () => {
return { isUpdateInProgress: true, notificationMessage: "refresh performed successfully" };
});
const exampleData: SelfServeDescriptor = {
initialize: initializeMock,
onSubmit: onSubmitMock,
inputNames: ["throughput", "containerId", "analyticalStore", "database"],
onSave: onSaveMock,
onRefresh: onRefreshMock,
inputNames: ["throughput", "analyticalStore", "database"],
root: {
id: "root",
info: {
@@ -38,7 +51,7 @@ describe("SelfServeComponent", () => {
max: 500,
step: 10,
defaultValue: 400,
uiType: UiType.Spinner,
uiType: NumberUiType.Spinner,
},
},
{
@@ -78,27 +91,109 @@ describe("SelfServeComponent", () => {
},
};
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));
}
const isEqual = (source: Map<string, SmartUiInput>, target: Map<string, SmartUiInput>): void => {
expect(target.size).toEqual(source.size);
for (const key of source.keys()) {
expect(target.get(key)).toEqual(source.get(key));
}
};
it("should render", async () => {
it("should render and honor save, discard, refresh actions", 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);
// initialize() and onRefresh() should be called and defaults should be set when component is mounted
expect(initializeMock).toHaveBeenCalledTimes(1);
expect(onRefreshMock).toHaveBeenCalledTimes(1);
let state = wrapper.state() as SelfServeComponentState;
isEqual(state.currentValues, defaultValues);
// onSubmit() must be called when submit button is clicked
const submitButton = wrapper.find("#submitButton");
submitButton.simulate("click");
expect(onSubmitMock).toHaveBeenCalled();
// when currentValues and baselineValues differ, save and discard should not be disabled
wrapper.setState({ currentValues: updatedValues });
wrapper.update();
state = wrapper.state() as SelfServeComponentState;
isEqual(state.currentValues, updatedValues);
const selfServeComponent = wrapper.instance() as SelfServeComponent;
expect(selfServeComponent.isSaveButtonDisabled()).toBeFalsy();
expect(selfServeComponent.isDiscardButtonDisabled()).toBeFalsy();
// when errors exist, save is disabled but discard is enabled
wrapper.setState({ hasErrors: true });
wrapper.update();
state = wrapper.state() as SelfServeComponentState;
expect(selfServeComponent.isSaveButtonDisabled()).toBeTruthy();
expect(selfServeComponent.isDiscardButtonDisabled()).toBeFalsy();
// discard resets currentValues to baselineValues
selfServeComponent.discard();
state = wrapper.state() as SelfServeComponentState;
isEqual(state.currentValues, defaultValues);
isEqual(state.currentValues, state.baselineValues);
// resetBaselineValues sets baselineValues to currentValues
wrapper.setState({ baselineValues: updatedValues });
wrapper.update();
state = wrapper.state() as SelfServeComponentState;
isEqual(state.baselineValues, updatedValues);
selfServeComponent.resetBaselineValues();
state = wrapper.state() as SelfServeComponentState;
isEqual(state.baselineValues, defaultValues);
isEqual(state.currentValues, state.baselineValues);
// clicking refresh calls onRefresh. If component is not updating, it calls initialize() as well
selfServeComponent.onRefreshClicked();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(onRefreshMock).toHaveBeenCalledTimes(2);
expect(initializeMock).toHaveBeenCalledTimes(2);
selfServeComponent.onSaveButtonClick();
expect(onSaveMock).toHaveBeenCalledTimes(1);
});
it("getResolvedValue", async () => {
const wrapper = shallow(<SelfServeComponent descriptor={exampleData} />);
await new Promise((resolve) => setTimeout(resolve, 0));
const selfServeComponent = wrapper.instance() as SelfServeComponent;
const numberResult = 1;
const numberPromise = async (): Promise<number> => {
return numberResult;
};
expect(await selfServeComponent.getResolvedValue(numberResult)).toEqual(numberResult);
expect(await selfServeComponent.getResolvedValue(numberPromise)).toEqual(numberResult);
const stringResult = "result";
const stringPromise = async (): Promise<string> => {
return stringResult;
};
expect(await selfServeComponent.getResolvedValue(stringResult)).toEqual(stringResult);
expect(await selfServeComponent.getResolvedValue(stringPromise)).toEqual(stringResult);
});
it("message bar and spinner snapshots", async () => {
const newDescriptor = { ...exampleData, onRefresh: onRefreshIsUpdatingMock };
let wrapper = shallow(<SelfServeComponent descriptor={newDescriptor} />);
await new Promise((resolve) => setTimeout(resolve, 0));
let selfServeComponent = wrapper.instance() as SelfServeComponent;
selfServeComponent.onSaveButtonClick();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(wrapper).toMatchSnapshot();
newDescriptor.onRefresh = onRefreshMock;
wrapper = shallow(<SelfServeComponent descriptor={newDescriptor} />);
await new Promise((resolve) => setTimeout(resolve, 0));
selfServeComponent = wrapper.instance() as SelfServeComponent;
selfServeComponent.onSaveButtonClick();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(wrapper).toMatchSnapshot();
wrapper.setState({ isInitializing: true });
wrapper.update();
expect(wrapper).toMatchSnapshot();
wrapper.setState({ compileErrorMessage: "sample error message" });
wrapper.update();
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -1,62 +1,31 @@
import React from "react";
import { IStackTokens, PrimaryButton, Spinner, SpinnerSize, Stack } from "office-ui-fabric-react";
import {
ChoiceItem,
CommandBar,
ICommandBarItemProps,
IStackTokens,
MessageBar,
MessageBarType,
Spinner,
SpinnerSize,
Stack,
} from "office-ui-fabric-react";
import {
AnyDisplay,
Node,
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;
RefreshResult,
SelfServeDescriptor,
SelfServeNotification,
SmartUiInput,
DescriptionDisplay,
StringInput,
NumberInput,
BooleanInput,
ChoiceInput,
SelfServeNotificationType,
} from "./SelfServeTypes";
import { SmartUiComponent, SmartUiDescriptor } from "../Explorer/Controls/SmartUi/SmartUiComponent";
import { getMessageBarType } from "./SelfServeUtils";
export interface SelfServeComponentProps {
descriptor: SelfServeDescriptor;
@@ -64,13 +33,18 @@ export interface SelfServeComponentProps {
export interface SelfServeComponentState {
root: SelfServeDescriptor;
currentValues: Map<string, InputType>;
baselineValues: Map<string, InputType>;
isRefreshing: boolean;
currentValues: Map<string, SmartUiInput>;
baselineValues: Map<string, SmartUiInput>;
isInitializing: boolean;
hasErrors: boolean;
compileErrorMessage: string;
notification: SelfServeNotification;
refreshResult: RefreshResult;
}
export class SelfServeComponent extends React.Component<SelfServeComponentProps, SelfServeComponentState> {
componentDidMount(): void {
this.performRefresh();
this.initializeSmartUiComponent();
}
@@ -80,62 +54,108 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
root: this.props.descriptor,
currentValues: new Map(),
baselineValues: new Map(),
isRefreshing: false,
isInitializing: true,
hasErrors: false,
compileErrorMessage: undefined,
notification: undefined,
refreshResult: undefined,
};
}
private onError = (hasErrors: boolean): void => {
this.setState({ hasErrors });
};
private initializeSmartUiComponent = async (): Promise<void> => {
this.setState({ isRefreshing: true });
await this.initializeSmartUiNode(this.props.descriptor.root);
this.setState({ isInitializing: true });
await this.setDefaults();
this.setState({ isRefreshing: false });
const { currentValues, baselineValues } = this.state;
await this.initializeSmartUiNode(this.props.descriptor.root, currentValues, baselineValues);
this.setState({ isInitializing: false, currentValues, baselineValues });
};
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.`);
this.props.descriptor.inputNames.map((inputName) => {
let initialValue = initialValues.get(inputName);
if (!initialValue) {
initialValue = { value: undefined, hidden: false };
}
currentValues = currentValues.set(inputName, initialValue);
baselineValues = baselineValues.set(inputName, initialValue);
initialValues.delete(inputName);
});
if (initialValues.size > 0) {
const keys = [];
for (const key of initialValues.keys()) {
keys.push(key);
}
currentValues = currentValues.set(key, initialValues.get(key));
baselineValues = baselineValues.set(key, initialValues.get(key));
this.setState({
compileErrorMessage: `The following fields have default values set but are not input properties of this class: ${keys.join(
", "
)}`,
});
}
this.setState({ currentValues, baselineValues, isRefreshing: false });
this.setState({ currentValues, baselineValues });
};
public resetBaselineValues = (): void => {
const currentValues = this.state.currentValues;
let baselineValues = this.state.baselineValues;
for (const key of currentValues.keys()) {
const currentValue = currentValues.get(key);
baselineValues = baselineValues.set(key, { ...currentValue });
}
this.setState({ baselineValues });
};
public discard = (): void => {
let { currentValues } = this.state;
const { baselineValues } = this.state;
for (const key of baselineValues.keys()) {
currentValues = currentValues.set(key, baselineValues.get(key));
for (const key of currentValues.keys()) {
const baselineValue = baselineValues.get(key);
currentValues = currentValues.set(key, { ...baselineValue });
}
this.setState({ currentValues });
};
private initializeSmartUiNode = async (currentNode: Node): Promise<void> => {
private initializeSmartUiNode = async (
currentNode: Node,
currentValues: Map<string, SmartUiInput>,
baselineValues: Map<string, SmartUiInput>
): Promise<void> => {
currentNode.info = await this.getResolvedValue(currentNode.info);
if (currentNode.input) {
currentNode.input = await this.getResolvedInput(currentNode.input);
currentNode.input = await this.getResolvedInput(currentNode.input, currentValues, baselineValues);
}
const promises = currentNode.children?.map(async (child: Node) => await this.initializeSmartUiNode(child));
const promises = currentNode.children?.map(
async (child: Node) => await this.initializeSmartUiNode(child, currentValues, baselineValues)
);
if (promises) {
await Promise.all(promises);
}
};
private getResolvedInput = async (input: AnyInput): Promise<AnyInput> => {
private getResolvedInput = async (
input: AnyDisplay,
currentValues: Map<string, SmartUiInput>,
baselineValues: Map<string, SmartUiInput>
): Promise<AnyDisplay> => {
input.label = await this.getResolvedValue(input.label);
input.placeholder = await this.getResolvedValue(input.placeholder);
switch (input.type) {
case "string": {
if ("description" in input) {
const descriptionDisplay = input as DescriptionDisplay;
descriptionDisplay.description = await this.getResolvedValue(descriptionDisplay.description);
}
return input as StringInput;
}
case "number": {
@@ -143,6 +163,16 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
numberInput.min = await this.getResolvedValue(numberInput.min);
numberInput.max = await this.getResolvedValue(numberInput.max);
numberInput.step = await this.getResolvedValue(numberInput.step);
const dataFieldName = numberInput.dataFieldName;
const defaultValue = currentValues.get(dataFieldName)?.value;
if (!defaultValue) {
const newDefaultValue = { value: numberInput.min, hidden: currentValues.get(dataFieldName)?.hidden };
currentValues.set(dataFieldName, newDefaultValue);
baselineValues.set(dataFieldName, newDefaultValue);
}
return numberInput;
}
case "boolean": {
@@ -166,53 +196,157 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
return value;
}
private onInputChange = (input: AnyInput, newValue: InputType) => {
private onInputChange = (input: AnyDisplay, 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);
const currentInputValue = currentValues.get(dataFieldName);
currentValues.set(dataFieldName, { value: newValue, hidden: currentInputValue?.hidden });
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}
/>
public onSaveButtonClick = (): void => {
const onSavePromise = this.props.descriptor.onSave(this.state.currentValues);
onSavePromise.catch((error) => {
this.setState({
notification: {
message: `Error: ${error.message}`,
type: SelfServeNotificationType.error,
},
});
});
onSavePromise.then((notification: SelfServeNotification) => {
this.setState({
notification: {
message: notification.message,
type: notification.type,
},
});
this.resetBaselineValues();
this.onRefreshClicked();
});
};
<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();
}}
public isDiscardButtonDisabled = (): boolean => {
for (const key of this.state.currentValues.keys()) {
const currentValue = JSON.stringify(this.state.currentValues.get(key));
const baselineValue = JSON.stringify(this.state.baselineValues.get(key));
if (currentValue !== baselineValue) {
return false;
}
}
return true;
};
public isSaveButtonDisabled = (): boolean => {
if (this.state.hasErrors) {
return true;
}
for (const key of this.state.currentValues.keys()) {
const currentValue = JSON.stringify(this.state.currentValues.get(key));
const baselineValue = JSON.stringify(this.state.baselineValues.get(key));
if (currentValue !== baselineValue) {
return false;
}
}
return true;
};
private performRefresh = async (): Promise<RefreshResult> => {
const refreshResult = await this.props.descriptor.onRefresh();
this.setState({ refreshResult: { ...refreshResult } });
return refreshResult;
};
public onRefreshClicked = async (): Promise<void> => {
this.setState({ isInitializing: true });
const refreshResult = await this.performRefresh();
if (!refreshResult.isUpdateInProgress) {
this.initializeSmartUiComponent();
}
this.setState({ isInitializing: false });
};
private getCommandBarItems = (): ICommandBarItemProps[] => {
return [
{
key: "save",
text: "Save",
iconProps: { iconName: "Save" },
split: true,
disabled: this.isSaveButtonDisabled(),
onClick: this.onSaveButtonClick,
},
{
key: "discard",
text: "Discard",
iconProps: { iconName: "Undo" },
split: true,
disabled: this.isDiscardButtonDisabled(),
onClick: () => {
this.discard();
},
},
{
key: "refresh",
text: "Refresh",
disabled: this.state.isInitializing,
iconProps: { iconName: "Refresh" },
split: true,
onClick: () => {
this.onRefreshClicked();
},
},
];
};
public render(): JSX.Element {
const containerStackTokens: IStackTokens = { childrenGap: 5 };
if (this.state.compileErrorMessage) {
return <MessageBar messageBarType={MessageBarType.error}>{this.state.compileErrorMessage}</MessageBar>;
}
return (
<div style={{ overflowX: "auto" }}>
<Stack tokens={containerStackTokens} styles={{ root: { padding: 10 } }}>
<CommandBar styles={{ root: { paddingLeft: 0 } }} items={this.getCommandBarItems()} />
{this.state.isInitializing ? (
<Spinner
size={SpinnerSize.large}
styles={{ root: { textAlign: "center", justifyContent: "center", width: "100%", height: "100%" } }}
/>
<PrimaryButton
id="discardButton"
styles={{ root: { width: 100 } }}
text="discard"
onClick={() => this.discard()}
/>
</Stack>
) : (
<>
{this.state.refreshResult?.isUpdateInProgress && (
<MessageBar messageBarType={MessageBarType.info} styles={{ root: { width: 400 } }}>
{this.state.refreshResult.notificationMessage}
</MessageBar>
)}
{this.state.notification && (
<MessageBar
messageBarType={getMessageBarType(this.state.notification.type)}
styles={{ root: { width: 400 } }}
onDismiss={() => this.setState({ notification: undefined })}
>
{this.state.notification.message}
</MessageBar>
)}
<SmartUiComponent
disabled={this.state.refreshResult?.isUpdateInProgress}
descriptor={this.state.root as SmartUiDescriptor}
currentValues={this.state.currentValues}
onInputChange={this.onInputChange}
onError={this.onError}
/>
</>
)}
</Stack>
</div>
) : (
<Spinner
size={SpinnerSize.large}
styles={{ root: { textAlign: "center", justifyContent: "center", width: "100%", height: "100%" } }}
/>
);
}
}

View File

@@ -7,7 +7,8 @@ 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 { SelfServeComponent } from "./SelfServeComponent";
import { SelfServeDescriptor } from "./SelfServeTypes";
import { SelfServeType } from "./SelfServeUtils";
export class SelfServeComponentAdapter implements ReactAdapter {
@@ -28,6 +29,10 @@ export class SelfServeComponentAdapter implements ReactAdapter {
const SelfServeExample = await import(/* webpackChunkName: "SelfServeExample" */ "./Example/SelfServeExample");
return new SelfServeExample.default().toSelfServeDescriptor();
}
case SelfServeType.sqlx: {
const SqlX = await import(/* webpackChunkName: "SqlX" */ "./SqlX/SqlX");
return new SqlX.default().toSelfServeDescriptor();
}
default:
return undefined;
}

View File

@@ -0,0 +1,130 @@
interface BaseInput {
dataFieldName: string;
errorMessage?: string;
type: InputTypeValue;
label?: (() => Promise<string>) | string;
onChange?: (currentState: Map<string, SmartUiInput>, newValue: InputType) => Map<string, SmartUiInput>;
placeholder?: (() => Promise<string>) | string;
}
export interface NumberInput extends BaseInput {
min: (() => Promise<number>) | number;
max: (() => Promise<number>) | number;
step: (() => Promise<number>) | number;
defaultValue?: number;
uiType: NumberUiType;
}
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 DescriptionDisplay extends BaseInput {
description: (() => Promise<Description>) | Description;
}
export interface Node {
id: string;
info?: (() => Promise<Info>) | Info;
input?: AnyDisplay;
children?: Node[];
}
export interface SelfServeDescriptor {
root: Node;
initialize?: () => Promise<Map<string, SmartUiInput>>;
onSave?: (currentValues: Map<string, SmartUiInput>) => Promise<SelfServeNotification>;
inputNames?: string[];
onRefresh?: () => Promise<RefreshResult>;
}
export type AnyDisplay = NumberInput | BooleanInput | StringInput | ChoiceInput | DescriptionDisplay;
export abstract class SelfServeBaseClass {
public abstract initialize: () => Promise<Map<string, SmartUiInput>>;
public abstract onSave: (currentValues: Map<string, SmartUiInput>) => Promise<SelfServeNotification>;
public abstract onRefresh: () => Promise<RefreshResult>;
public toSelfServeDescriptor(): SelfServeDescriptor {
const className = this.constructor.name;
const selfServeDescriptor = Reflect.getMetadata(className, this) as SelfServeDescriptor;
if (!this.initialize) {
throw new Error(`initialize() was not declared for the class '${className}'`);
}
if (!this.onSave) {
throw new Error(`onSave() was not declared for the class '${className}'`);
}
if (!this.onRefresh) {
throw new Error(`onRefresh() was not declared for the class '${className}'`);
}
if (!selfServeDescriptor?.root) {
throw new Error(`@SmartUi decorator was not declared for the class '${className}'`);
}
selfServeDescriptor.initialize = this.initialize;
selfServeDescriptor.onSave = this.onSave;
selfServeDescriptor.onRefresh = this.onRefresh;
return selfServeDescriptor;
}
}
export type InputTypeValue = "number" | "string" | "boolean" | "object";
export enum NumberUiType {
Spinner = "Spinner",
Slider = "Slider",
}
export type ChoiceItem = { label: string; key: string };
export type InputType = number | string | boolean | ChoiceItem;
export interface Info {
message: string;
link?: {
href: string;
text: string;
};
}
export interface Description {
text: string;
link?: {
href: string;
text: string;
};
}
export interface SmartUiInput {
value: InputType;
hidden?: boolean;
disabled?: boolean;
}
export enum SelfServeNotificationType {
info = "info",
warning = "warning",
error = "error",
}
export interface SelfServeNotification {
message: string;
type: SelfServeNotificationType;
}
export interface RefreshResult {
isUpdateInProgress: boolean;
notificationMessage: string;
}

View File

@@ -1,40 +1,39 @@
import {
CommonInputTypes,
mapToSmartUiDescriptor,
SelfServeBaseClass,
updateContextWithDecorator,
} from "./SelfServeUtils";
import { InputType, UiType } from "./../Explorer/Controls/SmartUi/SmartUiComponent";
import { NumberUiType, RefreshResult, SelfServeBaseClass, SelfServeNotification, SmartUiInput } from "./SelfServeTypes";
import { DecoratorProperties, mapToSmartUiDescriptor, updateContextWithDecorator } from "./SelfServeUtils";
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>>;
public initialize: () => Promise<Map<string, SmartUiInput>>;
public onSave: (currentValues: Map<string, SmartUiInput>) => Promise<SelfServeNotification>;
public onRefresh: () => Promise<RefreshResult>;
}
expect(() => new Test().toSelfServeDescriptor()).toThrow("initialize() was not declared for the class 'Test'");
});
it("onSubmit should be declared for self serve classes", () => {
it("onSave should be declared for self serve classes", () => {
class Test extends SelfServeBaseClass {
public onSubmit: () => Promise<void>;
public initialize = async (): Promise<Map<string, InputType>> => {
return undefined;
};
public initialize = jest.fn();
public onSave: () => Promise<SelfServeNotification>;
public onRefresh: () => Promise<RefreshResult>;
}
expect(() => new Test().toSelfServeDescriptor()).toThrow("onSubmit() was not declared for the class 'Test'");
expect(() => new Test().toSelfServeDescriptor()).toThrow("onSave() was not declared for the class 'Test'");
});
it("onRefresh should be declared for self serve classes", () => {
class Test extends SelfServeBaseClass {
public initialize = jest.fn();
public onSave = jest.fn();
public onRefresh: () => Promise<RefreshResult>;
}
expect(() => new Test().toSelfServeDescriptor()).toThrow("onRefresh() 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;
};
public initialize = jest.fn();
public onSave = jest.fn();
public onRefresh = jest.fn();
}
expect(() => new Test().toSelfServeDescriptor()).toThrow(
"@SmartUi decorator was not declared for the class 'Test'"
@@ -42,7 +41,7 @@ describe("SelfServeUtils", () => {
});
it("updateContextWithDecorator", () => {
const context = new Map<string, CommonInputTypes>();
const context = new Map<string, DecoratorProperties>();
updateContextWithDecorator(context, "dbThroughput", "testClass", "max", 1);
updateContextWithDecorator(context, "dbThroughput", "testClass", "min", 2);
updateContextWithDecorator(context, "collThroughput", "testClass", "max", 5);
@@ -52,7 +51,7 @@ describe("SelfServeUtils", () => {
});
it("mapToSmartUiDescriptor", () => {
const context: Map<string, CommonInputTypes> = new Map([
const context: Map<string, DecoratorProperties> = new Map([
[
"dbThroughput",
{
@@ -63,7 +62,7 @@ describe("SelfServeUtils", () => {
min: 1,
max: 5,
step: 1,
uiType: UiType.Slider,
uiType: NumberUiType.Slider,
},
],
[
@@ -76,7 +75,7 @@ describe("SelfServeUtils", () => {
min: 1,
max: 5,
step: 1,
uiType: UiType.Spinner,
uiType: NumberUiType.Spinner,
},
],
[
@@ -89,7 +88,7 @@ describe("SelfServeUtils", () => {
min: 1,
max: 5,
step: 1,
uiType: UiType.Spinner,
uiType: NumberUiType.Spinner,
errorMessage: "label, truelabel and falselabel are required for boolean input",
},
],

View File

@@ -1,14 +1,22 @@
import { MessageBarType } from "office-ui-fabric-react";
import "reflect-metadata";
import { ChoiceItem, Info, InputTypeValue, InputType } from "../Explorer/Controls/SmartUi/SmartUiComponent";
import {
Node,
AnyDisplay,
BooleanInput,
ChoiceInput,
SelfServeDescriptor,
ChoiceItem,
Description,
DescriptionDisplay,
Info,
InputType,
InputTypeValue,
NumberInput,
SelfServeDescriptor,
SmartUiInput,
StringInput,
Node,
AnyInput,
} from "./SelfServeComponent";
SelfServeNotificationType,
} from "./SelfServeTypes";
export enum SelfServeType {
// No self serve type passed, launch explorer
@@ -17,33 +25,10 @@ export enum SelfServeType {
invalid = "invalid",
// Add your self serve types here
example = "example",
sqlx = "sqlx",
}
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 {
export interface DecoratorProperties {
id: string;
info?: (() => Promise<Info>) | Info;
type?: InputTypeValue;
@@ -58,41 +43,43 @@ export interface CommonInputTypes {
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>>;
description?: (() => Promise<Description>) | Description;
onChange?: (currentState: Map<string, SmartUiInput>, newValue: InputType) => Map<string, SmartUiInput>;
onSave?: (currentValues: Map<string, SmartUiInput>) => Promise<void>;
initialize?: () => Promise<Map<string, SmartUiInput>>;
}
const setValue = <T extends keyof CommonInputTypes, K extends CommonInputTypes[T]>(
const setValue = <T extends keyof DecoratorProperties, K extends DecoratorProperties[T]>(
name: T,
value: K,
fieldObject: CommonInputTypes
fieldObject: DecoratorProperties
): void => {
fieldObject[name] = value;
};
const getValue = <T extends keyof CommonInputTypes>(name: T, fieldObject: CommonInputTypes): unknown => {
const getValue = <T extends keyof DecoratorProperties>(name: T, fieldObject: DecoratorProperties): unknown => {
return fieldObject[name];
};
export const addPropertyToMap = <T extends keyof CommonInputTypes, K extends CommonInputTypes[T]>(
export const addPropertyToMap = <T extends keyof DecoratorProperties, K extends DecoratorProperties[T]>(
target: unknown,
propertyName: string,
className: string,
descriptorName: keyof CommonInputTypes,
descriptorName: keyof DecoratorProperties,
descriptorValue: K
): void => {
const context =
(Reflect.getMetadata(className, target) as Map<string, CommonInputTypes>) ?? new Map<string, CommonInputTypes>();
(Reflect.getMetadata(className, target) as Map<string, DecoratorProperties>) ??
new Map<string, DecoratorProperties>();
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>,
export const updateContextWithDecorator = <T extends keyof DecoratorProperties, K extends DecoratorProperties[T]>(
context: Map<string, DecoratorProperties>,
propertyName: string,
className: string,
descriptorName: keyof CommonInputTypes,
descriptorName: keyof DecoratorProperties,
descriptorValue: K
): void => {
if (!(context instanceof Map)) {
@@ -112,12 +99,12 @@ export const updateContextWithDecorator = <T extends keyof CommonInputTypes, K e
};
export const buildSmartUiDescriptor = (className: string, target: unknown): void => {
const context = Reflect.getMetadata(className, target) as Map<string, CommonInputTypes>;
const context = Reflect.getMetadata(className, target) as Map<string, DecoratorProperties>;
const smartUiDescriptor = mapToSmartUiDescriptor(context);
Reflect.defineMetadata(className, smartUiDescriptor, target);
};
export const mapToSmartUiDescriptor = (context: Map<string, CommonInputTypes>): SelfServeDescriptor => {
export const mapToSmartUiDescriptor = (context: Map<string, DecoratorProperties>): SelfServeDescriptor => {
const root = context.get("root");
context.delete("root");
const inputNames: string[] = [];
@@ -140,7 +127,7 @@ export const mapToSmartUiDescriptor = (context: Map<string, CommonInputTypes>):
};
const addToDescriptor = (
context: Map<string, CommonInputTypes>,
context: Map<string, DecoratorProperties>,
root: Node,
key: string,
inputNames: string[]
@@ -157,7 +144,7 @@ const addToDescriptor = (
root.children.push(element);
};
const getInput = (value: CommonInputTypes): AnyInput => {
const getInput = (value: DecoratorProperties): AnyDisplay => {
switch (value.type) {
case "number":
if (!value.label || !value.step || !value.uiType || !value.min || !value.max) {
@@ -165,6 +152,9 @@ const getInput = (value: CommonInputTypes): AnyInput => {
}
return value as NumberInput;
case "string":
if (value.description) {
return value as DescriptionDisplay;
}
if (!value.label) {
value.errorMessage = `label is required for string input '${value.id}'.`;
}
@@ -181,3 +171,14 @@ const getInput = (value: CommonInputTypes): AnyInput => {
return value as ChoiceInput;
}
};
export const getMessageBarType = (type: SelfServeNotificationType): MessageBarType => {
switch (type) {
case SelfServeNotificationType.info:
return MessageBarType.info;
case SelfServeNotificationType.warning:
return MessageBarType.warning;
case SelfServeNotificationType.error:
return MessageBarType.error;
}
};

View File

@@ -0,0 +1,33 @@
import { RefreshResult } from "../SelfServeTypes";
export interface DedicatedGatewayResponse {
sku: string;
instances: number;
}
export const getRegionSpecificMinInstances = async (): Promise<number> => {
// TODO: write RP call to get min number of instances needed for this region
throw new Error("getRegionSpecificMinInstances not implemented");
};
export const getRegionSpecificMaxInstances = async (): Promise<number> => {
// TODO: write RP call to get max number of instances needed for this region
throw new Error("getRegionSpecificMaxInstances not implemented");
};
export const updateDedicatedGatewayProvisioning = async (sku: string, instances: number): Promise<void> => {
// TODO: write RP call to update dedicated gateway provisioning
throw new Error(
`updateDedicatedGatewayProvisioning not implemented. Parameters- sku: ${sku}, instances:${instances}`
);
};
export const initializeDedicatedGatewayProvisioning = async (): Promise<DedicatedGatewayResponse> => {
// TODO: write RP call to initialize UI for dedicated gateway provisioning
throw new Error("initializeDedicatedGatewayProvisioning not implemented");
};
export const refreshDedicatedGatewayProvisioning = async (): Promise<RefreshResult> => {
// TODO: write RP call to check if dedicated gateway update has gone through
throw new Error("refreshDedicatedGatewayProvisioning not implemented");
};

View File

@@ -0,0 +1,97 @@
import { IsDisplayable, OnChange, Values } from "../Decorators";
import {
ChoiceItem,
InputType,
NumberUiType,
RefreshResult,
SelfServeBaseClass,
SelfServeNotification,
SmartUiInput,
} from "../SelfServeTypes";
import { refreshDedicatedGatewayProvisioning } from "./SqlX.rp";
const onEnableDedicatedGatewayChange = (
currentState: Map<string, SmartUiInput>,
newValue: InputType
): Map<string, SmartUiInput> => {
const sku = currentState.get("sku");
const instances = currentState.get("instances");
const isSkuHidden = newValue === undefined || !(newValue as boolean);
currentState.set("enableDedicatedGateway", { value: newValue });
currentState.set("sku", { value: sku.value, hidden: isSkuHidden });
currentState.set("instances", { value: instances.value, hidden: isSkuHidden });
return currentState;
};
const getSkus = async (): Promise<ChoiceItem[]> => {
// TODO: get SKUs from getRegionSpecificSkus() RP call and return array of {label:..., key:...}.
throw new Error("getSkus not implemented.");
};
const getInstancesMin = async (): Promise<number> => {
// TODO: get SKUs from getRegionSpecificSkus() RP call and return array of {label:..., key:...}.
throw new Error("getInstancesMin not implemented.");
};
const getInstancesMax = async (): Promise<number> => {
// TODO: get SKUs from getRegionSpecificSkus() RP call and return array of {label:..., key:...}.
throw new Error("getInstancesMax not implemented.");
};
const validate = (currentValues: Map<string, SmartUiInput>): void => {
// TODO: add cusom validation logic to be called before Saving the data.
throw new Error(`validate not implemented. No. of properties to validate: ${currentValues.size}`);
};
@IsDisplayable()
export default class SqlX extends SelfServeBaseClass {
public onRefresh = async (): Promise<RefreshResult> => {
return refreshDedicatedGatewayProvisioning();
};
public onSave = async (currentValues: Map<string, SmartUiInput>): Promise<SelfServeNotification> => {
validate(currentValues);
// TODO: add pre processing logic before calling the updateDedicatedGatewayProvisioning() RP call.
throw new Error(`onSave not implemented. No. of properties to save: ${currentValues.size}`);
};
public initialize = async (): Promise<Map<string, SmartUiInput>> => {
// TODO: get initialization data from initializeDedicatedGatewayProvisioning() RP call.
throw new Error("onSave not implemented");
};
@Values({
description: {
text: "Provisioning dedicated gateways for SqlX accounts.",
link: {
href: "https://docs.microsoft.com/en-us/azure/cosmos-db/introduction",
text: "Learn more about dedicated gateway.",
},
},
})
description: string;
@OnChange(onEnableDedicatedGatewayChange)
@Values({
label: "Dedicated Gateway",
trueLabel: "Enable",
falseLabel: "Disable",
})
enableDedicatedGateway: boolean;
@Values({
label: "SKUs",
choices: getSkus,
placeholder: "Select SKUs",
})
sku: ChoiceItem;
@Values({
label: "Number of instances",
min: getInstancesMin,
max: getInstancesMax,
step: 1,
uiType: NumberUiType.Spinner,
})
instances: number;
}

View File

@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SelfServeComponent should render 1`] = `
exports[`SelfServeComponent message bar and spinner snapshots 1`] = `
<div
style={
Object {
@@ -13,22 +13,95 @@ exports[`SelfServeComponent should render 1`] = `
Object {
"root": Object {
"padding": 10,
"width": 400,
},
}
}
tokens={
Object {
"childrenGap": 20,
"childrenGap": 5,
}
}
>
<StyledCommandBarBase
items={
Array [
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Save",
},
"key": "save",
"onClick": [Function],
"split": true,
"text": "Save",
},
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Undo",
},
"key": "discard",
"onClick": [Function],
"split": true,
"text": "Discard",
},
Object {
"disabled": false,
"iconProps": Object {
"iconName": "Refresh",
},
"key": "refresh",
"onClick": [Function],
"split": true,
"text": "Refresh",
},
]
}
styles={
Object {
"root": Object {
"paddingLeft": 0,
},
}
}
/>
<StyledMessageBarBase
messageBarType={0}
styles={
Object {
"root": Object {
"width": 400,
},
}
}
>
refresh performed successfully
</StyledMessageBarBase>
<StyledMessageBarBase
messageBarType={0}
onDismiss={[Function]}
styles={
Object {
"root": Object {
"width": 400,
},
}
}
>
submitted successfully
</StyledMessageBarBase>
<SmartUiComponent
currentValues={
Map {
"throughput" => "450",
"analyticalStore" => "false",
"database" => "db2",
"throughput" => Object {
"value": 450,
},
"analyticalStore" => Object {
"value": false,
},
"database" => Object {
"value": "db2",
},
}
}
descriptor={
@@ -36,21 +109,95 @@ exports[`SelfServeComponent should render 1`] = `
"initialize": [MockFunction] {
"calls": Array [
Array [],
Array [],
Array [],
Array [],
Array [],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
],
},
"inputNames": Array [
"throughput",
"containerId",
"analyticalStore",
"database",
],
"onSubmit": [MockFunction],
"onRefresh": [MockFunction] {
"calls": Array [
Array [],
Array [],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
],
},
"onSave": [MockFunction] {
"calls": Array [
Array [
Map {
"throughput" => Object {
"value": 450,
},
"analyticalStore" => Object {
"value": false,
},
"database" => Object {
"value": "db2",
},
},
],
Array [
Map {
"throughput" => Object {
"value": 450,
},
"analyticalStore" => Object {
"value": false,
},
"database" => Object {
"value": "db2",
},
},
],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
],
},
"root": Object {
"children": Array [
Object {
@@ -128,41 +275,612 @@ exports[`SelfServeComponent should render 1`] = `
},
}
}
disabled={true}
onError={[Function]}
onInputChange={[Function]}
/>
</Stack>
</div>
`;
exports[`SelfServeComponent message bar and spinner snapshots 2`] = `
<div
style={
Object {
"overflowX": "auto",
}
}
>
<Stack
styles={
Object {
"root": Object {
"padding": 10,
},
}
}
tokens={
Object {
"childrenGap": 5,
}
}
>
<StyledCommandBarBase
items={
Array [
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Save",
},
"key": "save",
"onClick": [Function],
"split": true,
"text": "Save",
},
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Undo",
},
"key": "discard",
"onClick": [Function],
"split": true,
"text": "Discard",
},
Object {
"disabled": false,
"iconProps": Object {
"iconName": "Refresh",
},
"key": "refresh",
"onClick": [Function],
"split": true,
"text": "Refresh",
},
]
}
styles={
Object {
"root": Object {
"paddingLeft": 0,
},
}
}
/>
<StyledMessageBarBase
messageBarType={0}
onDismiss={[Function]}
styles={
Object {
"root": Object {
"width": 400,
},
}
}
>
submitted successfully
</StyledMessageBarBase>
<SmartUiComponent
currentValues={
Map {
"throughput" => Object {
"value": 450,
},
"analyticalStore" => Object {
"value": false,
},
"database" => Object {
"value": "db2",
},
}
}
descriptor={
Object {
"initialize": [MockFunction] {
"calls": Array [
Array [],
Array [],
Array [],
Array [],
Array [],
Array [],
Array [],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
],
},
"inputNames": Array [
"throughput",
"analyticalStore",
"database",
],
"onRefresh": [MockFunction] {
"calls": Array [
Array [],
Array [],
Array [],
Array [],
Array [],
Array [],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
],
},
"onSave": [MockFunction] {
"calls": Array [
Array [
Map {
"throughput" => Object {
"value": 450,
},
"analyticalStore" => Object {
"value": false,
},
"database" => Object {
"value": "db2",
},
},
],
Array [
Map {
"throughput" => Object {
"value": 450,
},
"analyticalStore" => Object {
"value": false,
},
"database" => Object {
"value": "db2",
},
},
],
Array [
Map {
"throughput" => Object {
"value": 450,
},
"analyticalStore" => Object {
"value": false,
},
"database" => Object {
"value": "db2",
},
},
],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
],
},
"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",
},
},
}
}
disabled={false}
onError={[Function]}
onInputChange={[Function]}
/>
</Stack>
</div>
`;
exports[`SelfServeComponent message bar and spinner snapshots 3`] = `
<div
style={
Object {
"overflowX": "auto",
}
}
>
<Stack
styles={
Object {
"root": Object {
"padding": 10,
},
}
}
tokens={
Object {
"childrenGap": 5,
}
}
>
<StyledCommandBarBase
items={
Array [
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Save",
},
"key": "save",
"onClick": [Function],
"split": true,
"text": "Save",
},
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Undo",
},
"key": "discard",
"onClick": [Function],
"split": true,
"text": "Discard",
},
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Refresh",
},
"key": "refresh",
"onClick": [Function],
"split": true,
"text": "Refresh",
},
]
}
styles={
Object {
"root": Object {
"paddingLeft": 0,
},
}
}
/>
<StyledSpinnerBase
size={3}
styles={
Object {
"root": Object {
"height": "100%",
"justifyContent": "center",
"textAlign": "center",
"width": "100%",
},
}
}
/>
</Stack>
</div>
`;
exports[`SelfServeComponent message bar and spinner snapshots 4`] = `
<StyledMessageBarBase
messageBarType={1}
>
sample error message
</StyledMessageBarBase>
`;
exports[`SelfServeComponent should render and honor save, discard, refresh actions 1`] = `
<div
style={
Object {
"overflowX": "auto",
}
}
>
<Stack
styles={
Object {
"root": Object {
"padding": 10,
},
}
}
tokens={
Object {
"childrenGap": 5,
}
}
>
<StyledCommandBarBase
items={
Array [
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Save",
},
"key": "save",
"onClick": [Function],
"split": true,
"text": "Save",
},
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Undo",
},
"key": "discard",
"onClick": [Function],
"split": true,
"text": "Discard",
},
Object {
"disabled": false,
"iconProps": Object {
"iconName": "Refresh",
},
"key": "refresh",
"onClick": [Function],
"split": true,
"text": "Refresh",
},
]
}
styles={
Object {
"root": Object {
"paddingLeft": 0,
},
}
}
/>
<SmartUiComponent
currentValues={
Map {
"throughput" => Object {
"value": 450,
},
"analyticalStore" => Object {
"value": false,
},
"database" => Object {
"value": "db2",
},
}
}
descriptor={
Object {
"initialize": [MockFunction] {
"calls": Array [
Array [],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
],
},
"inputNames": Array [
"throughput",
"analyticalStore",
"database",
],
"onRefresh": [MockFunction] {
"calls": Array [
Array [],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
],
},
"onSave": [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",
},
},
}
}
disabled={false}
onError={[Function]}
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>
`;

View File

@@ -19,14 +19,13 @@ export function logConsoleMessage(type: ConsoleDataType, message: string, id?: s
if (!id) {
id = _.uniqueId();
}
dataExplorer.logConsoleData({ type: type, date: formattedDate, message: message, id: id });
dataExplorer.logConsoleData({ type, date: formattedDate, message, id });
}
return id || "";
}
export function clearInProgressMessageWithId(id: string): void {
const dataExplorer = _global.dataExplorer;
dataExplorer && dataExplorer.deleteInProgressConsoleDataWithId(id);
_global.dataExplorer?.deleteInProgressConsoleDataWithId(id);
}
export function logConsoleProgress(message: string): () => void {

View File

@@ -1,5 +1,4 @@
import { BindingHandlersRegisterer } from "./Bindings/BindingHandlersRegisterer";
import { sendMessage } from "./Common/MessageHandler";
import * as ko from "knockout";
import Explorer from "./Explorer/Explorer";
@@ -10,7 +9,6 @@ export const applyExplorerBindings = (explorer: Explorer) => {
ko.applyBindings(explorer);
// This message should ideally be sent immediately after explorer has been initialized for optimal data explorer load times.
// TODO: Send another message to describe that the bindings have been applied, and handle message transfers accordingly in the portal
sendMessage("ready");
$("#divExplorer").show();
}
};

14
src/hooks/useConfig.ts Normal file
View File

@@ -0,0 +1,14 @@
import { useEffect, useState } from "react";
import { ConfigContext, initializeConfiguration } from "../ConfigContext";
// This hook initializes global configuration from a config.json file that is injected at deploy time
// This allows the same main Data Explorer build to be exactly the same in all clouds/platforms,
// but override some of the configuration as nesssary
export function useConfig(): Readonly<ConfigContext> {
const [state, setState] = useState<ConfigContext>();
useEffect(() => {
initializeConfiguration().then((response) => setState(response));
}, []);
return state;
}

View File

@@ -0,0 +1,298 @@
import { useEffect } from "react";
import { applyExplorerBindings } from "../applyExplorerBindings";
import { AuthType } from "../AuthType";
import { AccountKind, DefaultAccountExperience, ServerIds } from "../Common/Constants";
import { sendMessage } from "../Common/MessageHandler";
import { configContext, ConfigContext, Platform } from "../ConfigContext";
import { ActionType, DataExplorerAction } from "../Contracts/ActionContracts";
import { MessageTypes } from "../Contracts/ExplorerContracts";
import { DataExplorerInputsFrame } from "../Contracts/ViewModels";
import Explorer, { ExplorerParams } from "../Explorer/Explorer";
import {
AAD,
ConnectionString,
EncryptedToken,
HostedExplorerChildFrame,
ResourceToken,
} from "../HostedExplorerChildFrame";
import { emulatorAccount } from "../Platform/Emulator/emulatorAccount";
import { extractFeatures } from "../Platform/Hosted/extractFeatures";
import { parseResourceTokenConnectionString } from "../Platform/Hosted/Helpers/ResourceTokenUtils";
import {
getDatabaseAccountKindFromExperience,
getDatabaseAccountPropertiesFromMetadata,
} from "../Platform/Hosted/HostedUtils";
import { SelfServeType } from "../SelfServe/SelfServeUtils";
import { CollectionCreation } from "../Shared/Constants";
import { DefaultExperienceUtility } from "../Shared/DefaultExperienceUtility";
import { updateUserContext } from "../UserContext";
import { listKeys } from "../Utils/arm/generatedClients/2020-04-01/databaseAccounts";
import { isInvalidParentFrameOrigin } from "../Utils/MessageValidation";
// This hook will create a new instance of Explorer.ts and bind it to the DOM
// This hook has a LOT of magic, but ideally we can delete it once we have removed KO and switched entirely to React
// Pleas tread carefully :)
let explorer: Explorer;
export function useKnockoutExplorer(config: ConfigContext, explorerParams: ExplorerParams): Explorer {
explorer = explorer || new Explorer(explorerParams);
useEffect(() => {
const effect = async () => {
if (config) {
if (config.platform === Platform.Hosted) {
await configureHosted(config);
} else if (config.platform === Platform.Emulator) {
configureEmulator();
} else if (config.platform === Platform.Portal) {
configurePortal();
}
applyExplorerBindings(explorer);
}
};
effect();
}, [config]);
return explorer;
}
async function configureHosted(config: ConfigContext) {
const win = (window as unknown) as HostedExplorerChildFrame;
explorer.selfServeType(SelfServeType.none);
if (win.hostedConfig.authType === AuthType.EncryptedToken) {
configureHostedWithEncryptedToken(win.hostedConfig, config);
} else if (win.hostedConfig.authType === AuthType.ResourceToken) {
configureHostedWithResourceToken(win.hostedConfig);
} else if (win.hostedConfig.authType === AuthType.ConnectionString) {
configureHostedWithConnectionString(win.hostedConfig);
} else if (win.hostedConfig.authType === AuthType.AAD) {
await configureHostedWithAAD(win.hostedConfig);
}
}
async function configureHostedWithAAD(config: AAD) {
window.authType = AuthType.AAD;
const account = config.databaseAccount;
const accountResourceId = account.id;
const subscriptionId = accountResourceId && accountResourceId.split("subscriptions/")[1].split("/")[0];
const resourceGroup = accountResourceId && accountResourceId.split("resourceGroups/")[1].split("/")[0];
updateUserContext({
authorizationToken: `Bearer ${config.authorizationToken}`,
databaseAccount: config.databaseAccount,
});
const keys = await listKeys(subscriptionId, resourceGroup, account.name);
explorer.configure({
databaseAccount: account,
subscriptionId,
resourceGroup,
masterKey: keys.primaryMasterKey,
hasWriteAccess: true,
authorizationToken: `Bearer ${config.authorizationToken}`,
features: extractFeatures(),
csmEndpoint: undefined,
dnsSuffix: undefined,
serverId: ServerIds.productionPortal,
extensionEndpoint: configContext.BACKEND_ENDPOINT,
subscriptionType: CollectionCreation.DefaultSubscriptionType,
quotaId: undefined,
addCollectionDefaultFlight: explorer.flight(),
isTryCosmosDBSubscription: explorer.isTryCosmosDBSubscription(),
});
explorer.isAccountReady(true);
}
function configureHostedWithConnectionString(config: ConnectionString) {
// For legacy reasons lots of code expects a connection string login to look and act like an encrypted token login
window.authType = AuthType.EncryptedToken;
// Impossible to tell if this is a try cosmos sub using an encrypted token
explorer.isTryCosmosDBSubscription(false);
updateUserContext({
accessToken: encodeURIComponent(config.encryptedToken),
});
const apiExperience: string = DefaultExperienceUtility.getDefaultExperienceFromApiKind(
config.encryptedTokenMetadata.apiKind
);
explorer.configure({
databaseAccount: {
id: "",
// id: Main._databaseAccountId,
name: config.encryptedTokenMetadata.accountName,
kind: getDatabaseAccountKindFromExperience(apiExperience),
properties: getDatabaseAccountPropertiesFromMetadata(config.encryptedTokenMetadata),
tags: [],
},
subscriptionId: undefined,
resourceGroup: undefined,
masterKey: config.masterKey,
hasWriteAccess: true,
authorizationToken: undefined,
features: extractFeatures(),
csmEndpoint: undefined,
dnsSuffix: undefined,
serverId: ServerIds.productionPortal,
extensionEndpoint: configContext.BACKEND_ENDPOINT,
subscriptionType: CollectionCreation.DefaultSubscriptionType,
quotaId: undefined,
addCollectionDefaultFlight: explorer.flight(),
isTryCosmosDBSubscription: explorer.isTryCosmosDBSubscription(),
});
explorer.isAccountReady(true);
}
function configureHostedWithResourceToken(config: ResourceToken) {
window.authType = AuthType.ResourceToken;
// Resource tokens can only be used with SQL API
const apiExperience: string = DefaultAccountExperience.DocumentDB;
const parsedResourceToken = parseResourceTokenConnectionString(config.resourceToken);
updateUserContext({
resourceToken: parsedResourceToken.resourceToken,
endpoint: parsedResourceToken.accountEndpoint,
});
explorer.resourceTokenDatabaseId(parsedResourceToken.databaseId);
explorer.resourceTokenCollectionId(parsedResourceToken.collectionId);
if (parsedResourceToken.partitionKey) {
explorer.resourceTokenPartitionKey(parsedResourceToken.partitionKey);
}
explorer.configure({
databaseAccount: {
id: "",
name: parsedResourceToken.accountEndpoint,
kind: AccountKind.GlobalDocumentDB,
properties: { documentEndpoint: parsedResourceToken.accountEndpoint },
tags: { defaultExperience: apiExperience },
},
subscriptionId: undefined,
resourceGroup: undefined,
masterKey: undefined,
hasWriteAccess: true,
authorizationToken: undefined,
features: extractFeatures(),
csmEndpoint: undefined,
dnsSuffix: undefined,
serverId: ServerIds.productionPortal,
extensionEndpoint: configContext.BACKEND_ENDPOINT,
subscriptionType: CollectionCreation.DefaultSubscriptionType,
quotaId: undefined,
addCollectionDefaultFlight: explorer.flight(),
isTryCosmosDBSubscription: explorer.isTryCosmosDBSubscription(),
isAuthWithresourceToken: true,
});
explorer.isAccountReady(true);
explorer.isRefreshingExplorer(false);
}
function configureHostedWithEncryptedToken(config: EncryptedToken, configContext: ConfigContext) {
window.authType = AuthType.EncryptedToken;
// Impossible to tell if this is a try cosmos sub using an encrypted token
explorer.isTryCosmosDBSubscription(false);
updateUserContext({
accessToken: encodeURIComponent(config.encryptedToken),
});
const apiExperience: string = DefaultExperienceUtility.getDefaultExperienceFromApiKind(
config.encryptedTokenMetadata.apiKind
);
explorer.configure({
databaseAccount: {
id: "",
name: config.encryptedTokenMetadata.accountName,
kind: getDatabaseAccountKindFromExperience(apiExperience),
properties: getDatabaseAccountPropertiesFromMetadata(config.encryptedTokenMetadata),
tags: [],
},
subscriptionId: undefined,
resourceGroup: undefined,
masterKey: undefined,
hasWriteAccess: true,
authorizationToken: undefined,
features: extractFeatures(),
csmEndpoint: undefined,
dnsSuffix: undefined,
serverId: ServerIds.productionPortal,
extensionEndpoint: configContext.BACKEND_ENDPOINT,
subscriptionType: CollectionCreation.DefaultSubscriptionType,
quotaId: undefined,
addCollectionDefaultFlight: explorer.flight(),
isTryCosmosDBSubscription: explorer.isTryCosmosDBSubscription(),
});
explorer.isAccountReady(true);
}
function configureEmulator() {
window.authType = AuthType.MasterKey;
explorer.selfServeType(SelfServeType.none);
explorer.databaseAccount(emulatorAccount);
explorer.isAccountReady(true);
}
function configurePortal() {
window.authType = AuthType.AAD;
// In development mode, try to load the iframe message from session storage.
// This allows webpack hot reload to function properly in the portal
if (process.env.NODE_ENV === "development" && !window.location.search.includes("disablePortalInitCache")) {
const initMessage = sessionStorage.getItem("portalDataExplorerInitMessage");
if (initMessage) {
const message = JSON.parse(initMessage);
console.warn(
"Loaded cached portal iframe message from session storage. Do a full page refresh to get a new message"
);
console.dir(message);
explorer.configure(message);
}
}
// In the Portal, configuration of Explorer happens via iframe message
window.addEventListener(
"message",
(event) => {
console.dir(event);
if (isInvalidParentFrameOrigin(event)) {
return;
}
if (!shouldProcessMessage(event)) {
return;
}
// Check for init message
const message: PortalMessage = event.data?.data;
const inputs = message?.inputs;
if (inputs) {
if (
configContext.BACKEND_ENDPOINT &&
configContext.platform === Platform.Portal &&
process.env.NODE_ENV === "development"
) {
inputs.extensionEndpoint = configContext.PROXY_PATH;
}
explorer.configure(inputs);
}
},
false
);
sendMessage("ready");
}
function shouldProcessMessage(event: MessageEvent): boolean {
if (typeof event.data !== "object") {
return false;
}
if (event.data["signature"] !== "pcIframe") {
return false;
}
if (!("data" in event.data)) {
return false;
}
if (typeof event.data["data"] !== "object") {
return false;
}
return true;
}
interface PortalMessage {
openAction?: DataExplorerAction;
actionType?: ActionType;
type?: MessageTypes;
inputs?: DataExplorerInputsFrame;
}

View File

@@ -12,10 +12,27 @@ describe("Self Serve", () => {
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");
// id of the display element is in the format {PROPERTY_NAME}-{DISPLAY_NAME}-{DISPLAY_TYPE}
await frame.waitForSelector("#description-text-display");
const regions = await frame.waitForSelector("#regions-dropdown-input");
let disabledLoggingToggle = await frame.$$("#enableLogging-toggle-input[disabled]");
expect(disabledLoggingToggle).toHaveLength(0);
await regions.click();
const regionsDropdownElement1 = await frame.waitForSelector("#regions-dropdown-input-list0");
await regionsDropdownElement1.click();
disabledLoggingToggle = await frame.$$("#enableLogging-toggle-input[disabled]");
expect(disabledLoggingToggle).toHaveLength(1);
await frame.waitForSelector("#accountName-textField-input");
const enableDbLevelThroughput = await frame.waitForSelector("#enableDbLevelThroughput-toggle-input");
const dbThroughput = await frame.$$("#dbThroughput-slider-input");
expect(dbThroughput).toHaveLength(0);
await enableDbLevelThroughput.click();
await frame.waitForSelector("#dbThroughput-slider-input");
await frame.waitForSelector("#collectionThroughput-spinner-input");
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@@ -1,7 +1,5 @@
import { MessageTypes } from "../../src/Contracts/ExplorerContracts";
import "../../less/hostedexplorer.less";
import { TestExplorerParams } from "./TestExplorerParams";
import { ClientSecretCredential } from "@azure/identity";
import { DatabaseAccountsGetResponse } from "@azure/arm-cosmosdb/esm/models";
import { CosmosDBManagementClient } from "@azure/arm-cosmosdb";
import * as msRest from "@azure/ms-rest-js";
@@ -19,26 +17,6 @@ class CustomSigner implements msRest.ServiceClientCredentials {
}
}
const handleMessage = (event: MessageEvent): void => {
if (event.data.type === MessageTypes.InitTestExplorer) {
sendMessageToExplorerFrame(event.data);
}
};
const AADLogin = async (
notebooksTestRunnerApplicationId: string,
notebooksTestRunnerClientId: string,
notebooksTestRunnerClientSecret: string
): Promise<string> => {
const credentials = new ClientSecretCredential(
notebooksTestRunnerApplicationId,
notebooksTestRunnerClientId,
notebooksTestRunnerClientSecret
);
const token = await credentials.getToken("https://management.core.windows.net/.default");
return token.token;
};
const getDatabaseAccount = async (
token: string,
notebooksAccountSubscriptonId: string,
@@ -49,34 +27,8 @@ const getDatabaseAccount = async (
return client.databaseAccounts.get(notebooksAccountResourceGroup, notebooksAccountName);
};
const sendMessageToExplorerFrame = (data: unknown): void => {
const explorerFrame = document.getElementById("explorerMenu") as HTMLIFrameElement;
explorerFrame &&
explorerFrame.contentDocument &&
explorerFrame.contentDocument.referrer &&
explorerFrame.contentWindow.postMessage(
{
signature: "pcIframe",
data: data,
},
explorerFrame.contentDocument.referrer || window.location.href
);
};
const initTestExplorer = async (): Promise<void> => {
window.addEventListener("message", handleMessage, false);
const urlSearchParams = new URLSearchParams(window.location.search);
const notebooksTestRunnerTenantId = decodeURIComponent(
urlSearchParams.get(TestExplorerParams.notebooksTestRunnerTenantId)
);
const notebooksTestRunnerClientId = decodeURIComponent(
urlSearchParams.get(TestExplorerParams.notebooksTestRunnerClientId)
);
const notebooksTestRunnerClientSecret = decodeURIComponent(
urlSearchParams.get(TestExplorerParams.notebooksTestRunnerClientSecret)
);
const portalRunnerDatabaseAccount = decodeURIComponent(
urlSearchParams.get(TestExplorerParams.portalRunnerDatabaseAccount)
);
@@ -89,11 +41,7 @@ const initTestExplorer = async (): Promise<void> => {
);
const selfServeType = urlSearchParams.get(TestExplorerParams.selfServeType);
const token = await AADLogin(
notebooksTestRunnerTenantId,
notebooksTestRunnerClientId,
notebooksTestRunnerClientSecret
);
const token = decodeURIComponent(urlSearchParams.get(TestExplorerParams.token));
const databaseAccount = await getDatabaseAccount(
token,
portalRunnerSubscripton,
@@ -102,7 +50,6 @@ const initTestExplorer = async (): Promise<void> => {
);
const initTestExplorerContent = {
type: MessageTypes.InitTestExplorer,
inputs: {
databaseAccount: databaseAccount,
subscriptionId: portalRunnerSubscripton,
@@ -130,11 +77,35 @@ const initTestExplorer = async (): Promise<void> => {
},
// add UI test only when feature is not dependent on flights anymore
flights: [],
selfServeType: selfServeType,
selfServeType,
} as ViewModels.DataExplorerInputsFrame,
};
window.postMessage(initTestExplorerContent, window.location.href);
const iframe = document.createElement("iframe");
window.addEventListener(
"message",
(event) => {
// After we have received the "ready" message from the child iframe we can post configuration
// This simulates the same action that happens in the portal
console.dir(event.data);
if (event.data?.data === "ready") {
iframe.contentWindow.postMessage(
{
signature: "pcIframe",
data: initTestExplorerContent,
},
iframe.contentDocument.referrer || window.location.href
);
}
},
false
);
iframe.id = "explorerMenu";
iframe.name = "explorer";
iframe.classList.add("iframe");
iframe.title = "explorer";
iframe.src = "explorer.html?platform=Portal&disablePortalInitCache";
document.body.appendChild(iframe);
};
window.addEventListener("load", initTestExplorer);
initTestExplorer();

View File

@@ -1,10 +1,8 @@
export enum TestExplorerParams {
notebooksTestRunnerTenantId = "notebooksTestRunnerTenantId",
notebooksTestRunnerClientId = "notebooksTestRunnerClientId",
notebooksTestRunnerClientSecret = "notebooksTestRunnerClientSecret",
portalRunnerDatabaseAccount = "portalRunnerDatabaseAccount",
portalRunnerDatabaseAccountKey = "portalRunnerDatabaseAccountKey",
portalRunnerSubscripton = "portalRunnerSubscripton",
portalRunnerResourceGroup = "portalRunnerResourceGroup",
selfServeType = "selfServeType",
token = "token",
}

View File

@@ -1,5 +1,6 @@
import { Frame } from "puppeteer";
import { TestExplorerParams } from "./TestExplorerParams";
import { ClientSecretCredential } from "@azure/identity";
let testExplorerFrame: Frame;
export const getTestExplorerFrame = async (params?: Map<string, string>): Promise<Frame> => {
@@ -15,19 +16,15 @@ export const getTestExplorerFrame = async (params?: Map<string, string>): Promis
const portalRunnerSubscripton = process.env.PORTAL_RUNNER_SUBSCRIPTION;
const portalRunnerResourceGroup = process.env.PORTAL_RUNNER_RESOURCE_GROUP;
const credentials = new ClientSecretCredential(
notebooksTestRunnerTenantId,
notebooksTestRunnerClientId,
notebooksTestRunnerClientSecret
);
const { token } = await credentials.getToken("https://management.core.windows.net/.default");
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)
@@ -41,6 +38,7 @@ export const getTestExplorerFrame = async (params?: Map<string, string>): Promis
TestExplorerParams.portalRunnerResourceGroup,
encodeURI(portalRunnerResourceGroup)
);
testExplorerUrl.searchParams.append(TestExplorerParams.token, encodeURI(token));
if (params) {
for (const key of params.keys()) {

View File

@@ -6,13 +6,5 @@
<link rel="shortcut icon" href="images/CosmosDB_rgb_ui_lighttheme.ico" type="image/x-icon" />
</head>
<body>
<iframe
id="explorerMenu"
name="explorer"
class="iframe"
title="explorer"
src="explorer.html?v=1.0.1&platform=Portal"
></iframe>
</body>
<body></body>
</html>

34
utils/codeMetrics.js Normal file
View File

@@ -0,0 +1,34 @@
/* eslint-disable no-console */
const fs = require("fs");
const fg = require("fast-glob");
const appInsights = require("applicationinsights");
appInsights.setup(process.env.CODE_METRICS_APP_ID).start();
const client = appInsights.defaultClient;
const htmlFiles = fg.sync(["**/*.html", "!node_modules"]);
const strictModeJSON = require("../tsconfig.strict.json");
const eslintIgnore = fs.readFileSync(".eslintignore", { encoding: "utf8" });
console.log("HTML File Count", htmlFiles.length);
client.trackMetric({
name: "HTML File Count",
value: htmlFiles.length,
});
console.log("TypeScript Strict File Count", strictModeJSON.files.length);
client.trackMetric({
name: "TypeScript Strict File Count",
value: strictModeJSON.files.length,
});
console.log("Unlinted File Count", eslintIgnore.split("\n").length);
client.trackMetric({
name: "Unlinted File Count",
value: eslintIgnore.split("\n").length,
});
appInsights.defaultClient.flush({
callback: () => {
process.exitCode = 0;
},
});