Compare commits

..

1 Commits

Author SHA1 Message Date
Steve Faulkner
cfbbf115f1 Attempt to aquire token via popup if silent fails 2021-01-21 18:35:09 -06:00
59 changed files with 1348 additions and 3756 deletions

View File

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

34
package-lock.json generated
View File

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

View File

@@ -151,7 +151,6 @@
"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",
}
export interface ConfigContext {
interface ConfigContext {
platform: Platform;
allowedParentFrameOrigins: string[];
gitSha?: string;

View File

@@ -33,6 +33,7 @@ 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.MongoDocumentsTabV2());
ko.components.register("documents-tab", new TabComponents.DocumentsTab());
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,6 +958,7 @@ exports[`SettingsComponent renders 1`] = `
"isMongoIndexingEnabled": [Function],
"isNotebookEnabled": [Function],
"isNotebooksEnabledForAccount": [Function],
"isNotificationConsoleExpanded": [Function],
"isPreferredApiCassandra": [Function],
"isPreferredApiDocumentDB": [Function],
"isPreferredApiGraph": [Function],
@@ -1017,6 +1018,12 @@ 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],
@@ -1122,9 +1129,6 @@ exports[`SettingsComponent renders 1`] = `
},
"selfServeType": [Function],
"serverId": [Function],
"setInProgressConsoleDataIdToBeDeleted": undefined,
"setIsNotificationConsoleExpanded": undefined,
"setNotificationConsoleData": undefined,
"settingsPane": SettingsPane {
"container": [Circular],
"crossPartitionQueryEnabled": [Function],
@@ -2237,6 +2241,7 @@ exports[`SettingsComponent renders 1`] = `
"isMongoIndexingEnabled": [Function],
"isNotebookEnabled": [Function],
"isNotebooksEnabledForAccount": [Function],
"isNotificationConsoleExpanded": [Function],
"isPreferredApiCassandra": [Function],
"isPreferredApiDocumentDB": [Function],
"isPreferredApiGraph": [Function],
@@ -2296,6 +2301,12 @@ 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],
@@ -2401,9 +2412,6 @@ exports[`SettingsComponent renders 1`] = `
},
"selfServeType": [Function],
"serverId": [Function],
"setInProgressConsoleDataIdToBeDeleted": undefined,
"setIsNotificationConsoleExpanded": undefined,
"setNotificationConsoleData": undefined,
"settingsPane": SettingsPane {
"container": [Circular],
"crossPartitionQueryEnabled": [Function],
@@ -3529,6 +3537,7 @@ exports[`SettingsComponent renders 1`] = `
"isMongoIndexingEnabled": [Function],
"isNotebookEnabled": [Function],
"isNotebooksEnabledForAccount": [Function],
"isNotificationConsoleExpanded": [Function],
"isPreferredApiCassandra": [Function],
"isPreferredApiDocumentDB": [Function],
"isPreferredApiGraph": [Function],
@@ -3588,6 +3597,12 @@ 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],
@@ -3693,9 +3708,6 @@ exports[`SettingsComponent renders 1`] = `
},
"selfServeType": [Function],
"serverId": [Function],
"setInProgressConsoleDataIdToBeDeleted": undefined,
"setIsNotificationConsoleExpanded": undefined,
"setNotificationConsoleData": undefined,
"settingsPane": SettingsPane {
"container": [Circular],
"crossPartitionQueryEnabled": [Function],
@@ -4808,6 +4820,7 @@ exports[`SettingsComponent renders 1`] = `
"isMongoIndexingEnabled": [Function],
"isNotebookEnabled": [Function],
"isNotebooksEnabledForAccount": [Function],
"isNotificationConsoleExpanded": [Function],
"isPreferredApiCassandra": [Function],
"isPreferredApiDocumentDB": [Function],
"isPreferredApiGraph": [Function],
@@ -4867,6 +4880,12 @@ 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],
@@ -4972,9 +4991,6 @@ exports[`SettingsComponent renders 1`] = `
},
"selfServeType": [Function],
"serverId": [Function],
"setInProgressConsoleDataIdToBeDeleted": undefined,
"setIsNotificationConsoleExpanded": undefined,
"setNotificationConsoleData": undefined,
"settingsPane": SettingsPane {
"container": [Circular],
"crossPartitionQueryEnabled": [Function],

View File

@@ -1,7 +1,6 @@
import React from "react";
import { shallow } from "enzyme";
import { SmartUiComponent, SmartUiDescriptor } from "./SmartUiComponent";
import { NumberUiType, SmartUiInput } from "../../../SelfServe/SelfServeTypes";
import { SmartUiComponent, SmartUiDescriptor, UiType } from "./SmartUiComponent";
describe("SmartUiComponent", () => {
const exampleData: SmartUiDescriptor = {
@@ -15,20 +14,6 @@ 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: {
@@ -39,7 +24,7 @@ describe("SmartUiComponent", () => {
max: 500,
step: 10,
defaultValue: 400,
uiType: NumberUiType.Spinner,
uiType: UiType.Spinner,
},
},
{
@@ -52,7 +37,7 @@ describe("SmartUiComponent", () => {
max: 500,
step: 10,
defaultValue: 400,
uiType: NumberUiType.Slider,
uiType: UiType.Slider,
},
},
{
@@ -65,7 +50,7 @@ describe("SmartUiComponent", () => {
max: 500,
step: 10,
defaultValue: 400,
uiType: NumberUiType.Spinner,
uiType: UiType.Spinner,
errorMessage: "label, truelabel and falselabel are required for boolean input 'throughput3'",
},
},
@@ -106,58 +91,11 @@ describe("SmartUiComponent", () => {
},
};
it("should render and honor input's hidden, disabled state", async () => {
const currentValues = new Map<string, SmartUiInput>();
it("should render", async () => {
const wrapper = shallow(
<SmartUiComponent
disabled={false}
descriptor={exampleData}
currentValues={currentValues}
onInputChange={jest.fn()}
onError={() => {
return;
}}
/>
<SmartUiComponent descriptor={exampleData} currentValues={new Map()} onInputChange={undefined} />
);
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,19 +5,11 @@ 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, Toggle } from "office-ui-fabric-react";
import { Link, MessageBar, MessageBarType } 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
@@ -27,14 +19,29 @@ import {
* - a descriptor of the UX.
*/
interface BaseDisplay {
dataFieldName: string;
errorMessage?: string;
type: InputTypeValue;
export type InputTypeValue = "number" | "string" | "boolean" | "object";
export enum UiType {
Spinner = "Spinner",
Slider = "Slider",
}
interface BaseInput extends BaseDisplay {
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;
dataFieldName: string;
type: InputTypeValue;
placeholder?: string;
errorMessage?: string;
}
@@ -47,7 +54,7 @@ interface NumberInput extends BaseInput {
max: number;
step: number;
defaultValue?: number;
uiType: NumberUiType;
uiType: UiType;
}
interface BooleanInput extends BaseInput {
@@ -65,16 +72,12 @@ interface ChoiceInput extends BaseInput {
defaultKey?: string;
}
interface DescriptionDisplay extends BaseDisplay {
description: Description;
}
type AnyDisplay = NumberInput | BooleanInput | StringInput | ChoiceInput | DescriptionDisplay;
type AnyInput = NumberInput | BooleanInput | StringInput | ChoiceInput;
interface Node {
id: string;
info?: Info;
input?: AnyDisplay;
input?: AnyInput;
children?: Node[];
}
@@ -83,12 +86,11 @@ export interface SmartUiDescriptor {
}
/************************** Component implementation starts here ************************************* */
export interface SmartUiComponentProps {
descriptor: SmartUiDescriptor;
currentValues: Map<string, SmartUiInput>;
onInputChange: (input: AnyDisplay, newValue: InputType) => void;
onError: (hasError: boolean) => void;
disabled: boolean;
currentValues: Map<string, InputType>;
onInputChange: (input: AnyInput, newValue: InputType) => void;
}
interface SmartUiComponentState {
@@ -96,22 +98,12 @@ 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 = {
@@ -121,7 +113,7 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
private renderInfo(info: Info): JSX.Element {
return (
<MessageBar styles={{ root: { width: 400 } }}>
<MessageBar>
{info.message}
{info.link && (
<Link href={info.link.href} target="_blank">
@@ -133,20 +125,17 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
}
private renderTextInput(input: StringInput): JSX.Element {
const value = this.props.currentValues.get(input.dataFieldName)?.value as string;
const disabled = this.props.disabled || this.props.currentValues.get(input.dataFieldName)?.disabled;
const value = this.props.currentValues.get(input.dataFieldName) as string;
return (
<div className="stringInputContainer">
<TextField
id={`${input.dataFieldName}-textField-input`}
id={`${input.dataFieldName}-textBox-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: {
@@ -161,27 +150,13 @@ 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: NumberInput, value: string, min: number, max: number): string => {
private onValidate = (input: AnyInput, value: string, min: number, max: number): string => {
const newValue = InputUtils.onValidateValueChange(value, min, max);
const dataFieldName = input.dataFieldName;
if (newValue) {
@@ -190,13 +165,13 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
return newValue.toString();
} else {
const { errors } = this.state;
errors.set(dataFieldName, `Invalid value '${value}'. It must be between ${min} and ${max}`);
errors.set(dataFieldName, `Invalid value ${value}: must be between ${min} and ${max}`);
this.setState({ errors });
}
return undefined;
};
private onIncrement = (input: NumberInput, value: string, step: number, max: number): string => {
private onIncrement = (input: AnyInput, value: string, step: number, max: number): string => {
const newValue = InputUtils.onIncrementValue(value, step, max);
const dataFieldName = input.dataFieldName;
if (newValue) {
@@ -207,7 +182,7 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
return undefined;
};
private onDecrement = (input: NumberInput, value: string, step: number, min: number): string => {
private onDecrement = (input: AnyInput, value: string, step: number, min: number): string => {
const newValue = InputUtils.onDecrementValue(value, step, min);
const dataFieldName = input.dataFieldName;
if (newValue) {
@@ -228,11 +203,10 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
step: step,
};
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) {
const value = this.props.currentValues.get(dataFieldName) as number;
if (input.uiType === UiType.Spinner) {
return (
<Stack styles={{ root: { width: 400 } }} tokens={{ childrenGap: 2 }}>
<>
<SpinButton
{...props}
id={`${input.dataFieldName}-spinner-input`}
@@ -241,7 +215,6 @@ 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,
@@ -252,18 +225,16 @@ 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 === NumberUiType.Slider) {
} else if (input.uiType === UiType.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,
@@ -279,44 +250,49 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
}
private renderBooleanInput(input: BooleanInput): JSX.Element {
const value = this.props.currentValues.get(input.dataFieldName)?.value as boolean;
const disabled = this.props.disabled || this.props.currentValues.get(input.dataFieldName)?.disabled;
const value = this.props.currentValues.get(input.dataFieldName) as boolean;
const selectedKey = value || input.defaultValue ? "true" : "false";
return (
<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 } }}
/>
<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>
);
}
private renderChoiceInput(input: ChoiceInput): JSX.Element {
const { label, defaultKey: defaultKey, dataFieldName, choices, placeholder } = input;
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 = "";
}
const value = this.props.currentValues.get(dataFieldName) as string;
return (
<Dropdown
id={`${input.dataFieldName}-dropdown-input`}
id={`${input.dataFieldName}-dropown-input`}
label={label}
selectedKey={selectedKey}
selectedKey={value ? value : defaultKey}
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,
@@ -327,23 +303,16 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
);
}
private renderError(input: AnyDisplay): JSX.Element {
private renderError(input: AnyInput): JSX.Element {
return <MessageBar messageBarType={MessageBarType.error}>Error: {input.errorMessage}</MessageBar>;
}
private renderDisplay(input: AnyDisplay): JSX.Element {
private renderInput(input: AnyInput): 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);
@@ -357,13 +326,13 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
}
private renderNode(node: Node): JSX.Element {
const containerStackTokens: IStackTokens = { childrenGap: 10 };
const containerStackTokens: IStackTokens = { childrenGap: 15 };
return (
<Stack tokens={containerStackTokens} className="widgetRendererContainer">
<Stack.Item>
{node.info && this.renderInfo(node.info as Info)}
{node.input && this.renderDisplay(node.input)}
{node.input && this.renderInput(node.input)}
</Stack.Item>
{node.children && node.children.map((child) => <div key={child.id}>{this.renderNode(child)}</div>)}
</Stack>
@@ -371,6 +340,11 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
}
render(): JSX.Element {
return this.renderNode(this.props.descriptor.root);
const containerStackTokens: IStackTokens = { childrenGap: 20 };
return (
<Stack tokens={containerStackTokens} styles={{ root: { width: 400, padding: 10 } }}>
{this.renderNode(this.props.descriptor.root)}
</Stack>
);
}
}

View File

@@ -1,405 +1,52 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SmartUiComponent disable all inputs 1`] = `
exports[`SmartUiComponent should render 1`] = `
<Stack
className="widgetRendererContainer"
styles={
Object {
"root": Object {
"padding": 10,
"width": 400,
},
}
}
tokens={
Object {
"childrenGap": 10,
"childrenGap": 20,
}
}
>
<StackItem>
<StyledMessageBarBase
styles={
Object {
"root": Object {
"width": 400,
},
}
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 15,
}
>
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={
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,
},
}
}
>
<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"
>
Start at $24/mo per database
<StyledLinkBase
href="https://aka.ms/azure-cosmos-db-pricing"
target="_blank"
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 15,
}
}
>
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,
}
}
>
<StackItem>
<CustomizedSpinButton
ariaLabel="Throughput (input)"
decrementButtonIcon={
@@ -433,203 +80,210 @@ exports[`SmartUiComponent should render and honor input's hidden, disabled state
}
}
/>
</Stack>
</StackItem>
</Stack>
</div>
<div
key="throughput2"
>
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 10,
}
}
</StackItem>
</Stack>
</div>
<div
key="throughput2"
>
<StackItem>
<div
id="throughput2-slider-input"
>
<StyledSliderBase
ariaLabel="Throughput (Slider)"
label="Throughput (Slider)"
max={500}
min={400}
<Stack
className="widgetRendererContainer"
tokens={
Object {
"childrenGap": 15,
}
}
>
<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"
onChange={[Function]}
step={10}
options={
Array [
Object {
"key": "db1",
"text": "Database 1",
},
Object {
"key": "db2",
"text": "Database 2",
},
Object {
"key": "db3",
"text": "Database 3",
},
]
}
selectedKey="db2"
styles={
Object {
"root": Object {
"width": 400,
"dropdown": Object {
"color": "#393939",
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
},
"titleLabel": Object {
"label": 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
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>
</StackItem>
</Stack>
</div>
</Stack>
</Stack>
`;

View File

@@ -55,6 +55,7 @@ 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";
@@ -106,12 +107,6 @@ 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
@@ -151,9 +146,8 @@ export default class Explorer {
public mostRecentActivity: MostRecentActivity.MostRecentActivity;
// Notification Console
private setIsNotificationConsoleExpanded: (isExpanded: boolean) => void;
private setNotificationConsoleData: (consoleData: ConsoleData) => void;
private setInProgressConsoleDataIdToBeDeleted: (id: string) => void;
public notificationConsoleData: ko.ObservableArray<ConsoleData>;
public isNotificationConsoleExpanded: ko.Observable<boolean>;
// Panes
public contextPanes: ContextualPaneBase[];
@@ -266,6 +260,7 @@ 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;
@@ -274,11 +269,7 @@ export default class Explorer {
private static readonly MaxNbDatabasesToAutoExpand = 5;
constructor(params?: ExplorerParams) {
this.setIsNotificationConsoleExpanded = params?.setIsNotificationConsoleExpanded;
this.setNotificationConsoleData = params?.setNotificationConsoleData;
this.setInProgressConsoleDataIdToBeDeleted = params?.setInProgressConsoleDataIdToBeDeleted;
constructor() {
const startKey: number = TelemetryProcessor.traceStart(Action.InitializeDataExplorer, {
dataExplorerArea: Constants.Areas.ResourceTree,
});
@@ -439,6 +430,7 @@ 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);
@@ -486,6 +478,7 @@ 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(
@@ -899,6 +892,7 @@ export default class Explorer {
this.commandBarComponentAdapter = new CommandBarComponentAdapter(this);
this.selfServeLoadingComponentAdapter = new SelfServeLoadingComponentAdapter();
this.notificationConsoleComponentAdapter = new NotificationConsoleComponentAdapter(this);
this._initSettings();
@@ -1355,19 +1349,23 @@ export default class Explorer {
}
public logConsoleData(consoleData: ConsoleData): void {
this.setNotificationConsoleData(consoleData);
this.notificationConsoleData.splice(0, 0, consoleData);
}
public deleteInProgressConsoleDataWithId(id: string): void {
this.setInProgressConsoleDataIdToBeDeleted(id);
const updatedConsoleData = _.reject(
this.notificationConsoleData(),
(data: ConsoleData) => data.type === ConsoleDataType.InProgress && data.id === id
);
this.notificationConsoleData(updatedConsoleData);
}
public expandConsole(): void {
this.setIsNotificationConsoleExpanded(true);
this.isNotificationConsoleExpanded(true);
}
public collapseConsole(): void {
this.setIsNotificationConsoleExpanded(false);
this.isNotificationConsoleExpanded(false);
}
public toggleLeftPaneExpanded() {
@@ -1720,7 +1718,58 @@ export default class Explorer {
this._addSynapseLinkDialogProps.valueHasMutated();
};
public handleMessage(message: any) {
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);
const openAction: ActionContracts.DataExplorerAction = message.openAction;
if (!!openAction) {
if (this.isRefreshingExplorer()) {
@@ -1825,7 +1874,7 @@ export default class Explorer {
}
}
public configure(inputs: ViewModels.DataExplorerInputsFrame): void {
public initDataExplorerWithFrameInputs(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
@@ -2346,11 +2395,13 @@ 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,6 +2,7 @@ import React from "react";
import { shallow } from "enzyme";
import {
NotificationConsoleComponentProps,
ConsoleData,
NotificationConsoleComponent,
ConsoleDataType,
} from "./NotificationConsoleComponent";
@@ -9,40 +10,38 @@ import {
describe("NotificationConsoleComponent", () => {
const createBlankProps = (): NotificationConsoleComponentProps => {
return {
consoleData: undefined,
isConsoleExpanded: false,
inProgressConsoleDataIdToBeDeleted: "",
setIsConsoleExpanded: (isExpanded: boolean): void => {},
consoleData: [],
isConsoleExpanded: true,
onConsoleDataChange: (consoleData: ConsoleData[]) => {},
onConsoleExpandedChange: (isExpanded: boolean) => {},
};
};
it("renders the console", () => {
it("renders the console (expanded)", () => {
const props = createBlankProps();
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
expect(wrapper).toMatchSnapshot();
props.consoleData = {
props.consoleData.push({
type: ConsoleDataType.Info,
date: "date",
message: "message",
};
wrapper.setProps(props);
});
const wrapper = shallow(<NotificationConsoleComponent {...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 = {
props.consoleData.push({
type: ConsoleDataType.InProgress,
date: "date" + i,
date: "date",
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");
@@ -51,17 +50,16 @@ 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 = {
props.consoleData.push({
type: ConsoleDataType.Error,
date: "date" + i,
date: "date",
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");
@@ -70,34 +68,31 @@ 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 = {
props.consoleData.push({
type: ConsoleDataType.Info,
date: "date" + i,
date: "date",
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, message: string, type: ConsoleDataType, iconClassName: string) => {
const testRenderNotification = (date: string, msg: 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(message);
expect(wrapper.find(".notificationConsoleData .message").text()).toEqual(msg);
expect(wrapper.exists(`.notificationConsoleData .${iconClassName}`));
};
@@ -115,78 +110,55 @@ describe("NotificationConsoleComponent", () => {
it("clears notifications", () => {
const props = createBlankProps();
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
props.consoleData = {
props.consoleData.push({
type: ConsoleDataType.InProgress,
date: "date",
message: "message1",
};
wrapper.setProps(props);
props.consoleData = {
});
props.consoleData.push({
type: ConsoleDataType.Error,
date: "date",
message: "message2",
};
wrapper.setProps(props);
props.consoleData = {
});
props.consoleData.push({
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();
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
props.consoleData = {
type: ConsoleDataType.Info,
props.consoleData.push({
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 props = createBlankProps();
const wrapper = shallow(<NotificationConsoleComponent {...props} />);
props.consoleData = {
type: ConsoleDataType.Info,
const props1 = createBlankProps();
const props2 = createBlankProps();
props2.consoleData.push({
date: "date",
message: latestData,
};
props.isConsoleExpanded = true;
wrapper.setProps(props);
type: ConsoleDataType.Info,
});
props2.isConsoleExpanded = true;
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;
consoleData: ConsoleData;
inProgressConsoleDataIdToBeDeleted: string;
setIsConsoleExpanded: (isExpanded: boolean) => void;
onConsoleExpandedChange: (isExpanded: boolean) => void;
consoleData: ConsoleData[];
onConsoleDataChange: (consoleData: ConsoleData[]) => void;
}
interface NotificationConsoleComponentState {
headerStatus: string;
selectedFilter: string;
allConsoleData: ConsoleData[];
isExpanded: boolean;
}
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;
private prevHeaderStatus: string | null;
private consoleHeaderElement?: HTMLElement;
constructor(props: NotificationConsoleComponentProps) {
super(props);
this.state = {
headerStatus: undefined,
selectedFilter: NotificationConsoleComponent.FilterOptions[0].key,
allConsoleData: props.consoleData ? [props.consoleData] : [],
headerStatus: "",
selectedFilter: NotificationConsoleComponent.FilterOptions[0].key || "",
isExpanded: props.isConsoleExpanded,
};
this.prevHeaderStatus = undefined;
this.prevHeaderStatus = null;
}
public componentDidUpdate(
prevProps: NotificationConsoleComponentProps,
prevState: NotificationConsoleComponentState
) {
const currentHeaderStatus = NotificationConsoleComponent.extractHeaderStatus(this.props.consoleData);
const currentHeaderStatus = NotificationConsoleComponent.extractHeaderStatus(this.props);
if (
this.prevHeaderStatus !== currentHeaderStatus &&
currentHeaderStatus !== undefined &&
currentHeaderStatus !== null &&
prevState.headerStatus !== currentHeaderStatus
) {
this.setHeaderStatus(currentHeaderStatus);
@@ -92,8 +92,10 @@ export class NotificationConsoleComponent extends React.Component<
// updates: currentHeaderStatus -> "" -> currentHeaderStatus -> "" etc.
this.prevHeaderStatus = currentHeaderStatus;
if (this.props.consoleData || this.props.inProgressConsoleDataIdToBeDeleted) {
this.updateConsoleData(prevProps);
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 });
}
}
@@ -102,14 +104,12 @@ export class NotificationConsoleComponent extends React.Component<
};
public render(): JSX.Element {
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)
const numInProgress = this.props.consoleData.filter((data: ConsoleData) => data.type === ConsoleDataType.InProgress)
.length;
const numInfoItems = this.state.allConsoleData.filter((data: ConsoleData) => data.type === ConsoleDataType.Info)
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)
.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.props.isConsoleExpanded ? " collapsed" : " expanded")}
aria-expanded={!this.props.isConsoleExpanded}
aria-label={"console button" + (this.state.isExpanded ? " collapsed" : " expanded")}
aria-expanded={!this.state.isExpanded}
>
<img
src={this.props.isConsoleExpanded ? ChevronDownIcon : ChevronUpIcon}
alt={this.props.isConsoleExpanded ? "ChevronDownIcon" : "ChevronUpIcon"}
src={this.state.isExpanded ? ChevronDownIcon : ChevronUpIcon}
alt={this.state.isExpanded ? "ChevronDownIcon" : "ChevronUpIcon"}
/>
</div>
</div>
<AnimateHeight
duration={NotificationConsoleComponent.transitionDurationMs}
height={this.props.isConsoleExpanded ? "auto" : 0}
height={this.state.isExpanded ? "auto" : 0}
onAnimationEnd={this.onConsoleWasExpanded}
>
<div className="notificationConsoleContents">
@@ -189,7 +189,7 @@ export class NotificationConsoleComponent extends React.Component<
);
}
private expandCollapseConsole() {
this.props.setIsConsoleExpanded(!this.props.isConsoleExpanded);
this.setState({ isExpanded: !this.state.isExpanded });
}
private onExpandCollapseKeyPress = (event: React.KeyboardEvent<HTMLDivElement>): void => {
@@ -209,7 +209,7 @@ export class NotificationConsoleComponent extends React.Component<
};
private clearNotifications(): void {
this.setState({ allConsoleData: [] });
this.props.onConsoleDataChange([]);
}
private renderAllFilteredConsoleData(rowData: ConsoleData[]): JSX.Element[] {
@@ -229,9 +229,12 @@ export class NotificationConsoleComponent extends React.Component<
};
private getFilteredConsoleData(): ConsoleData[] {
let filterType: ConsoleDataType;
let filterType: ConsoleDataType | null = null;
switch (this.state.selectedFilter) {
case "All":
filterType = null;
break;
case "In Progress":
filterType = ConsoleDataType.InProgress;
break;
@@ -242,12 +245,12 @@ export class NotificationConsoleComponent extends React.Component<
filterType = ConsoleDataType.Error;
break;
default:
filterType = undefined;
filterType = null;
}
return filterType
? this.state.allConsoleData.filter((data: ConsoleData) => data.type === filterType)
: this.state.allConsoleData;
return filterType == null
? this.props.consoleData
: this.props.consoleData.filter((data: ConsoleData) => data.type === filterType);
}
private setHeaderStatus(statusMessage: string): void {
@@ -263,43 +266,18 @@ export class NotificationConsoleComponent extends React.Component<
);
}
private static extractHeaderStatus(consoleData: ConsoleData) {
return consoleData?.message.split(":\n")[0];
private static extractHeaderStatus(props: NotificationConsoleComponentProps) {
if (props.consoleData && props.consoleData.length > 0) {
return props.consoleData[0].message.split(":\n")[0];
} else {
return null;
}
}
private onConsoleWasExpanded = (): void => {
if (this.props.isConsoleExpanded && this.consoleHeaderElement) {
this.props.onConsoleExpandedChange(this.state.isExpanded);
if (this.state.isExpanded && 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

@@ -0,0 +1,47 @@
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,169 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
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`] = `
exports[`NotificationConsoleComponent renders the console (expanded) 1`] = `
<div
className="notificationConsoleContainer"
>
@@ -227,20 +64,18 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
>
<span
className="headerStatusEllipsis"
>
message
</span>
/>
</span>
</div>
<div
aria-expanded={true}
aria-label="console button expanded"
aria-expanded={false}
aria-label="console button collapsed"
className="expandCollapseButton"
role="button"
tabIndex={0}
>
<img
alt="ChevronUpIcon"
alt="ChevronDownIcon"
src=""
/>
</div>
@@ -265,7 +100,7 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
delay={0}
duration={200}
easing="ease"
height={0}
height="auto"
onAnimationEnd={[Function]}
style={Object {}}
>

View File

@@ -1,26 +0,0 @@
.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

@@ -1,185 +0,0 @@
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

@@ -1,89 +0,0 @@
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,18 +45,17 @@ type NotebookRendererProps = NotebookRendererBaseProps & NotebookRendererDispatc
const decorate = (id: string, contentRef: ContentRef, cell_type: CellType, children: React.ReactNode) => {
const Cell = () => (
// 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>
<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,6 +29,10 @@ 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,6 +57,7 @@ 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({
@@ -100,6 +101,7 @@ 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,6 +55,7 @@ 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",
@@ -91,6 +92,7 @@ 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,6 +34,13 @@ 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,7 +240,10 @@ 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 notificationConsoleHeight = 32; /** Header height **/
const explorer = window.dataExplorer;
const notificationConsoleHeight = explorer.isNotificationConsoleExpanded()
? 252 /** 32px(header) + 220px(content height) **/
: 32; /** Header height **/
var scrollHeight =
bodyHeight -

View File

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

View File

@@ -1,49 +0,0 @@
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

@@ -1,50 +0,0 @@
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,7 +2,9 @@ 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";
@@ -15,27 +17,33 @@ 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 { Action } from "../../Shared/Telemetry/TelemetryConstants";
import { ArmApiVersions } from "../../Common/Constants";
import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
import { Areas, 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 } from "../Notebook/NotebookClientV2";
import { KernelSpecsDisplay, NotebookClientV2 } 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 NotebookTabBaseOptions {
export interface NotebookTabOptions extends ViewModels.TabOptions {
account: DataModels.DatabaseAccount;
masterKey: string;
container: Explorer;
notebookContentItem: NotebookContentItem;
}
export default class NotebookTabV2 extends NotebookTabBase {
export default class NotebookTabV2 extends TabsBase {
private static clientManager: NotebookClientV2;
private container: Explorer;
public notebookPath: ko.Observable<string>;
private selectedSparkPool: ko.Observable<string>;
private notebookComponentAdapter: NotebookComponentAdapter;
@@ -44,6 +52,16 @@ export default class NotebookTabV2 extends NotebookTabBase {
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) => {
@@ -53,7 +71,7 @@ export default class NotebookTabV2 extends NotebookTabBase {
this.notebookComponentAdapter = new NotebookComponentAdapter({
contentItem: options.notebookContentItem,
notebooksBasePath: this.container.getNotebookBasePath(),
notebookClient: NotebookTabBase.clientManager,
notebookClient: NotebookTabV2.clientManager,
onUpdateKernelInfo: this.onKernelUpdate,
});
@@ -99,6 +117,10 @@ export default class NotebookTabV2 extends NotebookTabBase {
return await this.configureServiceEndpoints(this.notebookComponentAdapter.getCurrentKernelName());
}
protected getContainer(): Explorer {
return this.container;
}
protected getTabsButtons(): CommandButtonComponentProps[] {
const availableKernels = NotebookTabV2.clientManager.getAvailableKernelSpecs();
@@ -482,4 +504,12 @@ export default class NotebookTabV2 extends NotebookTabBase {
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,7 +5,6 @@ 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";
@@ -106,15 +105,6 @@ export class MongoQueryTab {
}
}
export class MongoDocumentsTabV2 {
constructor() {
return {
viewModel: TabComponent,
template: MongoDocumentsTabV2Template,
};
}
}
export class MongoShellTab {
constructor() {
return {

View File

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

View File

@@ -1,18 +1,17 @@
import { AuthType } from "./AuthType";
import { AccessInputMetadata, DatabaseAccount } from "./Contracts/DataModels";
type HostedConfig = AAD | ConnectionString | EncryptedToken | ResourceToken;
export interface HostedExplorerChildFrame extends Window {
hostedConfig: HostedConfig;
hostedConfig: AAD | ConnectionString | EncryptedToken | ResourceToken;
}
export interface AAD {
interface AAD {
authType: AuthType.AAD;
databaseAccount: DatabaseAccount;
authorizationToken: string;
}
export interface ConnectionString {
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;
@@ -21,13 +20,13 @@ export interface ConnectionString {
masterKey?: string;
}
export interface EncryptedToken {
interface EncryptedToken {
authType: AuthType.EncryptedToken;
encryptedToken: string;
encryptedTokenMetadata: AccessInputMetadata;
}
export interface ResourceToken {
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/${sampleSubscriptionId}/${sampleDatabaseAccount.name}/gallery/${id}/favorite`,
`${configContext.JUNO_ENDPOINT}/api/notebooks/${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.getNotebooksSubscriptionIdAccountUrl()}/github/pinnedrepos`, {
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/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.getNotebooksSubscriptionIdAccountUrl()}/github/pinnedrepos`, {
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/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.getNotebooksSubscriptionIdAccountUrl()}/github`, {
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/github`, {
method: "DELETE",
headers: JunoClient.getHeaders(),
});
@@ -135,12 +135,9 @@ export class JunoClient {
const githubParams = JunoClient.getGitHubClientParams();
githubParams.append("code", code);
const response = await window.fetch(
`${this.getNotebooksSubscriptionIdAccountUrl()}/github/token?${githubParams.toString()}`,
{
headers: JunoClient.getHeaders(),
}
);
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/github/token?${githubParams.toString()}`, {
headers: JunoClient.getHeaders(),
});
let data: IGitHubOAuthToken;
const body = await response.text();
@@ -162,13 +159,10 @@ export class JunoClient {
const githubParams = JunoClient.getGitHubClientParams();
githubParams.append("access_token", token);
const response = await window.fetch(
`${this.getNotebooksSubscriptionIdAccountUrl()}/github/token?${githubParams.toString()}`,
{
method: "DELETE",
headers: JunoClient.getHeaders(),
}
);
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/github/token?${githubParams.toString()}`, {
method: "DELETE",
headers: JunoClient.getHeaders(),
});
return {
status: response.status,
@@ -185,7 +179,7 @@ export class JunoClient {
}
public async getPublicGalleryData(): Promise<IJunoResponse<IPublicGalleryData>> {
const url = `${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/public`;
const url = `${this.getNotebooksAccountUrl()}/gallery/public`;
const response = await window.fetch(url, {
method: "PATCH",
headers: JunoClient.getHeaders(),
@@ -203,7 +197,7 @@ export class JunoClient {
}
public async acceptCodeOfConduct(): Promise<IJunoResponse<boolean>> {
const url = `${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/acceptCodeOfConduct`;
const url = `${this.getNotebooksAccountUrl()}/gallery/acceptCodeOfConduct`;
const response = await window.fetch(url, {
method: "PATCH",
headers: JunoClient.getHeaders(),
@@ -221,7 +215,7 @@ export class JunoClient {
}
public async isCodeOfConductAccepted(): Promise<IJunoResponse<boolean>> {
const url = `${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/isCodeOfConductAccepted`;
const url = `${this.getNotebooksAccountUrl()}/gallery/isCodeOfConductAccepted`;
const response = await window.fetch(url, {
method: "PATCH",
headers: JunoClient.getHeaders(),
@@ -300,7 +294,7 @@ export class JunoClient {
}
public async favoriteNotebook(id: string): Promise<IJunoResponse<IGalleryItem>> {
const response = await window.fetch(`${this.getNotebooksSubscriptionIdAccountUrl()}/gallery/${id}/favorite`, {
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/gallery/${id}/favorite`, {
method: "PATCH",
headers: JunoClient.getHeaders(),
});
@@ -371,19 +365,22 @@ export class JunoClient {
content: string,
isLinkInjectionEnabled: boolean
): Promise<IJunoResponse<IGalleryItem>> {
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),
});
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),
}
);
let data: IGalleryItem;
if (response.status === HttpStatusCodes.OK) {
@@ -505,10 +502,6 @@ 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,33 +53,215 @@ 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 { ExplorerParams } from "./Explorer/Explorer";
import React, { useState } from "react";
import { applyExplorerBindings } from "./applyExplorerBindings";
import { configContext, initializeConfiguration, Platform } from "./ConfigContext";
import Explorer from "./Explorer/Explorer";
import React, { useEffect } 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 { useConfig } from "./hooks/useConfig";
import { useKnockoutExplorer } from "./hooks/useKnockoutExplorer";
import { NotificationConsoleComponent } from "./Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
initializeIcons();
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";
const App: React.FunctionComponent = () => {
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);
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);
});
}, []);
return (
<div className="flexContainer">
@@ -281,14 +463,8 @@ const App: React.FunctionComponent = () => {
role="contentinfo"
aria-label="Notification console"
id="explorerNotificationConsole"
>
<NotificationConsoleComponent
isConsoleExpanded={isNotificationConsoleExpanded}
consoleData={notificationConsoleData}
inProgressConsoleDataIdToBeDeleted={inProgressConsoleDataIdToBeDeleted}
setIsConsoleExpanded={setIsNotificationConsoleExpanded}
/>
</div>
data-bind="react: notificationConsoleComponentAdapter"
/>
</div>
{/* Global loader - Start */}

View File

@@ -0,0 +1,14 @@
import { Info } from "../Explorer/Controls/SmartUi/SmartUiComponent";
import { addPropertyToMap, buildSmartUiDescriptor } from "./SelfServeUtils";
export const IsDisplayable = (): ClassDecorator => {
return (target) => {
buildSmartUiDescriptor(target.name, target.prototype);
};
};
export const ClassInfo = (info: (() => Promise<Info>) | Info): ClassDecorator => {
return (target) => {
addPropertyToMap(target.prototype, "root", target.name, "info", info);
};
};

View File

@@ -1,64 +0,0 @@
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,57 +1,37 @@
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";
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";
const regionDropdownItems: ChoiceItem[] = [
export enum Regions {
NorthCentralUS = "NCUS",
WestUS = "WUS",
EastUS2 = "EUS2",
}
export const regionDropdownItems: ChoiceItem[] = [
{ label: "North Central US", key: Regions.NorthCentralUS },
{ label: "West US", key: Regions.WestUS },
{ label: "East US 2", key: Regions.EastUS2 },
];
const selfServeExampleInfo: Info = {
export const selfServeExampleInfo: Info = {
message: "This is a self serve class",
};
const regionDropdownInfo: Info = {
export const regionDropdownInfo: Info = {
message: "More regions can be added in the future.",
};
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 });
}
const onDbThroughputChange = (currentState: Map<string, InputType>, newValue: InputType): Map<string, InputType> => {
currentState.set("dbThroughput", newValue);
currentState.set("collectionThroughput", newValue);
return currentState;
};
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.");
}
const initializeMaxThroughput = async (): Promise<number> => {
return 10000;
};
/*
@@ -60,9 +40,8 @@ const validate = (currentvalues: Map<string, SmartUiInput>): void => {
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 onSave() function, a callback for when the submit button is clicked.
- Needs to define an onSubmit() function, a callback for when the submit button is clicked.
- Needs to define an initialize() function, to set default values for the inputs.
- 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.
@@ -82,46 +61,25 @@ const validate = (currentvalues: Map<string, SmartUiInput>): void => {
@ClassInfo(selfServeExampleInfo)
export default class SelfServeExample extends SelfServeBaseClass {
/*
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()
onSubmit()
- input: (currentValues: Map<string, InputType>) => Promise<void>
- role: Callback that is triggerred when the submit button is clicked. You should perform your rest API
calls here using the data from the different inputs passed as a Map to this callback function.
In this example, the onSave callback simply sets the value for keys corresponding to the field name
In this example, the onSubmit 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 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 };
public onSubmit = async (currentValues: Map<string, InputType>): Promise<void> => {
SessionStorageUtility.setEntry("regions", currentValues.get("regions")?.toString());
SessionStorageUtility.setEntry("enableLogging", currentValues.get("enableLogging")?.toString());
SessionStorageUtility.setEntry("accountName", currentValues.get("accountName")?.toString());
SessionStorageUtility.setEntry("dbThroughput", currentValues.get("dbThroughput")?.toString());
SessionStorageUtility.setEntry("collectionThroughput", currentValues.get("collectionThroughput")?.toString());
};
/*
initialize()
- input: () => Promise<Map<string, InputType>>
- role: Set default values for the properties of this class.
The properties of this class (namely regions, enableLogging, accountName, dbThroughput, collectionThroughput),
@@ -129,46 +87,24 @@ 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 onSave callback, to reinitialize the defaults.
these fields. This is called after the onSubmit callback, to reinitialize the defaults.
In this example, the initialize function simply reads the SessionStorage to fetch the default values
for these fields. These are then set when the changes are submitted.
- returns: () => Promise<Map<string, InputType>>
*/
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 });
public initialize = async (): Promise<Map<string, InputType>> => {
const defaults = new Map<string, InputType>();
defaults.set("regions", SessionStorageUtility.getEntry("regions"));
defaults.set("enableLogging", SessionStorageUtility.getEntry("enableLogging") === "true");
const stringInput = SessionStorageUtility.getEntry("accountName");
defaults.set("accountName", stringInput ? stringInput : "");
const numberSliderInput = parseInt(SessionStorageUtility.getEntry("dbThroughput"));
defaults.set("dbThroughput", isNaN(numberSliderInput) ? 1 : numberSliderInput);
const numberSpinnerInput = parseInt(SessionStorageUtility.getEntry("collectionThroughput"));
defaults.set("collectionThroughput", isNaN(numberSpinnerInput) ? 1 : numberSpinnerInput);
return defaults;
};
/*
@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
@@ -178,22 +114,11 @@ export default class SelfServeExample extends SelfServeBaseClass {
@PropertyInfo(regionDropdownInfo)
/*
@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() :
- input: NumberInputOptions | StringInputOptions | BooleanInputOptions | ChoiceInputOptions
- role: Specifies the required options to display the property as TextBox, Number Spinner/Slider, Radio buton or Dropdown.
*/
@OnChange(onRegionsChange)
@Values({ label: "Regions", choices: regionDropdownItems, placeholder: "Select a region" })
@Values({ label: "Regions", choices: regionDropdownItems })
regions: ChoiceItem;
@Values({
@@ -209,33 +134,34 @@ export default class SelfServeExample extends SelfServeBaseClass {
})
accountName: string;
@Values({
label: "Collection Throughput",
min: 400,
max: getMaxThroughput,
step: 100,
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;
@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: getMaxThroughput,
max: initializeMaxThroughput,
step: 100,
uiType: NumberUiType.Slider,
uiType: UiType.Slider,
})
dbThroughput: number;
@Values({
label: "Collection Throughput",
min: 400,
max: initializeMaxThroughput,
step: 100,
uiType: UiType.Spinner,
})
collectionThroughput: number;
}

View File

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

@@ -1,36 +1,23 @@
import React from "react";
import { shallow } from "enzyme";
import { SelfServeComponent, SelfServeComponentState } from "./SelfServeComponent";
import { NumberUiType, SelfServeDescriptor, SelfServeNotificationType, SmartUiInput } from "./SelfServeTypes";
import { SelfServeDescriptor, SelfServeComponent, SelfServeComponentState } from "./SelfServeComponent";
import { InputType, UiType } from "../Explorer/Controls/SmartUi/SmartUiComponent";
describe("SelfServeComponent", () => {
const defaultValues = new Map<string, SmartUiInput>([
["throughput", { value: 450 }],
["analyticalStore", { value: false }],
["database", { value: "db2" }],
const defaultValues = new Map<string, InputType>([
["throughput", "450"],
["analyticalStore", "false"],
["database", "db2"],
]);
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 initializeMock = jest.fn(async () => defaultValues);
const onSubmitMock = jest.fn(async () => {
return;
});
const exampleData: SelfServeDescriptor = {
initialize: initializeMock,
onSave: onSaveMock,
onRefresh: onRefreshMock,
inputNames: ["throughput", "analyticalStore", "database"],
onSubmit: onSubmitMock,
inputNames: ["throughput", "containerId", "analyticalStore", "database"],
root: {
id: "root",
info: {
@@ -51,7 +38,7 @@ describe("SelfServeComponent", () => {
max: 500,
step: 10,
defaultValue: 400,
uiType: NumberUiType.Spinner,
uiType: UiType.Spinner,
},
},
{
@@ -91,109 +78,27 @@ describe("SelfServeComponent", () => {
},
};
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));
const verifyDefaultsSet = (currentValues: Map<string, InputType>): void => {
for (const key of currentValues.keys()) {
if (defaultValues.has(key)) {
expect(defaultValues.get(key)).toEqual(currentValues.get(key));
}
}
};
it("should render and honor save, discard, refresh actions", async () => {
it("should render", async () => {
const wrapper = shallow(<SelfServeComponent descriptor={exampleData} />);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(wrapper).toMatchSnapshot();
// 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);
// 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);
// 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();
// onSubmit() must be called when submit button is clicked
const submitButton = wrapper.find("#submitButton");
submitButton.simulate("click");
expect(onSubmitMock).toHaveBeenCalled();
});
});

View File

@@ -1,31 +1,62 @@
import React from "react";
import { IStackTokens, PrimaryButton, Spinner, SpinnerSize, Stack } from "office-ui-fabric-react";
import {
CommandBar,
ICommandBarItemProps,
IStackTokens,
MessageBar,
MessageBarType,
Spinner,
SpinnerSize,
Stack,
} from "office-ui-fabric-react";
import {
AnyDisplay,
Node,
ChoiceItem,
InputType,
RefreshResult,
SelfServeDescriptor,
SelfServeNotification,
SmartUiInput,
DescriptionDisplay,
StringInput,
NumberInput,
BooleanInput,
ChoiceInput,
SelfServeNotificationType,
} from "./SelfServeTypes";
import { SmartUiComponent, SmartUiDescriptor } from "../Explorer/Controls/SmartUi/SmartUiComponent";
import { getMessageBarType } from "./SelfServeUtils";
InputTypeValue,
SmartUiComponent,
UiType,
SmartUiDescriptor,
Info,
} from "../Explorer/Controls/SmartUi/SmartUiComponent";
export interface BaseInput {
label: (() => Promise<string>) | string;
dataFieldName: string;
type: InputTypeValue;
onChange?: (currentState: Map<string, InputType>, newValue: InputType) => Map<string, InputType>;
placeholder?: (() => Promise<string>) | string;
errorMessage?: string;
}
export interface NumberInput extends BaseInput {
min: (() => Promise<number>) | number;
max: (() => Promise<number>) | number;
step: (() => Promise<number>) | number;
defaultValue?: number;
uiType: UiType;
}
export interface BooleanInput extends BaseInput {
trueLabel: (() => Promise<string>) | string;
falseLabel: (() => Promise<string>) | string;
defaultValue?: boolean;
}
export interface StringInput extends BaseInput {
defaultValue?: string;
}
export interface ChoiceInput extends BaseInput {
choices: (() => Promise<ChoiceItem[]>) | ChoiceItem[];
defaultKey?: string;
}
export interface Node {
id: string;
info?: (() => Promise<Info>) | Info;
input?: AnyInput;
children?: Node[];
}
export interface SelfServeDescriptor {
root: Node;
initialize?: () => Promise<Map<string, InputType>>;
onSubmit?: (currentValues: Map<string, InputType>) => Promise<void>;
inputNames?: string[];
}
export type AnyInput = NumberInput | BooleanInput | StringInput | ChoiceInput;
export interface SelfServeComponentProps {
descriptor: SelfServeDescriptor;
@@ -33,18 +64,13 @@ export interface SelfServeComponentProps {
export interface SelfServeComponentState {
root: SelfServeDescriptor;
currentValues: Map<string, SmartUiInput>;
baselineValues: Map<string, SmartUiInput>;
isInitializing: boolean;
hasErrors: boolean;
compileErrorMessage: string;
notification: SelfServeNotification;
refreshResult: RefreshResult;
currentValues: Map<string, InputType>;
baselineValues: Map<string, InputType>;
isRefreshing: boolean;
}
export class SelfServeComponent extends React.Component<SelfServeComponentProps, SelfServeComponentState> {
componentDidMount(): void {
this.performRefresh();
this.initializeSmartUiComponent();
}
@@ -54,108 +80,62 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
root: this.props.descriptor,
currentValues: new Map(),
baselineValues: new Map(),
isInitializing: true,
hasErrors: false,
compileErrorMessage: undefined,
notification: undefined,
refreshResult: undefined,
isRefreshing: false,
};
}
private onError = (hasErrors: boolean): void => {
this.setState({ hasErrors });
};
private initializeSmartUiComponent = async (): Promise<void> => {
this.setState({ isInitializing: true });
this.setState({ isRefreshing: true });
await this.initializeSmartUiNode(this.props.descriptor.root);
await this.setDefaults();
const { currentValues, baselineValues } = this.state;
await this.initializeSmartUiNode(this.props.descriptor.root, currentValues, baselineValues);
this.setState({ isInitializing: false, currentValues, baselineValues });
this.setState({ isRefreshing: false });
};
private setDefaults = async (): Promise<void> => {
this.setState({ isRefreshing: true });
let { currentValues, baselineValues } = this.state;
const initialValues = await this.props.descriptor.initialize();
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);
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.setState({
compileErrorMessage: `The following fields have default values set but are not input properties of this class: ${keys.join(
", "
)}`,
});
currentValues = currentValues.set(key, initialValues.get(key));
baselineValues = baselineValues.set(key, initialValues.get(key));
}
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 });
this.setState({ currentValues, baselineValues, isRefreshing: false });
};
public discard = (): void => {
let { currentValues } = this.state;
const { baselineValues } = this.state;
for (const key of currentValues.keys()) {
const baselineValue = baselineValues.get(key);
currentValues = currentValues.set(key, { ...baselineValue });
for (const key of baselineValues.keys()) {
currentValues = currentValues.set(key, baselineValues.get(key));
}
this.setState({ currentValues });
};
private initializeSmartUiNode = async (
currentNode: Node,
currentValues: Map<string, SmartUiInput>,
baselineValues: Map<string, SmartUiInput>
): Promise<void> => {
private initializeSmartUiNode = async (currentNode: Node): Promise<void> => {
currentNode.info = await this.getResolvedValue(currentNode.info);
if (currentNode.input) {
currentNode.input = await this.getResolvedInput(currentNode.input, currentValues, baselineValues);
currentNode.input = await this.getResolvedInput(currentNode.input);
}
const promises = currentNode.children?.map(
async (child: Node) => await this.initializeSmartUiNode(child, currentValues, baselineValues)
);
const promises = currentNode.children?.map(async (child: Node) => await this.initializeSmartUiNode(child));
if (promises) {
await Promise.all(promises);
}
};
private getResolvedInput = async (
input: AnyDisplay,
currentValues: Map<string, SmartUiInput>,
baselineValues: Map<string, SmartUiInput>
): Promise<AnyDisplay> => {
private getResolvedInput = async (input: AnyInput): Promise<AnyInput> => {
input.label = await this.getResolvedValue(input.label);
input.placeholder = await this.getResolvedValue(input.placeholder);
switch (input.type) {
case "string": {
if ("description" in input) {
const descriptionDisplay = input as DescriptionDisplay;
descriptionDisplay.description = await this.getResolvedValue(descriptionDisplay.description);
}
return input as StringInput;
}
case "number": {
@@ -163,16 +143,6 @@ 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": {
@@ -196,157 +166,53 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
return value;
}
private onInputChange = (input: AnyDisplay, newValue: InputType) => {
private onInputChange = (input: AnyInput, newValue: InputType) => {
if (input.onChange) {
const newValues = input.onChange(this.state.currentValues, newValue);
this.setState({ currentValues: newValues });
} else {
const dataFieldName = input.dataFieldName;
const { currentValues } = this.state;
const currentInputValue = currentValues.get(dataFieldName);
currentValues.set(dataFieldName, { value: newValue, hidden: currentInputValue?.hidden });
currentValues.set(dataFieldName, newValue);
this.setState({ currentValues });
}
};
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();
});
};
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 (
const containerStackTokens: IStackTokens = { childrenGap: 20 };
return !this.state.isRefreshing ? (
<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%" } }}
<Stack tokens={containerStackTokens} styles={{ root: { width: 400, padding: 10 } }}>
<SmartUiComponent
descriptor={this.state.root as SmartUiDescriptor}
currentValues={this.state.currentValues}
onInputChange={this.onInputChange}
/>
<Stack horizontal tokens={{ childrenGap: 10 }}>
<PrimaryButton
id="submitButton"
styles={{ root: { width: 100 } }}
text="submit"
onClick={async () => {
await this.props.descriptor.onSubmit(this.state.currentValues);
this.setDefaults();
}}
/>
) : (
<>
{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}
/>
</>
)}
<PrimaryButton
id="discardButton"
styles={{ root: { width: 100 } }}
text="discard"
onClick={() => this.discard()}
/>
</Stack>
</Stack>
</div>
) : (
<Spinner
size={SpinnerSize.large}
styles={{ root: { textAlign: "center", justifyContent: "center", width: "100%", height: "100%" } }}
/>
);
}
}

View File

@@ -7,8 +7,7 @@ import * as ko from "knockout";
import * as React from "react";
import { ReactAdapter } from "../Bindings/ReactBindingHandler";
import Explorer from "../Explorer/Explorer";
import { SelfServeComponent } from "./SelfServeComponent";
import { SelfServeDescriptor } from "./SelfServeTypes";
import { SelfServeDescriptor, SelfServeComponent } from "./SelfServeComponent";
import { SelfServeType } from "./SelfServeUtils";
export class SelfServeComponentAdapter implements ReactAdapter {
@@ -29,10 +28,6 @@ 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

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

View File

@@ -1,22 +1,14 @@
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,
ChoiceItem,
Description,
DescriptionDisplay,
Info,
InputType,
InputTypeValue,
NumberInput,
SelfServeDescriptor,
SmartUiInput,
NumberInput,
StringInput,
SelfServeNotificationType,
} from "./SelfServeTypes";
Node,
AnyInput,
} from "./SelfServeComponent";
export enum SelfServeType {
// No self serve type passed, launch explorer
@@ -25,10 +17,33 @@ export enum SelfServeType {
invalid = "invalid",
// Add your self serve types here
example = "example",
sqlx = "sqlx",
}
export interface DecoratorProperties {
export abstract class SelfServeBaseClass {
public abstract onSubmit: (currentValues: Map<string, InputType>) => Promise<void>;
public abstract initialize: () => Promise<Map<string, InputType>>;
public toSelfServeDescriptor(): SelfServeDescriptor {
const className = this.constructor.name;
const smartUiDescriptor = Reflect.getMetadata(className, this) as SelfServeDescriptor;
if (!this.initialize) {
throw new Error(`initialize() was not declared for the class '${className}'`);
}
if (!this.onSubmit) {
throw new Error(`onSubmit() was not declared for the class '${className}'`);
}
if (!smartUiDescriptor?.root) {
throw new Error(`@SmartUi decorator was not declared for the class '${className}'`);
}
smartUiDescriptor.initialize = this.initialize;
smartUiDescriptor.onSubmit = this.onSubmit;
return smartUiDescriptor;
}
}
export interface CommonInputTypes {
id: string;
info?: (() => Promise<Info>) | Info;
type?: InputTypeValue;
@@ -43,43 +58,41 @@ export interface DecoratorProperties {
choices?: (() => Promise<ChoiceItem[]>) | ChoiceItem[];
uiType?: string;
errorMessage?: string;
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>>;
onChange?: (currentState: Map<string, InputType>, newValue: InputType) => Map<string, InputType>;
onSubmit?: (currentValues: Map<string, InputType>) => Promise<void>;
initialize?: () => Promise<Map<string, InputType>>;
}
const setValue = <T extends keyof DecoratorProperties, K extends DecoratorProperties[T]>(
const setValue = <T extends keyof CommonInputTypes, K extends CommonInputTypes[T]>(
name: T,
value: K,
fieldObject: DecoratorProperties
fieldObject: CommonInputTypes
): void => {
fieldObject[name] = value;
};
const getValue = <T extends keyof DecoratorProperties>(name: T, fieldObject: DecoratorProperties): unknown => {
const getValue = <T extends keyof CommonInputTypes>(name: T, fieldObject: CommonInputTypes): unknown => {
return fieldObject[name];
};
export const addPropertyToMap = <T extends keyof DecoratorProperties, K extends DecoratorProperties[T]>(
export const addPropertyToMap = <T extends keyof CommonInputTypes, K extends CommonInputTypes[T]>(
target: unknown,
propertyName: string,
className: string,
descriptorName: keyof DecoratorProperties,
descriptorName: keyof CommonInputTypes,
descriptorValue: K
): void => {
const context =
(Reflect.getMetadata(className, target) as Map<string, DecoratorProperties>) ??
new Map<string, DecoratorProperties>();
(Reflect.getMetadata(className, target) as Map<string, CommonInputTypes>) ?? new Map<string, CommonInputTypes>();
updateContextWithDecorator(context, propertyName, className, descriptorName, descriptorValue);
Reflect.defineMetadata(className, context, target);
};
export const updateContextWithDecorator = <T extends keyof DecoratorProperties, K extends DecoratorProperties[T]>(
context: Map<string, DecoratorProperties>,
export const updateContextWithDecorator = <T extends keyof CommonInputTypes, K extends CommonInputTypes[T]>(
context: Map<string, CommonInputTypes>,
propertyName: string,
className: string,
descriptorName: keyof DecoratorProperties,
descriptorName: keyof CommonInputTypes,
descriptorValue: K
): void => {
if (!(context instanceof Map)) {
@@ -99,12 +112,12 @@ export const updateContextWithDecorator = <T extends keyof DecoratorProperties,
};
export const buildSmartUiDescriptor = (className: string, target: unknown): void => {
const context = Reflect.getMetadata(className, target) as Map<string, DecoratorProperties>;
const context = Reflect.getMetadata(className, target) as Map<string, CommonInputTypes>;
const smartUiDescriptor = mapToSmartUiDescriptor(context);
Reflect.defineMetadata(className, smartUiDescriptor, target);
};
export const mapToSmartUiDescriptor = (context: Map<string, DecoratorProperties>): SelfServeDescriptor => {
export const mapToSmartUiDescriptor = (context: Map<string, CommonInputTypes>): SelfServeDescriptor => {
const root = context.get("root");
context.delete("root");
const inputNames: string[] = [];
@@ -127,7 +140,7 @@ export const mapToSmartUiDescriptor = (context: Map<string, DecoratorProperties>
};
const addToDescriptor = (
context: Map<string, DecoratorProperties>,
context: Map<string, CommonInputTypes>,
root: Node,
key: string,
inputNames: string[]
@@ -144,7 +157,7 @@ const addToDescriptor = (
root.children.push(element);
};
const getInput = (value: DecoratorProperties): AnyDisplay => {
const getInput = (value: CommonInputTypes): AnyInput => {
switch (value.type) {
case "number":
if (!value.label || !value.step || !value.uiType || !value.min || !value.max) {
@@ -152,9 +165,6 @@ const getInput = (value: DecoratorProperties): AnyDisplay => {
}
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}'.`;
}
@@ -171,14 +181,3 @@ const getInput = (value: DecoratorProperties): AnyDisplay => {
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

@@ -1,33 +0,0 @@
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

@@ -1,97 +0,0 @@
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 message bar and spinner snapshots 1`] = `
exports[`SelfServeComponent should render 1`] = `
<div
style={
Object {
@@ -13,95 +13,22 @@ exports[`SelfServeComponent message bar and spinner snapshots 1`] = `
Object {
"root": Object {
"padding": 10,
"width": 400,
},
}
}
tokens={
Object {
"childrenGap": 5,
"childrenGap": 20,
}
}
>
<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" => Object {
"value": 450,
},
"analyticalStore" => Object {
"value": false,
},
"database" => Object {
"value": "db2",
},
"throughput" => "450",
"analyticalStore" => "false",
"database" => "db2",
}
}
descriptor={
@@ -109,95 +36,21 @@ exports[`SelfServeComponent message bar and spinner snapshots 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",
],
"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 {},
},
],
},
"onSubmit": [MockFunction],
"root": Object {
"children": Array [
Object {
@@ -275,612 +128,41 @@ exports[`SelfServeComponent message bar and spinner snapshots 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={
<Stack
horizontal={true}
tokens={
Object {
"root": Object {
"paddingLeft": 0,
},
}
}
/>
<StyledMessageBarBase
messageBarType={0}
onDismiss={[Function]}
styles={
Object {
"root": Object {
"width": 400,
},
"childrenGap": 10,
}
}
>
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",
<CustomizedPrimaryButton
id="submitButton"
onClick={[Function]}
styles={
Object {
"root": Object {
"width": 100,
},
},
}
}
}
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]}
/>
text="submit"
/>
<CustomizedPrimaryButton
id="discardButton"
onClick={[Function]}
styles={
Object {
"root": Object {
"width": 100,
},
}
}
text="discard"
/>
</Stack>
</Stack>
</div>
`;

View File

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

View File

@@ -1,4 +1,5 @@
import { BindingHandlersRegisterer } from "./Bindings/BindingHandlersRegisterer";
import { sendMessage } from "./Common/MessageHandler";
import * as ko from "knockout";
import Explorer from "./Explorer/Explorer";
@@ -9,6 +10,7 @@ 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();
}
};

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import { useBoolean } from "@uifabric/react-hooks";
import { UserAgentApplication, Account, Configuration } from "msal";
import { UserAgentApplication, Account, Configuration, InteractionRequiredAuthError } from "msal";
const config: Configuration = {
cache: {
@@ -69,18 +69,42 @@ export function useAADAuth(): ReturnType {
React.useEffect(() => {
if (account && tenantId) {
Promise.all([
msal.acquireTokenSilent({
// There is a bug in MSALv1 that requires us to refresh the token. Their internal cache is not respecting authority
forceRefresh: true,
authority: `https://login.microsoftonline.com/${tenantId}`,
scopes: ["https://graph.windows.net//.default"],
}),
msal.acquireTokenSilent({
// There is a bug in MSALv1 that requires us to refresh the token. Their internal cache is not respecting authority
forceRefresh: true,
authority: `https://login.microsoftonline.com/${tenantId}`,
scopes: ["https://management.azure.com//.default"],
}),
msal
.acquireTokenSilent({
// There is a bug in MSALv1 that requires us to refresh the token. Their internal cache is not respecting authority
forceRefresh: true,
authority: `https://login.microsoftonline.com/${tenantId}`,
scopes: ["https://graph.windows.net//.default"],
})
.catch((error: unknown) => {
if (error instanceof InteractionRequiredAuthError) {
return msal.acquireTokenPopup({
// There is a bug in MSALv1 that requires us to refresh the token. Their internal cache is not respecting authority
forceRefresh: true,
authority: `https://login.microsoftonline.com/${tenantId}`,
scopes: ["https://graph.windows.net//.default"],
});
}
throw error;
}),
msal
.acquireTokenSilent({
// There is a bug in MSALv1 that requires us to refresh the token. Their internal cache is not respecting authority
forceRefresh: true,
authority: `https://login.microsoftonline.com/${tenantId}`,
scopes: ["https://management.azure.com//.default"],
})
.catch((error: unknown) => {
if (error instanceof InteractionRequiredAuthError) {
return msal.acquireTokenPopup({
// There is a bug in MSALv1 that requires us to refresh the token. Their internal cache is not respecting authority
forceRefresh: true,
authority: `https://login.microsoftonline.com/${tenantId}`,
scopes: ["https://management.azure.com//.default"],
});
}
throw error;
}),
]).then(([graphTokenResponse, armTokenResponse]) => {
setGraphToken(graphTokenResponse.accessToken);
setArmToken(armTokenResponse.accessToken);

View File

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

@@ -1,298 +0,0 @@
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,27 +12,10 @@ describe("Self Serve", () => {
frame = await getTestExplorerFrame(
new Map<string, string>([[TestExplorerParams.selfServeType, SelfServeType.example]])
);
// 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("#regions-dropown-input");
await frame.waitForSelector("#enableLogging-radioSwitch-input");
await frame.waitForSelector("#accountName-textBox-input");
await frame.waitForSelector("#dbThroughput-slider-input");
await frame.waitForSelector("#collectionThroughput-spinner-input");
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@@ -1,5 +1,7 @@
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";
@@ -17,6 +19,26 @@ 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,
@@ -27,8 +49,34 @@ 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)
);
@@ -41,7 +89,11 @@ const initTestExplorer = async (): Promise<void> => {
);
const selfServeType = urlSearchParams.get(TestExplorerParams.selfServeType);
const token = decodeURIComponent(urlSearchParams.get(TestExplorerParams.token));
const token = await AADLogin(
notebooksTestRunnerTenantId,
notebooksTestRunnerClientId,
notebooksTestRunnerClientSecret
);
const databaseAccount = await getDatabaseAccount(
token,
portalRunnerSubscripton,
@@ -50,6 +102,7 @@ const initTestExplorer = async (): Promise<void> => {
);
const initTestExplorerContent = {
type: MessageTypes.InitTestExplorer,
inputs: {
databaseAccount: databaseAccount,
subscriptionId: portalRunnerSubscripton,
@@ -77,35 +130,11 @@ const initTestExplorer = async (): Promise<void> => {
},
// add UI test only when feature is not dependent on flights anymore
flights: [],
selfServeType,
selfServeType: selfServeType,
} as ViewModels.DataExplorerInputsFrame,
};
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.postMessage(initTestExplorerContent, window.location.href);
};
initTestExplorer();
window.addEventListener("load", initTestExplorer);

View File

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

View File

@@ -1,6 +1,5 @@
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> => {
@@ -16,15 +15,19 @@ 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)
@@ -38,7 +41,6 @@ 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,5 +6,13 @@
<link rel="shortcut icon" href="images/CosmosDB_rgb_ui_lighttheme.ico" type="image/x-icon" />
</head>
<body></body>
<body>
<iframe
id="explorerMenu"
name="explorer"
class="iframe"
title="explorer"
src="explorer.html?v=1.0.1&platform=Portal"
></iframe>
</body>
</html>

View File

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