From c7cca5afea12f977e66f0d078222ca95394b2f79 Mon Sep 17 00:00:00 2001 From: Arvind Ranasaria Date: Wed, 18 Nov 2020 22:03:59 -0800 Subject: [PATCH] Improved Validations for ARC Wizards (#12945) --- extensions/arc/package.json | 108 +++- extensions/arc/package.nls.json | 9 +- extensions/asde-deployment/package.json | 8 +- .../resource-deployment/src/common/utils.ts | 9 + .../resource-deployment/src/interfaces.ts | 5 +- .../test/ui/validation/validations.test.ts | 95 ++- .../pages/clusterSettingsPage.ts | 13 +- .../pages/serviceSettingsPage.ts | 30 +- .../src/ui/modelViewUtils.ts | 240 ++++---- .../src/ui/validation/validations.ts | 97 ++- .../sample-resource-deployment/package.json | 556 ++++++++++-------- .../package.nls.json | 14 +- 12 files changed, 728 insertions(+), 456 deletions(-) diff --git a/extensions/arc/package.json b/extensions/arc/package.json index f11a354f32..3d5a035aa4 100644 --- a/extensions/arc/package.json +++ b/extensions/arc/package.json @@ -226,9 +226,11 @@ { "type": "text", "label": "%arc.data.controller.arc.data.controller.namespace%", - "textValidationRequired": true, - "textValidationRegex": "^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$", - "textValidationDescription": "%arc.data.controller.arc.data.controller.namespace.validation.description%", + "validations" : [{ + "type": "regex_match", + "regex": "^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$", + "description": "%arc.data.controller.arc.data.controller.namespace.validation.description%" + }], "defaultValue": "arc", "required": true, "variableName": "AZDATA_NB_VAR_ARC_DATA_CONTROLLER_NAMESPACE" @@ -236,9 +238,11 @@ { "type": "text", "label": "%arc.data.controller.arc.data.controller.name%", - "textValidationRequired": true, - "textValidationRegex": "^[a-z0-9]([-.a-z0-9]{0,251}[a-z0-9])?$", - "textValidationDescription": "%arc.data.controller.arc.data.controller.name.validation.description%", + "validations" : [{ + "type": "regex_match", + "regex": "^[a-z0-9]([-.a-z0-9]{0,251}[a-z0-9])?$", + "description": "%arc.data.controller.arc.data.controller.name.validation.description%" + }], "defaultValue": "arc-dc", "required": true, "variableName": "AZDATA_NB_VAR_ARC_DATA_CONTROLLER_NAME" @@ -559,18 +563,22 @@ "type": "text", "defaultValue": "sqlinstance1", "required": true, - "textValidationRequired": true, - "textValidationRegex": "^[a-z]([-a-z0-9]{0,11}[a-z0-9])?$", - "textValidationDescription": "%arc.sql.invalid.instance.name%" + "validations" : [{ + "type": "regex_match", + "regex": "^[a-z]([-a-z0-9]{0,11}[a-z0-9])?$", + "description": "%arc.sql.invalid.instance.name%" + }] }, { "label": "%arc.sql.username%", "variableName": "AZDATA_NB_VAR_SQL_USERNAME", "type": "text", "required": true, - "textValidationRequired": true, - "textValidationRegex": "^(?!sa$)", - "textValidationDescription": "%arc.sql.invalid.username%" + "validations" : [{ + "type": "regex_match", + "regex": "^(?!sa$)", + "description": "%arc.sql.invalid.username%" + }] }, { "label": "%arc.password%", @@ -607,7 +615,14 @@ "variableName": "AZDATA_NB_VAR_SQL_CORES_REQUEST", "type": "number", "min": 1, - "required": false + "required": false, + "validations": [ + { + "type": "<=", + "target": "AZDATA_NB_VAR_SQL_CORES_LIMIT", + "description": "%requested.cores.less.than.or.equal.to.cores.limit%" + } + ] }, { "label": "%arc.cores-limit.label%", @@ -615,7 +630,14 @@ "variableName": "AZDATA_NB_VAR_SQL_CORES_LIMIT", "type": "number", "min": 1, - "required": false + "required": false, + "validations": [ + { + "type": ">=", + "target": "AZDATA_NB_VAR_SQL_CORES_REQUEST", + "description": "%cores.limit.greater.than.or.equal.to.requested.cores%" + } + ] }, { "label": "%arc.memory-request.label%", @@ -623,7 +645,12 @@ "variableName": "AZDATA_NB_VAR_SQL_MEMORY_REQUEST", "type": "number", "min": 2, - "required": false + "required": false, + "validations": [{ + "type": "<=", + "target": "AZDATA_NB_VAR_SQL_MEMORY_LIMIT", + "description": "%requested.memory.less.than.or.equal.to.memory.limit%" + }] }, { "label": "%arc.memory-limit.label%", @@ -631,7 +658,12 @@ "variableName": "AZDATA_NB_VAR_SQL_MEMORY_LIMIT", "type": "number", "min": 2, - "required": false + "required": false, + "validations": [{ + "type": ">=", + "target": "AZDATA_NB_VAR_SQL_MEMORY_REQUEST", + "description": "%memory.limit.greater.than.or.equal.to.requested.memory%" + }] } ] } @@ -719,9 +751,11 @@ "variableName": "AZDATA_NB_VAR_POSTGRES_SERVER_GROUP_NAME", "type": "text", "description": "%arc.postgres.server.group.name.validation.description%", - "textValidationRequired": true, - "textValidationRegex": "^[a-z]([-a-z0-9]{0,10}[a-z0-9])?$", - "textValidationDescription": "%arc.postgres.server.group.name.validation.description%", + "validations" : [{ + "type": "regex_match", + "regex": "^[a-z]([-a-z0-9]{0,10}[a-z0-9])?$", + "description": "%arc.postgres.server.group.name.validation.description%" + }], "required": true }, { @@ -738,6 +772,10 @@ "description": "%arc.postgres.server.group.workers.description%", "variableName": "AZDATA_NB_VAR_POSTGRES_SERVER_GROUP_WORKERS", "type": "number", + "validations": [{ + "type": "is_integer", + "description": "%should.be.integer%" + }], "defaultValue": "0", "min": 0 }, @@ -745,6 +783,10 @@ "label": "%arc.postgres.server.group.port%", "variableName": "AZDATA_NB_VAR_POSTGRES_SERVER_GROUP_PORT", "type": "number", + "validations": [{ + "type": "is_integer", + "description": "%should.be.integer%" + }], "defaultValue": "5432", "min": 1, "max": 65535 @@ -825,28 +867,48 @@ "description": "%arc.postgres.server.group.cores.request.description%", "variableName": "AZDATA_NB_VAR_POSTGRES_SERVER_GROUP_CORES_REQUEST", "type": "number", - "min": 1 + "min": 1, + "validations": [{ + "type": "<=", + "target": "AZDATA_NB_VAR_POSTGRES_SERVER_GROUP_CORES_LIMIT", + "description": "%requested.cores.less.than.or.equal.to.cores.limit%" + }] }, { "label": "%arc.postgres.server.group.cores.limit.label%", "description": "%arc.postgres.server.group.cores.limit.description%", "variableName": "AZDATA_NB_VAR_POSTGRES_SERVER_GROUP_CORES_LIMIT", "type": "number", - "min": 1 + "min": 1, + "validations": [{ + "type": ">=", + "target": "AZDATA_NB_VAR_POSTGRES_SERVER_GROUP_CORES_REQUEST", + "description": "%cores.limit.greater.than.or.equal.to.requested.cores%" + }] }, { "label": "%arc.postgres.server.group.memory.request.label%", "description": "%arc.postgres.server.group.memory.request.description%", "variableName": "AZDATA_NB_VAR_POSTGRES_SERVER_GROUP_MEMORY_REQUEST", "type": "number", - "min": 0.25 + "min": 0.25, + "validations": [{ + "type": "<=", + "target": "AZDATA_NB_VAR_POSTGRES_SERVER_GROUP_MEMORY_LIMIT", + "description": "%requested.memory.less.than.or.equal.to.memory.limit%" + }] }, { "label": "%arc.postgres.server.group.memory.limit.label%", "description": "%arc.postgres.server.group.memory.limit.description%", "variableName": "AZDATA_NB_VAR_POSTGRES_SERVER_GROUP_MEMORY_LIMIT", "type": "number", - "min": 0.25 + "min": 0.25, + "validations": [{ + "type": ">=", + "target": "AZDATA_NB_VAR_POSTGRES_SERVER_GROUP_MEMORY_REQUEST", + "description": "%memory.limit.greater.than.or.equal.to.requested.memory%" + }] } ] } diff --git a/extensions/arc/package.nls.json b/extensions/arc/package.nls.json index 7cb1a9b527..8452b7c729 100644 --- a/extensions/arc/package.nls.json +++ b/extensions/arc/package.nls.json @@ -129,6 +129,11 @@ "arc.postgres.server.group.memory.limit.label": "Memory limit (GB per node)", "arc.postgres.server.group.memory.limit.description": "The memory limit of the Postgres instance per node in GB.", "arc.agreement": "I accept {0} and {1}.", - "arc.agreement.sql.terms.conditions":"Azure SQL managed instance - Azure Arc terms and conditions", - "arc.agreement.postgres.terms.conditions":"Azure Arc enabled PostgreSQL Hyperscale terms and conditions" + "arc.agreement.sql.terms.conditions": "Azure SQL managed instance - Azure Arc terms and conditions", + "arc.agreement.postgres.terms.conditions": "Azure Arc enabled PostgreSQL Hyperscale terms and conditions", + "should.be.integer": "Value must be an integer", + "requested.cores.less.than.or.equal.to.cores.limit": "Requested cores must be less than or equal to cores limit", + "cores.limit.greater.than.or.equal.to.requested.cores": "Cores limit must be greater than or equal to requested cores", + "requested.memory.less.than.or.equal.to.memory.limit": "Requested memory must be less than or equal to memory limit", + "memory.limit.greater.than.or.equal.to.requested.memory": "Memory limit must be greater than or equal to requested memory" } diff --git a/extensions/asde-deployment/package.json b/extensions/asde-deployment/package.json index 12529e6c3c..2144544cfd 100644 --- a/extensions/asde-deployment/package.json +++ b/extensions/asde-deployment/package.json @@ -339,9 +339,11 @@ "confirmationRequired": true, "confirmationLabel": "%vm_password_confirm%", "required": true, - "textValidationRequired": true, - "textValidationRegex": "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[\\W_])[A-Za-z\\d\\W_]{12,123}$", - "textValidationDescription": "%vm_password_validation_error_message%" + "validations" : [{ + "type": "regex_match", + "regex": "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[\\W_])[A-Za-z\\d\\W_]{12,123}$", + "description": "%vm_password_validation_error_message%" + }] } ] }, diff --git a/extensions/resource-deployment/src/common/utils.ts b/extensions/resource-deployment/src/common/utils.ts index fce3e7c0ce..1be1206843 100644 --- a/extensions/resource-deployment/src/common/utils.ts +++ b/extensions/resource-deployment/src/common/utils.ts @@ -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 diff --git a/extensions/resource-deployment/src/interfaces.ts b/extensions/resource-deployment/src/interfaces.ts index be458b589a..b6059651e4 100644 --- a/extensions/resource-deployment/src/interfaces.ts +++ b/extensions/resource-deployment/src/interfaces.ts @@ -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 { diff --git a/extensions/resource-deployment/src/test/ui/validation/validations.test.ts b/extensions/resource-deployment/src/test/ui/validation/validations.test.ts index 107a2e627f..c6511f5d04 100644 --- a/extensions/resource-deployment/src/test/ui/validation/validations.test.ts +++ b/extensions/resource-deployment/src/test/ui/validation/validations.test.ts @@ -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 = { 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; describe('Validation', () => { + beforeEach('validation setup', () => { + sinon.restore(); //cleanup all previously defined sinon mocks + inputBoxStub = sinon.stub(inputBox, 'updateProperty').resolves(); + onValidityChangedEmitter = new vscode.EventEmitter(); // 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; } diff --git a/extensions/resource-deployment/src/ui/deployClusterWizard/pages/clusterSettingsPage.ts b/extensions/resource-deployment/src/ui/deployClusterWizard/pages/clusterSettingsPage.ts index e672800b3d..455eaa2a6f 100644 --- a/extensions/resource-deployment/src/ui/deployClusterWizard/pages/clusterSettingsPage.ts +++ b/extensions/resource-deployment/src/ui/deployClusterWizard/pages/clusterSettingsPage.ts @@ -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, diff --git a/extensions/resource-deployment/src/ui/deployClusterWizard/pages/serviceSettingsPage.ts b/extensions/resource-deployment/src/ui/deployClusterWizard/pages/serviceSettingsPage.ts index 75465d4e95..10827234e5 100644 --- a/extensions/resource-deployment/src/ui/deployClusterWizard/pages/serviceSettingsPage.ts +++ b/extensions/resource-deployment/src/ui/deployClusterWizard/pages/serviceSettingsPage.ts @@ -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 }); diff --git a/extensions/resource-deployment/src/ui/modelViewUtils.ts b/extensions/resource-deployment/src/ui/modelViewUtils.ts index 62d78032f3..b9686e5aca 100644 --- a/extensions/resource-deployment/src/ui/modelViewUtils.ts +++ b/extensions/resource-deployment/src/ui/modelViewUtils.ts @@ -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; +export type InputValueType = string | number | undefined; +export type InputValueTransformer = (inputValue: string) => InputValueType | Promise; 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({ 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({ - 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 { + //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 = (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) => (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({ - 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)) ?? ''; + const value = (await getInputComponentValue(inputComponents[key])) ?? ''; 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({ 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({ label: loc.signIn, width: '100px' }).component(); const refreshButton = context.view!.modelBuilder.button().withProperties({ 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(); 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 { 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 { - const input = inputComponents[key].component; +async function getInputComponentValue(inputComponentInfo: InputComponentInfo): Promise { + 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 { diff --git a/extensions/resource-deployment/src/ui/validation/validations.ts b/extensions/resource-deployment/src/ui/validation/validations.ts index 278c72ec95..a3abb9bf80 100644 --- a/extensions/resource-deployment/src/ui/validation/validations.ts +++ b/extensions/resource-deployment/src/ui/validation/validations.ts @@ -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; -export type ValidationValueType = string | number | undefined; -export type VariableValueGetter = (variable: string) => Promise; -export type ValueGetter = () => Promise; + +export type OnValidation = (isValid: boolean) => Promise; +export type ValueGetter = () => Promise; +export type TargetValueGetter = (variable: string) => Promise; +export type OnTargetValidityChangedGetter = (variable: string) => vscode.Event; 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; - protected getValue(): Promise { + protected getValue(): Promise { return this._valueGetter(); } - protected getVariableValue(variable: string): Promise { - return this._variableValueGetter!(variable); + protected getTargetValue(variable: string): Promise { + 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 { + private async isIntegerOrEmptyOrUndefined(): Promise { 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 { - 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 { - 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 { + 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; async validate(): Promise { + 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(validation, valueGetter); - case ValidationType.IsInteger: return new IntegerValidation(validation, valueGetter); - case ValidationType.LessThanOrEqualsTo: return new LessThanOrEqualsValidation(validation, valueGetter, variableValueGetter!); - case ValidationType.GreaterThanOrEqualsTo: return new GreaterThanOrEqualsValidation(validation, valueGetter, variableValueGetter!); + case ValidationType.Regex: return new RegexValidation(validation, onValidation, valueGetter); + case ValidationType.IsInteger: return new IntegerValidation(validation, onValidation, valueGetter); + case ValidationType.LessThanOrEqualsTo: return new LessThanOrEqualsValidation(validation, onValidation, valueGetter, targetValueGetter!, onTargetValidityChangedGetter!, onDisposableCreated!); + case ValidationType.GreaterThanOrEqualsTo: return new GreaterThanOrEqualsValidation(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; diff --git a/samples/sample-resource-deployment/package.json b/samples/sample-resource-deployment/package.json index 1a6cf89852..dc4e3464b1 100644 --- a/samples/sample-resource-deployment/package.json +++ b/samples/sample-resource-deployment/package.json @@ -1,41 +1,133 @@ { - "name": "sample-resource-deployment", - "displayName": "%extension-displayName%", - "description": "%extension-description%", - "version": "0.0.1", - "publisher": "Contoso", - "preview": true, - "license": "https://raw.githubusercontent.com/Microsoft/azuredatastudio/main/LICENSE.txt", - "icon": "images/sqlserver.png", - "aiKey": "AIF-37eefaf0-8022-4671-a3fb-64752724682e", - "engines": { - "vscode": "*", - "azdata": ">=1.19.0" - }, - "repository": { - "type": "git", - "url": "https://github.com/Microsoft/azuredatastudio.git" - }, - "extensionDependencies": [ - "microsoft.mssql", - "microsoft.notebook", - "microsoft.resource-deployment" - ], - "contributes": { - "resourceDeploymentTypes": [ - { + "name": "sample-resource-deployment", + "displayName": "%extension-displayName%", + "description": "%extension-description%", + "version": "0.0.1", + "publisher": "Contoso", + "preview": true, + "license": "https://raw.githubusercontent.com/Microsoft/azuredatastudio/main/LICENSE.txt", + "icon": "images/sqlserver.png", + "aiKey": "AIF-37eefaf0-8022-4671-a3fb-64752724682e", + "engines": { + "vscode": "*", + "azdata": ">=1.19.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/Microsoft/azuredatastudio.git" + }, + "extensionDependencies": [ + "microsoft.mssql", + "microsoft.notebook", + "microsoft.resource-deployment" + ], + "contributes": { + "resourceDeploymentTypes": [ + { + "name": "validations-wizard", + "displayName": "%validation.wizard.display.name%", + "description": "%validation.wizard.description%", + "platforms": "*", + "icon": { + "light": "./images/book.svg", + "dark": "./images/book_inverse.svg" + }, + "providers": [ + { + "notebookWizard": { + "notebook": "%deployment-notebook-1%", + "runNotebook": false, + "codeCellInsertionPosition": 1, + "actionText": "%deploy.wizard.action%", + "title": "%wizard.new.wizard.title%", + "name": "wizard.new.wizard", + "labelPosition": "left", + "generateSummaryPage": false, + "pages": [ + { + "title": "%wizard.select.cluster.title%", + "sections": [ + { + "fields": [ + { + "label": "%cores-limit.label%", + "description": "%cores-limit.description%", + "variableName": "AZDATA_NB_VAR_CORES_LIMIT", + "type": "number", + "defaultValue": 5, + "required": false, + "validations": [ + { + "type": "is_integer", + "description": "%cores.limit.should.be.integer%" + }, + { + "type": ">=", + "target": "AZDATA_NB_VAR_CORES_REQUEST", + "description": "%cores.limit.greater.than.or.equal.to.requested.cores%" + } + ] + }, + { + "label": "%cores-request.label%", + "description": "%cores-request.description%", + "variableName": "AZDATA_NB_VAR_CORES_REQUEST", + "type": "number", + "defaultValue": 2, + "required": false, + "validations": [ + { + "type": "is_integer", + "description": "%requested.cores.should.be.integer%" + }, + { + "type": "<=", + "target": "AZDATA_NB_VAR_CORES_LIMIT", + "description": "%requested.cores.less.than.or.equal.to.cores.limit%" + } + ] + } + ] + } + ] + } + ] + }, + "requiredTools": [ + { + "name": "kubectl" + } + ], + "when": true + } + ], + "agreement": { + "template": "%wizard.data.controller.agreement%", + "links": [ + { + "text": "%contoso.agreement.privacy.statement%", + "url": "https://go.contoso.com/fwlink/?LinkId=853010" + }, + { + "text": "%wizard.agreement.contosoCmd.eula%", + "url": "https://aka.ms/eula-contosoCmd-en" + } + ] + } + }, + { "name": "test-wizard", "displayName": "%resource.type.wizard.display.name%", "description": "%resource.type.wizard.description%", "platforms": "*", - "icon": { - "light": "./images/book.svg", - "dark": "./images/book_inverse.svg" - }, + "icon": { + "light": "./images/book.svg", + "dark": "./images/book_inverse.svg" + }, "providers": [ { "notebookWizard": { - "notebook": "%deployment-notebook-1%", + "notebook": "%deployment-notebook-1%", "type": "new-arc-control-plane", "runNotebook": false, "codeCellInsertionPosition": 1, @@ -67,7 +159,7 @@ "sections": [ { "fields": [ - { + { "type": "readonly_text", "label": "%wizard.project.details.description%", "labelWidth": "600px" @@ -90,39 +182,39 @@ "defaultValue": "aks-dev-test", "optionsType": "radio" } - }, - { + }, + { "type": "options", "label": "%wizard.data.controllers%", "required": true, "variableName": "AZDATA_NB_VAR_CONTROLLER", "editable": false, "options": { - "source": { - "type": "ArcControllersOptionsSource", - "variableNames": { - "endpoint": "AZDATA_NB_VAR_CONTROLLER_ENDPOINT", - "username": "AZDATA_NB_VAR_CONTROLLER_USERNAME", - "password": "AZDATA_NB_VAR_CONTROLLER_PASSWORD" - } - }, + "source": { + "type": "ArcControllersOptionsSource", + "variableNames": { + "endpoint": "AZDATA_NB_VAR_CONTROLLER_ENDPOINT", + "username": "AZDATA_NB_VAR_CONTROLLER_USERNAME", + "password": "AZDATA_NB_VAR_CONTROLLER_PASSWORD" + } + }, "values":[ "ignored1", "ignored2" ], "optionsType": "dropdown" } - }, - { - "label": "%wizard.dropdown.options.field%", - "variableName": "AZDATA_NB_VAR_DROPDOWN_OPTIONS", - "type": "options", - "options": { - "values": ["1","2","3"], - "defaultValue": "2", - "optionsType": "dropdown" - } - } + }, + { + "label": "%wizard.dropdown.options.field%", + "variableName": "AZDATA_NB_VAR_DROPDOWN_OPTIONS", + "type": "options", + "options": { + "values": ["1","2","3"], + "defaultValue": "2", + "optionsType": "dropdown" + } + } ] } ] @@ -273,31 +365,31 @@ "type": "readonly_text", "isEvaluated": true, "defaultValue": "$(AZDATA_NB_VAR_DROPDOWN_OPTIONS)" - }, - { - "label": "%wizard.summary.controller%", - "type": "readonly_text", - "isEvaluated": true, - "defaultValue": "$(AZDATA_NB_VAR_CONTROLLER)" - }, - { - "label": "%wizard.summary.controller.endpoint%", - "type": "readonly_text", - "isEvaluated": true, - "defaultValue": "$(AZDATA_NB_VAR_CONTROLLER_ENDPOINT)" - }, - { - "label": "%wizard.summary.controller.username%", - "type": "readonly_text", - "isEvaluated": true, - "defaultValue": "$(AZDATA_NB_VAR_CONTROLLER_USERNAME)" - }, - { - "label": "%wizard.summary.controller.password%", - "type": "readonly_text", - "isEvaluated": true, - "defaultValue": "$(AZDATA_NB_VAR_CONTROLLER_PASSWORD)" - } + }, + { + "label": "%wizard.summary.controller%", + "type": "readonly_text", + "isEvaluated": true, + "defaultValue": "$(AZDATA_NB_VAR_CONTROLLER)" + }, + { + "label": "%wizard.summary.controller.endpoint%", + "type": "readonly_text", + "isEvaluated": true, + "defaultValue": "$(AZDATA_NB_VAR_CONTROLLER_ENDPOINT)" + }, + { + "label": "%wizard.summary.controller.username%", + "type": "readonly_text", + "isEvaluated": true, + "defaultValue": "$(AZDATA_NB_VAR_CONTROLLER_USERNAME)" + }, + { + "label": "%wizard.summary.controller.password%", + "type": "readonly_text", + "isEvaluated": true, + "defaultValue": "$(AZDATA_NB_VAR_CONTROLLER_PASSWORD)" + } ] } ] @@ -326,159 +418,159 @@ ] } }, - { - "name": "x-data-service", - "displayName": "%resource-type-display-name%", - "description": "%resource-type-description%", - "platforms": [ - "darwin", - "win32", - "linux" - ], - "icon": { - "light": "./images/book.svg", - "dark": "./images/book_inverse.svg" - }, - "options": [ - { - "name": "edition", - "displayName": "%option-display-name%", - "values": [ - { - "name": "evaluation", - "displayName": "%option-value-name-1%" - }, - { - "name": "standard", - "displayName": "%option-value-name-2%" - } - ] - } - ], - "providers": [ - { - "dialog": { - "notebook": "%deployment-notebook-1%", - "title": "%dialog-title-1%", - "name": "dialog1", - "tabs": [ - { - "title": "", - "sections": [ - { - "title": "", - "fields": [ - { - "label": "%text-field%", - "variableName": "AZDATA_NB_VAR_TEXT", - "type": "text", - "defaultValue": "abc", - "required": true - }, - { - "label": "%password-field%", - "variableName": "AZDATA_NB_VAR_PASSWORD", - "type": "password", - "confirmationRequired": true, - "confirmationLabel": "%confirm-password%", - "defaultValue": "", - "required": true - }, - { - "type": "kube_cluster_context_picker", - "label": "%kube.cluster.context%", - "required": true, - "inputWidth": "350px", - "variableName": "AZDATA_NB_VAR_CLUSTER_CONTEXT", - "configFileVariableName": "AZDATA_NB_VAR_CONFIG_FILE" - }, - { - "label": "%number-field%", - "variableName": "AZDATA_NB_VAR_NUMBER", - "type": "number", - "defaultValue": "100", - "required": true, - "min": 1, - "max": 65535 - } - ] - } - ] - } - ] - }, - "requiredTools": [ - { - "name": "kubectl" - } - ], - "when": "edition=evaluation" - }, - { - "dialog": { - "notebook": "%deployment-notebook-2%", - "title": "%dialog-title-2%", - "name": "dialog2", - "tabs": [ - { - "title": "", - "sections": [ - { - "title": "", - "fields": [ - { - "label": "%text-field%", - "variableName": "AZDATA_NB_VAR_TEXT", - "type": "text", - "defaultValue": "abc", - "required": true - }, - { - "label": "%password-field%", - "variableName": "AZDATA_NB_VAR_PASSWORD", - "type": "password", - "confirmationRequired": true, - "confirmationLabel": "%confirm-password%", - "defaultValue": "", - "required": true - }, - { - "label": "%number-field%", - "variableName": "AZDATA_NB_VAR_NUMBER", - "type": "number", - "defaultValue": "100", - "required": true, - "min": 1, - "max": 65535 - } - ] - } - ] - } - ] - }, - "requiredTools": [ - { - "name": "docker" - } - ], - "when": "edition=standard" - } - ], - "agreement": { - "template": "%agreement%", - "links": [ - { - "text": "%agreement-1-name%", - "url": "https://www.contoso.com" - }, - { - "text": "%agreement-2-name%", - "url": "https://portal.azure.com" - } - ] - } - } - ] - } + { + "name": "x-data-service", + "displayName": "%resource-type-display-name%", + "description": "%resource-type-description%", + "platforms": [ + "darwin", + "win32", + "linux" + ], + "icon": { + "light": "./images/book.svg", + "dark": "./images/book_inverse.svg" + }, + "options": [ + { + "name": "edition", + "displayName": "%option-display-name%", + "values": [ + { + "name": "evaluation", + "displayName": "%option-value-name-1%" + }, + { + "name": "standard", + "displayName": "%option-value-name-2%" + } + ] + } + ], + "providers": [ + { + "dialog": { + "notebook": "%deployment-notebook-1%", + "title": "%dialog-title-1%", + "name": "dialog1", + "tabs": [ + { + "title": "", + "sections": [ + { + "title": "", + "fields": [ + { + "label": "%text-field%", + "variableName": "AZDATA_NB_VAR_TEXT", + "type": "text", + "defaultValue": "abc", + "required": true + }, + { + "label": "%password-field%", + "variableName": "AZDATA_NB_VAR_PASSWORD", + "type": "password", + "confirmationRequired": true, + "confirmationLabel": "%confirm-password%", + "defaultValue": "", + "required": true + }, + { + "type": "kube_cluster_context_picker", + "label": "%kube.cluster.context%", + "required": true, + "inputWidth": "350px", + "variableName": "AZDATA_NB_VAR_CLUSTER_CONTEXT", + "configFileVariableName": "AZDATA_NB_VAR_CONFIG_FILE" + }, + { + "label": "%number-field%", + "variableName": "AZDATA_NB_VAR_NUMBER", + "type": "number", + "defaultValue": "100", + "required": true, + "min": 1, + "max": 65535 + } + ] + } + ] + } + ] + }, + "requiredTools": [ + { + "name": "kubectl" + } + ], + "when": "edition=evaluation" + }, + { + "dialog": { + "notebook": "%deployment-notebook-2%", + "title": "%dialog-title-2%", + "name": "dialog2", + "tabs": [ + { + "title": "", + "sections": [ + { + "title": "", + "fields": [ + { + "label": "%text-field%", + "variableName": "AZDATA_NB_VAR_TEXT", + "type": "text", + "defaultValue": "abc", + "required": true + }, + { + "label": "%password-field%", + "variableName": "AZDATA_NB_VAR_PASSWORD", + "type": "password", + "confirmationRequired": true, + "confirmationLabel": "%confirm-password%", + "defaultValue": "", + "required": true + }, + { + "label": "%number-field%", + "variableName": "AZDATA_NB_VAR_NUMBER", + "type": "number", + "defaultValue": "100", + "required": true, + "min": 1, + "max": 65535 + } + ] + } + ] + } + ] + }, + "requiredTools": [ + { + "name": "docker" + } + ], + "when": "edition=standard" + } + ], + "agreement": { + "template": "%agreement%", + "links": [ + { + "text": "%agreement-1-name%", + "url": "https://www.contoso.com" + }, + { + "text": "%agreement-2-name%", + "url": "https://portal.azure.com" + } + ] + } + } + ] + } } diff --git a/samples/sample-resource-deployment/package.nls.json b/samples/sample-resource-deployment/package.nls.json index ed07558764..316c5e375e 100644 --- a/samples/sample-resource-deployment/package.nls.json +++ b/samples/sample-resource-deployment/package.nls.json @@ -22,6 +22,9 @@ "resource.type.wizard.display.name": "Test controller", "resource.type.wizard.description": "Creates a Test controller", + "validation.wizard.display.name": "Validation Wizard", + "validation.wizard.description": "A wizard to test out validations", + "wizard.new.wizard.title": "Create Test controller", "wizard.cluster.environment.title": "What is your target existing Kubernetes cluster environment?", "wizard.select.cluster.title": "Select from installed existing Kubernetes clusters", @@ -52,6 +55,15 @@ "wizard.data.controller.agreement": "I accept {0} and {1}.", "contoso.agreement.privacy.statement":"contoso Privacy Statement", "wizard.agreement.contosoCmd.eula":"contoso cmd license terms", - "deploy.wizard.action":"Script to notebook" + "deploy.wizard.action":"Script to notebook", + "cores-limit.label": "Cores Limit", + "cores-limit.description": "The cores limit must be an integer.", + "cores-request.label": "Cores Request", + "cores-request.description": "The requested cores must be an integer.", + + "requested.cores.should.be.integer": "Requested cores must be an integer", + "cores.limit.should.be.integer": "Cores limit must be an integer", + "requested.cores.less.than.or.equal.to.cores.limit": "Requested cores must be <= cores limit", + "cores.limit.greater.than.or.equal.to.requested.cores": "Cores limit must be >= requested cores" }