Merge branch 'master' into v-yiqcao/nullCheck

This commit is contained in:
Chris-MS-896
2021-01-26 17:50:56 -06:00
committed by GitHub
35 changed files with 2915 additions and 895 deletions

View File

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

34
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -55,7 +55,6 @@ import { sendMessage, sendCachedDataMessage, handleCachedDataMessage } from "../
import { NotebookContentItem, NotebookContentItemType } from "./Notebook/NotebookContentItem";
import { NotebookUtil } from "./Notebook/NotebookUtil";
import { NotebookWorkspaceManager } from "../NotebookWorkspaceManager/NotebookWorkspaceManager";
import { NotificationConsoleComponentAdapter } from "./Menus/NotificationConsole/NotificationConsoleComponentAdapter";
import * as NotificationConsoleUtils from "../Utils/NotificationConsoleUtils";
import { QueriesClient } from "../Common/QueriesClient";
import { QuerySelectPane } from "./Panes/Tables/QuerySelectPane";
@@ -107,6 +106,12 @@ interface AdHocAccessData {
readUrl: string;
}
export interface ExplorerParams {
setIsNotificationConsoleExpanded: (isExpanded: boolean) => void;
setNotificationConsoleData: (consoleData: ConsoleData) => void;
setInProgressConsoleDataIdToBeDeleted: (id: string) => void;
}
export default class Explorer {
public flight: ko.Observable<string> = ko.observable<string>(
SharedConstants.CollectionCreation.DefaultAddCollectionDefaultFlight
@@ -146,8 +151,9 @@ export default class Explorer {
public mostRecentActivity: MostRecentActivity.MostRecentActivity;
// Notification Console
public notificationConsoleData: ko.ObservableArray<ConsoleData>;
public isNotificationConsoleExpanded: ko.Observable<boolean>;
private setIsNotificationConsoleExpanded: (isExpanded: boolean) => void;
private setNotificationConsoleData: (consoleData: ConsoleData) => void;
private setInProgressConsoleDataIdToBeDeleted: (id: string) => void;
// Panes
public contextPanes: ContextualPaneBase[];
@@ -260,7 +266,6 @@ export default class Explorer {
// React adapters
private commandBarComponentAdapter: CommandBarComponentAdapter;
private splashScreenAdapter: SplashScreenComponentAdapter;
private notificationConsoleComponentAdapter: NotificationConsoleComponentAdapter;
private dialogComponentAdapter: DialogComponentAdapter;
private _dialogProps: ko.Observable<DialogProps>;
private addSynapseLinkDialog: DialogComponentAdapter;
@@ -269,7 +274,11 @@ export default class Explorer {
private static readonly MaxNbDatabasesToAutoExpand = 5;
constructor() {
constructor(params?: ExplorerParams) {
this.setIsNotificationConsoleExpanded = params?.setIsNotificationConsoleExpanded;
this.setNotificationConsoleData = params?.setNotificationConsoleData;
this.setInProgressConsoleDataIdToBeDeleted = params?.setInProgressConsoleDataIdToBeDeleted;
const startKey: number = TelemetryProcessor.traceStart(Action.InitializeDataExplorer, {
dataExplorerArea: Constants.Areas.ResourceTree,
});
@@ -430,7 +439,6 @@ export default class Explorer {
);
this.isSchemaEnabled = ko.computed<boolean>(() => this.isFeatureEnabled(Constants.Features.enableSchema));
this.isNotificationConsoleExpanded = ko.observable<boolean>(false);
this.isAutoscaleDefaultEnabled = ko.observable<boolean>(false);
@@ -478,7 +486,6 @@ export default class Explorer {
bounds: splitterBounds,
direction: SplitterDirection.Vertical,
});
this.notificationConsoleData = ko.observableArray<ConsoleData>([]);
this.defaultExperience = ko.observable<string>();
this.databaseAccount.subscribe((databaseAccount) => {
const defaultExperience: string = DefaultExperienceUtility.getDefaultExperienceFromDatabaseAccount(
@@ -892,7 +899,6 @@ export default class Explorer {
this.commandBarComponentAdapter = new CommandBarComponentAdapter(this);
this.selfServeLoadingComponentAdapter = new SelfServeLoadingComponentAdapter();
this.notificationConsoleComponentAdapter = new NotificationConsoleComponentAdapter(this);
this._initSettings();
@@ -1349,23 +1355,19 @@ export default class Explorer {
}
public logConsoleData(consoleData: ConsoleData): void {
this.notificationConsoleData.splice(0, 0, consoleData);
this.setNotificationConsoleData(consoleData);
}
public deleteInProgressConsoleDataWithId(id: string): void {
const updatedConsoleData = _.reject(
this.notificationConsoleData(),
(data: ConsoleData) => data.type === ConsoleDataType.InProgress && data.id === id
);
this.notificationConsoleData(updatedConsoleData);
this.setInProgressConsoleDataIdToBeDeleted(id);
}
public expandConsole(): void {
this.isNotificationConsoleExpanded(true);
this.setIsNotificationConsoleExpanded(true);
}
public collapseConsole(): void {
this.isNotificationConsoleExpanded(false);
this.setIsNotificationConsoleExpanded(false);
}
public toggleLeftPaneExpanded() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -54,7 +54,8 @@ import "./Libs/is-integer-polyfill";
import "url-polyfill/url-polyfill.min";
import { initializeIcons } from "office-ui-fabric-react/lib/Icons";
import React from "react";
import { ExplorerParams } from "./Explorer/Explorer";
import React, { useState } from "react";
import ReactDOM from "react-dom";
import copyImage from "../images/Copy.svg";
import hdeConnectImage from "../images/HdeConnectCosmosDB.svg";
@@ -63,12 +64,22 @@ 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();
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);
useKnockoutExplorer(config, explorerParams);
return (
<div className="flexContainer">
@@ -270,8 +281,14 @@ const App: React.FunctionComponent = () => {
role="contentinfo"
aria-label="Notification console"
id="explorerNotificationConsole"
data-bind="react: notificationConsoleComponentAdapter"
/>
>
<NotificationConsoleComponent
isConsoleExpanded={isNotificationConsoleExpanded}
consoleData={notificationConsoleData}
inProgressConsoleDataIdToBeDeleted={inProgressConsoleDataIdToBeDeleted}
setIsConsoleExpanded={setIsNotificationConsoleExpanded}
/>
</div>
</div>
{/* Global loader - Start */}

View File

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

View File

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

View File

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

View File

@@ -1,37 +1,57 @@
import { PropertyInfo, OnChange, Values } from "../PropertyDecorators";
import { ClassInfo, IsDisplayable } from "../ClassDecorators";
import { SelfServeBaseClass } from "../SelfServeUtils";
import { ChoiceItem, Info, InputType, UiType } from "../../Explorer/Controls/SmartUi/SmartUiComponent";
import { SessionStorageUtility } from "../../Shared/StorageUtility";
import { PropertyInfo, OnChange, Values, IsDisplayable, ClassInfo } from "../Decorators";
import {
ChoiceItem,
Info,
InputType,
NumberUiType,
RefreshResult,
SelfServeBaseClass,
SelfServeNotification,
SelfServeNotificationType,
SmartUiInput,
} from "../SelfServeTypes";
import { onRefreshSelfServeExample, getMaxThroughput, Regions, update, initialize } from "./SelfServeExample.rp";
export enum Regions {
NorthCentralUS = "NCUS",
WestUS = "WUS",
EastUS2 = "EUS2",
}
export const regionDropdownItems: ChoiceItem[] = [
const regionDropdownItems: ChoiceItem[] = [
{ label: "North Central US", key: Regions.NorthCentralUS },
{ label: "West US", key: Regions.WestUS },
{ label: "East US 2", key: Regions.EastUS2 },
];
export const selfServeExampleInfo: Info = {
const selfServeExampleInfo: Info = {
message: "This is a self serve class",
};
export const regionDropdownInfo: Info = {
const regionDropdownInfo: Info = {
message: "More regions can be added in the future.",
};
const onDbThroughputChange = (currentState: Map<string, InputType>, newValue: InputType): Map<string, InputType> => {
currentState.set("dbThroughput", newValue);
currentState.set("collectionThroughput", newValue);
const onRegionsChange = (currentState: Map<string, SmartUiInput>, newValue: InputType): Map<string, SmartUiInput> => {
currentState.set("regions", { value: newValue });
const currentEnableLogging = currentState.get("enableLogging");
if (newValue === Regions.NorthCentralUS) {
currentState.set("enableLogging", { value: false, disabled: true });
} else {
currentState.set("enableLogging", { value: currentEnableLogging.value, disabled: false });
}
return currentState;
};
const initializeMaxThroughput = async (): Promise<number> => {
return 10000;
const onEnableDbLevelThroughputChange = (
currentState: Map<string, SmartUiInput>,
newValue: InputType
): Map<string, SmartUiInput> => {
currentState.set("enableDbLevelThroughput", { value: newValue });
const currentDbThroughput = currentState.get("dbThroughput");
const isDbThroughputHidden = newValue === undefined || !(newValue as boolean);
currentState.set("dbThroughput", { value: currentDbThroughput.value, hidden: isDbThroughputHidden });
return currentState;
};
const validate = (currentvalues: Map<string, SmartUiInput>): void => {
if (!currentvalues.get("regions").value || !currentvalues.get("accountName").value) {
throw new Error("Regions and AccountName should not be empty.");
}
};
/*
@@ -40,8 +60,9 @@ const initializeMaxThroughput = async (): Promise<number> => {
Each self serve class
- Needs to extends the SelfServeBase class.
- Needs to have the @IsDisplayable() decorator to tell the compiler that UI needs to be generated from this class.
- Needs to define an onSubmit() function, a callback for when the submit button is clicked.
- Needs to define an onSave() function, a callback for when the submit button is clicked.
- Needs to define an initialize() function, to set default values for the inputs.
- Needs to define an onRefresh() function, a callback for when the refresh button is clicked.
You can test this self serve UI by using the featureflag '?feature.selfServeType=example'
and plumb in similar feature flags for your own self serve class.
@@ -61,25 +82,46 @@ const initializeMaxThroughput = async (): Promise<number> => {
@ClassInfo(selfServeExampleInfo)
export default class SelfServeExample extends SelfServeBaseClass {
/*
onSubmit()
onRefresh()
- role : Callback that is triggerrd when the refresh button is clicked. You should perform the your rest API
call to check if the update action is completed.
- returns:
RefreshResult -
isComponentUpdating: Indicated if the state is still being updated
notificationMessage: Notification message to be shown in case the component is still being updated
i.e, isComponentUpdating is true
*/
public onRefresh = async (): Promise<RefreshResult> => {
return onRefreshSelfServeExample();
};
/*
onSave()
- input: (currentValues: Map<string, InputType>) => Promise<void>
- role: Callback that is triggerred when the submit button is clicked. You should perform your rest API
calls here using the data from the different inputs passed as a Map to this callback function.
In this example, the onSubmit callback simply sets the value for keys corresponding to the field name
In this example, the onSave callback simply sets the value for keys corresponding to the field name
in the SessionStorage.
- returns: SelfServeNotification -
message: The message to be displayed in the message bar after the onSave is completed
type: The type of message bar to be used (info, warning, error)
*/
public onSubmit = async (currentValues: Map<string, InputType>): Promise<void> => {
SessionStorageUtility.setEntry("regions", currentValues.get("regions")?.toString());
SessionStorageUtility.setEntry("enableLogging", currentValues.get("enableLogging")?.toString());
SessionStorageUtility.setEntry("accountName", currentValues.get("accountName")?.toString());
SessionStorageUtility.setEntry("dbThroughput", currentValues.get("dbThroughput")?.toString());
SessionStorageUtility.setEntry("collectionThroughput", currentValues.get("collectionThroughput")?.toString());
public onSave = async (currentValues: Map<string, SmartUiInput>): Promise<SelfServeNotification> => {
validate(currentValues);
const regions = Regions[currentValues.get("regions")?.value as keyof typeof Regions];
const enableLogging = currentValues.get("enableLogging")?.value as boolean;
const accountName = currentValues.get("accountName")?.value as string;
const collectionThroughput = currentValues.get("collectionThroughput")?.value as number;
const enableDbLevelThroughput = currentValues.get("enableDbLevelThroughput")?.value as boolean;
let dbThroughput = currentValues.get("dbThroughput")?.value as number;
dbThroughput = enableDbLevelThroughput ? dbThroughput : undefined;
await update(regions, enableLogging, accountName, collectionThroughput, dbThroughput);
return { message: "submitted successfully", type: SelfServeNotificationType.info };
};
/*
initialize()
- input: () => Promise<Map<string, InputType>>
- role: Set default values for the properties of this class.
The properties of this class (namely regions, enableLogging, accountName, dbThroughput, collectionThroughput),
@@ -87,24 +129,46 @@ export default class SelfServeExample extends SelfServeBaseClass {
defaults can be set by setting values in a Map corresponding to the field's name.
Typically, you can make rest calls in the async initialize function, to fetch the initial values for
these fields. This is called after the onSubmit callback, to reinitialize the defaults.
these fields. This is called after the onSave callback, to reinitialize the defaults.
In this example, the initialize function simply reads the SessionStorage to fetch the default values
for these fields. These are then set when the changes are submitted.
- returns: () => Promise<Map<string, InputType>>
*/
public initialize = async (): Promise<Map<string, InputType>> => {
const defaults = new Map<string, InputType>();
defaults.set("regions", SessionStorageUtility.getEntry("regions"));
defaults.set("enableLogging", SessionStorageUtility.getEntry("enableLogging") === "true");
const stringInput = SessionStorageUtility.getEntry("accountName");
defaults.set("accountName", stringInput ? stringInput : "");
const numberSliderInput = parseInt(SessionStorageUtility.getEntry("dbThroughput"));
defaults.set("dbThroughput", isNaN(numberSliderInput) ? 1 : numberSliderInput);
const numberSpinnerInput = parseInt(SessionStorageUtility.getEntry("collectionThroughput"));
defaults.set("collectionThroughput", isNaN(numberSpinnerInput) ? 1 : numberSpinnerInput);
public initialize = async (): Promise<Map<string, SmartUiInput>> => {
const initializeResponse = await initialize();
const defaults = new Map<string, SmartUiInput>();
defaults.set("regions", { value: initializeResponse.regions });
defaults.set("enableLogging", { value: initializeResponse.enableLogging });
const accountName = initializeResponse.accountName;
defaults.set("accountName", { value: accountName ? accountName : "" });
defaults.set("collectionThroughput", { value: initializeResponse.collectionThroughput });
const enableDbLevelThroughput = !!initializeResponse.dbThroughput;
defaults.set("enableDbLevelThroughput", { value: enableDbLevelThroughput });
defaults.set("dbThroughput", { value: initializeResponse.dbThroughput, hidden: !enableDbLevelThroughput });
return defaults;
};
/*
@Values() :
- input: NumberInputOptions | StringInputOptions | BooleanInputOptions | ChoiceInputOptions | DescriptionDisplay
- role: Specifies the required options to display the property as
a) TextBox for text input
b) Spinner/Slider for number input
c) Radio buton/Toggle for boolean input
d) Dropdown for choice input
e) Text (with optional hyperlink) for descriptions
*/
@Values({
description: {
text: "This class sets collection and database throughput.",
link: {
href: "https://docs.microsoft.com/en-us/azure/cosmos-db/introduction",
text: "Click here for more information",
},
},
})
description: string;
/*
@PropertyInfo()
- optional
@@ -114,11 +178,22 @@ export default class SelfServeExample extends SelfServeBaseClass {
@PropertyInfo(regionDropdownInfo)
/*
@Values() :
- input: NumberInputOptions | StringInputOptions | BooleanInputOptions | ChoiceInputOptions
- role: Specifies the required options to display the property as TextBox, Number Spinner/Slider, Radio buton or Dropdown.
@OnChange()
- optional
- input: (currentValues: Map<string, InputType>, newValue: InputType) => Map<string, InputType>
- role: Takes a Map of current values and the newValue for this property as inputs. This is called when a property,
say prop1, changes its value in the UI. This can be used to
a) Change the value (and reflect it in the UI) for prop2 based on prop1.
b) Change the visibility for prop2 in the UI, based on prop1
The new Map of propertyName -> value is returned.
In this example, the onRegionsChange function sets the enableLogging property to false (and disables
the corresponsing toggle UI) when "regions" is set to "North Central US", and enables the toggle for
any other value of "regions"
*/
@Values({ label: "Regions", choices: regionDropdownItems })
@OnChange(onRegionsChange)
@Values({ label: "Regions", choices: regionDropdownItems, placeholder: "Select a region" })
regions: ChoiceItem;
@Values({
@@ -134,34 +209,33 @@ export default class SelfServeExample extends SelfServeBaseClass {
})
accountName: string;
/*
@OnChange()
- optional
- input: (currentValues: Map<string, InputType>, newValue: InputType) => Map<string, InputType>
- role: Takes a Map of current values and the newValue for this property as inputs. This is called when a property
changes its value in the UI. This can be used to change other input values based on some other input.
The new Map of propertyName -> value is returned.
In this example, the onDbThroughputChange function sets the collectionThroughput to the same value as the dbThroughput
when the slider in moved in the UI.
*/
@OnChange(onDbThroughputChange)
@Values({
label: "Database Throughput",
min: 400,
max: initializeMaxThroughput,
step: 100,
uiType: UiType.Slider,
})
dbThroughput: number;
@Values({
label: "Collection Throughput",
min: 400,
max: initializeMaxThroughput,
max: getMaxThroughput,
step: 100,
uiType: UiType.Spinner,
uiType: NumberUiType.Spinner,
})
collectionThroughput: number;
/*
In this example, the onEnableDbLevelThroughputChange function makes the dbThroughput property visible when
enableDbLevelThroughput, a boolean, is set to true and hides dbThroughput property when it is set to false.
*/
@OnChange(onEnableDbLevelThroughputChange)
@Values({
label: "Enable DB level throughput",
trueLabel: "Enable",
falseLabel: "Disable",
})
enableDbLevelThroughput: boolean;
@Values({
label: "Database Throughput",
min: 400,
max: getMaxThroughput,
step: 100,
uiType: NumberUiType.Slider,
})
dbThroughput: number;
}

View File

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

View File

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

View File

@@ -7,7 +7,8 @@ import * as ko from "knockout";
import * as React from "react";
import { ReactAdapter } from "../Bindings/ReactBindingHandler";
import Explorer from "../Explorer/Explorer";
import { SelfServeDescriptor, SelfServeComponent } from "./SelfServeComponent";
import { SelfServeComponent } from "./SelfServeComponent";
import { SelfServeDescriptor } from "./SelfServeTypes";
import { SelfServeType } from "./SelfServeUtils";
export class SelfServeComponentAdapter implements ReactAdapter {
@@ -28,6 +29,10 @@ export class SelfServeComponentAdapter implements ReactAdapter {
const SelfServeExample = await import(/* webpackChunkName: "SelfServeExample" */ "./Example/SelfServeExample");
return new SelfServeExample.default().toSelfServeDescriptor();
}
case SelfServeType.sqlx: {
const SqlX = await import(/* webpackChunkName: "SqlX" */ "./SqlX/SqlX");
return new SqlX.default().toSelfServeDescriptor();
}
default:
return undefined;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SelfServeComponent should render 1`] = `
exports[`SelfServeComponent message bar and spinner snapshots 1`] = `
<div
style={
Object {
@@ -13,22 +13,95 @@ exports[`SelfServeComponent should render 1`] = `
Object {
"root": Object {
"padding": 10,
"width": 400,
},
}
}
tokens={
Object {
"childrenGap": 20,
"childrenGap": 5,
}
}
>
<StyledCommandBarBase
items={
Array [
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Save",
},
"key": "save",
"onClick": [Function],
"split": true,
"text": "Save",
},
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Undo",
},
"key": "discard",
"onClick": [Function],
"split": true,
"text": "Discard",
},
Object {
"disabled": false,
"iconProps": Object {
"iconName": "Refresh",
},
"key": "refresh",
"onClick": [Function],
"split": true,
"text": "Refresh",
},
]
}
styles={
Object {
"root": Object {
"paddingLeft": 0,
},
}
}
/>
<StyledMessageBarBase
messageBarType={0}
styles={
Object {
"root": Object {
"width": 400,
},
}
}
>
refresh performed successfully
</StyledMessageBarBase>
<StyledMessageBarBase
messageBarType={0}
onDismiss={[Function]}
styles={
Object {
"root": Object {
"width": 400,
},
}
}
>
submitted successfully
</StyledMessageBarBase>
<SmartUiComponent
currentValues={
Map {
"throughput" => "450",
"analyticalStore" => "false",
"database" => "db2",
"throughput" => Object {
"value": 450,
},
"analyticalStore" => Object {
"value": false,
},
"database" => Object {
"value": "db2",
},
}
}
descriptor={
@@ -36,21 +109,95 @@ exports[`SelfServeComponent should render 1`] = `
"initialize": [MockFunction] {
"calls": Array [
Array [],
Array [],
Array [],
Array [],
Array [],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
],
},
"inputNames": Array [
"throughput",
"containerId",
"analyticalStore",
"database",
],
"onSubmit": [MockFunction],
"onRefresh": [MockFunction] {
"calls": Array [
Array [],
Array [],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
],
},
"onSave": [MockFunction] {
"calls": Array [
Array [
Map {
"throughput" => Object {
"value": 450,
},
"analyticalStore" => Object {
"value": false,
},
"database" => Object {
"value": "db2",
},
},
],
Array [
Map {
"throughput" => Object {
"value": 450,
},
"analyticalStore" => Object {
"value": false,
},
"database" => Object {
"value": "db2",
},
},
],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
],
},
"root": Object {
"children": Array [
Object {
@@ -128,41 +275,612 @@ exports[`SelfServeComponent should render 1`] = `
},
}
}
disabled={true}
onError={[Function]}
onInputChange={[Function]}
/>
</Stack>
</div>
`;
exports[`SelfServeComponent message bar and spinner snapshots 2`] = `
<div
style={
Object {
"overflowX": "auto",
}
}
>
<Stack
styles={
Object {
"root": Object {
"padding": 10,
},
}
}
tokens={
Object {
"childrenGap": 5,
}
}
>
<StyledCommandBarBase
items={
Array [
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Save",
},
"key": "save",
"onClick": [Function],
"split": true,
"text": "Save",
},
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Undo",
},
"key": "discard",
"onClick": [Function],
"split": true,
"text": "Discard",
},
Object {
"disabled": false,
"iconProps": Object {
"iconName": "Refresh",
},
"key": "refresh",
"onClick": [Function],
"split": true,
"text": "Refresh",
},
]
}
styles={
Object {
"root": Object {
"paddingLeft": 0,
},
}
}
/>
<StyledMessageBarBase
messageBarType={0}
onDismiss={[Function]}
styles={
Object {
"root": Object {
"width": 400,
},
}
}
>
submitted successfully
</StyledMessageBarBase>
<SmartUiComponent
currentValues={
Map {
"throughput" => Object {
"value": 450,
},
"analyticalStore" => Object {
"value": false,
},
"database" => Object {
"value": "db2",
},
}
}
descriptor={
Object {
"initialize": [MockFunction] {
"calls": Array [
Array [],
Array [],
Array [],
Array [],
Array [],
Array [],
Array [],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
],
},
"inputNames": Array [
"throughput",
"analyticalStore",
"database",
],
"onRefresh": [MockFunction] {
"calls": Array [
Array [],
Array [],
Array [],
Array [],
Array [],
Array [],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
],
},
"onSave": [MockFunction] {
"calls": Array [
Array [
Map {
"throughput" => Object {
"value": 450,
},
"analyticalStore" => Object {
"value": false,
},
"database" => Object {
"value": "db2",
},
},
],
Array [
Map {
"throughput" => Object {
"value": 450,
},
"analyticalStore" => Object {
"value": false,
},
"database" => Object {
"value": "db2",
},
},
],
Array [
Map {
"throughput" => Object {
"value": 450,
},
"analyticalStore" => Object {
"value": false,
},
"database" => Object {
"value": "db2",
},
},
],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
Object {
"type": "return",
"value": Promise {},
},
],
},
"root": Object {
"children": Array [
Object {
"id": "throughput",
"info": undefined,
"input": Object {
"dataFieldName": "throughput",
"defaultValue": 400,
"label": "Throughput (input)",
"max": 500,
"min": 400,
"placeholder": undefined,
"step": 10,
"type": "number",
"uiType": "Spinner",
},
},
Object {
"id": "containerId",
"info": undefined,
"input": Object {
"dataFieldName": "containerId",
"label": "Container id",
"placeholder": undefined,
"type": "string",
},
},
Object {
"id": "analyticalStore",
"info": undefined,
"input": Object {
"dataFieldName": "analyticalStore",
"defaultValue": true,
"falseLabel": "Disabled",
"label": "Analytical Store",
"placeholder": undefined,
"trueLabel": "Enabled",
"type": "boolean",
},
},
Object {
"id": "database",
"info": undefined,
"input": Object {
"choices": Array [
Object {
"key": "db1",
"label": "Database 1",
},
Object {
"key": "db2",
"label": "Database 2",
},
Object {
"key": "db3",
"label": "Database 3",
},
],
"dataFieldName": "database",
"defaultKey": "db2",
"label": "Database",
"placeholder": undefined,
"type": "object",
},
},
],
"id": "root",
"info": Object {
"link": Object {
"href": "https://aka.ms/azure-cosmos-db-pricing",
"text": "More Details",
},
"message": "Start at $24/mo per database",
},
},
}
}
disabled={false}
onError={[Function]}
onInputChange={[Function]}
/>
</Stack>
</div>
`;
exports[`SelfServeComponent message bar and spinner snapshots 3`] = `
<div
style={
Object {
"overflowX": "auto",
}
}
>
<Stack
styles={
Object {
"root": Object {
"padding": 10,
},
}
}
tokens={
Object {
"childrenGap": 5,
}
}
>
<StyledCommandBarBase
items={
Array [
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Save",
},
"key": "save",
"onClick": [Function],
"split": true,
"text": "Save",
},
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Undo",
},
"key": "discard",
"onClick": [Function],
"split": true,
"text": "Discard",
},
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Refresh",
},
"key": "refresh",
"onClick": [Function],
"split": true,
"text": "Refresh",
},
]
}
styles={
Object {
"root": Object {
"paddingLeft": 0,
},
}
}
/>
<StyledSpinnerBase
size={3}
styles={
Object {
"root": Object {
"height": "100%",
"justifyContent": "center",
"textAlign": "center",
"width": "100%",
},
}
}
/>
</Stack>
</div>
`;
exports[`SelfServeComponent message bar and spinner snapshots 4`] = `
<StyledMessageBarBase
messageBarType={1}
>
sample error message
</StyledMessageBarBase>
`;
exports[`SelfServeComponent should render and honor save, discard, refresh actions 1`] = `
<div
style={
Object {
"overflowX": "auto",
}
}
>
<Stack
styles={
Object {
"root": Object {
"padding": 10,
},
}
}
tokens={
Object {
"childrenGap": 5,
}
}
>
<StyledCommandBarBase
items={
Array [
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Save",
},
"key": "save",
"onClick": [Function],
"split": true,
"text": "Save",
},
Object {
"disabled": true,
"iconProps": Object {
"iconName": "Undo",
},
"key": "discard",
"onClick": [Function],
"split": true,
"text": "Discard",
},
Object {
"disabled": false,
"iconProps": Object {
"iconName": "Refresh",
},
"key": "refresh",
"onClick": [Function],
"split": true,
"text": "Refresh",
},
]
}
styles={
Object {
"root": Object {
"paddingLeft": 0,
},
}
}
/>
<SmartUiComponent
currentValues={
Map {
"throughput" => Object {
"value": 450,
},
"analyticalStore" => Object {
"value": false,
},
"database" => Object {
"value": "db2",
},
}
}
descriptor={
Object {
"initialize": [MockFunction] {
"calls": Array [
Array [],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
],
},
"inputNames": Array [
"throughput",
"analyticalStore",
"database",
],
"onRefresh": [MockFunction] {
"calls": Array [
Array [],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
],
},
"onSave": [MockFunction],
"root": Object {
"children": Array [
Object {
"id": "throughput",
"info": undefined,
"input": Object {
"dataFieldName": "throughput",
"defaultValue": 400,
"label": "Throughput (input)",
"max": 500,
"min": 400,
"placeholder": undefined,
"step": 10,
"type": "number",
"uiType": "Spinner",
},
},
Object {
"id": "containerId",
"info": undefined,
"input": Object {
"dataFieldName": "containerId",
"label": "Container id",
"placeholder": undefined,
"type": "string",
},
},
Object {
"id": "analyticalStore",
"info": undefined,
"input": Object {
"dataFieldName": "analyticalStore",
"defaultValue": true,
"falseLabel": "Disabled",
"label": "Analytical Store",
"placeholder": undefined,
"trueLabel": "Enabled",
"type": "boolean",
},
},
Object {
"id": "database",
"info": undefined,
"input": Object {
"choices": Array [
Object {
"key": "db1",
"label": "Database 1",
},
Object {
"key": "db2",
"label": "Database 2",
},
Object {
"key": "db3",
"label": "Database 3",
},
],
"dataFieldName": "database",
"defaultKey": "db2",
"label": "Database",
"placeholder": undefined,
"type": "object",
},
},
],
"id": "root",
"info": Object {
"link": Object {
"href": "https://aka.ms/azure-cosmos-db-pricing",
"text": "More Details",
},
"message": "Start at $24/mo per database",
},
},
}
}
disabled={false}
onError={[Function]}
onInputChange={[Function]}
/>
<Stack
horizontal={true}
tokens={
Object {
"childrenGap": 10,
}
}
>
<CustomizedPrimaryButton
id="submitButton"
onClick={[Function]}
styles={
Object {
"root": Object {
"width": 100,
},
}
}
text="submit"
/>
<CustomizedPrimaryButton
id="discardButton"
onClick={[Function]}
styles={
Object {
"root": Object {
"width": 100,
},
}
}
text="discard"
/>
</Stack>
</Stack>
</div>
`;

View File

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

View File

@@ -7,7 +7,7 @@ import { configContext, ConfigContext, Platform } from "../ConfigContext";
import { ActionType, DataExplorerAction } from "../Contracts/ActionContracts";
import { MessageTypes } from "../Contracts/ExplorerContracts";
import { DataExplorerInputsFrame } from "../Contracts/ViewModels";
import Explorer from "../Explorer/Explorer";
import Explorer, { ExplorerParams } from "../Explorer/Explorer";
import {
AAD,
ConnectionString,
@@ -34,8 +34,8 @@ import { isInvalidParentFrameOrigin } from "../Utils/MessageValidation";
// Pleas tread carefully :)
let explorer: Explorer;
export function useKnockoutExplorer(config: ConfigContext): Explorer {
explorer = explorer || new Explorer();
export function useKnockoutExplorer(config: ConfigContext, explorerParams: ExplorerParams): Explorer {
explorer = explorer || new Explorer(explorerParams);
useEffect(() => {
const effect = async () => {
if (config) {

View File

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

34
utils/codeMetrics.js Normal file
View File

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