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

@@ -8,11 +8,12 @@ import { EOL } from 'os';
import * as vscode from 'vscode';
import * as nls from 'vscode-nls';
import { FieldType, LabelPosition, SectionInfo } from '../../../interfaces';
import * as localizedConstants from '../../../localizedConstants';
import { createSection, getInputBoxComponent, getInvalidSQLPasswordMessage, getPasswordMismatchMessage, InputComponentInfo, InputComponents, isValidSQLPassword, setModelValues, Validator } from '../../modelViewUtils';
import { ResourceTypePage } from '../../resourceTypePage';
import { ValidationType } from '../../validation/validations';
import * as VariableNames from '../constants';
import { AuthenticationMode, DeployClusterWizardModel } from '../deployClusterWizardModel';
import * as localizedConstants from '../../../localizedConstants';
import { ResourceTypePage } from '../../resourceTypePage';
const localize = nls.loadMessageBundle();
const ConfirmPasswordName = 'ConfirmPassword';
@@ -40,9 +41,11 @@ export class ClusterSettingsPage extends ResourceTypePage {
required: true,
variableName: VariableNames.ClusterName_VariableName,
defaultValue: 'mssql-cluster',
textValidationRequired: true,
textValidationRegex: '^[a-z0-9]$|^[a-z0-9][a-z0-9-]*[a-z0-9]$',
textValidationDescription: clusterNameFieldDescription,
validations: [{
type: ValidationType.Regex,
regex: new RegExp('^[a-z0-9]$|^[a-z0-9][a-z0-9-]*[a-z0-9]$'),
description: clusterNameFieldDescription
}],
description: clusterNameFieldDescription
}, {
type: FieldType.Text,

View File

@@ -6,7 +6,7 @@ import * as azdata from 'azdata';
import * as vscode from 'vscode';
import * as nls from 'vscode-nls';
import { FieldType, SectionInfo } from '../../../interfaces';
import { createFlexContainer, createGroupContainer, createLabel, createNumberInput, createSection, createTextInput, getCheckboxComponent, getDropdownComponent, getInputBoxComponent, InputComponentInfo, InputComponents, setModelValues, Validator } from '../../modelViewUtils';
import { createFlexContainer, createGroupContainer, createLabel, createNumberInput, createSection, createInputBox, getCheckboxComponent, getDropdownComponent, getInputBoxComponent, InputComponentInfo, InputComponents, setModelValues, Validator } from '../../modelViewUtils';
import { ResourceTypePage } from '../../resourceTypePage';
import * as VariableNames from '../constants';
import { AuthenticationMode, DeployClusterWizardModel } from '../deployClusterWizardModel';
@@ -175,42 +175,42 @@ export class ServiceSettingsPage extends ResourceTypePage {
this.endpointHeaderRow = createFlexContainer(view, [this.endpointNameColumnHeader, this.dnsColumnHeader, this.portColumnHeader]);
this.controllerNameLabel = createLabel(view, { text: localize('deployCluster.ControllerText', "Controller"), width: labelWidth, required: true });
this.controllerDNSInput = createTextInput(view, { ariaLabel: localize('deployCluster.ControllerDNSName', "Controller DNS name"), required: false, width: inputWidth });
this.controllerDNSInput = createInputBox(view, { ariaLabel: localize('deployCluster.ControllerDNSName', "Controller DNS name"), required: false, width: inputWidth });
this.controllerPortInput = createNumberInput(view, { ariaLabel: localize('deployCluster.ControllerPortName', "Controller port"), required: true, width: NumberInputWidth, min: 1 });
this.controllerEndpointRow = createFlexContainer(view, [this.controllerNameLabel, this.controllerDNSInput, this.controllerPortInput]);
this.onNewInputComponentCreated(VariableNames.ControllerDNSName_VariableName, { component: this.controllerDNSInput });
this.onNewInputComponentCreated(VariableNames.ControllerPort_VariableName, { component: this.controllerPortInput });
this.SqlServerNameLabel = createLabel(view, { text: localize('deployCluster.MasterSqlText', "SQL Server Master"), width: labelWidth, required: true });
this.sqlServerDNSInput = createTextInput(view, { ariaLabel: localize('deployCluster.MasterSQLServerDNSName', "SQL Server Master DNS name"), required: false, width: inputWidth });
this.sqlServerDNSInput = createInputBox(view, { ariaLabel: localize('deployCluster.MasterSQLServerDNSName', "SQL Server Master DNS name"), required: false, width: inputWidth });
this.sqlServerPortInput = createNumberInput(view, { ariaLabel: localize('deployCluster.MasterSQLServerPortName', "SQL Server Master port"), required: true, width: NumberInputWidth, min: 1 });
this.sqlServerEndpointRow = createFlexContainer(view, [this.SqlServerNameLabel, this.sqlServerDNSInput, this.sqlServerPortInput]);
this.onNewInputComponentCreated(VariableNames.SQLServerDNSName_VariableName, { component: this.sqlServerDNSInput });
this.onNewInputComponentCreated(VariableNames.SQLServerPort_VariableName, { component: this.sqlServerPortInput });
this.gatewayNameLabel = createLabel(view, { text: localize('deployCluster.GatewayText', "Gateway"), width: labelWidth, required: true });
this.gatewayDNSInput = createTextInput(view, { ariaLabel: localize('deployCluster.GatewayDNSName', "Gateway DNS name"), required: false, width: inputWidth });
this.gatewayDNSInput = createInputBox(view, { ariaLabel: localize('deployCluster.GatewayDNSName', "Gateway DNS name"), required: false, width: inputWidth });
this.gatewayPortInput = createNumberInput(view, { ariaLabel: localize('deployCluster.GatewayPortName', "Gateway port"), required: true, width: NumberInputWidth, min: 1 });
this.gatewayEndpointRow = createFlexContainer(view, [this.gatewayNameLabel, this.gatewayDNSInput, this.gatewayPortInput]);
this.onNewInputComponentCreated(VariableNames.GatewayDNSName_VariableName, { component: this.gatewayDNSInput });
this.onNewInputComponentCreated(VariableNames.GateWayPort_VariableName, { component: this.gatewayPortInput });
this.serviceProxyNameLabel = createLabel(view, { text: localize('deployCluster.ServiceProxyText', "Management proxy"), width: labelWidth, required: true });
this.serviceProxyDNSInput = createTextInput(view, { ariaLabel: localize('deployCluster.ServiceProxyDNSName', "Management proxy DNS name"), required: false, width: inputWidth });
this.serviceProxyDNSInput = createInputBox(view, { ariaLabel: localize('deployCluster.ServiceProxyDNSName', "Management proxy DNS name"), required: false, width: inputWidth });
this.serviceProxyPortInput = createNumberInput(view, { ariaLabel: localize('deployCluster.ServiceProxyPortName', "Management proxy port"), required: true, width: NumberInputWidth, min: 1 });
this.serviceProxyEndpointRow = createFlexContainer(view, [this.serviceProxyNameLabel, this.serviceProxyDNSInput, this.serviceProxyPortInput]);
this.onNewInputComponentCreated(VariableNames.ServiceProxyDNSName_VariableName, { component: this.serviceProxyDNSInput });
this.onNewInputComponentCreated(VariableNames.ServiceProxyPort_VariableName, { component: this.serviceProxyPortInput });
this.appServiceProxyNameLabel = createLabel(view, { text: localize('deployCluster.AppServiceProxyText', "Application proxy"), width: labelWidth, required: true });
this.appServiceProxyDNSInput = createTextInput(view, { ariaLabel: localize('deployCluster.AppServiceProxyDNSName', "Application proxy DNS name"), required: false, width: inputWidth });
this.appServiceProxyDNSInput = createInputBox(view, { ariaLabel: localize('deployCluster.AppServiceProxyDNSName', "Application proxy DNS name"), required: false, width: inputWidth });
this.appServiceProxyPortInput = createNumberInput(view, { ariaLabel: localize('deployCluster.AppServiceProxyPortName', "Application proxy port"), required: true, width: NumberInputWidth, min: 1 });
this.appServiceProxyEndpointRow = createFlexContainer(view, [this.appServiceProxyNameLabel, this.appServiceProxyDNSInput, this.appServiceProxyPortInput]);
this.onNewInputComponentCreated(VariableNames.AppServiceProxyDNSName_VariableName, { component: this.appServiceProxyDNSInput });
this.onNewInputComponentCreated(VariableNames.AppServiceProxyPort_VariableName, { component: this.appServiceProxyPortInput });
this.readableSecondaryNameLabel = createLabel(view, { text: localize('deployCluster.ReadableSecondaryText', "Readable secondary"), width: labelWidth, required: true });
this.readableSecondaryDNSInput = createTextInput(view, { ariaLabel: localize('deployCluster.ReadableSecondaryDNSName', "Readable secondary DNS name"), required: false, width: inputWidth });
this.readableSecondaryDNSInput = createInputBox(view, { ariaLabel: localize('deployCluster.ReadableSecondaryDNSName', "Readable secondary DNS name"), required: false, width: inputWidth });
this.readableSecondaryPortInput = createNumberInput(view, { ariaLabel: localize('deployCluster.ReadableSecondaryPortName', "Readable secondary port"), required: false, width: NumberInputWidth, min: 1 });
this.readableSecondaryEndpointRow = createFlexContainer(view, [this.readableSecondaryNameLabel, this.readableSecondaryDNSInput, this.readableSecondaryPortInput]);
this.onNewInputComponentCreated(VariableNames.ReadableSecondaryDNSName_VariableName, { component: this.readableSecondaryDNSInput });
@@ -231,9 +231,9 @@ export class ServiceSettingsPage extends ResourceTypePage {
required: true,
description: localize('deployCluster.AdvancedStorageDescription', "By default Controller storage settings will be applied to other services as well, you can expand the advanced storage settings to configure storage for other services.")
});
const controllerDataStorageClassInput = createTextInput(view, { ariaLabel: localize('deployCluster.controllerDataStorageClass', "Controller's data storage class"), width: inputWidth, required: true });
const controllerDataStorageClassInput = createInputBox(view, { ariaLabel: localize('deployCluster.controllerDataStorageClass', "Controller's data storage class"), width: inputWidth, required: true });
const controllerDataStorageClaimSizeInput = createNumberInput(view, { ariaLabel: localize('deployCluster.controllerDataStorageClaimSize', "Controller's data storage claim size"), width: inputWidth, required: true, min: 1 });
const controllerLogsStorageClassInput = createTextInput(view, { ariaLabel: localize('deployCluster.controllerLogsStorageClass', "Controller's logs storage class"), width: inputWidth, required: true });
const controllerLogsStorageClassInput = createInputBox(view, { ariaLabel: localize('deployCluster.controllerLogsStorageClass', "Controller's logs storage class"), width: inputWidth, required: true });
const controllerLogsStorageClaimSizeInput = createNumberInput(view, { ariaLabel: localize('deployCluster.controllerLogsStorageClaimSize', "Controller's logs storage claim size"), width: inputWidth, required: true, min: 1 });
const storagePoolLabel = createLabel(view,
@@ -242,9 +242,9 @@ export class ServiceSettingsPage extends ResourceTypePage {
width: inputWidth,
required: false
});
const storagePoolDataStorageClassInput = createTextInput(view, { ariaLabel: localize('deployCluster.storagePoolDataStorageClass', "Storage pool's data storage class"), width: inputWidth, required: false, placeHolder: hintTextForStorageFields });
const storagePoolDataStorageClassInput = createInputBox(view, { ariaLabel: localize('deployCluster.storagePoolDataStorageClass', "Storage pool's data storage class"), width: inputWidth, required: false, placeHolder: hintTextForStorageFields });
const storagePoolDataStorageClaimSizeInput = createNumberInput(view, { ariaLabel: localize('deployCluster.storagePoolDataStorageClaimSize', "Storage pool's data storage claim size"), width: inputWidth, required: false, min: 1, placeHolder: hintTextForStorageFields });
const storagePoolLogsStorageClassInput = createTextInput(view, { ariaLabel: localize('deployCluster.storagePoolLogsStorageClass', "Storage pool's logs storage class"), width: inputWidth, required: false, placeHolder: hintTextForStorageFields });
const storagePoolLogsStorageClassInput = createInputBox(view, { ariaLabel: localize('deployCluster.storagePoolLogsStorageClass', "Storage pool's logs storage class"), width: inputWidth, required: false, placeHolder: hintTextForStorageFields });
const storagePoolLogsStorageClaimSizeInput = createNumberInput(view, { ariaLabel: localize('deployCluster.storagePoolLogsStorageClaimSize', "Storage pool's logs storage claim size"), width: inputWidth, required: false, min: 1, placeHolder: hintTextForStorageFields });
const dataPoolLabel = createLabel(view,
@@ -253,9 +253,9 @@ export class ServiceSettingsPage extends ResourceTypePage {
width: inputWidth,
required: false
});
const dataPoolDataStorageClassInput = createTextInput(view, { ariaLabel: localize('deployCluster.dataPoolDataStorageClass', "Data pool's data storage class"), width: inputWidth, required: false, placeHolder: hintTextForStorageFields });
const dataPoolDataStorageClassInput = createInputBox(view, { ariaLabel: localize('deployCluster.dataPoolDataStorageClass', "Data pool's data storage class"), width: inputWidth, required: false, placeHolder: hintTextForStorageFields });
const dataPoolDataStorageClaimSizeInput = createNumberInput(view, { ariaLabel: localize('deployCluster.dataPoolDataStorageClaimSize', "Data pool's data storage claim size"), width: inputWidth, required: false, min: 1, placeHolder: hintTextForStorageFields });
const dataPoolLogsStorageClassInput = createTextInput(view, { ariaLabel: localize('deployCluster.dataPoolLogsStorageClass', "Data pool's logs storage class"), width: inputWidth, required: false, placeHolder: hintTextForStorageFields });
const dataPoolLogsStorageClassInput = createInputBox(view, { ariaLabel: localize('deployCluster.dataPoolLogsStorageClass', "Data pool's logs storage class"), width: inputWidth, required: false, placeHolder: hintTextForStorageFields });
const dataPoolLogsStorageClaimSizeInput = createNumberInput(view, { ariaLabel: localize('deployCluster.dataPoolLogsStorageClaimSize', "Data pool's logs storage claim size"), width: inputWidth, required: false, min: 1, placeHolder: hintTextForStorageFields });
@@ -265,9 +265,9 @@ export class ServiceSettingsPage extends ResourceTypePage {
width: inputWidth,
required: false
});
const sqlServerMasterDataStorageClassInput = createTextInput(view, { ariaLabel: localize('deployCluster.sqlServerMasterDataStorageClass', "SQL Server master's data storage class"), width: inputWidth, required: false, placeHolder: hintTextForStorageFields });
const sqlServerMasterDataStorageClassInput = createInputBox(view, { ariaLabel: localize('deployCluster.sqlServerMasterDataStorageClass', "SQL Server master's data storage class"), width: inputWidth, required: false, placeHolder: hintTextForStorageFields });
const sqlServerMasterDataStorageClaimSizeInput = createNumberInput(view, { ariaLabel: localize('deployCluster.sqlServerMasterDataStorageClaimSize', "SQL Server master's data storage claim size"), width: inputWidth, required: false, min: 1, placeHolder: hintTextForStorageFields });
const sqlServerMasterLogsStorageClassInput = createTextInput(view, { ariaLabel: localize('deployCluster.sqlServerMasterLogsStorageClass', "SQL Server master's logs storage class"), width: inputWidth, required: false, placeHolder: hintTextForStorageFields });
const sqlServerMasterLogsStorageClassInput = createInputBox(view, { ariaLabel: localize('deployCluster.sqlServerMasterLogsStorageClass', "SQL Server master's logs storage class"), width: inputWidth, required: false, placeHolder: hintTextForStorageFields });
const sqlServerMasterLogsStorageClaimSizeInput = createNumberInput(view, { ariaLabel: localize('deployCluster.sqlServerMasterLogsStorageClaimSize', "SQL Server master's logs storage claim size"), width: inputWidth, required: false, min: 1, placeHolder: hintTextForStorageFields });
this.onNewInputComponentCreated(VariableNames.ControllerDataStorageClassName_VariableName, { component: controllerDataStorageClassInput });

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 {

View File

@@ -4,7 +4,9 @@
*--------------------------------------------------------------------------------------------*/
import * as azdata from 'azdata';
import { throwUnless } from '../../common/utils';
import * as vscode from 'vscode';
import { isUndefinedOrEmpty, throwUnless } from '../../common/utils';
import { InputValueType } from '../modelViewUtils';
export interface ValidationResult {
valid: boolean;
@@ -12,10 +14,12 @@ export interface ValidationResult {
}
export type Validator = () => Promise<ValidationResult>;
export type ValidationValueType = string | number | undefined;
export type VariableValueGetter = (variable: string) => Promise<ValidationValueType>;
export type ValueGetter = () => Promise<ValidationValueType>;
export type OnValidation = (isValid: boolean) => Promise<void>;
export type ValueGetter = () => Promise<InputValueType>;
export type TargetValueGetter = (variable: string) => Promise<InputValueType>;
export type OnTargetValidityChangedGetter = (variable: string) => vscode.Event<boolean>;
export const enum ValidationType {
IsInteger = 'is_integer',
@@ -51,35 +55,43 @@ export abstract class Validation {
get description(): string {
return this._description;
}
protected get onValidation(): OnValidation {
return this._onValidation;
}
// gets the validation result for this validation object
abstract validate(): Promise<ValidationResult>;
protected getValue(): Promise<ValidationValueType> {
protected getValue(): Promise<InputValueType> {
return this._valueGetter();
}
protected getVariableValue(variable: string): Promise<ValidationValueType> {
return this._variableValueGetter!(variable);
protected getTargetValue(variable: string): Promise<InputValueType> {
return this._targetValueGetter!(variable);
}
constructor(validation: ValidationInfo, protected _valueGetter: ValueGetter, protected _variableValueGetter?: VariableValueGetter) {
constructor(validation: ValidationInfo, protected _onValidation: OnValidation, protected _valueGetter: ValueGetter, protected _targetValueGetter?: TargetValueGetter, protected _onTargetValidityChangedGetter?: OnTargetValidityChangedGetter, protected _onNewDisposableCreated?: (disposable: vscode.Disposable) => void) {
this._description = validation.description;
}
}
export class IntegerValidation extends Validation {
constructor(validation: IntegerValidationInfo, valueGetter: ValueGetter) {
super(validation, valueGetter);
constructor(validation: IntegerValidationInfo, onValidation: OnValidation, valueGetter: ValueGetter) {
super(validation, onValidation, valueGetter);
}
private async isInteger(): Promise<boolean> {
private async isIntegerOrEmptyOrUndefined(): Promise<boolean> {
const value = await this.getValue();
return (typeof value === 'string') ? Number.isInteger(parseFloat(value)) : Number.isInteger(value);
return (isUndefinedOrEmpty(value))
? true
: (typeof value === 'string')
? Number.isInteger(parseFloat(value))
: Number.isInteger(value);
}
async validate(): Promise<ValidationResult> {
const isValid = await this.isInteger();
const isValid = await this.isIntegerOrEmptyOrUndefined();
await this.onValidation(isValid);
return {
valid: isValid,
message: isValid ? undefined : this.description
@@ -94,17 +106,16 @@ export class RegexValidation extends Validation {
return this._regex;
}
constructor(validation: RegexValidationInfo, valueGetter: ValueGetter) {
super(validation, valueGetter);
constructor(validation: RegexValidationInfo, validationMessageUpdater: OnValidation, valueGetter: ValueGetter) {
super(validation, validationMessageUpdater, valueGetter);
throwUnless(validation.regex !== undefined);
this._regex = (typeof validation.regex === 'string') ? new RegExp(validation.regex) : validation.regex;
}
async validate(): Promise<ValidationResult> {
const value = await this.getValue();
const isValid = value === undefined
? false
: this.regex.test(value.toString());
const value = (await this.getValue())?.toString();
const isValid = isUndefinedOrEmpty(value) ? true : this.regex.test(value!);
await this.onValidation(isValid);
return {
valid: isValid,
message: isValid ? undefined : this.description
@@ -113,21 +124,40 @@ export class RegexValidation extends Validation {
}
export abstract class Comparison extends Validation {
protected _target: string; // comparison object require a target so override the base optional setting.
protected _target: string; // comparison object requires a target so override the base optional setting.
protected _ensureOnTargetValidityChangeListenerAdded = false;
get target(): string {
return this._target;
}
constructor(validation: ComparisonValidationInfo, valueGetter: ValueGetter, variableValueGetter: VariableValueGetter) {
super(validation, valueGetter, variableValueGetter);
protected onTargetValidityChanged(onTargetValidityChangedAction: (e: boolean) => Promise<void>): void {
const onValidityChanged = this._onTargetValidityChangedGetter(this.target);
this._onNewDisposableCreated(onValidityChanged(isValid => onTargetValidityChangedAction(isValid)));
}
constructor(validation: ComparisonValidationInfo, onValidation: OnValidation, valueGetter: ValueGetter, targetValueGetter: TargetValueGetter, protected _onTargetValidityChangedGetter: OnTargetValidityChangedGetter, protected _onNewDisposableCreated: (disposable: vscode.Disposable) => void) {
super(validation, onValidation, valueGetter, targetValueGetter);
throwUnless(validation.target !== undefined);
this._target = validation.target;
}
private validateOnTargetValidityChange() {
if (!this._ensureOnTargetValidityChangeListenerAdded) {
this._ensureOnTargetValidityChangeListenerAdded = true;
this.onTargetValidityChanged(async (isTargetValid: boolean) => {
if (isTargetValid) { // if target is valid
await this.validate();
}
});
}
}
abstract isComparisonSuccessful(): Promise<boolean>;
async validate(): Promise<ValidationResult> {
this.validateOnTargetValidityChange();
const isValid = await this.isComparisonSuccessful();
await this.onValidation(isValid);
return {
valid: isValid,
message: isValid ? undefined : this.description
@@ -137,22 +167,26 @@ export abstract class Comparison extends Validation {
export class LessThanOrEqualsValidation extends Comparison {
async isComparisonSuccessful() {
return (await this.getValue())! <= ((await this.getVariableValue(this.target))!);
const value = (await this.getValue());
const targetValue = (await this.getTargetValue(this.target));
return (isUndefinedOrEmpty(value) || isUndefinedOrEmpty(targetValue)) ? true : value! <= targetValue!;
}
}
export class GreaterThanOrEqualsValidation extends Comparison {
async isComparisonSuccessful() {
return (await this.getValue())! >= ((await this.getVariableValue(this.target))!);
const value = (await this.getValue());
const targetValue = (await this.getTargetValue(this.target));
return (isUndefinedOrEmpty(value) || isUndefinedOrEmpty(targetValue)) ? true : value! >= targetValue!;
}
}
export function createValidation(validation: ValidationInfo, valueGetter: ValueGetter, variableValueGetter?: VariableValueGetter): Validation {
export function createValidation(validation: ValidationInfo, onValidation: OnValidation, valueGetter: ValueGetter, targetValueGetter?: TargetValueGetter, onTargetValidityChangedGetter?: OnTargetValidityChangedGetter, onDisposableCreated?: (disposable: vscode.Disposable) => void): Validation {
switch (validation.type) {
case ValidationType.Regex: return new RegexValidation(<RegexValidationInfo>validation, valueGetter);
case ValidationType.IsInteger: return new IntegerValidation(<IntegerValidationInfo>validation, valueGetter);
case ValidationType.LessThanOrEqualsTo: return new LessThanOrEqualsValidation(<ComparisonValidationInfo>validation, valueGetter, variableValueGetter!);
case ValidationType.GreaterThanOrEqualsTo: return new GreaterThanOrEqualsValidation(<ComparisonValidationInfo>validation, valueGetter, variableValueGetter!);
case ValidationType.Regex: return new RegexValidation(<RegexValidationInfo>validation, onValidation, valueGetter);
case ValidationType.IsInteger: return new IntegerValidation(<IntegerValidationInfo>validation, onValidation, valueGetter);
case ValidationType.LessThanOrEqualsTo: return new LessThanOrEqualsValidation(<ComparisonValidationInfo>validation, onValidation, valueGetter, targetValueGetter!, onTargetValidityChangedGetter!, onDisposableCreated!);
case ValidationType.GreaterThanOrEqualsTo: return new GreaterThanOrEqualsValidation(<ComparisonValidationInfo>validation, onValidation, valueGetter, targetValueGetter!, onTargetValidityChangedGetter!, onDisposableCreated!);
default: throw new Error(`unknown validation type:${validation.type}`); //dev error
}
}
@@ -161,8 +195,7 @@ export async function validateInputBoxComponent(component: azdata.InputBoxCompon
for (const validation of validations) {
const result = await validation.validate();
if (!result.valid) {
component.updateProperty('validationErrorMessage', result.message);
return false;
return false; //bail out on first failure, remaining validations are processed after this one has been fixed by the user.
}
}
return true;