Add dependent field provider to resource deployment (#13664)

* Add dependent field provider to resource deployment

* Change name to value provider service

* Add error handling

* providerId -> id

* Set dropdown value correctly

* missed id

* back to providerId

* fix updating missed id

* remove placeholder
This commit is contained in:
Charles Gagnon
2020-12-04 17:21:30 -08:00
committed by GitHub
parent 757ac1d4aa
commit a70dce7855
12 changed files with 147 additions and 15 deletions

View File

@@ -315,10 +315,10 @@
"type": "text",
"required": true,
"defaultValue": "",
"placeHolder": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"enabled": {
"target": "AZDATA_NB_VAR_ARC_DATA_CONTROLLER_CONNECTIVITY_MODE",
"value": "direct"
"enabled": false,
"valueProvider": {
"providerId": "subscription-id-to-tenant-id",
"triggerField": "AZDATA_NB_VAR_ARC_SUBSCRIPTION"
},
"validations" : [{
"type": "regex_match",

View File

@@ -17,7 +17,7 @@ import { AzureArcTreeDataProvider } from '../ui/tree/azureArcTreeDataProvider';
*/
export class ArcControllersOptionsSourceProvider implements rd.IOptionsSourceProvider {
private _cacheManager = new CacheManager<string, string>();
readonly optionsSourceId = 'arc.controllers';
readonly id = 'arc.controllers';
constructor(private _treeProvider: AzureArcTreeDataProvider) { }
async getOptions(): Promise<string[] | azdata.CategoryValue[]> {

View File

@@ -10,7 +10,7 @@ import * as azdataExt from 'azdata-ext';
* Class that provides options sources for an Arc Data Controller
*/
export class ArcControllerConfigProfilesOptionsSource implements rd.IOptionsSourceProvider {
readonly optionsSourceId = 'arc.controller.config.profiles';
readonly id = 'arc.controller.config.profiles';
constructor(private _azdataExtApi: azdataExt.IExtension) { }
async getOptions(): Promise<string[]> {
const isEulaAccepted = await this._azdataExtApi.isEulaAccepted();

View File

@@ -8,6 +8,7 @@ import * as vscode from 'vscode';
import { promises as fs } from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as resourceDeployment from 'resource-deployment';
import { AppContext } from './appContext';
import { AzureAccountProviderService } from './account-provider/azureAccountProviderService';
@@ -86,8 +87,8 @@ export async function activate(context: vscode.ExtensionContext): Promise<azurec
registerAzureServices(appContext);
const azureResourceTree = new AzureResourceTreeProvider(appContext);
const connectionDialogTree = new ConnectionDialogTreeProvider(appContext);
pushDisposable(vscode.window.registerTreeDataProvider('connectionDialog/azureResourceExplorer', connectionDialogTree));
pushDisposable(vscode.window.registerTreeDataProvider('azureResourceExplorer', azureResourceTree));
pushDisposable(vscode.window.registerTreeDataProvider('connectionDialog/azureResourceExplorer', connectionDialogTree));
pushDisposable(vscode.workspace.onDidChangeConfiguration(e => onDidChangeConfiguration(e), this));
registerAzureResourceCommands(appContext, azureResourceTree, connectionDialogTree);
azdata.dataprotocol.registerDataGridProvider(new AzureDataGridProvider(appContext));
@@ -105,6 +106,40 @@ export async function activate(context: vscode.ExtensionContext): Promise<azurec
}
});
// Don't block on this since there's a bit of a circular dependency here with the extension activation since resource deployment
// depends on this extension too. It's fine to wait a bit for that to finish before registering the provider
vscode.extensions.getExtension(resourceDeployment.extension.name).activate().then((api: resourceDeployment.IExtension) => {
api.registerValueProvider({
id: 'subscription-id-to-tenant-id',
getValue: async (triggerValue: string) => {
if (triggerValue === '') {
return '';
}
let accounts: azdata.Account[] = [];
try {
accounts = await azdata.accounts.getAllAccounts();
} catch (err) {
console.warn(`Error fetching accounts for subscription-id-to-tenant-id provider : ${err}`);
return '';
}
for (const account of accounts) {
// Ignore any errors - they'll be logged in the called function and we still want to look
// at any subscriptions that are returned - worst case we'll just return an empty string if we didn't
// find the matching subscription
const subs = await azureResourceUtils.getSubscriptions(appContext, account, true);
const sub = subs.subscriptions.find(sub => sub.id === triggerValue);
if (sub) {
return sub.tenant;
}
}
console.error(`Unable to find subscription with ID ${triggerValue} when mapping subscription ID to tenant ID`);
return '';
}
});
});
return {
getSubscriptions(account?: azdata.Account, ignoreErrors?: boolean, selectedOnly: boolean = false): Thenable<azurecore.GetSubscriptionsResult> {
return selectedOnly

View File

@@ -7,4 +7,5 @@
/// <reference path='../../../../src/vs/vscode.proposed.d.ts'/>
/// <reference path='../../../../src/sql/azdata.d.ts'/>
/// <reference path='../../../../src/sql/azdata.proposed.d.ts'/>
/// <reference path='../../../resource-deployment/src/typings/resource-deployment.d.ts'/>
/// <reference types='@types/node'/>

View File

@@ -4,11 +4,13 @@
*--------------------------------------------------------------------------------------------*/
import * as rd from 'resource-deployment';
import { valueProviderService } from './services/valueProviderService';
import { optionsSourcesService } from './services/optionSourcesService';
export function getExtensionApi(): rd.IExtension {
return {
registerOptionsSourceProvider: (provider: rd.IOptionsSourceProvider) => optionsSourcesService.registerOptionsSourceProvider(provider)
registerOptionsSourceProvider: (provider: rd.IOptionsSourceProvider) => optionsSourcesService.registerOptionsSourceProvider(provider),
registerValueProvider: (provider: rd.IValueProvider) => valueProviderService.registerValueProvider(provider)
};
}

View File

@@ -261,6 +261,11 @@ export interface DynamicEnablementInfo {
value: string
}
export interface ValueProviderInfo {
providerId: string,
triggerField: string
}
export interface FieldInfoBase {
labelWidth?: string;
inputWidth?: string;
@@ -307,9 +312,8 @@ export interface FieldInfo extends SubFieldInfo, FieldInfoBase {
editable?: boolean; // for editable drop-down,
enabled?: boolean | DynamicEnablementInfo;
isEvaluated?: boolean;
valueLookup?: string; // for fetching dropdown options
validationLookup?: string // for fetching text field validations
validations?: ValidationInfo[];
valueProvider?: ValueProviderInfo;
}
export interface KubeClusterContextFieldInfo extends FieldInfo {

View File

@@ -28,7 +28,9 @@ export const NewResourceGroupAriaLabel = localize('azure.resourceGroup.NewResour
export const realm = localize('deployCluster.Realm', "Realm");
export const unknownFieldTypeError = (type: FieldType) => localize('UnknownFieldTypeError', "Unknown field type: \"{0}\"", type);
export const optionsSourceAlreadyDefined = (optionsSourceId: string) => localize('optionsSource.alreadyDefined', "Options Source with id:{0} is already defined", optionsSourceId);
export const valueProviderAlreadyDefined = (providerId: string) => localize('valueProvider.alreadyDefined', "Value Provider with id:{0} is already defined", providerId);
export const noOptionsSourceDefined = (optionsSourceId: string) => localize('optionsSource.notDefined', "No Options Source defined for id: {0}", optionsSourceId);
export const noValueProviderDefined = (providerId: string) => localize('valueProvider.notDefined', "No Value Provider defined for id: {0}", providerId);
export const variableValueFetchForUnsupportedVariable = (variableName: string) => localize('getVariableValue.unknownVariableName', "Attempt to get variable value for unknown variable:{0}", variableName);
export const isPasswordFetchForUnsupportedVariable = (variableName: string) => localize('getIsPassword.unknownVariableName', "Attempt to get isPassword for unknown variable:{0}", variableName);
export const optionsNotDefined = (fieldType: FieldType) => localize('optionsNotDefined', "FieldInfo.options was not defined for field type: {0}", fieldType);

View File

@@ -9,10 +9,10 @@ import * as loc from '../localizedConstants';
class OptionsSourcesService {
private _optionsSourceStore = new Map<string, rd.IOptionsSourceProvider>();
registerOptionsSourceProvider(provider: rd.IOptionsSourceProvider): void {
if (this._optionsSourceStore.has(provider.optionsSourceId)) {
throw new Error(loc.optionsSourceAlreadyDefined(provider.optionsSourceId));
if (this._optionsSourceStore.has(provider.id)) {
throw new Error(loc.optionsSourceAlreadyDefined(provider.id));
}
this._optionsSourceStore.set(provider.optionsSourceId, provider);
this._optionsSourceStore.set(provider.id, provider);
}
getOptionsSource(optionsSourceProviderId: string): rd.IOptionsSourceProvider {

View File

@@ -0,0 +1,27 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as rd from 'resource-deployment';
import * as loc from '../localizedConstants';
class ValueProviderService {
private _valueProviderStore = new Map<string, rd.IValueProvider>();
registerValueProvider(provider: rd.IValueProvider): void {
if (this._valueProviderStore.has(provider.id)) {
throw new Error(loc.valueProviderAlreadyDefined(provider.id));
}
this._valueProviderStore.set(provider.id, provider);
}
getValueProvider(providerId: string): rd.IValueProvider {
const valueProvider = this._valueProviderStore.get(providerId);
if (valueProvider === undefined) {
throw new Error(loc.noValueProviderDefined(providerId));
}
return valueProvider;
}
}
export const valueProviderService = new ValueProviderService();

View File

@@ -17,12 +17,17 @@ declare module 'resource-deployment' {
name = 'Microsoft.resource-deployment'
}
export interface IOptionsSourceProvider {
readonly optionsSourceId: string,
readonly id: string,
getOptions(): Promise<string[] | azdata.CategoryValue[]> | string[] | azdata.CategoryValue[];
getVariableValue?: (variableName: string, input: string) => Promise<string> | string;
getIsPassword?: (variableName: string) => boolean | Promise<boolean>;
}
export interface IValueProvider {
readonly id: string,
getValue(triggerValue: string): Promise<string>;
}
/**
* Covers defining what the resource-deployment extension exports to other extensions
*
@@ -31,6 +36,7 @@ declare module 'resource-deployment' {
*/
export interface IExtension {
registerOptionsSourceProvider(provider: IOptionsSourceProvider): void
registerOptionsSourceProvider(provider: IOptionsSourceProvider): void,
registerValueProvider(provider: IValueProvider): void
}
}

View File

@@ -14,6 +14,7 @@ import { getDateTimeString, getErrorMessage, throwUnless } from '../common/utils
import { AzureAccountFieldInfo, AzureLocationsFieldInfo, ComponentCSSStyles, DialogInfoBase, FieldInfo, FieldType, FilePickerFieldInfo, instanceOfDynamicEnablementInfo, IOptionsSource, KubeClusterContextFieldInfo, LabelPosition, NoteBookEnvironmentVariablePrefix, OptionsInfo, OptionsType, PageInfoBase, RowInfo, SectionInfo, TextCSSStyles } from '../interfaces';
import * as loc from '../localizedConstants';
import { apiService } from '../services/apiService';
import { valueProviderService } from '../services/valueProviderService';
import { getDefaultKubeConfigPath, getKubeConfigClusterContexts } from '../services/kubeService';
import { optionsSourcesService } from '../services/optionSourcesService';
import { KubeCtlTool, KubeCtlToolName } from '../services/tools/kubeCtlTool';
@@ -38,6 +39,7 @@ export type InputComponentInfo<T extends InputComponent> = {
component: T;
labelComponent?: azdata.TextComponent;
getValue: () => Promise<InputValueType>;
setValue: (value: InputValueType) => void;
getDisplayValue?: () => Promise<string>;
onValueChanged: vscode.Event<void>;
isPassword?: boolean
@@ -200,6 +202,7 @@ export function createInputBoxInputInfo(view: azdata.ModelView, inputInfo: Input
return {
component: component,
getValue: async (): Promise<InputValueType> => component.value,
setValue: (value: InputValueType) => component.value = value?.toString(),
onValueChanged: component.onTextChanged
};
}
@@ -240,6 +243,7 @@ export function createCheckboxInputInfo(view: azdata.ModelView, info: { initialV
return {
component: checkbox,
getValue: async () => checkbox.checked ? 'true' : 'false',
setValue: (value: InputValueType) => checkbox.checked = value?.toString().toLowerCase() === 'true' ? true : false,
onValueChanged: checkbox.onChanged
};
}
@@ -265,6 +269,7 @@ export function createDropdownInputInfo(view: azdata.ModelView, info: { defaultV
return {
component: dropdown,
getValue: async (): Promise<InputValueType> => typeof dropdown.value === 'string' ? dropdown.value : dropdown.value?.name,
setValue: (value: InputValueType) => setDropdownValue(dropdown, value?.toString()),
getDisplayValue: async (): Promise<string> => (typeof dropdown.value === 'string' ? dropdown.value : dropdown.value?.displayName) || '',
onValueChanged: dropdown.onValueChanged,
};
@@ -331,6 +336,7 @@ export function initializeWizardPage(context: WizardPageContext): void {
});
}));
await hookUpDynamicEnablement(context);
await hookUpValueProviders(context);
const formBuilder = view.modelBuilder.formContainer().withFormItems(
sections.map(section => { return { title: '', component: section }; }),
{
@@ -397,6 +403,35 @@ async function hookUpDynamicEnablement(context: WizardPageContext): Promise<void
}));
}
async function hookUpValueProviders(context: WizardPageContext): Promise<void> {
await Promise.all(context.pageInfo.sections.map(async section => {
if (!section.fields) {
return;
}
await Promise.all(section.fields.map(async field => {
if (field.valueProvider) {
const fieldKey = field.variableName || field.label;
const fieldComponent = context.inputComponents[fieldKey];
const targetComponent = context.inputComponents[field.valueProvider.triggerField];
if (!targetComponent) {
console.error(`Could not find target component ${field.valueProvider.triggerField} when hooking up value providers for ${field.label}`);
return;
}
const provider = valueProviderService.getValueProvider(field.valueProvider.providerId);
const updateFields = async () => {
const targetComponentValue = await targetComponent.getValue();
const newFieldValue = await provider.getValue(targetComponentValue?.toString() ?? '');
fieldComponent.setValue(newFieldValue);
};
targetComponent.onValueChanged(() => {
updateFields();
});
await updateFields();
}
}));
}));
}
export async function createSection(context: SectionContext): Promise<azdata.GroupContainer> {
const components: azdata.Component[] = [];
context.sectionInfo.inputWidth = context.sectionInfo.inputWidth || DefaultInputWidth;
@@ -630,6 +665,7 @@ async function configureOptionsSourceSubfields(context: FieldContext, optionsSou
throw e;
}
},
setValue: (_value: InputValueType) => { throw new Error('Setting value of radio group isn\'t currently supported'); },
onValueChanged: optionsComponent.onValueChanged
});
}
@@ -666,6 +702,7 @@ function processNumberField(context: FieldContext): void {
const value = await input.getValue();
return typeof value === 'string' && value.length > 0 ? parseFloat(value) : value;
},
setValue: (value: InputValueType) => input.component.value = value?.toString(),
onValueChanged: input.onValueChanged
});
}
@@ -762,6 +799,7 @@ function processEvaluatedTextField(context: FieldContext): ReadOnlyFieldInputs {
readOnlyField.text!.value = await substituteVariableValues(context.inputComponents, context.fieldInfo.defaultValue);
return readOnlyField.text!.value;
},
setValue: (value: InputValueType) => readOnlyField.text!.value = value?.toString(),
onValueChanged: onChangedEmitter.event,
});
return readOnlyField;
@@ -945,6 +983,7 @@ async function createRadioOptions(context: FieldContext, getRadioButtonInfo?: ((
component: radioGroupLoadingComponentBuilder,
labelComponent: label,
getValue: async (): Promise<InputValueType> => radioGroupLoadingComponentBuilder.value,
setValue: (value: InputValueType) => { throw new Error('Setting value of radio group isn\'t currently supported'); },
getDisplayValue: async (): Promise<string> => radioGroupLoadingComponentBuilder.displayValue,
onValueChanged: radioGroupLoadingComponentBuilder.onValueChanged,
});
@@ -1140,6 +1179,7 @@ function createAzureSubscriptionDropdown(
const inputValue = (await subscriptionDropdown.getValue())?.toString() || '';
return subscriptionValueToSubscriptionMap.get(inputValue)?.id || inputValue;
},
setValue: (value: InputValueType) => setDropdownValue(subscriptionDropdown.component, value?.toString()),
getDisplayValue: subscriptionDropdown.getDisplayValue,
onValueChanged: subscriptionDropdown.onValueChanged
});
@@ -1401,3 +1441,18 @@ export function isInputBoxEmpty(input: azdata.InputBoxComponent): boolean {
return input.value === undefined || input.value === '';
}
/**
* Sets the dropdown value to the corresponding value from the list of current values, converting
* into a CategoryValue if necessary (using the name field).
* @param dropdown The dropdown component to set the value for
* @param value The value to set - either the direct string value or the name of the CategoryValue to use
*/
function setDropdownValue(dropdown: azdata.DropDownComponent, value: string = ''): void {
const values = dropdown.values ?? [];
if (typeof values[0] === 'object') {
dropdown.value = (<azdata.CategoryValue[]>values).find(v => v.name === value);
} else {
dropdown.value = value;
}
}