diff --git a/extensions/azurePublish/src/components/ResourceGroupPicker.tsx b/extensions/azurePublish/src/components/ResourceGroupPicker.tsx new file mode 100644 index 0000000000..b41cc7374a --- /dev/null +++ b/extensions/azurePublish/src/components/ResourceGroupPicker.tsx @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as React from 'react'; +import formatMessage from 'format-message'; +import { Dropdown, IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; +import { Icon } from 'office-ui-fabric-react/lib/Icon'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { FluentTheme } from '@uifabric/fluent-theme'; +import { TooltipHost } from 'office-ui-fabric-react/lib/Tooltip'; + +import { useDebounce } from './useDebounce'; + +const stackStyles = { root: { marginBottom: '8px' } }; +const dropdownStyles = { root: { marginBottom: '8px' }, dropdown: { width: '100%' } }; +const itemIconStyles = { marginRight: '8px' }; +const newNameTextFileStyles = { root: { marginTop: '10px' } }; + +const getInfoIconStyle = (required) => { + return { + root: { + selectors: { + '&::before': { + content: required ? " '*'" : '', + color: FluentTheme.palette.red, + paddingRight: 3, + }, + }, + }, + }; +}; + +const CREATE_NEW_KEY = 'CREATE_NEW'; + +const createNewOption: IDropdownOption = { + key: CREATE_NEW_KEY, + data: { iconName: 'Add' }, + text: formatMessage('Create new'), +}; + +type ResourceGroupItemChoice = { + name: string; + isNew: boolean; + errorMessage?: string; +}; + +type Props = { + /** + * The resource groups to choose from. + * Set to undefined to disable this picker. + */ + resourceGroupNames?: string[]; + /** + * The selected name of the existing resource. + * When undefined, the 'Create new' option will be selected. + */ + selectedResourceGroupName?: string; + /** + * The name chosen for a new resource group. + * Used when the 'Create new' option is selected. + */ + newResourceGroupName?: string; + /** + * Called when the selection or new resource name changes. + */ + onChange: (choice: ResourceGroupItemChoice) => void; +}; + +const onRenderLabel = (props) => { + return ( +
+
+ {` ${props.label} `} +
+ + + +
+ ); +}; + +export const ResourceGroupPicker = ({ + resourceGroupNames, + selectedResourceGroupName: controlledSelectedName, + newResourceGroupName: controlledNewName, + onChange, +}: Props) => { + // ----- Hooks -----// + const [selectedName, setSelectedName] = React.useState(controlledSelectedName || CREATE_NEW_KEY); + const [newName, setNewName] = React.useState(controlledNewName); + const debouncedNewName = useDebounce(newName, 300); + const [newNameErrorMessage, setNewNameErrorMessage] = React.useState(''); + + React.useEffect(() => { + setSelectedName(controlledSelectedName || CREATE_NEW_KEY); + }, [controlledSelectedName]); + + React.useEffect(() => { + setNewName(controlledNewName || ''); + }, [controlledNewName]); + + React.useEffect(() => { + const alreadyExists = resourceGroupNames?.some((name) => name === debouncedNewName); + + if (debouncedNewName && !debouncedNewName.match(/^[-\w._()]+$/)) { + setNewNameErrorMessage( + formatMessage( + 'Resource group names only allow alphanumeric characters, periods, underscores, hyphens and parenthesis and cannot end in a period.' + ) + ); + } else if (alreadyExists) { + setNewNameErrorMessage(formatMessage('A resource with this name already exists.')); + } else { + setNewNameErrorMessage(undefined); + } + }, [debouncedNewName, resourceGroupNames]); + + React.useEffect(() => { + const isNew = selectedName === CREATE_NEW_KEY; + onChange({ + isNew, + name: isNew ? debouncedNewName : selectedName, + errorMessage: isNew ? newNameErrorMessage : undefined, + }); + }, [selectedName, debouncedNewName, newNameErrorMessage]); + + const options = React.useMemo(() => { + const optionsList: IDropdownOption[] = + resourceGroupNames?.map((p) => { + return { key: p, text: p }; + }) || []; + + optionsList.unshift(createNewOption); + return optionsList; + }, [resourceGroupNames]); + + // ----- Render -----// + + const loading = resourceGroupNames === undefined; + + const onRenderOption = (option) => { + return ( +
+ {option.data?.iconName && ( +
+ ); + }; + + return ( + + { + setSelectedName(opt.key as string); + }} + onRenderLabel={onRenderLabel} + onRenderOption={onRenderOption} + /> + {selectedName === CREATE_NEW_KEY && ( + { + setNewName(val || ''); + }} + onRenderLabel={onRenderLabel} + /> + )} + + ); +}; diff --git a/extensions/azurePublish/src/components/azureProvisionDialog.tsx b/extensions/azurePublish/src/components/azureProvisionDialog.tsx index 0e2b6e7023..091f5dc715 100644 --- a/extensions/azurePublish/src/components/azureProvisionDialog.tsx +++ b/extensions/azurePublish/src/components/azureProvisionDialog.tsx @@ -32,6 +32,7 @@ import { } from 'office-ui-fabric-react'; import { JsonEditor } from '@bfc/code-editor'; import { SharedColors } from '@uifabric/fluent-theme'; +import { ResourceGroup } from '@azure/arm-resources/esm/models'; import { AzureResourceTypes, ResourcesItem } from '../types'; @@ -42,9 +43,11 @@ import { getPreview, getLuisAuthoringRegions, CheckWebAppNameAvailability, + getResourceGroups, } from './api'; import { ChooseResourcesList } from './ChooseResourcesList'; import { getExistResources, removePlaceholder, decodeToken, defaultExtensionState } from './util'; +import { ResourceGroupPicker } from './ResourceGroupPicker'; // ---------- Styles ---------- // @@ -236,11 +239,15 @@ export const AzureProvisionDialog: React.FC = () => { const [loginErrorMsg, setLoginErrorMsg] = useState(''); const [choice, setChoice] = useState(extensionState.choice); - const [currentSubscription, setSubscription] = useState(extensionState.subscriptionId); - const [currentResourceGroup, setResourceGroup] = useState(extensionState.resourceGroup); + const [currentSubscription, setCurrentSubscription] = useState(extensionState.subscriptionId); + + const [resourceGroups, setResourceGroups] = useState(); + const [isNewResourceGroupName, setIsNewResourceGroupName] = useState(true); + const [currentResourceGroupName, setCurrentResourceGroupName] = useState(extensionState.resourceGroup); + const [errorResourceGroupName, setErrorResourceGroupName] = useState(); + const [currentHostName, setHostName] = useState(extensionState.hostName); const [errorHostName, setErrorHostName] = useState(''); - const [errorResourceGroupName, setErrorResourceGroupName] = useState(''); const [currentLocation, setLocation] = useState(currentConfig?.region || extensionState.location); const [currentLuisLocation, setCurrentLuisLocation] = useState( currentConfig?.settings?.luis?.region || extensionState.luisLocation @@ -252,7 +259,7 @@ export const AzureProvisionDialog: React.FC = () => { const [isEditorError, setEditorError] = useState(false); const [importConfig, setImportConfig] = useState(); - const [page, setPage] = useState(); + const [page, setPage] = useState(PageTypes.ConfigProvision); const [listItems, setListItems] = useState<(ResourcesItem & { icon?: string })[]>(); const [reviewListItems, setReviewListItems] = useState([]); const isMounted = useRef(); @@ -342,10 +349,10 @@ export const AzureProvisionDialog: React.FC = () => { setSelectedTenant(currentConfig.tennantId); } if (currentConfig.subscriptionId) { - setSubscription(currentConfig.subscriptionId); + setCurrentSubscription(currentConfig.subscriptionId); } if (currentConfig.resourceGroup) { - setResourceGroup(currentConfig.resourceGroup); + setCurrentResourceGroupName(currentConfig.resourceGroup); } if (currentConfig.hostname) { setHostName(currentConfig.hostname); @@ -378,6 +385,28 @@ export const AzureProvisionDialog: React.FC = () => { } }, [token]); + const loadResourceGroups = async () => { + if (token && currentSubscription) { + try { + const resourceGroups = await getResourceGroups(token, currentSubscription); + setResourceGroups(resourceGroups); + + // After the resource groups load, isNewResourceGroupName can be determined + setIsNewResourceGroupName(!resourceGroups?.some((r) => r.name === currentResourceGroupName)); + } catch (err) { + // todo: how do we handle API errors in this component + console.log('ERROR', err); + setResourceGroups(undefined); + } + } else { + setResourceGroups(undefined); + } + }; + + useEffect(() => { + loadResourceGroups(); + }, [token, currentSubscription]); + const subscriptionOption = useMemo(() => { return subscriptions?.map((t) => ({ key: t.subscriptionId, text: t.displayName })); }, [subscriptions]); @@ -413,25 +442,6 @@ export const AzureProvisionDialog: React.FC = () => { [publishType, currentSubscription, token] ); - const checkResourceGroupName = useCallback((group: string) => { - if (group.match(/^[-\w._()]+$/)) { - setErrorResourceGroupName(''); - } else { - setErrorResourceGroupName( - 'Resource group names only allow alphanumeric characters, periods, underscores, hyphens and parenthesis and cannot end in a period.' - ); - } - }, []); - - const updateCurrentResourceGroup = useMemo( - () => (e, newGroup) => { - setResourceGroup(newGroup); - // check resource group name - checkResourceGroupName(newGroup); - }, - [checkResourceGroupName] - ); - const newHostName = useCallback( (e, newName) => { setHostName(newName); @@ -547,168 +557,180 @@ export const AzureProvisionDialog: React.FC = () => { [] ); - const isDisAble = useMemo(() => { + const isNextDisabled = useMemo(() => { return ( !currentSubscription || + !currentResourceGroupName || !currentHostName || - errorHostName !== '' || - errorResourceGroupName !== '' || - !currentLocation + !currentLocation || + errorResourceGroupName || + errorHostName !== '' ); - }, [currentSubscription, currentHostName, errorHostName, currentLocation, errorResourceGroupName]); + }, [ + currentSubscription, + currentResourceGroupName, + currentHostName, + currentLocation, + errorResourceGroupName, + errorHostName, + ]); const isSelectAddResources = useMemo(() => { return enabledResources.length > 0 || requireResources.length > 0; }, [enabledResources]); + const resourceGroupNames = resourceGroups?.map((r) => r.name) || []; + const PageFormConfig = ( -
-
- { - setChoice(option); - }} - /> -
-
- }> - {subscriptionOption?.length > 0 && choice.key === 'create' && ( -
- ({ key: t.tenantId, text: t.displayName }))} - selectedKey={selectedTenant} - styles={{ root: { paddingBottom: '8px' } }} - onChange={(_e, o) => { - setSelectedTenant(o.key as string); - }} - /> - { - setSubscription(o.key as string); - }} - onRenderLabel={onRenderLabel} - /> - - - {currentConfig?.region ? ( - +
+
+ { + setChoice(option); + }} + /> +
+
+ }> + {subscriptionOption?.length > 0 && choice.key === 'create' && ( + + ({ key: t.tenantId, text: t.displayName }))} + selectedKey={selectedTenant} styles={{ root: { paddingBottom: '8px' } }} - onRenderLabel={onRenderLabel} + onChange={(_e, o) => { + setSelectedTenant(o.key as string); + }} /> - ) : ( { + setCurrentSubscription(o.key as string); + }} + onRenderLabel={onRenderLabel} + /> + { + setIsNewResourceGroupName(choice.isNew); + setCurrentResourceGroupName(choice.name); + setErrorResourceGroupName(choice.errorMessage); + }} /> - )} - {currentConfig?.settings?.luis?.region && currentLocation !== currentLuisLocation && ( - )} - {!currentConfig?.settings?.luis?.region && currentLocation !== currentLuisLocation && ( - + ) : ( + + )} + {currentConfig?.settings?.luis?.region && currentLocation !== currentLuisLocation && ( + + )} + {!currentConfig?.settings?.luis?.region && currentLocation !== currentLuisLocation && ( + + )} + + )} + {choice.key === 'import' && ( +
+
+ {formatMessage('Publish Configuration')} +
+ { + setEditorError(false); + setImportConfig(value); + }} + onError={() => { + setEditorError(true); + }} /> - )} - - )} - {choice.key === 'import' && ( -
-
- {formatMessage('Publish Configuration')}
- { - setEditorError(false); - setImportConfig(value); - }} - onError={() => { - setEditorError(true); - }} - /> -
- )} - + )} + +
-
+ ); useEffect(() => { @@ -805,7 +827,7 @@ export const AzureProvisionDialog: React.FC = () => { clearAll(); setItem(profileName, { subscriptionId: currentSubscription, - resourceGroup: currentResourceGroup, + resourceGroup: currentResourceGroupName, hostName: currentHostName, location: currentLocation, luisLocation: currentLuisLocation, @@ -818,7 +840,7 @@ export const AzureProvisionDialog: React.FC = () => { /> {choice.key === 'create' ? ( { @@ -872,7 +894,7 @@ export const AzureProvisionDialog: React.FC = () => { return { ...item, region: region, - resourceGroup: currentConfig?.resourceGroup || currentResourceGroup, + resourceGroup: currentConfig?.resourceGroup || currentResourceGroupName, }; }); setReviewListItems(selectedResources); @@ -886,7 +908,7 @@ export const AzureProvisionDialog: React.FC = () => {
{currentUser ? ( { }} /> { const selectedResources = requireResources.concat(enabledResources); onSubmit({ subscription: currentSubscription, - resourceGroup: currentResourceGroup, + resourceGroup: currentResourceGroupName, hostname: currentHostName, location: currentLocation, luisLocation: currentLuisLocation || currentLocation, @@ -945,9 +967,9 @@ export const AzureProvisionDialog: React.FC = () => { page, choice, isEditorError, - isDisAble, + isNextDisabled, currentSubscription, - currentResourceGroup, + currentResourceGroupName, currentHostName, currentLocation, publishType, diff --git a/extensions/azurePublish/src/components/useDebounce.ts b/extensions/azurePublish/src/components/useDebounce.ts new file mode 100644 index 0000000000..ed4fb00b14 --- /dev/null +++ b/extensions/azurePublish/src/components/useDebounce.ts @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useEffect, useState } from 'react'; + +export const useDebounce = (value: T, delay: number) => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value]); + + return debouncedValue; +};