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

@@ -40,6 +40,15 @@ export function setEnvironmentVariablesForInstallPaths(tools: ITool[], env: Node
}
}
/**
* returns true if input is undefined or empty
*
* @param input - input value to test
*/
export function isUndefinedOrEmpty(input: any): boolean {
return input === undefined || (typeof input === 'string' && input.length === 0);
}
/**
* Throws an Error with given {@link message} unless {@link condition} is true.
* This also tells the typescript compiler that the condition is 'truthy' in the remainder of the scope

View File

@@ -6,6 +6,7 @@
import * as azdata from 'azdata';
import { IOptionsSourceProvider } from 'resource-deployment';
import * as vscode from 'vscode';
import { ValidationInfo } from './ui/validation/validations';
export const NoteBookEnvironmentVariablePrefix = 'AZDATA_NB_VAR_';
@@ -284,9 +285,6 @@ export interface FieldInfo extends SubFieldInfo, FieldInfoBase {
defaultValue?: string;
confirmationRequired?: boolean;
confirmationLabel?: string;
textValidationRequired?: boolean;
textValidationRegex?: string;
textValidationDescription?: string;
min?: number;
max?: number;
required?: boolean;
@@ -302,6 +300,7 @@ export interface FieldInfo extends SubFieldInfo, FieldInfoBase {
isEvaluated?: boolean;
valueLookup?: string; // for fetching dropdown options
validationLookup?: string // for fetching text field validations
validations?: ValidationInfo[];
}
export interface KubeClusterContextFieldInfo extends FieldInfo {

View File

@@ -7,12 +7,15 @@ import * as azdata from 'azdata';
import 'mocha';
import * as should from 'should';
import * as sinon from 'sinon';
import { createValidation, GreaterThanOrEqualsValidation, IntegerValidation, LessThanOrEqualsValidation, RegexValidation, validateInputBoxComponent, Validation, ValidationType, ValidationValueType } from '../../../ui/validation/validations';
import * as vscode from 'vscode';
import { InputValueType } from '../../../ui/modelViewUtils';
import { createValidation, GreaterThanOrEqualsValidation, IntegerValidation, LessThanOrEqualsValidation, RegexValidation, validateInputBoxComponent, Validation, ValidationType } from '../../../ui/validation/validations';
const inputBox = <azdata.InputBoxComponent>{
updateProperty(key: string, value: any) { }
};
let inputBoxStub: sinon.SinonStub;
const validationMessage = 'The field value is not valid';
const testValidations = [
{
type: ValidationType.IsInteger,
@@ -34,27 +37,34 @@ const testValidations = [
target: 'field1'
}
];
let onValidityChangedEmitter: vscode.EventEmitter<boolean>;
describe('Validation', () => {
beforeEach('validation setup', () => {
sinon.restore(); //cleanup all previously defined sinon mocks
inputBoxStub = sinon.stub(inputBox, 'updateProperty').resolves();
onValidityChangedEmitter = new vscode.EventEmitter<boolean>(); // recreate for every test so that any previous subscriptions on the event are cleared out.
});
describe('createValidation and validate input Box', () => {
beforeEach(() => {
sinon.restore(); //cleanup all previously defined sinon mocks
inputBoxStub = sinon.stub(inputBox, 'updateProperty').resolves();
});
testValidations.forEach(testObj => {
it(`validationType: ${testObj.type}`, async () => {
const validation = createValidation(testObj, async () => undefined, async (_varName: string) => undefined);
const validation = createValidation(
testObj,
async (isValid) => (isValid) ? inputBox.updateProperty('validationErrorMessage', undefined) : inputBox.updateProperty('validationErrorMessage', validationMessage),
async () => undefined,
async (_varName: string) => undefined,
(_variableName) => onValidityChangedEmitter.event,
(_disposable: vscode.Disposable) => { }
);
switch (testObj.type) {
case ValidationType.IsInteger: should(validation).be.instanceOf(IntegerValidation); break;
case ValidationType.Regex: should(validation).be.instanceOf(RegexValidation); break;
case ValidationType.LessThanOrEqualsTo: should(validation).be.instanceOf(LessThanOrEqualsValidation); break;
case ValidationType.GreaterThanOrEqualsTo: should(validation).be.instanceOf(GreaterThanOrEqualsValidation); break;
default: console.log(`unexpected validation type: ${testObj.type}`); break;
}
should(await validateInputBoxComponent(inputBox, [validation])).be.false();
should(await validateInputBoxComponent(inputBox, [validation])).be.true(); // undefined and '' values are valid so validation should return true. This allows for fields that are not required
should(inputBoxStub.calledOnce).be.true();
should(inputBoxStub.getCall(0).args[0]).equal('validationErrorMessage');
should(inputBoxStub.getCall(0).args[1]).equal(testObj.description);
should(inputBoxStub.getCall(0).args[1]).be.undefined();
});
});
});
@@ -68,7 +78,8 @@ describe('Validation', () => {
{ value: 3.14, expected: false },
{ value: '3.14e2', expected: true },
{ value: 3.14e2, expected: true },
{ value: undefined, expected: false },
{ value: undefined, expected: true },
{ value: '', expected: true },
{ value: NaN, expected: false },
].forEach((testObj) => {
const displayTestValue = getDisplayString(testObj.value);
@@ -76,6 +87,7 @@ describe('Validation', () => {
const validationDescription = `value: ${displayTestValue} was not an integer`;
const validation = new IntegerValidation(
{ type: ValidationType.IsInteger, description: validationDescription },
async (isValid) => (isValid) ? inputBox.updateProperty('validationErrorMessage', undefined) : inputBox.updateProperty('validationErrorMessage', validationMessage),
async () => testObj.value
);
await testValidation(validation, testObj, validationDescription);
@@ -94,13 +106,15 @@ describe('Validation', () => {
{ value: '3.14e2', expected: false },
{ value: 3.14e2, expected: true }, // value of 3.14e2 literal is 342 which in string matches the testRegex
{ value: 'arbitraryString', expected: false },
{ value: undefined, expected: false },
{ value: undefined, expected: true },
{ value: '', expected: true },
].forEach(testOb => {
const displayTestValue = getDisplayString(testOb.value);
it(`regex: /${testRegex}/, testValue:${displayTestValue}, expect result: ${testOb.expected}`, async () => {
const validationDescription = `value:${displayTestValue} did not match the regex:/${testRegex}/`;
const validation = new RegexValidation(
{ type: ValidationType.IsInteger, description: validationDescription, regex: testRegex },
async (isValid) => (isValid) ? inputBox.updateProperty('validationErrorMessage', undefined) : inputBox.updateProperty('validationErrorMessage', validationMessage),
async () => testOb.value
);
await testValidation(validation, testOb, validationDescription);
@@ -137,13 +151,21 @@ describe('Validation', () => {
{ value: 342.15, targetValue: 342.15, expected: true },
// undefined values - if one operand is undefined result is always false
{ value: undefined, targetValue: '42', expected: false },
{ value: undefined, targetValue: 42, expected: false },
{ value: '42', targetValue: undefined, expected: false },
{ value: 42, targetValue: undefined, expected: false },
{ value: undefined, targetValue: undefined, expected: false },
// undefined values - if one operand is undefined result is always true - this is to allow fields that are not a required value to be valid.
{ value: undefined, targetValue: '42', expected: true },
{ value: undefined, targetValue: 42, expected: true },
{ value: '42', targetValue: undefined, expected: true },
{ value: 42, targetValue: undefined, expected: true },
{ value: undefined, targetValue: '', expected: true },
{ value: undefined, targetValue: undefined, expected: true },
// '' values - if one operand is '' result is always true - this is to allow fields that are not a required value to be valid.
{ value: '', targetValue: '42', expected: true },
{ value: '', targetValue: 42, expected: true },
{ value: '42', targetValue: '', expected: true },
{ value: 42, targetValue: '', expected: true },
{ value: '', targetValue: undefined, expected: true },
{ value: '', targetValue: '', expected: true },
].forEach(testObj => {
const displayTestValue = getDisplayString(testObj.value);
const displayTargetValue = getDisplayString(testObj.targetValue);
@@ -151,8 +173,11 @@ describe('Validation', () => {
const validationDescription = `${displayTestValue} did not test as <= ${displayTargetValue}`;
const validation = new LessThanOrEqualsValidation(
{ type: ValidationType.IsInteger, description: validationDescription, target: targetVariableName },
async (isValid) => (isValid) ? inputBox.updateProperty('validationErrorMessage', undefined) : inputBox.updateProperty('validationErrorMessage', validationMessage),
async () => testObj.value,
async (_variableName: string) => testObj.targetValue
async (_variableName: string) => testObj.targetValue,
(_variableName) => onValidityChangedEmitter.event,
(_disposable) => { } // do nothing with the disposable for the test.
);
await testValidation(validation, testObj, validationDescription);
});
@@ -181,12 +206,21 @@ describe('Validation', () => {
{ value: '342.15', targetValue: 342.15, expected: true },
{ value: 342.15, targetValue: 342.15, expected: true },
// undefined values - if one operand is undefined result is always false
{ value: undefined, targetValue: '42', expected: false },
{ value: undefined, targetValue: 42, expected: false },
{ value: '42', targetValue: undefined, expected: false },
{ value: 42, targetValue: undefined, expected: false },
{ value: undefined, targetValue: undefined, expected: false },
// undefined values - if one operand is undefined result is always false - this is to allow fields that are not a required value to be valid.
{ value: undefined, targetValue: '42', expected: true },
{ value: undefined, targetValue: 42, expected: true },
{ value: '42', targetValue: undefined, expected: true },
{ value: 42, targetValue: undefined, expected: true },
{ value: undefined, targetValue: '', expected: true },
{ value: undefined, targetValue: undefined, expected: true },
// '' values - if one operand is '' result is always false - this is to allow fields that are not a required value to be valid.
{ value: '', targetValue: '42', expected: true },
{ value: '', targetValue: 42, expected: true },
{ value: '42', targetValue: '', expected: true },
{ value: 42, targetValue: '', expected: true },
{ value: '', targetValue: undefined, expected: true },
{ value: '', targetValue: '', expected: true },
].forEach(testObj => {
const displayTestValue = getDisplayString(testObj.value);
const displayTargetValue = getDisplayString(testObj.targetValue);
@@ -194,8 +228,11 @@ describe('Validation', () => {
const validationDescription = `${displayTestValue} did not test as >= ${displayTargetValue}`;
const validation = new GreaterThanOrEqualsValidation(
{ type: ValidationType.IsInteger, description: validationDescription, target: targetVariableName },
async (isValid) => (isValid) ? inputBox.updateProperty('validationErrorMessage', undefined) : inputBox.updateProperty('validationErrorMessage', validationMessage),
async () => testObj.value,
async (_variableName: string) => testObj.targetValue
async (_variableName: string) => testObj.targetValue,
(_variableName) => onValidityChangedEmitter.event,
(_disposable) => { } // do nothing with the disposable for the test
);
await testValidation(validation, testObj, validationDescription);
});
@@ -204,8 +241,8 @@ describe('Validation', () => {
});
interface TestObject {
value: ValidationValueType;
targetValue?: ValidationValueType;
value: InputValueType;
targetValue?: InputValueType;
expected: boolean;
}
@@ -217,7 +254,7 @@ async function testValidation(validation: Validation, test: TestObject, validati
: should(validationResult.message).be.equal(validationDescription);
}
function getDisplayString(value: ValidationValueType) {
function getDisplayString(value: InputValueType) {
return typeof value === 'string' ? `"${value}"` : value;
}

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;