Improved Validations for ARC Wizards (#12945)

This commit is contained in:
Arvind Ranasaria
2020-11-18 22:03:59 -08:00
committed by GitHub
parent e63e4f0901
commit c7cca5afea
12 changed files with 728 additions and 456 deletions

View File

@@ -7,25 +7,33 @@ import { azureResource } from 'azureResource';
import * as fs from 'fs';
import { EOL } from 'os';
import * as path from 'path';
import { IOptionsSourceProvider } from 'resource-deployment';
import * as vscode from 'vscode';
import * as nls from 'vscode-nls';
import { getDateTimeString, getErrorMessage, throwUnless } from '../common/utils';
import { AzureAccountFieldInfo, AzureLocationsFieldInfo, ComponentCSSStyles, DialogInfoBase, FieldInfo, FieldType, FilePickerFieldInfo, IOptionsSource, KubeClusterContextFieldInfo, LabelPosition, NoteBookEnvironmentVariablePrefix, OptionsInfo, OptionsType, PageInfoBase, RowInfo, SectionInfo, TextCSSStyles } from '../interfaces';
import * as loc from '../localizedConstants';
import { apiService } from '../services/apiService';
import { getDefaultKubeConfigPath, getKubeConfigClusterContexts } from '../services/kubeService';
import { optionsSourcesService } from '../services/optionSourcesService';
import { KubeCtlTool, KubeCtlToolName } from '../services/tools/kubeCtlTool';
import { IToolsService } from '../services/toolsService';
import { getDateTimeString, getErrorMessage, throwUnless } from '../common/utils';
import { WizardInfoBase } from './../interfaces';
import { Model } from './model';
import { RadioGroupLoadingComponentBuilder } from './radioGroupLoadingComponentBuilder';
import { optionsSourcesService } from '../services/optionSourcesService';
import { IOptionsSourceProvider } from 'resource-deployment';
import { createValidation, validateInputBoxComponent, Validation } from './validation/validations';
const localize = nls.loadMessageBundle();
/*
* A quick note on the naming convention for some functions in this module.
* 'Field' suffix is used for functions that create a label+input component pair and the one without this suffix just creates one of these items.
*
*/
export type Validator = () => { valid: boolean, message: string };
export type InputValueTransformer = (inputValue: string) => string | Promise<string>;
export type InputValueType = string | number | undefined;
export type InputValueTransformer = (inputValue: string) => InputValueType | Promise<InputValueType>;
export type InputComponent = azdata.TextComponent | azdata.InputBoxComponent | azdata.DropDownComponent | azdata.CheckBoxComponent | RadioGroupLoadingComponentBuilder;
export type InputComponentInfo = {
component: InputComponent;
@@ -80,6 +88,7 @@ export interface FieldContext extends ContextBase {
fieldInfo: FieldInfo;
components: azdata.Component[];
view: azdata.ModelView;
fieldValidations?: Validation[]
}
export interface FilePickerInputs {
@@ -118,32 +127,73 @@ interface ContextBase {
onNewInputComponentCreated: (name: string, inputComponentInfo: InputComponentInfo) => void;
}
export function createTextInput(view: azdata.ModelView, inputInfo: {
type?: azdata.InputBoxInputType,
defaultValue?: string,
ariaLabel: string,
required?: boolean,
placeHolder?: string,
width?: string,
enabled?: boolean,
validationRegex?: RegExp,
validationErrorMessage?: string
}): azdata.InputBoxComponent {
/**
* An object to define the properties of an InputBox
*/
interface InputBoxInfo {
/**
* the type of inputBox, default value is 'text'
*/
type?: azdata.InputBoxInputType;
defaultValue?: string;
ariaLabel: string;
required?: boolean;
/**
* the min value of this field when the type is 'number', value set is ignored if the type is not 'number'
*/
min?: number;
/**
* the min value of this field when the type is 'number', value set is ignored if the type is not 'number'
*/
max?: number;
/**
* an informational string to display in the inputBox when no value has been set.
*/
placeHolder?: string;
width?: string;
enabled?: boolean;
/**
* an array of validation objects used to validate the inputBox
*/
validations?: Validation[];
}
/**
* Creates an inputBox using the properties defined in context.fieldInfo object
*
* @param context - the fieldContext object for this field
* @param inputBoxType - the type of inputBox
*/
function createInputBoxField({ context, inputBoxType = 'text' }: { context: FieldContext; inputBoxType?: azdata.InputBoxInputType; }) {
const label = createLabel(context.view, { text: context.fieldInfo.label, description: context.fieldInfo.description, required: context.fieldInfo.required, width: context.fieldInfo.labelWidth, cssStyles: context.fieldInfo.labelCSSStyles });
const input = createInputBox(context.view, {
type: inputBoxType,
defaultValue: context.fieldInfo.defaultValue,
ariaLabel: context.fieldInfo.label,
required: context.fieldInfo.required,
min: context.fieldInfo.min,
max: context.fieldInfo.max,
placeHolder: context.fieldInfo.placeHolder,
width: context.fieldInfo.inputWidth,
enabled: context.fieldInfo.enabled,
validations: context.fieldValidations
});
addLabelInputPairToContainer(context.view, context.components, label, input, context.fieldInfo);
return input;
}
export function createInputBox(view: azdata.ModelView, inputInfo: InputBoxInfo): azdata.InputBoxComponent {
return view.modelBuilder.inputBox().withProperties<azdata.InputBoxProperties>({
value: inputInfo.defaultValue,
ariaLabel: inputInfo.ariaLabel,
inputType: inputInfo.type || 'text',
required: inputInfo.required,
min: inputInfo.min,
max: inputInfo.max,
placeHolder: inputInfo.placeHolder,
width: inputInfo.width,
enabled: inputInfo.enabled,
validationErrorMessage: inputInfo.validationErrorMessage
}).withValidation(component => {
if (inputInfo.validationRegex?.test(component.value || '') === false) {
return false;
}
return true;
}).component();
enabled: inputInfo.enabled
}).withValidation(async (component) => await validateInputBoxComponent(component, inputInfo.validations)).component();
}
export function createLabel(view: azdata.ModelView, info: { text: string, description?: string, required?: boolean, width?: string, links?: azdata.LinkArea[], cssStyles?: TextCSSStyles }): azdata.TextComponent {
@@ -166,17 +216,15 @@ export function createLabel(view: azdata.ModelView, info: { text: string, descri
return text;
}
export function createNumberInput(view: azdata.ModelView, info: { defaultValue?: string, ariaLabel?: string, min?: number, max?: number, required?: boolean, width?: string, placeHolder?: string }): azdata.InputBoxComponent {
return view.modelBuilder.inputBox().withProperties<azdata.InputBoxProperties>({
value: info.defaultValue,
ariaLabel: info.ariaLabel,
inputType: 'number',
min: info.min,
max: info.max,
required: info.required,
width: info.width,
placeHolder: info.placeHolder
}).component();
/**
* Creates an inputBox component of 'number' type.
*
* @param view - the ModelView object used to create the inputBox
* @param info - an object to define the properties of the 'number' inputBox component. If the type property is set then it is overridden with 'number' type.
*/
export function createNumberInput(view: azdata.ModelView, info: InputBoxInfo): azdata.InputBoxComponent {
info.type = 'number'; // for the type to be 'number'
return createInputBox(view, info);
}
export function createCheckbox(view: azdata.ModelView, info: { initialValue: boolean, label: string, required?: boolean }): azdata.CheckBoxComponent {
@@ -369,6 +417,21 @@ function addLabelInputPairToContainer(view: azdata.ModelView, components: azdata
}
async function processField(context: FieldContext): Promise<void> {
//populate the fieldValidations objects for each field based on the information from the fieldInfo
context.fieldValidations = context.fieldInfo.validations?.map((validation => createValidation(
validation,
async (isValid: boolean) => {
const inputBox = (<azdata.InputBoxComponent>context.inputComponents[context.fieldInfo.variableName || context.fieldInfo.label].component);
const validationMessage = (isValid) ? '' : validation.description;
if (inputBox.validationErrorMessage !== validationMessage) { // unset validationErrorMessage if it is set
await inputBox.updateProperty('validationErrorMessage', validationMessage);
}
},
() => getInputComponentValue(context.inputComponents[context.fieldInfo.variableName || context.fieldInfo.label]), // callback to fetch the value of this field, and return the default value if the field value is undefined
(variable: string) => getInputComponentValue(context.inputComponents[variable]), // callback to fetch the value of a variable corresponding to any field already defined.
(targetVariable: string) => (<azdata.InputBoxComponent>context.inputComponents[targetVariable].component).onValidityChanged,
(disposable: vscode.Disposable) => context.onNewDisposableCreated(disposable)
)));
switch (context.fieldInfo.type) {
case FieldType.Options:
await processOptionsTypeField(context);
@@ -503,79 +566,33 @@ function processDropdownOptionsTypeField(context: FieldContext): azdata.DropDown
label: context.fieldInfo.label
});
dropdown.fireOnTextChange = true;
context.onNewInputComponentCreated(context.fieldInfo.variableName!, { component: dropdown });
context.onNewInputComponentCreated(context.fieldInfo.variableName || context.fieldInfo.label, { component: dropdown });
addLabelInputPairToContainer(context.view, context.components, label, dropdown, context.fieldInfo);
return dropdown;
}
function processDateTimeTextField(context: FieldContext): void {
const label = createLabel(context.view, { text: context.fieldInfo.label, description: context.fieldInfo.description, required: context.fieldInfo.required, width: context.fieldInfo.labelWidth, cssStyles: context.fieldInfo.labelCSSStyles });
const defaultValue = context.fieldInfo.defaultValue + getDateTimeString();
const input = context.view.modelBuilder.inputBox().withProperties<azdata.InputBoxProperties>({
value: defaultValue,
ariaLabel: context.fieldInfo.label,
inputType: 'text',
required: context.fieldInfo.required,
placeHolder: context.fieldInfo.placeHolder
}).component();
input.width = context.fieldInfo.inputWidth;
context.onNewInputComponentCreated(context.fieldInfo.variableName!, { component: input });
addLabelInputPairToContainer(context.view, context.components, label, input, context.fieldInfo);
context.fieldInfo.defaultValue = context.fieldInfo.defaultValue + getDateTimeString();
const input = createInputBoxField({ context });
context.onNewInputComponentCreated(context.fieldInfo.variableName || context.fieldInfo.label, { component: input });
}
function processNumberField(context: FieldContext): void {
const label = createLabel(context.view, { text: context.fieldInfo.label, description: context.fieldInfo.description, required: context.fieldInfo.required, width: context.fieldInfo.labelWidth, cssStyles: context.fieldInfo.labelCSSStyles });
const input = createNumberInput(context.view, {
defaultValue: context.fieldInfo.defaultValue,
ariaLabel: context.fieldInfo.label,
min: context.fieldInfo.min,
max: context.fieldInfo.max,
required: context.fieldInfo.required,
width: context.fieldInfo.inputWidth,
placeHolder: context.fieldInfo.placeHolder
const input = createInputBoxField({ context, inputBoxType: 'number' });
context.onNewInputComponentCreated(context.fieldInfo.variableName || context.fieldInfo.label, {
component: input,
inputValueTransformer: (value: string | number | undefined) => (typeof value === 'string') && value.length > 0 ? parseFloat(value) : value
});
context.onNewInputComponentCreated(context.fieldInfo.variableName!, { component: input });
addLabelInputPairToContainer(context.view, context.components, label, input, context.fieldInfo);
}
function processTextField(context: FieldContext): azdata.InputBoxComponent {
const isPasswordField = context.fieldInfo.type === FieldType.Password || context.fieldInfo.type === FieldType.SQLPassword;
let validationRegex: RegExp | undefined = context.fieldInfo.textValidationRequired ? new RegExp(context.fieldInfo.textValidationRegex!) : undefined;
const label = createLabel(context.view, { text: context.fieldInfo.label, description: context.fieldInfo.description, required: context.fieldInfo.required, width: context.fieldInfo.labelWidth, cssStyles: context.fieldInfo.labelCSSStyles });
const input = createTextInput(context.view, {
type: isPasswordField ? 'password' : 'text',
defaultValue: context.fieldInfo.defaultValue,
ariaLabel: context.fieldInfo.label,
required: context.fieldInfo.required,
placeHolder: context.fieldInfo.placeHolder,
width: context.fieldInfo.inputWidth,
enabled: context.fieldInfo.enabled,
validationRegex: validationRegex,
validationErrorMessage: context.fieldInfo.textValidationDescription
});
context.onNewInputComponentCreated(context.fieldInfo.variableName!, { component: input, isPassword: isPasswordField });
addLabelInputPairToContainer(context.view, context.components, label, input, context.fieldInfo);
if (context.fieldInfo.textValidationRequired) {
const removeInvalidInputMessage = (): void => {
if (validationRegex!.test(input.value!)) { // input is valid
removeValidationMessage(context.container, context.fieldInfo.textValidationDescription!);
}
};
context.onNewDisposableCreated(input.onTextChanged(() => {
removeInvalidInputMessage();
}));
const inputValidator: Validator = (): { valid: boolean; message: string; } => {
const inputIsValid = validationRegex!.test(input.value!);
return { valid: inputIsValid, message: context.fieldInfo.textValidationDescription! };
};
context.onNewValidatorCreated(inputValidator);
}
const inputBoxType = isPasswordField ? 'password' : 'text';
const input = createInputBoxField({ context, inputBoxType });
context.onNewInputComponentCreated(context.fieldInfo.variableName || context.fieldInfo.label, { component: input, isPassword: isPasswordField });
return input;
}
}
function processPasswordField(context: FieldContext): void {
const passwordInput = processTextField(context);
@@ -674,9 +691,9 @@ async function substituteVariableValues(inputComponents: InputComponents, inputV
await Promise.all(Object.keys(inputComponents)
.filter(key => key.startsWith(NoteBookEnvironmentVariablePrefix))
.map(async key => {
const value = (await getInputComponentValue(inputComponents, key)) ?? '<undefined>';
const value = (await getInputComponentValue(inputComponents[key])) ?? '<undefined>';
const re: RegExp = new RegExp(`\\\$\\\(${key}\\\)`, 'gi');
inputValue = inputValue?.replace(re, value);
inputValue = inputValue?.replace(re, value.toString());
})
);
return inputValue;
@@ -685,7 +702,7 @@ async function substituteVariableValues(inputComponents: InputComponents, inputV
function processCheckboxField(context: FieldContext): void {
const checkbox = createCheckbox(context.view, { initialValue: context.fieldInfo.defaultValue! === 'true', label: context.fieldInfo.label, required: context.fieldInfo.required });
context.components.push(checkbox);
context.onNewInputComponentCreated(context.fieldInfo.variableName!, { component: checkbox });
context.onNewInputComponentCreated(context.fieldInfo.variableName || context.fieldInfo.label, { component: checkbox });
}
/**
@@ -697,15 +714,16 @@ function processFilePickerField(context: FieldContext): FilePickerInputs {
const buttonWidth = 100;
const label = createLabel(context.view, { text: context.fieldInfo.label, description: context.fieldInfo.description, required: context.fieldInfo.required, width: context.fieldInfo.labelWidth, cssStyles: context.fieldInfo.labelCSSStyles });
const input = createTextInput(context.view, {
const input = createInputBox(context.view, {
defaultValue: context.fieldInfo.defaultValue || '',
ariaLabel: context.fieldInfo.label,
required: context.fieldInfo.required,
placeHolder: context.fieldInfo.placeHolder,
width: `${inputWidth - buttonWidth}px`,
enabled: context.fieldInfo.enabled
enabled: context.fieldInfo.enabled,
validations: context.fieldValidations
});
context.onNewInputComponentCreated(context.fieldInfo.variableName!, { component: input });
context.onNewInputComponentCreated(context.fieldInfo.variableName || context.fieldInfo.label, { component: input });
input.enabled = false;
const browseFileButton = context.view!.modelBuilder.button().withProperties<azdata.ButtonProperties>({ label: loc.browse, width: buttonWidth }).component();
const fieldInfo = context.fieldInfo as FilePickerFieldInfo;
@@ -833,7 +851,7 @@ async function createRadioOptions(context: FieldContext, getRadioButtonInfo?: ((
const label = createLabel(context.view, { text: context.fieldInfo.label, description: context.fieldInfo.description, required: context.fieldInfo.required, width: context.fieldInfo.labelWidth, cssStyles: context.fieldInfo.labelCSSStyles });
const radioGroupLoadingComponentBuilder = new RadioGroupLoadingComponentBuilder(context.view, context.onNewDisposableCreated, context.fieldInfo);
context.fieldInfo.labelPosition = LabelPosition.Left;
context.onNewInputComponentCreated(context.fieldInfo.variableName!, { component: radioGroupLoadingComponentBuilder });
context.onNewInputComponentCreated(context.fieldInfo.variableName || context.fieldInfo.label, { component: radioGroupLoadingComponentBuilder });
addLabelInputPairToContainer(context.view, context.components, label, radioGroupLoadingComponentBuilder.component(), context.fieldInfo);
const options = context.fieldInfo.options as OptionsInfo;
await radioGroupLoadingComponentBuilder.loadOptions(
@@ -869,7 +887,7 @@ async function processAzureAccountField(context: AzureAccountFieldContext): Prom
if (context.fieldInfo.allowNewResourceGroup) {
const newRGCheckbox = createCheckbox(context.view, { initialValue: false, label: loc.createNewResourceGroup });
context.onNewInputComponentCreated(context.fieldInfo.newResourceGroupFlagVariableName!, { component: newRGCheckbox });
const newRGNameInput = createTextInput(context.view, { ariaLabel: loc.NewResourceGroupAriaLabel });
const newRGNameInput = createInputBox(context.view, { ariaLabel: loc.NewResourceGroupAriaLabel });
context.onNewInputComponentCreated(context.fieldInfo.newResourceGroupNameVariableName!, { component: newRGNameInput });
context.components.push(newRGCheckbox);
context.components.push(newRGNameInput);
@@ -956,7 +974,7 @@ async function processKubeStorageClassField(context: FieldContext): Promise<void
defaultValue: defaultStorageClass
});
storageClassDropdown.fireOnTextChange = true;
context.onNewInputComponentCreated(context.fieldInfo.variableName!, { component: storageClassDropdown });
context.onNewInputComponentCreated(context.fieldInfo.variableName || context.fieldInfo.label, { component: storageClassDropdown });
addLabelInputPairToContainer(context.view, context.components, label, storageClassDropdown, context.fieldInfo);
}
@@ -980,7 +998,7 @@ function createAzureAccountDropdown(context: AzureAccountFieldContext): AzureAcc
label: loc.account
});
accountDropdown.fireOnTextChange = true;
context.onNewInputComponentCreated(context.fieldInfo.variableName!, { component: accountDropdown });
context.onNewInputComponentCreated(context.fieldInfo.variableName || context.fieldInfo.label, { component: accountDropdown });
const signInButton = context.view!.modelBuilder.button().withProperties<azdata.ButtonProperties>({ label: loc.signIn, width: '100px' }).component();
const refreshButton = context.view!.modelBuilder.button().withProperties<azdata.ButtonProperties>({ label: loc.refresh, width: '100px' }).component();
addLabelInputPairToContainer(context.view, context.components, label, accountDropdown, context.fieldInfo);
@@ -1017,7 +1035,7 @@ function createAzureSubscriptionDropdown(
label: label.value!,
variableName: context.fieldInfo.subscriptionVariableName
});
context.onNewInputComponentCreated(context.fieldInfo.subscriptionVariableName!, {
context.onNewInputComponentCreated(context.fieldInfo.subscriptionVariableName || context.fieldInfo.label, {
component: subscriptionDropdown,
inputValueTransformer: (inputValue: string) => {
return subscriptionValueToSubscriptionMap.get(inputValue)?.id || inputValue;
@@ -1028,7 +1046,7 @@ function createAzureSubscriptionDropdown(
label: label.value!,
variableName: context.fieldInfo.displaySubscriptionVariableName
});
context.onNewInputComponentCreated(context.fieldInfo.displaySubscriptionVariableName, { component: subscriptionDropdown });
context.onNewInputComponentCreated(context.fieldInfo.displaySubscriptionVariableName!, { component: subscriptionDropdown });
}
addLabelInputPairToContainer(context.view, context.components, label, subscriptionDropdown, context.fieldInfo);
return subscriptionDropdown;
@@ -1157,7 +1175,7 @@ function createAzureResourceGroupsDropdown(
});
const rgValueChangedEmitter = new vscode.EventEmitter<void>();
resourceGroupDropdown.onValueChanged(() => rgValueChangedEmitter.fire());
context.onNewInputComponentCreated(context.fieldInfo.resourceGroupVariableName!, { component: resourceGroupDropdown });
context.onNewInputComponentCreated(context.fieldInfo.resourceGroupVariableName || context.fieldInfo.label, { component: resourceGroupDropdown });
addLabelInputPairToContainer(context.view, context.components, label, resourceGroupDropdown, context.fieldInfo);
subscriptionDropdown.onValueChanged(async selectedItem => {
const selectedAccount = !accountDropdown || !accountDropdown.value ? undefined : accountValueToAccountMap.get(accountDropdown.value.toString());
@@ -1284,24 +1302,24 @@ export function getPasswordMismatchMessage(fieldName: string): string {
export async function setModelValues(inputComponents: InputComponents, model: Model): Promise<void> {
await Promise.all(Object.keys(inputComponents).map(async key => {
const value = await getInputComponentValue(inputComponents, key);
const value = await getInputComponentValue(inputComponents[key]);
model.setPropertyValue(key, value);
}));
}
async function getInputComponentValue(inputComponents: InputComponents, key: string): Promise<string | undefined> {
const input = inputComponents[key].component;
async function getInputComponentValue(inputComponentInfo: InputComponentInfo): Promise<InputValueType> {
const input = inputComponentInfo.component;
if (input === undefined) {
return undefined;
}
let value: string | undefined;
let value: string | number | undefined;
if (input instanceof RadioGroupLoadingComponentBuilder) {
value = input.value;
} else if ('checked' in input) { // CheckBoxComponent
value = input.checked ? 'true' : 'false';
} else if ('value' in input) { // InputBoxComponent or DropDownComponent
const inputValue = input.value;
if (typeof inputValue === 'string' || typeof inputValue === 'undefined') {
if (typeof inputValue === 'string' || typeof inputValue === 'undefined' || typeof inputValue === 'number') {
value = inputValue;
} else {
value = inputValue.name;
@@ -1309,7 +1327,7 @@ async function getInputComponentValue(inputComponents: InputComponents, key: str
} else {
throw new Error(`Unknown input type with ID ${input.id}`);
}
return inputComponents[key].inputValueTransformer ? await inputComponents[key].inputValueTransformer!(value ?? '') : value;
return inputComponentInfo.inputValueTransformer ? await inputComponentInfo.inputValueTransformer(value ?? '') : value;
}
export function isInputBoxEmpty(input: azdata.InputBoxComponent): boolean {