diff --git a/src/connectionconfig/connectionDialogWebviewController.ts b/src/connectionconfig/connectionDialogWebviewController.ts index f76b4fc970..cf558e3b59 100644 --- a/src/connectionconfig/connectionDialogWebviewController.ts +++ b/src/connectionconfig/connectionDialogWebviewController.ts @@ -1138,6 +1138,8 @@ export class ConnectionDialogWebviewController extends ReactWebviewPanelControll string[] | undefined >(azureSubscriptionFilterConfigKey) !== undefined; + const startTime = Date.now(); + this._azureSubscriptions = new Map( (await auth.getSubscriptions(shouldUseFilter)).map((s) => [ s.subscriptionId, @@ -1145,7 +1147,7 @@ export class ConnectionDialogWebviewController extends ReactWebviewPanelControll ]), ); const tenantSubMap = this.groupBy( - await auth.getSubscriptions(shouldUseFilter), + Array.from(this._azureSubscriptions.values()), "tenantId", ); // TODO: replace with Object.groupBy once ES2024 is supported @@ -1164,6 +1166,16 @@ export class ConnectionDialogWebviewController extends ReactWebviewPanelControll state.azureSubscriptions = subs; state.loadingAzureSubscriptionsStatus = ApiStatus.Loaded; + sendActionEvent( + TelemetryViews.ConnectionDialog, + TelemetryActions.LoadAzureSubscriptions, + undefined, // additionalProperties + { + subscriptionCount: subs.length, + msToLoadSubscriptions: Date.now() - startTime, + }, + ); + this.updateState(); return tenantSubMap; @@ -1171,6 +1183,14 @@ export class ConnectionDialogWebviewController extends ReactWebviewPanelControll state.formError = l10n.t("Error loading Azure subscriptions."); state.loadingAzureSubscriptionsStatus = ApiStatus.Error; console.error(state.formError + "\n" + getErrorMessage(error)); + + sendErrorEvent( + TelemetryViews.ConnectionDialog, + TelemetryActions.LoadAzureSubscriptions, + error, + false, // includeErrorMessage + ); + return undefined; } } @@ -1179,7 +1199,6 @@ export class ConnectionDialogWebviewController extends ReactWebviewPanelControll state: ConnectionDialogWebviewState, ): Promise { try { - const startTime = Date.now(); const tenantSubMap = await this.loadAzureSubscriptions(state); if (!tenantSubMap) { @@ -1192,8 +1211,11 @@ export class ConnectionDialogWebviewController extends ReactWebviewPanelControll ); } else { state.loadingAzureServersStatus = ApiStatus.Loading; + state.azureServers = []; this.updateState(); + const startTime = Date.now(); + const promiseArray: Promise[] = []; for (const t of tenantSubMap.keys()) { for (const s of tenantSubMap.get(t)) { @@ -1229,12 +1251,7 @@ export class ConnectionDialogWebviewController extends ReactWebviewPanelControll TelemetryViews.ConnectionDialog, TelemetryActions.LoadAzureServers, error, - true, // includeErrorMessage - undefined, // errorCode - undefined, // errorType - { - connectionInputType: this.state.selectedInputMode, - }, + false, // includeErrorMessage ); return; diff --git a/src/reactviews/common/comboboxHelper.ts b/src/reactviews/common/comboboxHelper.ts new file mode 100644 index 0000000000..4cc158628b --- /dev/null +++ b/src/reactviews/common/comboboxHelper.ts @@ -0,0 +1,56 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** Behavior for how the default selection is determined */ +export enum DefaultSelectionMode { + /** If there are any options, the first is always selected. Otherwise, selects nothing. */ + SelectFirstIfAny, + /** Always selects nothing, regardless of if there are available options */ + AlwaysSelectNone, + /** Selects the only option if there's only one. Otherwise (many or no options) selects nothing. */ + SelectOnlyOrNone, +} + +export function updateComboboxSelection( + /** current selected (valid) option */ + selected: string | undefined, + /** callback to set the selected (valid) option */ + setSelected: (s: string | undefined) => void, + /** callback to set the displayed value (not guaranteed to be valid if the user has manually typed something) */ + setValue: (v: string) => void, + /** list of valid options */ + optionList: string[], + /** behavior for choosing the default selected value */ + defaultSelectionMode: DefaultSelectionMode = DefaultSelectionMode.AlwaysSelectNone, +) { + // if there is no current selection or if the current selection is no longer in the list of options (due to filter changes), + // then select the only option if there is only one option, then make a default selection according to specified `defaultSelectionMode` + + if ( + selected === undefined || + (selected && !optionList.includes(selected)) + ) { + let optionToSelect: string | undefined = undefined; + + if (optionList.length > 0) { + switch (defaultSelectionMode) { + case DefaultSelectionMode.SelectFirstIfAny: + optionToSelect = + optionList.length > 0 ? optionList[0] : undefined; + break; + case DefaultSelectionMode.SelectOnlyOrNone: + optionToSelect = + optionList.length === 1 ? optionList[0] : undefined; + break; + case DefaultSelectionMode.AlwaysSelectNone: + default: + optionToSelect = undefined; + } + } + + setSelected(optionToSelect); // selected value's unselected state should be undefined + setValue(optionToSelect ?? ""); // displayed value's unselected state should be an empty string + } +} diff --git a/src/reactviews/common/utils.ts b/src/reactviews/common/utils.ts index 74d8a641c7..53d7e8ea9d 100644 --- a/src/reactviews/common/utils.ts +++ b/src/reactviews/common/utils.ts @@ -49,3 +49,8 @@ export function themeType(theme: Theme): string { } return themeType; } + +/** Removes duplicate values from an array */ +export function removeDuplicates(array: T[]): T[] { + return Array.from(new Set(array)); +} diff --git a/src/reactviews/pages/ConnectionDialog/AzureFilterCombobox.component.tsx b/src/reactviews/pages/ConnectionDialog/AzureFilterCombobox.component.tsx new file mode 100644 index 0000000000..bec1f5635d --- /dev/null +++ b/src/reactviews/pages/ConnectionDialog/AzureFilterCombobox.component.tsx @@ -0,0 +1,132 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + Combobox, + ComboboxProps, + Field, + makeStyles, + OptionOnSelectData, + SelectionEvents, + Option, +} from "@fluentui/react-components"; +import { useFormStyles } from "../../common/forms/form.component"; +import { useEffect, useState } from "react"; + +const useFieldDecorationStyles = makeStyles({ + decoration: { + display: "flex", + alignItems: "center", + columnGap: "4px", + }, +}); + +export const AzureFilterCombobox = ({ + label, + required, + clearable, + content, + decoration, + props, +}: { + label: string; + required?: boolean; + clearable?: boolean; + content: { + /** list of valid values for the combo box */ + valueList: string[]; + /** currently-selected value from `valueList` */ + selection?: string; + /** callback when the user has selected a value from `valueList` */ + setSelection: (value: string | undefined) => void; + /** currently-entered text in the combox, may not be a valid selection value if the user is typing */ + value: string; + /** callback when the user types in the combobox */ + setValue: (value: string) => void; + /** placeholder text for the combobox */ + placeholder?: string; + /** message displayed if focus leaves this combobox and `value` is not a valid value from `valueList` */ + invalidOptionErrorMessage: string; + }; + decoration?: JSX.Element; + props?: Partial; +}) => { + const formStyles = useFormStyles(); + const decorationStyles = useFieldDecorationStyles(); + const [validationMessage, setValidationMessage] = useState(""); + + // clear validation message as soon as value is valid + useEffect(() => { + if (content.valueList.includes(content.value)) { + setValidationMessage(""); + } + }, [content.value]); + + // only display validation error if focus leaves the field and the value is not valid + const onBlur = () => { + if (content.value) { + setValidationMessage( + content.valueList.includes(content.value) + ? "" + : content.invalidOptionErrorMessage, + ); + } + }; + + const onOptionSelect: ( + _: SelectionEvents, + data: OptionOnSelectData, + ) => void = (_, data: OptionOnSelectData) => { + content.setSelection( + data.selectedOptions.length > 0 ? data.selectedOptions[0] : "", + ); + content.setValue(data.optionText ?? ""); + }; + + function onInput(ev: React.ChangeEvent) { + content.setValue(ev.target.value); + } + + return ( +
+ + {label} + {decoration} +
+ ) : ( + label + ) + } + orientation="horizontal" + required={required} + validationMessage={validationMessage} + onBlur={onBlur} + > + + {content.valueList.map((val, idx) => { + return ( + + ); + })} + + + + ); +}; diff --git a/src/reactviews/pages/ConnectionDialog/azureBrowsePage.tsx b/src/reactviews/pages/ConnectionDialog/azureBrowsePage.tsx index a0e3187e19..fcf75b129c 100644 --- a/src/reactviews/pages/ConnectionDialog/azureBrowsePage.tsx +++ b/src/reactviews/pages/ConnectionDialog/azureBrowsePage.tsx @@ -3,20 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { useContext, useEffect, useState, JSX } from "react"; +import { useContext, useEffect, useState } from "react"; import { ConnectionDialogContext } from "./connectionDialogStateProvider"; import { ConnectButton } from "./components/connectButton.component"; -import { - Field, - Option, - Button, - Combobox, - Spinner, - makeStyles, - ComboboxProps, - OptionOnSelectData, - SelectionEvents, -} from "@fluentui/react-components"; +import { Button, Spinner } from "@fluentui/react-components"; import { Filter16Filled } from "@fluentui/react-icons"; import { FormField, useFormStyles } from "../../common/forms/form.component"; import { FormItemSpec } from "../../common/forms/form"; @@ -24,10 +14,12 @@ import { IConnectionDialogProfile } from "../../../sharedInterfaces/connectionDi import { AdvancedOptionsDrawer } from "./components/advancedOptionsDrawer.component"; import { locConstants as Loc } from "../../common/locConstants"; import { ApiStatus } from "../../../sharedInterfaces/webview"; - -function removeDuplicates(array: T[]): T[] { - return Array.from(new Set(array)); -} +import { removeDuplicates } from "../../common/utils"; +import { + DefaultSelectionMode, + updateComboboxSelection, +} from "../../common/comboboxHelper"; +import { AzureFilterCombobox } from "./AzureFilterCombobox.component"; export const AzureBrowsePage = () => { const context = useContext(ConnectionDialogContext); @@ -70,6 +62,7 @@ export const AzureBrowsePage = () => { // #region Effects + // subscriptions useEffect(() => { const subs = removeDuplicates( context.state.azureSubscriptions.map( @@ -78,12 +71,16 @@ export const AzureBrowsePage = () => { ); setSubscriptions(subs.sort()); - if (!selectedSubscription && subs.length === 1) { - setSubscriptionValue(subs[0]); - setSelectedSubscription(subs[0]); - } + updateComboboxSelection( + selectedSubscription, + setSelectedSubscription, + setSubscriptionValue, + subs, + DefaultSelectionMode.AlwaysSelectNone, + ); }, [context.state.azureSubscriptions]); + // resource groups useEffect(() => { let activeServers = context.state.azureServers; @@ -98,13 +95,16 @@ export const AzureBrowsePage = () => { ); setResourceGroups(rgs.sort()); - // if current selection is no longer in the list of options, - // set selection to undefined (if multiple options) or the only option (if only one) - if (selectedResourceGroup && !rgs.includes(selectedResourceGroup)) { - setSelectedResourceGroup(rgs.length === 1 ? rgs[0] : undefined); - } + updateComboboxSelection( + selectedResourceGroup, + setSelectedResourceGroup, + setResourceGroupValue, + rgs, + DefaultSelectionMode.AlwaysSelectNone, + ); }, [subscriptions, selectedSubscription, context.state.azureServers]); + // locations useEffect(() => { let activeServers = context.state.azureServers; @@ -126,13 +126,16 @@ export const AzureBrowsePage = () => { setLocations(locs.sort()); - // if current selection is no longer in the list of options, - // set selection to undefined (if multiple options) or the only option (if only one) - if (selectedLocation && !locs.includes(selectedLocation)) { - setSelectedLocation(locs.length === 1 ? locs[0] : undefined); - } + updateComboboxSelection( + selectedLocation, + setSelectedLocation, + setLocationValue, + locs, + DefaultSelectionMode.AlwaysSelectNone, + ); }, [resourceGroups, selectedResourceGroup, context.state.azureServers]); + // servers useEffect(() => { let activeServers = context.state.azureServers; @@ -163,15 +166,13 @@ export const AzureBrowsePage = () => { if (selectedServer === "") { setServerValue(""); } else { - // if there is no current selection or if the selection is no longer in the list of options (due to changed filters), - // set selection to the first option (if any) - if ( - selectedServer === undefined || - !srvs.includes(selectedServer) - ) { - setSelectedServer(srvs.length > 0 ? srvs[0] : undefined); - setServerValue(srvs.length > 0 ? srvs[0] : ""); - } + updateComboboxSelection( + selectedServer, + setSelectedServer, + setServerValue, + srvs, + DefaultSelectionMode.SelectFirstIfAny, + ); } }, [locations, selectedLocation, context.state.azureServers]); @@ -205,7 +206,7 @@ export const AzureBrowsePage = () => { return (
- { ), }} /> - { ), }} /> - { ), }} /> - { idx={0} props={{ orientation: "horizontal" }} /> - {
); }; - -const useFieldDecorationStyles = makeStyles({ - decoration: { - display: "flex", - alignItems: "center", - columnGap: "4px", - }, -}); - -const AzureBrowseDropdown = ({ - label, - required, - clearable, - content, - decoration, - props, -}: { - label: string; - required?: boolean; - clearable?: boolean; - content: { - /** list of valid values for the combo box */ - valueList: string[]; - /** currently-selected value from `valueList` */ - selection?: string; - /** callback when the user has selected a value from `valueList` */ - setSelection: (value: string | undefined) => void; - /** currently-entered text in the combox, may not be a valid selection value if the user is typing */ - value: string; - /** callback when the user types in the combobox */ - setValue: (value: string) => void; - /** placeholder text for the combobox */ - placeholder?: string; - /** message displayed if focus leaves this combobox and `value` is not a valid value from `valueList` */ - invalidOptionErrorMessage: string; - }; - decoration?: JSX.Element; - props?: Partial; -}) => { - const formStyles = useFormStyles(); - const decorationStyles = useFieldDecorationStyles(); - const [validationMessage, setValidationMessage] = useState(""); - - // clear validation message as soon as value is valid - useEffect(() => { - if (content.valueList.includes(content.value)) { - setValidationMessage(""); - } - }, [content.value]); - - // only display validation error if focus leaves the field and the value is not valid - const onBlur = () => { - if (content.value) { - setValidationMessage( - content.valueList.includes(content.value) - ? "" - : content.invalidOptionErrorMessage, - ); - } - }; - - const onOptionSelect: ( - _: SelectionEvents, - data: OptionOnSelectData, - ) => void = (_, data: OptionOnSelectData) => { - content.setSelection( - data.selectedOptions.length > 0 ? data.selectedOptions[0] : "", - ); - content.setValue(data.optionText ?? ""); - }; - - function onInput(ev: React.ChangeEvent) { - content.setValue(ev.target.value); - } - - return ( -
- - {label} - {decoration} -
- ) : ( - label - ) - } - orientation="horizontal" - required={required} - validationMessage={validationMessage} - onBlur={onBlur} - > - - {content.valueList.map((val, idx) => { - return ( - - ); - })} - - - - ); -}; diff --git a/src/sharedInterfaces/telemetry.ts b/src/sharedInterfaces/telemetry.ts index 6e1976fb90..1a8d01c544 100644 --- a/src/sharedInterfaces/telemetry.ts +++ b/src/sharedInterfaces/telemetry.ts @@ -54,4 +54,5 @@ export enum TelemetryActions { LoadAzureServers = "LoadAzureServers", LoadConnectionProperties = "LoadConnectionProperties", LoadRecentConnections = "LoadRecentConnections", + LoadAzureSubscriptions = "LoadAzureSubscriptions", }