Added more selfserve changes (#443)

* exposed baselineValues

* added getOnSaveNotification

* disable UI when onSave is taking place

* added optional polling

* Added portal notifications

* minor edits

* added label for description

* Added correlationids and polling of refresh

* added label tooltip

* removed ClassInfo decorator

* Added dynamic decription

* added info and warninf types for description

* promise retry changes

* compile errors fixed

* merged sqlxEdits

* undid sqlx changes

* added completed notification

* passed retryInterval in notif options

* added polling on landing on the page

* edits for error display

* added link generation

* addressed PR comments

* modified test

* fixed compilation error
This commit is contained in:
Srinath Narayanan 2021-03-09 16:07:23 -08:00 committed by GitHub
parent c1b74266eb
commit ecdc41ada9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 886 additions and 603 deletions

View File

@ -0,0 +1,9 @@
/**
* Messaging types used with SelfServe Component <-> Portal communication
* and Hosted <-> SelfServe Component communication
*/
export enum SelfServeMessageTypes {
TelemetryInfo = "TelemetryInfo",
Notification = "Notification",
}

View File

@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { shallow } from "enzyme"; import { shallow } from "enzyme";
import { SmartUiComponent, SmartUiDescriptor } from "./SmartUiComponent"; import { SmartUiComponent, SmartUiDescriptor } from "./SmartUiComponent";
import { NumberUiType, SmartUiInput } from "../../../SelfServe/SelfServeTypes"; import { NumberUiType, SmartUiInput, DescriptionType } from "../../../SelfServe/SelfServeTypes";
describe("SmartUiComponent", () => { describe("SmartUiComponent", () => {
const exampleData: SmartUiDescriptor = { const exampleData: SmartUiDescriptor = {
@ -18,10 +18,12 @@ describe("SmartUiComponent", () => {
{ {
id: "description", id: "description",
input: { input: {
labelTKey: undefined,
dataFieldName: "description", dataFieldName: "description",
type: "string", type: "string",
description: { description: {
textTKey: "this is an example description text.", textTKey: "this is an example description text.",
type: DescriptionType.Text,
link: { link: {
href: "https://docs.microsoft.com/en-us/azure/cosmos-db/introduction", href: "https://docs.microsoft.com/en-us/azure/cosmos-db/introduction",
textTKey: "Click here for more information.", textTKey: "Click here for more information.",

View File

@ -6,12 +6,13 @@ import { Dropdown, IDropdownOption } from "office-ui-fabric-react/lib/Dropdown";
import { TextField } from "office-ui-fabric-react/lib/TextField"; import { TextField } from "office-ui-fabric-react/lib/TextField";
import { Text } from "office-ui-fabric-react/lib/Text"; import { Text } from "office-ui-fabric-react/lib/Text";
import { Stack, IStackTokens } from "office-ui-fabric-react/lib/Stack"; import { Stack, IStackTokens } from "office-ui-fabric-react/lib/Stack";
import { Link, MessageBar, MessageBarType, Toggle } from "office-ui-fabric-react"; import { Label, Link, MessageBar, MessageBarType, Toggle } from "office-ui-fabric-react";
import * as InputUtils from "./InputUtils"; import * as InputUtils from "./InputUtils";
import "./SmartUiComponent.less"; import "./SmartUiComponent.less";
import { import {
ChoiceItem, ChoiceItem,
Description, Description,
DescriptionType,
Info, Info,
InputType, InputType,
InputTypeValue, InputTypeValue,
@ -19,6 +20,7 @@ import {
SmartUiInput, SmartUiInput,
} from "../../../SelfServe/SelfServeTypes"; } from "../../../SelfServe/SelfServeTypes";
import { TFunction } from "i18next"; import { TFunction } from "i18next";
import { ToolTipLabelComponent } from "../Settings/SettingsSubComponents/ToolTipLabelComponent";
/** /**
* Generic UX renderer * Generic UX renderer
@ -29,15 +31,14 @@ import { TFunction } from "i18next";
*/ */
interface BaseDisplay { interface BaseDisplay {
labelTKey: string;
dataFieldName: string; dataFieldName: string;
errorMessage?: string; errorMessage?: string;
type: InputTypeValue; type: InputTypeValue;
} }
interface BaseInput extends BaseDisplay { interface BaseInput extends BaseDisplay {
labelTKey: string;
placeholderTKey?: string; placeholderTKey?: string;
errorMessage?: string;
} }
/** /**
@ -67,7 +68,8 @@ interface ChoiceInput extends BaseInput {
} }
interface DescriptionDisplay extends BaseDisplay { interface DescriptionDisplay extends BaseDisplay {
description: Description; description?: Description;
isDynamicDescription?: boolean;
} }
type AnyDisplay = NumberInput | BooleanInput | StringInput | ChoiceInput | DescriptionDisplay; type AnyDisplay = NumberInput | BooleanInput | StringInput | ChoiceInput | DescriptionDisplay;
@ -123,25 +125,28 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
private renderInfo(info: Info): JSX.Element { private renderInfo(info: Info): JSX.Element {
return ( return (
<MessageBar styles={{ root: { width: 400 } }}> info && (
{this.props.getTranslation(info.messageTKey)} <Text>
{info.link && ( {this.props.getTranslation(info.messageTKey)}
<Link href={info.link.href} target="_blank"> {` `}
{this.props.getTranslation(info.link.textTKey)} {info.link && (
</Link> <Link href={info.link.href} target="_blank">
)} {this.props.getTranslation(info.link.textTKey)}
</MessageBar> </Link>
)}
</Text>
)
); );
} }
private renderTextInput(input: StringInput): JSX.Element { private renderTextInput(input: StringInput, labelId: string): JSX.Element {
const value = this.props.currentValues.get(input.dataFieldName)?.value 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; const disabled = this.props.disabled || this.props.currentValues.get(input.dataFieldName)?.disabled;
return ( return (
<div className="stringInputContainer"> <div className="stringInputContainer">
<TextField <TextField
id={`${input.dataFieldName}-textField-input`} id={`${input.dataFieldName}-textField-input`}
label={this.props.getTranslation(input.labelTKey)} aria-labelledby={labelId}
type="text" type="text"
value={value || ""} value={value || ""}
placeholder={this.props.getTranslation(input.placeholderTKey)} placeholder={this.props.getTranslation(input.placeholderTKey)}
@ -149,32 +154,36 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
onChange={(_, newValue) => this.props.onInputChange(input, newValue)} onChange={(_, newValue) => this.props.onInputChange(input, newValue)}
styles={{ styles={{
root: { width: 400 }, root: { width: 400 },
subComponentStyles: {
label: {
root: {
...SmartUiComponent.labelStyle,
fontWeight: 600,
},
},
},
}} }}
/> />
</div> </div>
); );
} }
private renderDescription(input: DescriptionDisplay): JSX.Element { private renderDescription(input: DescriptionDisplay, labelId: string): JSX.Element {
const description = input.description; const dataFieldName = input.dataFieldName;
return ( const description = input.description || (this.props.currentValues.get(dataFieldName)?.value as Description);
<Text id={`${input.dataFieldName}-text-display`}> if (!description) {
{this.props.getTranslation(input.description.textTKey)}{" "} return this.renderError("Description is not provided.");
}
const descriptionElement = (
<Text id={`${dataFieldName}-text-display`} aria-labelledby={labelId}>
{this.props.getTranslation(description.textTKey)}
{` `}
{description.link && ( {description.link && (
<Link target="_blank" href={input.description.link.href}> <Link target="_blank" href={description.link.href}>
{this.props.getTranslation(input.description.link.textTKey)} {this.props.getTranslation(description.link.textTKey)}
</Link> </Link>
)} )}
</Text> </Text>
); );
if (description.type === DescriptionType.Text) {
return descriptionElement;
}
const messageBarType =
description.type === DescriptionType.InfoMessageBar ? MessageBarType.info : MessageBarType.warning;
return <MessageBar messageBarType={messageBarType}>{descriptionElement}</MessageBar>;
} }
private clearError(dataFieldName: string): void { private clearError(dataFieldName: string): void {
@ -220,13 +229,12 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
return undefined; return undefined;
}; };
private renderNumberInput(input: NumberInput): JSX.Element { private renderNumberInput(input: NumberInput, labelId: string): JSX.Element {
const { labelTKey, min, max, dataFieldName, step } = input; const { labelTKey, min, max, dataFieldName, step } = input;
const props = { const props = {
label: this.props.getTranslation(labelTKey),
min: min, min: min,
max: max, max: max,
ariaLabel: labelTKey, ariaLabel: this.props.getTranslation(labelTKey),
step: step, step: step,
}; };
@ -243,13 +251,8 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
onIncrement={(newValue) => this.onIncrement(input, newValue, props.step, props.max)} onIncrement={(newValue) => this.onIncrement(input, newValue, props.step, props.max)}
onDecrement={(newValue) => this.onDecrement(input, newValue, props.step, props.min)} onDecrement={(newValue) => this.onDecrement(input, newValue, props.step, props.min)}
labelPosition={Position.top} labelPosition={Position.top}
aria-labelledby={labelId}
disabled={disabled} disabled={disabled}
styles={{
label: {
...SmartUiComponent.labelStyle,
fontWeight: 600,
},
}}
/> />
{this.state.errors.has(dataFieldName) && ( {this.state.errors.has(dataFieldName) && (
<MessageBar messageBarType={MessageBarType.error}>Error: {this.state.errors.get(dataFieldName)}</MessageBar> <MessageBar messageBarType={MessageBarType.error}>Error: {this.state.errors.get(dataFieldName)}</MessageBar>
@ -266,10 +269,6 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
onChange={(newValue) => this.props.onInputChange(input, newValue)} onChange={(newValue) => this.props.onInputChange(input, newValue)}
styles={{ styles={{
root: { width: 400 }, root: { width: 400 },
titleLabel: {
...SmartUiComponent.labelStyle,
fontWeight: 600,
},
valueLabel: SmartUiComponent.labelStyle, valueLabel: SmartUiComponent.labelStyle,
}} }}
/> />
@ -280,13 +279,13 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
} }
} }
private renderBooleanInput(input: BooleanInput): JSX.Element { private renderBooleanInput(input: BooleanInput, labelId: string): JSX.Element {
const value = this.props.currentValues.get(input.dataFieldName)?.value as boolean; const value = this.props.currentValues.get(input.dataFieldName)?.value as boolean;
const disabled = this.props.disabled || this.props.currentValues.get(input.dataFieldName)?.disabled; const disabled = this.props.disabled || this.props.currentValues.get(input.dataFieldName)?.disabled;
return ( return (
<Toggle <Toggle
id={`${input.dataFieldName}-toggle-input`} id={`${input.dataFieldName}-toggle-input`}
label={this.props.getTranslation(input.labelTKey)} aria-labelledby={labelId}
checked={value || false} checked={value || false}
onText={this.props.getTranslation(input.trueLabelTKey)} onText={this.props.getTranslation(input.trueLabelTKey)}
offText={this.props.getTranslation(input.falseLabelTKey)} offText={this.props.getTranslation(input.falseLabelTKey)}
@ -297,8 +296,8 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
); );
} }
private renderChoiceInput(input: ChoiceInput): JSX.Element { private renderChoiceInput(input: ChoiceInput, labelId: string): JSX.Element {
const { labelTKey, defaultKey, dataFieldName, choices, placeholderTKey } = input; const { defaultKey, dataFieldName, choices, placeholderTKey } = input;
const value = this.props.currentValues.get(dataFieldName)?.value as string; const value = this.props.currentValues.get(dataFieldName)?.value as string;
const disabled = this.props.disabled || this.props.currentValues.get(dataFieldName)?.disabled; const disabled = this.props.disabled || this.props.currentValues.get(dataFieldName)?.disabled;
let selectedKey = value ? value : defaultKey; let selectedKey = value ? value : defaultKey;
@ -308,7 +307,7 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
return ( return (
<Dropdown <Dropdown
id={`${input.dataFieldName}-dropdown-input`} id={`${input.dataFieldName}-dropdown-input`}
label={this.props.getTranslation(labelTKey)} aria-labelledby={labelId}
selectedKey={selectedKey} selectedKey={selectedKey}
onChange={(_, item: IDropdownOption) => this.props.onInputChange(input, item.key.toString())} onChange={(_, item: IDropdownOption) => this.props.onInputChange(input, item.key.toString())}
placeholder={this.props.getTranslation(placeholderTKey)} placeholder={this.props.getTranslation(placeholderTKey)}
@ -319,40 +318,53 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
}))} }))}
styles={{ styles={{
root: { width: 400 }, root: { width: 400 },
label: {
...SmartUiComponent.labelStyle,
fontWeight: 600,
},
dropdown: SmartUiComponent.labelStyle, dropdown: SmartUiComponent.labelStyle,
}} }}
/> />
); );
} }
private renderError(input: AnyDisplay): JSX.Element { private renderError(errorMessage: string): JSX.Element {
return <MessageBar messageBarType={MessageBarType.error}>Error: {input.errorMessage}</MessageBar>; return <MessageBar messageBarType={MessageBarType.error}>Error: {errorMessage}</MessageBar>;
} }
private renderDisplay(input: AnyDisplay): JSX.Element { private renderDisplayWithInfoBubble(input: AnyDisplay, info: Info): JSX.Element {
if (input.errorMessage) { if (input.errorMessage) {
return this.renderError(input); return this.renderError(input.errorMessage);
} }
const inputHidden = this.props.currentValues.get(input.dataFieldName)?.hidden; const inputHidden = this.props.currentValues.get(input.dataFieldName)?.hidden;
if (inputHidden) { if (inputHidden) {
return <></>; return <></>;
} }
const labelId = `${input.dataFieldName}-label`;
return (
<Stack>
{input.labelTKey && (
<Label id={labelId}>
<ToolTipLabelComponent
label={this.props.getTranslation(input.labelTKey)}
toolTipElement={this.renderInfo(info)}
/>
</Label>
)}
{this.renderDisplay(input, labelId)}
</Stack>
);
}
private renderDisplay(input: AnyDisplay, labelId: string): JSX.Element {
switch (input.type) { switch (input.type) {
case "string": case "string":
if ("description" in input) { if ("description" in input || "isDynamicDescription" in input) {
return this.renderDescription(input as DescriptionDisplay); return this.renderDescription(input as DescriptionDisplay, labelId);
} }
return this.renderTextInput(input as StringInput); return this.renderTextInput(input as StringInput, labelId);
case "number": case "number":
return this.renderNumberInput(input as NumberInput); return this.renderNumberInput(input as NumberInput, labelId);
case "boolean": case "boolean":
return this.renderBooleanInput(input as BooleanInput); return this.renderBooleanInput(input as BooleanInput, labelId);
case "object": case "object":
return this.renderChoiceInput(input as ChoiceInput); return this.renderChoiceInput(input as ChoiceInput, labelId);
default: default:
throw new Error(`Unknown input type: ${input.type}`); throw new Error(`Unknown input type: ${input.type}`);
} }
@ -363,10 +375,7 @@ export class SmartUiComponent extends React.Component<SmartUiComponentProps, Sma
return ( return (
<Stack tokens={containerStackTokens} className="widgetRendererContainer"> <Stack tokens={containerStackTokens} className="widgetRendererContainer">
<Stack.Item> <Stack.Item>{node.input && this.renderDisplayWithInfoBubble(node.input, node.info as Info)}</Stack.Item>
{node.info && this.renderInfo(node.info as Info)}
{node.input && this.renderDisplay(node.input)}
</Stack.Item>
{node.children && node.children.map((child) => <div key={child.id}>{this.renderNode(child)}</div>)} {node.children && node.children.map((child) => <div key={child.id}>{this.renderNode(child)}</div>)}
</Stack> </Stack>
); );

View File

@ -9,25 +9,7 @@ exports[`SmartUiComponent disable all inputs 1`] = `
} }
} }
> >
<StackItem> <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 <div
key="description" key="description"
> >
@ -40,18 +22,21 @@ exports[`SmartUiComponent disable all inputs 1`] = `
} }
> >
<StackItem> <StackItem>
<Text <Stack>
id="description-text-display" <Text
> aria-labelledby="description-label"
this is an example description text. id="description-text-display"
<StyledLinkBase
href="https://docs.microsoft.com/en-us/azure/cosmos-db/introduction"
target="_blank"
> >
Click here for more information. this is an example description text.
</StyledLinkBase>
</Text> <StyledLinkBase
href="https://docs.microsoft.com/en-us/azure/cosmos-db/introduction"
target="_blank"
>
Click here for more information.
</StyledLinkBase>
</Text>
</Stack>
</StackItem> </StackItem>
</Stack> </Stack>
</div> </div>
@ -67,53 +52,53 @@ exports[`SmartUiComponent disable all inputs 1`] = `
} }
> >
<StackItem> <StackItem>
<Stack <Stack>
styles={ <StyledLabelBase
Object { id="throughput-label"
"root": Object { >
"width": 400, <ToolTipLabelComponent
}, label="Throughput (input)"
} />
} </StyledLabelBase>
tokens={ <Stack
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={ styles={
Object { Object {
"label": Object { "root": Object {
"color": "#393939", "width": 400,
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
"fontWeight": 600,
}, },
} }
} }
/> tokens={
Object {
"childrenGap": 2,
}
}
>
<CustomizedSpinButton
aria-labelledby="throughput-label"
ariaLabel="Throughput (input)"
decrementButtonIcon={
Object {
"iconName": "ChevronDownSmall",
}
}
disabled={true}
id="throughput-spinner-input"
incrementButtonIcon={
Object {
"iconName": "ChevronUpSmall",
}
}
label=""
labelPosition={0}
max={500}
min={400}
onDecrement={[Function]}
onIncrement={[Function]}
onValidate={[Function]}
step={10}
/>
</Stack>
</Stack> </Stack>
</StackItem> </StackItem>
</Stack> </Stack>
@ -130,37 +115,39 @@ exports[`SmartUiComponent disable all inputs 1`] = `
} }
> >
<StackItem> <StackItem>
<div <Stack>
id="throughput2-slider-input" <StyledLabelBase
> id="throughput2-label"
<StyledSliderBase >
ariaLabel="Throughput (Slider)" <ToolTipLabelComponent
disabled={true} label="Throughput (Slider)"
label="Throughput (Slider)" />
max={500} </StyledLabelBase>
min={400} <div
onChange={[Function]} id="throughput2-slider-input"
step={10} >
styles={ <StyledSliderBase
Object { ariaLabel="Throughput (Slider)"
"root": Object { disabled={true}
"width": 400, max={500}
}, min={400}
"titleLabel": Object { onChange={[Function]}
"color": "#393939", step={10}
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif", styles={
"fontSize": 12, Object {
"fontWeight": 600, "root": Object {
}, "width": 400,
"valueLabel": Object { },
"color": "#393939", "valueLabel": Object {
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif", "color": "#393939",
"fontSize": 12, "fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
}, "fontSize": 12,
},
}
} }
} />
/> </div>
</div> </Stack>
</StackItem> </StackItem>
</Stack> </Stack>
</div> </div>
@ -197,35 +184,34 @@ exports[`SmartUiComponent disable all inputs 1`] = `
} }
> >
<StackItem> <StackItem>
<div <Stack>
className="stringInputContainer" <StyledLabelBase
> id="containerId-label"
<StyledTextFieldBase >
disabled={true} <ToolTipLabelComponent
id="containerId-textField-input" label="Container id"
label="Container id" />
onChange={[Function]} </StyledLabelBase>
styles={ <div
Object { className="stringInputContainer"
"root": Object { >
"width": 400, <StyledTextFieldBase
}, aria-labelledby="containerId-label"
"subComponentStyles": Object { disabled={true}
"label": Object { id="containerId-textField-input"
"root": Object { onChange={[Function]}
"color": "#393939", styles={
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif", Object {
"fontSize": 12, "root": Object {
"fontWeight": 600, "width": 400,
},
}, },
}, }
} }
} type="text"
type="text" value=""
value="" />
/> </div>
</div> </Stack>
</StackItem> </StackItem>
</Stack> </Stack>
</div> </div>
@ -241,22 +227,31 @@ exports[`SmartUiComponent disable all inputs 1`] = `
} }
> >
<StackItem> <StackItem>
<StyledToggleBase <Stack>
checked={false} <StyledLabelBase
disabled={true} id="analyticalStore-label"
id="analyticalStore-toggle-input" >
label="Analytical Store" <ToolTipLabelComponent
offText="Disabled" label="Analytical Store"
onChange={[Function]} />
onText="Enabled" </StyledLabelBase>
styles={ <StyledToggleBase
Object { aria-labelledby="analyticalStore-label"
"root": Object { checked={false}
"width": 400, disabled={true}
}, id="analyticalStore-toggle-input"
offText="Disabled"
onChange={[Function]}
onText="Enabled"
styles={
Object {
"root": Object {
"width": 400,
},
}
} }
} />
/> </Stack>
</StackItem> </StackItem>
</Stack> </Stack>
</div> </div>
@ -272,47 +267,50 @@ exports[`SmartUiComponent disable all inputs 1`] = `
} }
> >
<StackItem> <StackItem>
<StyledWithResponsiveMode <Stack>
disabled={true} <StyledLabelBase
id="database-dropdown-input" id="database-label"
label="Database" >
onChange={[Function]} <ToolTipLabelComponent
options={ label="Database"
Array [ />
Object { </StyledLabelBase>
"key": "db1", <StyledWithResponsiveMode
"text": "Database 1", aria-labelledby="database-label"
}, disabled={true}
Object { id="database-dropdown-input"
"key": "db2", onChange={[Function]}
"text": "Database 2", options={
}, Array [
Object { Object {
"key": "db3", "key": "db1",
"text": "Database 3", "text": "Database 1",
}, },
] Object {
} "key": "db2",
selectedKey="db2" "text": "Database 2",
styles={ },
Object { Object {
"dropdown": Object { "key": "db3",
"color": "#393939", "text": "Database 3",
"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,
},
} }
} selectedKey="db2"
/> 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,
},
}
}
/>
</Stack>
</StackItem> </StackItem>
</Stack> </Stack>
</div> </div>
@ -328,25 +326,7 @@ exports[`SmartUiComponent should render and honor input's hidden, disabled state
} }
} }
> >
<StackItem> <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 <div
key="description" key="description"
> >
@ -359,18 +339,21 @@ exports[`SmartUiComponent should render and honor input's hidden, disabled state
} }
> >
<StackItem> <StackItem>
<Text <Stack>
id="description-text-display" <Text
> aria-labelledby="description-label"
this is an example description text. id="description-text-display"
<StyledLinkBase
href="https://docs.microsoft.com/en-us/azure/cosmos-db/introduction"
target="_blank"
> >
Click here for more information. this is an example description text.
</StyledLinkBase>
</Text> <StyledLinkBase
href="https://docs.microsoft.com/en-us/azure/cosmos-db/introduction"
target="_blank"
>
Click here for more information.
</StyledLinkBase>
</Text>
</Stack>
</StackItem> </StackItem>
</Stack> </Stack>
</div> </div>
@ -386,53 +369,53 @@ exports[`SmartUiComponent should render and honor input's hidden, disabled state
} }
> >
<StackItem> <StackItem>
<Stack <Stack>
styles={ <StyledLabelBase
Object { id="throughput-label"
"root": Object { >
"width": 400, <ToolTipLabelComponent
}, label="Throughput (input)"
} />
} </StyledLabelBase>
tokens={ <Stack
Object {
"childrenGap": 2,
}
}
>
<CustomizedSpinButton
ariaLabel="Throughput (input)"
decrementButtonIcon={
Object {
"iconName": "ChevronDownSmall",
}
}
disabled={false}
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={ styles={
Object { Object {
"label": Object { "root": Object {
"color": "#393939", "width": 400,
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
"fontSize": 12,
"fontWeight": 600,
}, },
} }
} }
/> tokens={
Object {
"childrenGap": 2,
}
}
>
<CustomizedSpinButton
aria-labelledby="throughput-label"
ariaLabel="Throughput (input)"
decrementButtonIcon={
Object {
"iconName": "ChevronDownSmall",
}
}
disabled={false}
id="throughput-spinner-input"
incrementButtonIcon={
Object {
"iconName": "ChevronUpSmall",
}
}
label=""
labelPosition={0}
max={500}
min={400}
onDecrement={[Function]}
onIncrement={[Function]}
onValidate={[Function]}
step={10}
/>
</Stack>
</Stack> </Stack>
</StackItem> </StackItem>
</Stack> </Stack>
@ -449,36 +432,38 @@ exports[`SmartUiComponent should render and honor input's hidden, disabled state
} }
> >
<StackItem> <StackItem>
<div <Stack>
id="throughput2-slider-input" <StyledLabelBase
> id="throughput2-label"
<StyledSliderBase >
ariaLabel="Throughput (Slider)" <ToolTipLabelComponent
label="Throughput (Slider)" label="Throughput (Slider)"
max={500} />
min={400} </StyledLabelBase>
onChange={[Function]} <div
step={10} id="throughput2-slider-input"
styles={ >
Object { <StyledSliderBase
"root": Object { ariaLabel="Throughput (Slider)"
"width": 400, max={500}
}, min={400}
"titleLabel": Object { onChange={[Function]}
"color": "#393939", step={10}
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif", styles={
"fontSize": 12, Object {
"fontWeight": 600, "root": Object {
}, "width": 400,
"valueLabel": Object { },
"color": "#393939", "valueLabel": Object {
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif", "color": "#393939",
"fontSize": 12, "fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif",
}, "fontSize": 12,
},
}
} }
} />
/> </div>
</div> </Stack>
</StackItem> </StackItem>
</Stack> </Stack>
</div> </div>
@ -515,34 +500,33 @@ exports[`SmartUiComponent should render and honor input's hidden, disabled state
} }
> >
<StackItem> <StackItem>
<div <Stack>
className="stringInputContainer" <StyledLabelBase
> id="containerId-label"
<StyledTextFieldBase >
id="containerId-textField-input" <ToolTipLabelComponent
label="Container id" label="Container id"
onChange={[Function]} />
styles={ </StyledLabelBase>
Object { <div
"root": Object { className="stringInputContainer"
"width": 400, >
}, <StyledTextFieldBase
"subComponentStyles": Object { aria-labelledby="containerId-label"
"label": Object { id="containerId-textField-input"
"root": Object { onChange={[Function]}
"color": "#393939", styles={
"fontFamily": "wf_segoe-ui_normal, 'Segoe UI', 'Segoe WP', Tahoma, Arial, sans-serif", Object {
"fontSize": 12, "root": Object {
"fontWeight": 600, "width": 400,
},
}, },
}, }
} }
} type="text"
type="text" value=""
value="" />
/> </div>
</div> </Stack>
</StackItem> </StackItem>
</Stack> </Stack>
</div> </div>
@ -558,21 +542,30 @@ exports[`SmartUiComponent should render and honor input's hidden, disabled state
} }
> >
<StackItem> <StackItem>
<StyledToggleBase <Stack>
checked={false} <StyledLabelBase
id="analyticalStore-toggle-input" id="analyticalStore-label"
label="Analytical Store" >
offText="Disabled" <ToolTipLabelComponent
onChange={[Function]} label="Analytical Store"
onText="Enabled" />
styles={ </StyledLabelBase>
Object { <StyledToggleBase
"root": Object { aria-labelledby="analyticalStore-label"
"width": 400, checked={false}
}, id="analyticalStore-toggle-input"
offText="Disabled"
onChange={[Function]}
onText="Enabled"
styles={
Object {
"root": Object {
"width": 400,
},
}
} }
} />
/> </Stack>
</StackItem> </StackItem>
</Stack> </Stack>
</div> </div>
@ -588,46 +581,49 @@ exports[`SmartUiComponent should render and honor input's hidden, disabled state
} }
> >
<StackItem> <StackItem>
<StyledWithResponsiveMode <Stack>
id="database-dropdown-input" <StyledLabelBase
label="Database" id="database-label"
onChange={[Function]} >
options={ <ToolTipLabelComponent
Array [ label="Database"
Object { />
"key": "db1", </StyledLabelBase>
"text": "Database 1", <StyledWithResponsiveMode
}, aria-labelledby="database-label"
Object { id="database-dropdown-input"
"key": "db2", onChange={[Function]}
"text": "Database 2", options={
}, Array [
Object { Object {
"key": "db3", "key": "db1",
"text": "Database 3", "text": "Database 1",
}, },
] Object {
} "key": "db2",
selectedKey="db2" "text": "Database 2",
styles={ },
Object { Object {
"dropdown": Object { "key": "db3",
"color": "#393939", "text": "Database 3",
"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,
},
} }
} selectedKey="db2"
/> 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,
},
}
}
/>
</Stack>
</StackItem> </StackItem>
</Stack> </Stack>
</div> </div>

View File

@ -9,9 +9,11 @@
"North Central US": "North Central US", "North Central US": "North Central US",
"West US": "West US", "West US": "West US",
"East US 2": "East US 2", "East US 2": "East US 2",
"ClassInfo": "This is a self serve class", "Current Region": "Current Region",
"RegionDropdownInfo": "More regions can be added in the future.", "RegionDropdownInfo": "More regions can be added in the future.",
"ValidationError": "Regions and AccountName should not be empty.", "RegionsAndAccountNameValidationError": "Regions and account name should not be empty.",
"DbThroughputValidationError": "Please update throughput for database.",
"DescriptionLabel": "Description",
"DescriptionText": "This class sets collection and database throughput.", "DescriptionText": "This class sets collection and database throughput.",
"DecriptionLinkText": "Click here for more information", "DecriptionLinkText": "Click here for more information",
"Regions": "Regions", "Regions": "Regions",
@ -22,10 +24,17 @@
"Account Name": "Account Name", "Account Name": "Account Name",
"AccountNamePlaceHolder": "Enter the account name", "AccountNamePlaceHolder": "Enter the account name",
"Collection Throughput": "Collection Throughput", "Collection Throughput": "Collection Throughput",
"Enable DB level throughput": "Enable DB level throughput", "Enable DB level throughput": "Enable Database Level Throughput",
"Database Throughput": "Database Throughput", "Database Throughput": "Database Throughput",
"RefreshMessage": "Self Serve Example successfully refreshing", "UpdateInProgressMessage": "Data is being updated",
"SubmissionMessage": "Submitted successfully" "UpdateCompletedMessageTitle":"Update succeeded",
"UpdateCompletedMessageText": "Data updation completed.",
"SubmissionMessageSuccessTitle": "Update started",
"SubmissionMessageForNewRegionText": "Data update started. Region changed.",
"SubmissionMessageForSameRegionText": "Data update started. Region not changed.",
"SubmissionMessageErrorTitle": "Data update failed",
"SubmissionMessageErrorText": "Data update failed because of errors.",
"OnSaveFailureMessage": "Data save operation not currently permitted."
}, },
"SqlX": { "SqlX": {
} }

View File

@ -1,4 +1,4 @@
import { ChoiceItem, Description, Info, InputType, NumberUiType, SmartUiInput } from "./SelfServeTypes"; import { ChoiceItem, Description, Info, InputType, NumberUiType, SmartUiInput, RefreshParams } from "./SelfServeTypes";
import { addPropertyToMap, DecoratorProperties, buildSmartUiDescriptor } from "./SelfServeUtils"; import { addPropertyToMap, DecoratorProperties, buildSmartUiDescriptor } from "./SelfServeUtils";
type ValueOf<T> = T[keyof T]; type ValueOf<T> = T[keyof T];
@ -33,7 +33,9 @@ export interface ChoiceInputOptions extends InputOptionsBase {
} }
export interface DescriptionDisplayOptions { export interface DescriptionDisplayOptions {
labelTKey?: string;
description?: (() => Promise<Description>) | Description; description?: (() => Promise<Description>) | Description;
isDynamicDescription?: boolean;
} }
type InputOptions = type InputOptions =
@ -56,7 +58,7 @@ const isChoiceInputOptions = (inputOptions: InputOptions): inputOptions is Choic
}; };
const isDescriptionDisplayOptions = (inputOptions: InputOptions): inputOptions is DescriptionDisplayOptions => { const isDescriptionDisplayOptions = (inputOptions: InputOptions): inputOptions is DescriptionDisplayOptions => {
return "description" in inputOptions; return "description" in inputOptions || "isDynamicDescription" in inputOptions;
}; };
const addToMap = (...decorators: Decorator[]): PropertyDecorator => { const addToMap = (...decorators: Decorator[]): PropertyDecorator => {
@ -80,7 +82,11 @@ const addToMap = (...decorators: Decorator[]): PropertyDecorator => {
}; };
export const OnChange = ( export const OnChange = (
onChange: (currentState: Map<string, SmartUiInput>, newValue: InputType) => Map<string, SmartUiInput> onChange: (
newValue: InputType,
currentState: Map<string, SmartUiInput>,
baselineValues: ReadonlyMap<string, SmartUiInput>
) => Map<string, SmartUiInput>
): PropertyDecorator => { ): PropertyDecorator => {
return addToMap({ name: "onChange", value: onChange }); return addToMap({ name: "onChange", value: onChange });
}; };
@ -111,7 +117,11 @@ export const Values = (inputOptions: InputOptions): PropertyDecorator => {
{ name: "choices", value: inputOptions.choices } { name: "choices", value: inputOptions.choices }
); );
} else if (isDescriptionDisplayOptions(inputOptions)) { } else if (isDescriptionDisplayOptions(inputOptions)) {
return addToMap({ name: "description", value: inputOptions.description }); return addToMap(
{ name: "labelTKey", value: inputOptions.labelTKey },
{ name: "description", value: inputOptions.description },
{ name: "isDynamicDescription", value: inputOptions.isDynamicDescription }
);
} else { } else {
return addToMap( return addToMap(
{ name: "labelTKey", value: inputOptions.labelTKey }, { name: "labelTKey", value: inputOptions.labelTKey },
@ -126,8 +136,8 @@ export const IsDisplayable = (): ClassDecorator => {
}; };
}; };
export const ClassInfo = (info: (() => Promise<Info>) | Info): ClassDecorator => { export const RefreshOptions = (refreshParams: RefreshParams): ClassDecorator => {
return (target) => { return (target) => {
addPropertyToMap(target.prototype, "root", target.name, "info", info); addPropertyToMap(target.prototype, "root", target.name, "refreshParams", refreshParams);
}; };
}; };

View File

@ -64,13 +64,20 @@ export const initialize = async (): Promise<InitializeResponse> => {
}; };
export const onRefreshSelfServeExample = async (): Promise<RefreshResult> => { export const onRefreshSelfServeExample = async (): Promise<RefreshResult> => {
const refreshCountString = SessionStorageUtility.getEntry("refreshCount");
const refreshCount = refreshCountString ? parseInt(refreshCountString) : 0;
const subscriptionId = userContext.subscriptionId; const subscriptionId = userContext.subscriptionId;
const resourceGroup = userContext.resourceGroup; const resourceGroup = userContext.resourceGroup;
const databaseAccountName = userContext.databaseAccount.name; const databaseAccountName = userContext.databaseAccount.name;
const databaseAccountGetResults = await get(subscriptionId, resourceGroup, databaseAccountName); const databaseAccountGetResults = await get(subscriptionId, resourceGroup, databaseAccountName);
const isUpdateInProgress = databaseAccountGetResults.properties.provisioningState !== "Succeeded"; const isUpdateInProgress = databaseAccountGetResults.properties.provisioningState !== "Succeeded";
const progressToBeSent = refreshCount % 5 === 0 ? isUpdateInProgress : true;
SessionStorageUtility.setEntry("refreshCount", (refreshCount + 1).toString());
return { return {
isUpdateInProgress: isUpdateInProgress, isUpdateInProgress: progressToBeSent,
notificationMessage: "RefreshMessage", updateInProgressMessageTKey: "UpdateInProgressMessage",
}; };
}; };

View File

@ -1,13 +1,14 @@
import { PropertyInfo, OnChange, Values, IsDisplayable, ClassInfo } from "../Decorators"; import { PropertyInfo, OnChange, Values, IsDisplayable, RefreshOptions } from "../Decorators";
import { import {
ChoiceItem, ChoiceItem,
Description,
DescriptionType,
Info, Info,
InputType, InputType,
NumberUiType, NumberUiType,
OnSaveResult,
RefreshResult, RefreshResult,
SelfServeBaseClass, SelfServeBaseClass,
SelfServeNotification,
SelfServeNotificationType,
SmartUiInput, SmartUiInput,
} from "../SelfServeTypes"; } from "../SelfServeTypes";
import { import {
@ -27,16 +28,19 @@ const regionDropdownItems: ChoiceItem[] = [
{ label: "East US 2", key: Regions.EastUS2 }, { label: "East US 2", key: Regions.EastUS2 },
]; ];
const selfServeExampleInfo: Info = {
messageTKey: "ClassInfo",
};
const regionDropdownInfo: Info = { const regionDropdownInfo: Info = {
messageTKey: "RegionDropdownInfo", messageTKey: "RegionDropdownInfo",
}; };
const onRegionsChange = (currentState: Map<string, SmartUiInput>, newValue: InputType): Map<string, SmartUiInput> => { const onRegionsChange = (newValue: InputType, currentState: Map<string, SmartUiInput>): Map<string, SmartUiInput> => {
currentState.set("regions", { value: newValue }); currentState.set("regions", { value: newValue });
const currentRegionText = `current region selected is ${newValue}`;
currentState.set("currentRegionText", {
value: { textTKey: currentRegionText, type: DescriptionType.Text } as Description,
hidden: false,
});
const currentEnableLogging = currentState.get("enableLogging"); const currentEnableLogging = currentState.get("enableLogging");
if (newValue === Regions.NorthCentralUS) { if (newValue === Regions.NorthCentralUS) {
currentState.set("enableLogging", { value: false, disabled: true }); currentState.set("enableLogging", { value: false, disabled: true });
@ -47,8 +51,8 @@ const onRegionsChange = (currentState: Map<string, SmartUiInput>, newValue: Inpu
}; };
const onEnableDbLevelThroughputChange = ( const onEnableDbLevelThroughputChange = (
currentState: Map<string, SmartUiInput>, newValue: InputType,
newValue: InputType currentState: Map<string, SmartUiInput>
): Map<string, SmartUiInput> => { ): Map<string, SmartUiInput> => {
currentState.set("enableDbLevelThroughput", { value: newValue }); currentState.set("enableDbLevelThroughput", { value: newValue });
const currentDbThroughput = currentState.get("dbThroughput"); const currentDbThroughput = currentState.get("dbThroughput");
@ -57,9 +61,15 @@ const onEnableDbLevelThroughputChange = (
return currentState; return currentState;
}; };
const validate = (currentvalues: Map<string, SmartUiInput>): void => { const validate = (
currentvalues: Map<string, SmartUiInput>,
baselineValues: ReadonlyMap<string, SmartUiInput>
): void => {
if (currentvalues.get("dbThroughput") === baselineValues.get("dbThroughput")) {
throw new Error("DbThroughputValidationError");
}
if (!currentvalues.get("regions").value || !currentvalues.get("accountName").value) { if (!currentvalues.get("regions").value || !currentvalues.get("accountName").value) {
throw new Error("ValidationError"); throw new Error("RegionsAndAccountNameValidationError");
} }
}; };
@ -86,12 +96,12 @@ const validate = (currentvalues: Map<string, SmartUiInput>): void => {
*/ */
@IsDisplayable() @IsDisplayable()
/* /*
@ClassInfo() @RefreshOptions()
- optional - role: Passes the refresh options to be used by the self serve model.
- input: Info | () => Promise<Info> - inputs:
- role: Display an Info bar as the first element of the UI. retryIntervalInMs - The time interval between refresh attempts when an update in ongoing.
*/ */
@ClassInfo(selfServeExampleInfo) @RefreshOptions({ retryIntervalInMs: 2000 })
export default class SelfServeExample extends SelfServeBaseClass { export default class SelfServeExample extends SelfServeBaseClass {
/* /*
onRefresh() onRefresh()
@ -109,18 +119,21 @@ export default class SelfServeExample extends SelfServeBaseClass {
/* /*
onSave() onSave()
- input: (currentValues: Map<string, InputType>) => Promise<void> - input: (currentValues: Map<string, InputType>, baselineValues: ReadonlyMap<string, SmartUiInput>) => Promise<string>
- role: Callback that is triggerred when the submit button is clicked. You should perform your rest API - 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. calls here using the data from the different inputs passed as a Map to this callback function.
In this example, the onSave callback simply sets the value for keys corresponding to the field name In this example, the onSave callback simply sets the value for keys corresponding to the field name
in the SessionStorage. in the SessionStorage. It uses the currentValues and baselineValues maps to perform custom validations
- returns: SelfServeNotification - as well.
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) - returns: The initialize, success and failure messages to be displayed in the Portal Notification blade after the operation is completed.
*/ */
public onSave = async (currentValues: Map<string, SmartUiInput>): Promise<SelfServeNotification> => { public onSave = async (
validate(currentValues); currentValues: Map<string, SmartUiInput>,
baselineValues: ReadonlyMap<string, SmartUiInput>
): Promise<OnSaveResult> => {
validate(currentValues, baselineValues);
const regions = Regions[currentValues.get("regions")?.value as keyof typeof Regions]; const regions = Regions[currentValues.get("regions")?.value as keyof typeof Regions];
const enableLogging = currentValues.get("enableLogging")?.value as boolean; const enableLogging = currentValues.get("enableLogging")?.value as boolean;
const accountName = currentValues.get("accountName")?.value as string; const accountName = currentValues.get("accountName")?.value as string;
@ -128,8 +141,48 @@ export default class SelfServeExample extends SelfServeBaseClass {
const enableDbLevelThroughput = currentValues.get("enableDbLevelThroughput")?.value as boolean; const enableDbLevelThroughput = currentValues.get("enableDbLevelThroughput")?.value as boolean;
let dbThroughput = currentValues.get("dbThroughput")?.value as number; let dbThroughput = currentValues.get("dbThroughput")?.value as number;
dbThroughput = enableDbLevelThroughput ? dbThroughput : undefined; dbThroughput = enableDbLevelThroughput ? dbThroughput : undefined;
await update(regions, enableLogging, accountName, collectionThroughput, dbThroughput); try {
return { message: "SubmissionMessage", type: SelfServeNotificationType.info }; await update(regions, enableLogging, accountName, collectionThroughput, dbThroughput);
if (currentValues.get("regions") === baselineValues.get("regions")) {
return {
operationStatusUrl: undefined,
portalNotification: {
initialize: {
titleTKey: "SubmissionMessageSuccessTitle",
messageTKey: "SubmissionMessageForSameRegionText",
},
success: {
titleTKey: "UpdateCompletedMessageTitle",
messageTKey: "UpdateCompletedMessageText",
},
failure: {
titleTKey: "SubmissionMessageErrorTitle",
messageTKey: "SubmissionMessageErrorText",
},
},
};
} else {
return {
operationStatusUrl: undefined,
portalNotification: {
initialize: {
titleTKey: "SubmissionMessageSuccessTitle",
messageTKey: "SubmissionMessageForNewRegionText",
},
success: {
titleTKey: "UpdateCompletedMessageTitle",
messageTKey: "UpdateCompletedMessageText",
},
failure: {
titleTKey: "SubmissionMessageErrorTitle",
messageTKey: "SubmissionMessageErrorText",
},
},
};
}
} catch (error) {
throw new Error("OnSaveFailureMessage");
}
}; };
/* /*
@ -150,6 +203,11 @@ export default class SelfServeExample extends SelfServeBaseClass {
public initialize = async (): Promise<Map<string, SmartUiInput>> => { public initialize = async (): Promise<Map<string, SmartUiInput>> => {
const initializeResponse = await initialize(); const initializeResponse = await initialize();
const defaults = new Map<string, SmartUiInput>(); const defaults = new Map<string, SmartUiInput>();
const currentRegionText = `current region selected is ${initializeResponse.regions}`;
defaults.set("currentRegionText", {
value: { textTKey: currentRegionText, type: DescriptionType.Text } as Description,
hidden: false,
});
defaults.set("regions", { value: initializeResponse.regions }); defaults.set("regions", { value: initializeResponse.regions });
defaults.set("enableLogging", { value: initializeResponse.enableLogging }); defaults.set("enableLogging", { value: initializeResponse.enableLogging });
const accountName = initializeResponse.accountName; const accountName = initializeResponse.accountName;
@ -172,15 +230,24 @@ export default class SelfServeExample extends SelfServeBaseClass {
e) Text (with optional hyperlink) for descriptions e) Text (with optional hyperlink) for descriptions
*/ */
@Values({ @Values({
labelTKey: "DescriptionLabel",
description: { description: {
textTKey: "DescriptionText", textTKey: "DescriptionText",
type: DescriptionType.Text,
link: { link: {
href: "https://docs.microsoft.com/en-us/azure/cosmos-db/introduction", href: "https://aka.ms/cosmos-create-account-portal",
textTKey: "DecriptionLinkText", textTKey: "DecriptionLinkText",
}, },
}, },
}) })
description: string; description: string;
@Values({
labelTKey: "Current Region",
isDynamicDescription: true,
})
currentRegionText: string;
/* /*
@PropertyInfo() @PropertyInfo()
- optional - optional
@ -192,8 +259,8 @@ export default class SelfServeExample extends SelfServeBaseClass {
/* /*
@OnChange() @OnChange()
- optional - optional
- input: (currentValues: Map<string, InputType>, newValue: InputType) => Map<string, InputType> - input: (currentValues: Map<string, InputType>, newValue: InputType, baselineValues: ReadonlyMap<string, SmartUiInput>) => Map<string, InputType>
- role: Takes a Map of current values and the newValue for this property as inputs. This is called when a property, - role: Takes a Map of current values, the newValue for this property and a ReadonlyMap of baselineValues as inputs. This is called when a property,
say prop1, changes its value in the UI. This can be used to 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. 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 b) Change the visibility for prop2 in the UI, based on prop1

View File

@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { shallow } from "enzyme"; import { shallow } from "enzyme";
import { SelfServeComponent, SelfServeComponentState } from "./SelfServeComponent"; import { SelfServeComponent, SelfServeComponentState } from "./SelfServeComponent";
import { NumberUiType, SelfServeDescriptor, SelfServeNotificationType, SmartUiInput } from "./SelfServeTypes"; import { NumberUiType, OnSaveResult, SelfServeDescriptor, SmartUiInput } from "./SelfServeTypes";
describe("SelfServeComponent", () => { describe("SelfServeComponent", () => {
const defaultValues = new Map<string, SmartUiInput>([ const defaultValues = new Map<string, SmartUiInput>([
@ -17,13 +17,20 @@ describe("SelfServeComponent", () => {
const initializeMock = jest.fn(async () => new Map(defaultValues)); const initializeMock = jest.fn(async () => new Map(defaultValues));
const onSaveMock = jest.fn(async () => { const onSaveMock = jest.fn(async () => {
return { message: "submitted successfully", type: SelfServeNotificationType.info }; return {
operationStatusUrl: undefined,
} as OnSaveResult;
}); });
const refreshResult = {
isUpdateInProgress: false,
updateInProgressMessageTKey: "refresh performed successfully",
};
const onRefreshMock = jest.fn(async () => { const onRefreshMock = jest.fn(async () => {
return { isUpdateInProgress: false, notificationMessage: "refresh performed successfully" }; return { ...refreshResult };
}); });
const onRefreshIsUpdatingMock = jest.fn(async () => { const onRefreshIsUpdatingMock = jest.fn(async () => {
return { isUpdateInProgress: true, notificationMessage: "refresh performed successfully" }; return { ...refreshResult, isUpdateInProgress: true };
}); });
const exampleData: SelfServeDescriptor = { const exampleData: SelfServeDescriptor = {
@ -136,16 +143,15 @@ describe("SelfServeComponent", () => {
wrapper.update(); wrapper.update();
state = wrapper.state() as SelfServeComponentState; state = wrapper.state() as SelfServeComponentState;
isEqual(state.baselineValues, updatedValues); isEqual(state.baselineValues, updatedValues);
selfServeComponent.resetBaselineValues(); selfServeComponent.updateBaselineValues();
state = wrapper.state() as SelfServeComponentState; state = wrapper.state() as SelfServeComponentState;
isEqual(state.baselineValues, defaultValues); isEqual(state.baselineValues, defaultValues);
isEqual(state.currentValues, state.baselineValues); isEqual(state.currentValues, state.baselineValues);
// clicking refresh calls onRefresh. If component is not updating, it calls initialize() as well // clicking refresh calls onRefresh.
selfServeComponent.onRefreshClicked(); selfServeComponent.onRefreshClicked();
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
expect(onRefreshMock).toHaveBeenCalledTimes(2); expect(onRefreshMock).toHaveBeenCalledTimes(2);
expect(initializeMock).toHaveBeenCalledTimes(2);
selfServeComponent.onSaveButtonClick(); selfServeComponent.onSaveButtonClick();
expect(onSaveMock).toHaveBeenCalledTimes(1); expect(onSaveMock).toHaveBeenCalledTimes(1);

View File

@ -15,20 +15,45 @@ import {
InputType, InputType,
RefreshResult, RefreshResult,
SelfServeDescriptor, SelfServeDescriptor,
SelfServeNotification,
SmartUiInput, SmartUiInput,
DescriptionDisplay, DescriptionDisplay,
StringInput, StringInput,
NumberInput, NumberInput,
BooleanInput, BooleanInput,
ChoiceInput, ChoiceInput,
SelfServeNotificationType,
} from "./SelfServeTypes"; } from "./SelfServeTypes";
import { SmartUiComponent, SmartUiDescriptor } from "../Explorer/Controls/SmartUi/SmartUiComponent"; import { SmartUiComponent, SmartUiDescriptor } from "../Explorer/Controls/SmartUi/SmartUiComponent";
import { getMessageBarType } from "./SelfServeUtils";
import { Translation } from "react-i18next"; import { Translation } from "react-i18next";
import { TFunction } from "i18next"; import { TFunction } from "i18next";
import "../i18n"; import "../i18n";
import { sendMessage } from "../Common/MessageHandler";
import { SelfServeMessageTypes } from "../Contracts/SelfServeContracts";
import promiseRetry, { AbortError } from "p-retry";
interface SelfServeNotification {
message: string;
type: MessageBarType;
isCancellable: boolean;
}
interface PortalNotificationContent {
retryIntervalInMs: number;
operationStatusUrl: string;
portalNotification?: {
initialize: {
title: string;
message: string;
};
success: {
title: string;
message: string;
};
failure: {
title: string;
message: string;
};
};
}
export interface SelfServeComponentProps { export interface SelfServeComponentProps {
descriptor: SelfServeDescriptor; descriptor: SelfServeDescriptor;
@ -39,17 +64,26 @@ export interface SelfServeComponentState {
currentValues: Map<string, SmartUiInput>; currentValues: Map<string, SmartUiInput>;
baselineValues: Map<string, SmartUiInput>; baselineValues: Map<string, SmartUiInput>;
isInitializing: boolean; isInitializing: boolean;
isSaving: boolean;
hasErrors: boolean; hasErrors: boolean;
compileErrorMessage: string; compileErrorMessage: string;
notification: SelfServeNotification;
refreshResult: RefreshResult; refreshResult: RefreshResult;
notification: SelfServeNotification;
} }
export class SelfServeComponent extends React.Component<SelfServeComponentProps, SelfServeComponentState> { export class SelfServeComponent extends React.Component<SelfServeComponentProps, SelfServeComponentState> {
private static readonly defaultRetryIntervalInMs = 30000;
private smartUiGeneratorClassName: string; private smartUiGeneratorClassName: string;
private retryIntervalInMs: number;
private retryOptions: promiseRetry.Options;
private translationFunction: TFunction;
componentDidMount(): void { componentDidMount(): void {
this.performRefresh(); this.performRefresh().then(() => {
if (this.state.refreshResult?.isUpdateInProgress) {
promiseRetry(() => this.pollRefresh(), this.retryOptions);
}
});
this.initializeSmartUiComponent(); this.initializeSmartUiComponent();
} }
@ -60,12 +94,18 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
currentValues: new Map(), currentValues: new Map(),
baselineValues: new Map(), baselineValues: new Map(),
isInitializing: true, isInitializing: true,
isSaving: false,
hasErrors: false, hasErrors: false,
compileErrorMessage: undefined, compileErrorMessage: undefined,
notification: undefined,
refreshResult: undefined, refreshResult: undefined,
notification: undefined,
}; };
this.smartUiGeneratorClassName = this.props.descriptor.root.id; this.smartUiGeneratorClassName = this.props.descriptor.root.id;
this.retryIntervalInMs = this.props.descriptor.refreshParams?.retryIntervalInMs;
if (!this.retryIntervalInMs) {
this.retryIntervalInMs = SelfServeComponent.defaultRetryIntervalInMs;
}
this.retryOptions = { forever: true, maxTimeout: this.retryIntervalInMs, minTimeout: this.retryIntervalInMs };
} }
private onError = (hasErrors: boolean): void => { private onError = (hasErrors: boolean): void => {
@ -109,7 +149,7 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
this.setState({ currentValues, baselineValues }); this.setState({ currentValues, baselineValues });
}; };
public resetBaselineValues = (): void => { public updateBaselineValues = (): void => {
const currentValues = this.state.currentValues; const currentValues = this.state.currentValues;
let baselineValues = this.state.baselineValues; let baselineValues = this.state.baselineValues;
for (const key of currentValues.keys()) { for (const key of currentValues.keys()) {
@ -204,7 +244,11 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
private onInputChange = (input: AnyDisplay, newValue: InputType) => { private onInputChange = (input: AnyDisplay, newValue: InputType) => {
if (input.onChange) { if (input.onChange) {
const newValues = input.onChange(this.state.currentValues, newValue); const newValues = input.onChange(
newValue,
this.state.currentValues,
this.state.baselineValues as ReadonlyMap<string, SmartUiInput>
);
this.setState({ currentValues: newValues }); this.setState({ currentValues: newValues });
} else { } else {
const dataFieldName = input.dataFieldName; const dataFieldName = input.dataFieldName;
@ -215,42 +259,60 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
} }
}; };
public performSave = async (): Promise<void> => {
this.setState({ isSaving: true, notification: undefined });
try {
const onSaveResult = await this.props.descriptor.onSave(
this.state.currentValues,
this.state.baselineValues as ReadonlyMap<string, SmartUiInput>
);
if (onSaveResult.portalNotification) {
const requestInitializedPortalNotification = onSaveResult.portalNotification.initialize;
const requestSucceededPortalNotification = onSaveResult.portalNotification.success;
const requestFailedPortalNotification = onSaveResult.portalNotification.failure;
this.sendNotificationMessage({
retryIntervalInMs: this.retryIntervalInMs,
operationStatusUrl: onSaveResult.operationStatusUrl,
portalNotification: {
initialize: {
title: this.getTranslation(requestInitializedPortalNotification.titleTKey),
message: this.getTranslation(requestInitializedPortalNotification.messageTKey),
},
success: {
title: this.getTranslation(requestSucceededPortalNotification.titleTKey),
message: this.getTranslation(requestSucceededPortalNotification.messageTKey),
},
failure: {
title: this.getTranslation(requestFailedPortalNotification.titleTKey),
message: this.getTranslation(requestFailedPortalNotification.messageTKey),
},
},
});
}
promiseRetry(() => this.pollRefresh(), this.retryOptions);
} catch (error) {
this.setState({
notification: {
type: MessageBarType.error,
isCancellable: true,
message: this.getTranslation(error.message),
},
});
throw error;
} finally {
this.setState({ isSaving: false });
}
await this.onRefreshClicked();
this.updateBaselineValues();
};
public onSaveButtonClick = (): void => { public onSaveButtonClick = (): void => {
const onSavePromise = this.props.descriptor.onSave(this.state.currentValues); this.performSave();
onSavePromise.catch((error) => {
this.setState({
notification: {
message: `${error.message}`,
type: SelfServeNotificationType.error,
},
});
});
onSavePromise.then((notification: SelfServeNotification) => {
this.setState({
notification: {
message: notification.message,
type: notification.type,
},
});
this.resetBaselineValues();
this.onRefreshClicked();
});
}; };
public isDiscardButtonDisabled = (): boolean => { public isDiscardButtonDisabled = (): boolean => {
for (const key of this.state.currentValues.keys()) { if (this.state.isSaving) {
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; return true;
} }
for (const key of this.state.currentValues.keys()) { for (const key of this.state.currentValues.keys()) {
@ -264,38 +326,84 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
return true; return true;
}; };
private performRefresh = async (): Promise<RefreshResult> => { public isSaveButtonDisabled = (): boolean => {
if (this.state.hasErrors || this.state.isSaving) {
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<void> => {
const refreshResult = await this.props.descriptor.onRefresh(); const refreshResult = await this.props.descriptor.onRefresh();
this.setState({ refreshResult: { ...refreshResult } }); let updateInProgressNotification: SelfServeNotification;
return refreshResult; if (this.state.refreshResult?.isUpdateInProgress && !refreshResult.isUpdateInProgress) {
await this.initializeSmartUiComponent();
}
if (refreshResult.isUpdateInProgress) {
updateInProgressNotification = {
type: MessageBarType.info,
isCancellable: false,
message: this.getTranslation(refreshResult.updateInProgressMessageTKey),
};
}
this.setState({
refreshResult: { ...refreshResult },
notification: updateInProgressNotification,
});
}; };
public onRefreshClicked = async (): Promise<void> => { public onRefreshClicked = async (): Promise<void> => {
this.setState({ isInitializing: true }); this.setState({ isInitializing: true });
const refreshResult = await this.performRefresh(); await this.performRefresh();
if (!refreshResult.isUpdateInProgress) {
this.initializeSmartUiComponent();
}
this.setState({ isInitializing: false }); this.setState({ isInitializing: false });
}; };
public getCommonTranslation = (translationFunction: TFunction, key: string): string => { public pollRefresh = async (): Promise<void> => {
return translationFunction(`Common.${key}`); try {
await this.performRefresh();
} catch (error) {
throw new AbortError(error);
}
const refreshResult = this.state.refreshResult;
if (refreshResult.isUpdateInProgress) {
throw new Error("update in progress. retrying ...");
}
}; };
private getCommandBarItems = (translate: TFunction): ICommandBarItemProps[] => { public getCommonTranslation = (key: string): string => {
return this.getTranslation(key, "Common");
};
private getTranslation = (messageKey: string, prefix = `${this.smartUiGeneratorClassName}`): string => {
const translationKey = `${prefix}.${messageKey}`;
const translation = this.translationFunction ? this.translationFunction(translationKey) : messageKey;
if (translation === translationKey) {
return messageKey;
}
return translation;
};
private getCommandBarItems = (): ICommandBarItemProps[] => {
return [ return [
{ {
key: "save", key: "save",
text: this.getCommonTranslation(translate, "Save"), text: this.getCommonTranslation("Save"),
iconProps: { iconName: "Save" }, iconProps: { iconName: "Save" },
split: true, split: true,
disabled: this.isSaveButtonDisabled(), disabled: this.isSaveButtonDisabled(),
onClick: this.onSaveButtonClick, onClick: () => this.onSaveButtonClick(),
}, },
{ {
key: "discard", key: "discard",
text: this.getCommonTranslation(translate, "Discard"), text: this.getCommonTranslation("Discard"),
iconProps: { iconName: "Undo" }, iconProps: { iconName: "Undo" },
split: true, split: true,
disabled: this.isDiscardButtonDisabled(), disabled: this.isDiscardButtonDisabled(),
@ -305,7 +413,7 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
}, },
{ {
key: "refresh", key: "refresh",
text: this.getCommonTranslation(translate, "Refresh"), text: this.getCommonTranslation("Refresh"),
disabled: this.state.isInitializing, disabled: this.state.isInitializing,
iconProps: { iconName: "Refresh" }, iconProps: { iconName: "Refresh" },
split: true, split: true,
@ -316,12 +424,11 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
]; ];
}; };
private getNotificationMessageTranslation = (translationFunction: TFunction, messageKey: string): string => { private sendNotificationMessage = (portalNotificationContent: PortalNotificationContent): void => {
const translation = translationFunction(messageKey); sendMessage({
if (translation === `${this.smartUiGeneratorClassName}.${messageKey}`) { type: SelfServeMessageTypes.Notification,
return messageKey; data: { portalNotificationContent },
} });
return translation;
}; };
public render(): JSX.Element { public render(): JSX.Element {
@ -332,14 +439,14 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
return ( return (
<Translation> <Translation>
{(translate) => { {(translate) => {
const getTranslation = (key: string): string => { if (!this.translationFunction) {
return translate(`${this.smartUiGeneratorClassName}.${key}`); this.translationFunction = translate;
}; }
return ( return (
<div style={{ overflowX: "auto" }}> <div style={{ overflowX: "auto" }}>
<Stack tokens={containerStackTokens} styles={{ root: { padding: 10 } }}> <Stack tokens={containerStackTokens} styles={{ root: { padding: 10 } }}>
<CommandBar styles={{ root: { paddingLeft: 0 } }} items={this.getCommandBarItems(translate)} /> <CommandBar styles={{ root: { paddingLeft: 0 } }} items={this.getCommandBarItems()} />
{this.state.isInitializing ? ( {this.state.isInitializing ? (
<Spinner <Spinner
size={SpinnerSize.large} size={SpinnerSize.large}
@ -347,27 +454,25 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
/> />
) : ( ) : (
<> <>
{this.state.refreshResult?.isUpdateInProgress && (
<MessageBar messageBarType={MessageBarType.info} styles={{ root: { width: 400 } }}>
{getTranslation(this.state.refreshResult.notificationMessage)}
</MessageBar>
)}
{this.state.notification && ( {this.state.notification && (
<MessageBar <MessageBar
messageBarType={getMessageBarType(this.state.notification.type)} messageBarType={this.state.notification.type}
styles={{ root: { width: 400 } }} onDismiss={
onDismiss={() => this.setState({ notification: undefined })} this.state.notification.isCancellable
? () => this.setState({ notification: undefined })
: undefined
}
> >
{this.getNotificationMessageTranslation(getTranslation, this.state.notification.message)} {this.state.notification.message}
</MessageBar> </MessageBar>
)} )}
<SmartUiComponent <SmartUiComponent
disabled={this.state.refreshResult?.isUpdateInProgress} disabled={this.state.refreshResult?.isUpdateInProgress || this.state.isSaving}
descriptor={this.state.root as SmartUiDescriptor} descriptor={this.state.root as SmartUiDescriptor}
currentValues={this.state.currentValues} currentValues={this.state.currentValues}
onInputChange={this.onInputChange} onInputChange={this.onInputChange}
onError={this.onError} onError={this.onError}
getTranslation={getTranslation} getTranslation={this.getTranslation}
/> />
</> </>
)} )}

View File

@ -3,7 +3,11 @@ interface BaseInput {
errorMessage?: string; errorMessage?: string;
type: InputTypeValue; type: InputTypeValue;
labelTKey?: (() => Promise<string>) | string; labelTKey?: (() => Promise<string>) | string;
onChange?: (currentState: Map<string, SmartUiInput>, newValue: InputType) => Map<string, SmartUiInput>; onChange?: (
newValue: InputType,
currentState: Map<string, SmartUiInput>,
baselineValues: ReadonlyMap<string, SmartUiInput>
) => Map<string, SmartUiInput>;
placeholderTKey?: (() => Promise<string>) | string; placeholderTKey?: (() => Promise<string>) | string;
} }
@ -44,16 +48,23 @@ export interface Node {
export interface SelfServeDescriptor { export interface SelfServeDescriptor {
root: Node; root: Node;
initialize?: () => Promise<Map<string, SmartUiInput>>; initialize?: () => Promise<Map<string, SmartUiInput>>;
onSave?: (currentValues: Map<string, SmartUiInput>) => Promise<SelfServeNotification>; onSave?: (
currentValues: Map<string, SmartUiInput>,
baselineValues: ReadonlyMap<string, SmartUiInput>
) => Promise<OnSaveResult>;
inputNames?: string[]; inputNames?: string[];
onRefresh?: () => Promise<RefreshResult>; onRefresh?: () => Promise<RefreshResult>;
refreshParams?: RefreshParams;
} }
export type AnyDisplay = NumberInput | BooleanInput | StringInput | ChoiceInput | DescriptionDisplay; export type AnyDisplay = NumberInput | BooleanInput | StringInput | ChoiceInput | DescriptionDisplay;
export abstract class SelfServeBaseClass { export abstract class SelfServeBaseClass {
public abstract initialize: () => Promise<Map<string, SmartUiInput>>; public abstract initialize: () => Promise<Map<string, SmartUiInput>>;
public abstract onSave: (currentValues: Map<string, SmartUiInput>) => Promise<SelfServeNotification>; public abstract onSave: (
currentValues: Map<string, SmartUiInput>,
baselineValues: ReadonlyMap<string, SmartUiInput>
) => Promise<OnSaveResult>;
public abstract onRefresh: () => Promise<RefreshResult>; public abstract onRefresh: () => Promise<RefreshResult>;
public toSelfServeDescriptor(): SelfServeDescriptor { public toSelfServeDescriptor(): SelfServeDescriptor {
@ -70,7 +81,7 @@ export abstract class SelfServeBaseClass {
throw new Error(`onRefresh() was not declared for the class '${className}'`); throw new Error(`onRefresh() was not declared for the class '${className}'`);
} }
if (!selfServeDescriptor?.root) { if (!selfServeDescriptor?.root) {
throw new Error(`@SmartUi decorator was not declared for the class '${className}'`); throw new Error(`@IsDisplayable decorator was not declared for the class '${className}'`);
} }
selfServeDescriptor.initialize = this.initialize; selfServeDescriptor.initialize = this.initialize;
@ -89,7 +100,7 @@ export enum NumberUiType {
export type ChoiceItem = { label: string; key: string }; export type ChoiceItem = { label: string; key: string };
export type InputType = number | string | boolean | ChoiceItem; export type InputType = number | string | boolean | ChoiceItem | Description;
export interface Info { export interface Info {
messageTKey: string; messageTKey: string;
@ -99,8 +110,15 @@ export interface Info {
}; };
} }
export enum DescriptionType {
Text,
InfoMessageBar,
WarningMessageBar,
}
export interface Description { export interface Description {
textTKey: string; textTKey: string;
type: DescriptionType;
link?: { link?: {
href: string; href: string;
textTKey: string; textTKey: string;
@ -113,18 +131,29 @@ export interface SmartUiInput {
disabled?: boolean; disabled?: boolean;
} }
export enum SelfServeNotificationType { export interface OnSaveResult {
info = "info", operationStatusUrl: string;
warning = "warning", portalNotification?: {
error = "error", initialize: {
} titleTKey: string;
messageTKey: string;
export interface SelfServeNotification { };
message: string; success: {
type: SelfServeNotificationType; titleTKey: string;
messageTKey: string;
};
failure: {
titleTKey: string;
messageTKey: string;
};
};
} }
export interface RefreshResult { export interface RefreshResult {
isUpdateInProgress: boolean; isUpdateInProgress: boolean;
notificationMessage: string; updateInProgressMessageTKey: string;
}
export interface RefreshParams {
retryIntervalInMs: number;
} }

View File

@ -1,11 +1,11 @@
import { NumberUiType, RefreshResult, SelfServeBaseClass, SelfServeNotification, SmartUiInput } from "./SelfServeTypes"; import { NumberUiType, OnSaveResult, RefreshResult, SelfServeBaseClass, SmartUiInput } from "./SelfServeTypes";
import { DecoratorProperties, mapToSmartUiDescriptor, updateContextWithDecorator } from "./SelfServeUtils"; import { DecoratorProperties, mapToSmartUiDescriptor, updateContextWithDecorator } from "./SelfServeUtils";
describe("SelfServeUtils", () => { describe("SelfServeUtils", () => {
it("initialize should be declared for self serve classes", () => { it("initialize should be declared for self serve classes", () => {
class Test extends SelfServeBaseClass { class Test extends SelfServeBaseClass {
public initialize: () => Promise<Map<string, SmartUiInput>>; public initialize: () => Promise<Map<string, SmartUiInput>>;
public onSave: (currentValues: Map<string, SmartUiInput>) => Promise<SelfServeNotification>; public onSave: (currentValues: Map<string, SmartUiInput>) => Promise<OnSaveResult>;
public onRefresh: () => Promise<RefreshResult>; public onRefresh: () => Promise<RefreshResult>;
} }
expect(() => new Test().toSelfServeDescriptor()).toThrow("initialize() was not declared for the class 'Test'"); expect(() => new Test().toSelfServeDescriptor()).toThrow("initialize() was not declared for the class 'Test'");
@ -14,7 +14,7 @@ describe("SelfServeUtils", () => {
it("onSave should be declared for self serve classes", () => { it("onSave should be declared for self serve classes", () => {
class Test extends SelfServeBaseClass { class Test extends SelfServeBaseClass {
public initialize = jest.fn(); public initialize = jest.fn();
public onSave: () => Promise<SelfServeNotification>; public onSave: () => Promise<OnSaveResult>;
public onRefresh: () => Promise<RefreshResult>; public onRefresh: () => Promise<RefreshResult>;
} }
expect(() => new Test().toSelfServeDescriptor()).toThrow("onSave() was not declared for the class 'Test'"); expect(() => new Test().toSelfServeDescriptor()).toThrow("onSave() was not declared for the class 'Test'");
@ -29,14 +29,14 @@ describe("SelfServeUtils", () => {
expect(() => new Test().toSelfServeDescriptor()).toThrow("onRefresh() was not declared for the class 'Test'"); expect(() => new Test().toSelfServeDescriptor()).toThrow("onRefresh() was not declared for the class 'Test'");
}); });
it("@SmartUi decorator must be present for self serve classes", () => { it("@IsDisplayable decorator must be present for self serve classes", () => {
class Test extends SelfServeBaseClass { class Test extends SelfServeBaseClass {
public initialize = jest.fn(); public initialize = jest.fn();
public onSave = jest.fn(); public onSave = jest.fn();
public onRefresh = jest.fn(); public onRefresh = jest.fn();
} }
expect(() => new Test().toSelfServeDescriptor()).toThrow( expect(() => new Test().toSelfServeDescriptor()).toThrow(
"@SmartUi decorator was not declared for the class 'Test'" "@IsDisplayable decorator was not declared for the class 'Test'"
); );
}); });

View File

@ -1,4 +1,3 @@
import { MessageBarType } from "office-ui-fabric-react";
import "reflect-metadata"; import "reflect-metadata";
import { import {
Node, Node,
@ -15,8 +14,9 @@ import {
SelfServeDescriptor, SelfServeDescriptor,
SmartUiInput, SmartUiInput,
StringInput, StringInput,
SelfServeNotificationType, RefreshParams,
} from "./SelfServeTypes"; } from "./SelfServeTypes";
import { userContext } from "../UserContext";
export enum SelfServeType { export enum SelfServeType {
// No self serve type passed, launch explorer // No self serve type passed, launch explorer
@ -28,6 +28,14 @@ export enum SelfServeType {
sqlx = "sqlx", sqlx = "sqlx",
} }
export enum BladeType {
SqlKeys = "keys",
MongoKeys = "mongoDbKeys",
CassandraKeys = "cassandraDbKeys",
GremlinKeys = "keys",
TableKeys = "tableKeys",
}
export interface DecoratorProperties { export interface DecoratorProperties {
id: string; id: string;
info?: (() => Promise<Info>) | Info; info?: (() => Promise<Info>) | Info;
@ -44,9 +52,13 @@ export interface DecoratorProperties {
uiType?: string; uiType?: string;
errorMessage?: string; errorMessage?: string;
description?: (() => Promise<Description>) | Description; description?: (() => Promise<Description>) | Description;
onChange?: (currentState: Map<string, SmartUiInput>, newValue: InputType) => Map<string, SmartUiInput>; isDynamicDescription?: boolean;
onSave?: (currentValues: Map<string, SmartUiInput>) => Promise<void>; refreshParams?: RefreshParams;
initialize?: () => Promise<Map<string, SmartUiInput>>; onChange?: (
newValue: InputType,
currentState: Map<string, SmartUiInput>,
baselineValues: ReadonlyMap<string, SmartUiInput>
) => Map<string, SmartUiInput>;
} }
const setValue = <T extends keyof DecoratorProperties, K extends DecoratorProperties[T]>( const setValue = <T extends keyof DecoratorProperties, K extends DecoratorProperties[T]>(
@ -83,7 +95,7 @@ export const updateContextWithDecorator = <T extends keyof DecoratorProperties,
descriptorValue: K descriptorValue: K
): void => { ): void => {
if (!(context instanceof Map)) { if (!(context instanceof Map)) {
throw new Error(`@SmartUi should be the first decorator for the class '${className}'.`); throw new Error(`@IsDisplayable should be the first decorator for the class '${className}'.`);
} }
const propertyObject = context.get(propertyName) ?? { id: propertyName }; const propertyObject = context.get(propertyName) ?? { id: propertyName };
@ -108,16 +120,17 @@ export const mapToSmartUiDescriptor = (
className: string, className: string,
context: Map<string, DecoratorProperties> context: Map<string, DecoratorProperties>
): SelfServeDescriptor => { ): SelfServeDescriptor => {
const inputNames: string[] = [];
const root = context.get("root"); const root = context.get("root");
context.delete("root"); context.delete("root");
const inputNames: string[] = [];
const smartUiDescriptor: SelfServeDescriptor = { const smartUiDescriptor: SelfServeDescriptor = {
root: { root: {
id: className, id: className,
info: root?.info, info: undefined,
children: [], children: [],
}, },
refreshParams: root?.refreshParams,
}; };
while (context.size > 0) { while (context.size > 0) {
@ -155,7 +168,10 @@ const getInput = (value: DecoratorProperties): AnyDisplay => {
} }
return value as NumberInput; return value as NumberInput;
case "string": case "string":
if (value.description) { if (value.description || value.isDynamicDescription) {
if (value.description && value.isDynamicDescription) {
value.errorMessage = `dynamic descriptions should not have defaults set here.`;
}
return value as DescriptionDisplay; return value as DescriptionDisplay;
} }
if (!value.labelTKey) { if (!value.labelTKey) {
@ -175,13 +191,9 @@ const getInput = (value: DecoratorProperties): AnyDisplay => {
} }
}; };
export const getMessageBarType = (type: SelfServeNotificationType): MessageBarType => { export const generateBladeLink = (blade: BladeType): string => {
switch (type) { const subscriptionId = userContext.subscriptionId;
case SelfServeNotificationType.info: const resourceGroupName = userContext.resourceGroup;
return MessageBarType.info; const databaseAccountName = userContext.databaseAccount.name;
case SelfServeNotificationType.warning: return `www.portal.azure.com/#@microsoft.onmicrosoft.com/resource/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDb/databaseAccounts/${databaseAccountName}/${blade}`;
return MessageBarType.warning;
case SelfServeNotificationType.error:
return MessageBarType.error;
}
}; };

View File

@ -1,18 +1,19 @@
import { IsDisplayable, OnChange, Values } from "../Decorators"; import { IsDisplayable, OnChange, Values } from "../Decorators";
import { import {
ChoiceItem, ChoiceItem,
DescriptionType,
InputType, InputType,
NumberUiType, NumberUiType,
OnSaveResult,
RefreshResult, RefreshResult,
SelfServeBaseClass, SelfServeBaseClass,
SelfServeNotification,
SmartUiInput, SmartUiInput,
} from "../SelfServeTypes"; } from "../SelfServeTypes";
import { refreshDedicatedGatewayProvisioning } from "./SqlX.rp"; import { refreshDedicatedGatewayProvisioning } from "./SqlX.rp";
const onEnableDedicatedGatewayChange = ( const onEnableDedicatedGatewayChange = (
currentState: Map<string, SmartUiInput>, newValue: InputType,
newValue: InputType currentState: Map<string, SmartUiInput>
): Map<string, SmartUiInput> => { ): Map<string, SmartUiInput> => {
const sku = currentState.get("sku"); const sku = currentState.get("sku");
const instances = currentState.get("instances"); const instances = currentState.get("instances");
@ -49,7 +50,7 @@ export default class SqlX extends SelfServeBaseClass {
return refreshDedicatedGatewayProvisioning(); return refreshDedicatedGatewayProvisioning();
}; };
public onSave = async (currentValues: Map<string, SmartUiInput>): Promise<SelfServeNotification> => { public onSave = async (currentValues: Map<string, SmartUiInput>): Promise<OnSaveResult> => {
validate(currentValues); validate(currentValues);
// TODO: add pre processing logic before calling the updateDedicatedGatewayProvisioning() RP call. // TODO: add pre processing logic before calling the updateDedicatedGatewayProvisioning() RP call.
throw new Error(`onSave not implemented. No. of properties to save: ${currentValues.size}`); throw new Error(`onSave not implemented. No. of properties to save: ${currentValues.size}`);
@ -63,6 +64,7 @@ export default class SqlX extends SelfServeBaseClass {
@Values({ @Values({
description: { description: {
textTKey: "Provisioning dedicated gateways for SqlX accounts.", textTKey: "Provisioning dedicated gateways for SqlX accounts.",
type: DescriptionType.Text,
link: { link: {
href: "https://docs.microsoft.com/en-us/azure/cosmos-db/introduction", href: "https://docs.microsoft.com/en-us/azure/cosmos-db/introduction",
textTKey: "Learn more about dedicated gateway.", textTKey: "Learn more about dedicated gateway.",

View File

@ -47,15 +47,14 @@ interface Options {
queryParams?: ARMQueryParams; queryParams?: ARMQueryParams;
} }
// TODO: This is very similar to what is happening in ResourceProviderClient.ts. Should probably merge them. export async function armRequestWithoutPolling<T>({
export async function armRequest<T>({
host, host,
path, path,
apiVersion, apiVersion,
method, method,
body: requestBody, body: requestBody,
queryParams, queryParams,
}: Options): Promise<T> { }: Options): Promise<{ result: T; operationStatusUrl: string }> {
const url = new URL(path, host); const url = new URL(path, host);
url.searchParams.append("api-version", configContext.armAPIVersion || apiVersion); url.searchParams.append("api-version", configContext.armAPIVersion || apiVersion);
if (queryParams) { if (queryParams) {
@ -92,13 +91,33 @@ export async function armRequest<T>({
throw error; throw error;
} }
const operationStatusUrl = response.headers && response.headers.get("location"); const operationStatusUrl = (response.headers && response.headers.get("location")) || "";
const responseBody = (await response.json()) as T;
return { result: responseBody, operationStatusUrl: operationStatusUrl };
}
// TODO: This is very similar to what is happening in ResourceProviderClient.ts. Should probably merge them.
export async function armRequest<T>({
host,
path,
apiVersion,
method,
body: requestBody,
queryParams,
}: Options): Promise<T> {
const armRequestResult = await armRequestWithoutPolling<T>({
host,
path,
apiVersion,
method,
body: requestBody,
queryParams,
});
const operationStatusUrl = armRequestResult.operationStatusUrl;
if (operationStatusUrl) { if (operationStatusUrl) {
return await promiseRetry(() => getOperationStatus(operationStatusUrl)); return await promiseRetry(() => getOperationStatus(operationStatusUrl));
} }
return armRequestResult.result;
const responseBody = (await response.json()) as T;
return responseBody;
} }
async function getOperationStatus(operationStatusUrl: string) { async function getOperationStatus(operationStatusUrl: string) {

View File

@ -20,6 +20,7 @@ describe("Self Serve", () => {
// id of the display element is in the format {PROPERTY_NAME}-{DISPLAY_NAME}-{DISPLAY_TYPE} // id of the display element is in the format {PROPERTY_NAME}-{DISPLAY_NAME}-{DISPLAY_TYPE}
await frame.waitForSelector("#description-text-display"); await frame.waitForSelector("#description-text-display");
await frame.waitForSelector("#currentRegionText-text-display");
const regions = await frame.waitForSelector("#regions-dropdown-input"); const regions = await frame.waitForSelector("#regions-dropdown-input");
let disabledLoggingToggle = await frame.$$("#enableLogging-toggle-input[disabled]"); let disabledLoggingToggle = await frame.$$("#enableLogging-toggle-input[disabled]");