diff --git a/Composer/packages/client/src/components/CollapsableWrapper.tsx b/Composer/packages/client/src/components/CollapsableWrapper.tsx index 7571f2e61c..c027d48a8a 100644 --- a/Composer/packages/client/src/components/CollapsableWrapper.tsx +++ b/Composer/packages/client/src/components/CollapsableWrapper.tsx @@ -29,7 +29,7 @@ export const CollapsableWrapper: React.FC = (props) =
setIsCollapsed(!isCollapsed)} diff --git a/Composer/packages/client/src/components/ManageSpeech/ManageSpeech.tsx b/Composer/packages/client/src/components/ManageSpeech/ManageSpeech.tsx new file mode 100644 index 0000000000..b03d6e13a0 --- /dev/null +++ b/Composer/packages/client/src/components/ManageSpeech/ManageSpeech.tsx @@ -0,0 +1,617 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { useState, useEffect, Fragment } from 'react'; +import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import formatMessage from 'format-message'; +import { Icon } from 'office-ui-fabric-react/lib/Icon'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { Spinner } from 'office-ui-fabric-react/lib/Spinner'; +import { useRecoilValue } from 'recoil'; +import { Dropdown, IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; +import { SubscriptionClient } from '@azure/arm-subscriptions'; +import { Subscription } from '@azure/arm-subscriptions/esm/models'; +import { TokenCredentials } from '@azure/ms-rest-js'; +import { CognitiveServicesManagementClient } from '@azure/arm-cognitiveservices'; +import { ResourceManagementClient } from '@azure/arm-resources'; +import { ChoiceGroup, IChoiceGroupOption } from 'office-ui-fabric-react/lib/ChoiceGroup'; +import { ProvisionHandoff } from '@bfc/ui-shared'; + +import { AuthClient } from '../../utils/authClient'; +import { AuthDialog } from '../Auth/AuthDialog'; +import { armScopes } from '../../constants'; +import { getTokenFromCache, isShowAuthDialog, userShouldProvideTokens } from '../../utils/auth'; +import { dispatcherState } from '../../recoilModel/atoms'; + +type ManageSpeechProps = { + hidden: boolean; + onDismiss: () => void; + setVisibility: (visible: boolean) => void; + onGetKey: (settings: { subscriptionKey: string; region: string }) => void; + onNext?: () => void; +}; + +type KeyRec = { + name: string; + region: string; + resourceGroup: string; + key: string; +}; + +const dropdownStyles = { dropdown: { width: '100%' } }; +const summaryLabelStyles = { display: 'block', color: '#605E5C', fontSize: 14 }; +const summaryStyles = { background: '#F3F2F1', padding: '1px 1rem' }; +const mainElementStyle = { marginBottom: 20 }; +const CREATE_NEW_KEY = 'CREATE_NEW'; +const iconStyles = { marginRight: '8px' }; + +export const ManageSpeech = (props: ManageSpeechProps) => { + const [localKey, setLocalKey] = useState(''); + + const [showAuthDialog, setShowAuthDialog] = useState(false); + const [token, setToken] = useState(); + + const { setApplicationLevelError } = useRecoilValue(dispatcherState); + const [subscriptionId, setSubscriptionId] = useState(''); + const [resourceGroups, setResourceGroups] = useState([]); + const [createResourceGroup, setCreateResourceGroup] = useState(false); + const [newResourceGroupName, setNewResourceGroupName] = useState(''); + const [resourceGroupKey, setResourceGroupKey] = useState(''); + const [resourceGroup, setResourceGroup] = useState(''); + const [locationList, setLocationList] = useState<{ key: string; text: string }[]>([]); + + const [showHandoff, setShowHandoff] = useState(false); + const [resourceName, setResourceName] = useState(''); + const [loading, setLoading] = useState(false); + const [noKeys, setNoKeys] = useState(false); + const [nextAction, setNextAction] = useState('create'); + const [actionOptions, setActionOptions] = useState([]); + const [availableSubscriptions, setAvailableSubscriptions] = useState([]); + const [keys, setKeys] = useState([]); + const [localRegion, setLocalRegion] = useState('westus'); + + const [currentPage, setCurrentPage] = useState(1); + const [outcomeDescription, setOutcomeDescription] = useState(''); + const [outcomeSummary, setOutcomeSummary] = useState(); + const [outcomeError, setOutcomeError] = useState(false); + + const handoffInstructions = formatMessage( + 'Using the Azure portal, create a Language Understanding resource. Create these in a subscription that the developer has accesss to. This will result in an authoring key and an endpoint key. Provide these keys to the developer in a secure manner.' + ); + + /* Copied from Azure Publishing extension */ + const getSubscriptions = async (token: string): Promise> => { + const tokenCredentials = new TokenCredentials(token); + try { + const subscriptionClient = new SubscriptionClient(tokenCredentials); + const subscriptionsResult = await subscriptionClient.subscriptions.list(); + // eslint-disable-next-line no-underscore-dangle + return subscriptionsResult._response.parsedBody; + } catch (err) { + setApplicationLevelError(err); + return []; + } + }; + + const hasAuth = async () => { + let newtoken = ''; + if (userShouldProvideTokens()) { + if (isShowAuthDialog(false)) { + setShowAuthDialog(true); + } + newtoken = getTokenFromCache('accessToken'); + } else { + newtoken = await AuthClient.getAccessToken(armScopes); + } + + setToken(newtoken); + + if (newtoken) { + // reset the list + setAvailableSubscriptions([]); + + // fetch list of available subscriptions + setAvailableSubscriptions(await getSubscriptions(newtoken)); + } + }; + + useEffect(() => { + // reset the ui + setSubscriptionId(''); + setKeys([]); + setCurrentPage(1); + setActionOptions([ + { key: 'create', text: formatMessage('Create a new Speech resource'), disabled: true }, + { key: 'handoff', text: formatMessage('Generate a resource request'), disabled: true }, + { key: 'choose', text: formatMessage('Choose from existing'), disabled: true }, + ]); + if (!props.hidden) { + hasAuth(); + } + }, [props.hidden]); + + const handleRegionOnChange = (e, value?: IDropdownOption) => { + if (value) { + setLocalRegion(value.key as string); + } else { + setLocalRegion(''); + } + }; + + const fetchKeys = async (cognitiveServicesManagementClient, accounts) => { + const keyList: KeyRec[] = []; + for (const account in accounts) { + const resourceGroup = accounts[account].id?.replace(/.*?\/resourceGroups\/(.*?)\/.*/, '$1'); + const name = accounts[account].name; + if (resourceGroup && name) { + const keys = await cognitiveServicesManagementClient.accounts.listKeys(resourceGroup, name); + if (keys?.key1) { + keyList.push({ + name: name, + resourceGroup: resourceGroup, + region: accounts[account].location || '', + key: keys?.key1 || '', + }); + } + } + } + return keyList; + }; + + const fetchAccounts = async (subscriptionId) => { + if (token) { + setLoading(true); + setNoKeys(false); + const tokenCredentials = new TokenCredentials(token); + const cognitiveServicesManagementClient = new CognitiveServicesManagementClient(tokenCredentials, subscriptionId); + const accounts = await cognitiveServicesManagementClient.accounts.list(); + + const keys: KeyRec[] = await fetchKeys( + cognitiveServicesManagementClient, + accounts.filter((a) => a.kind === 'SpeechServices') + ); + setLoading(false); + if (keys.length === 0) { + setNoKeys(true); + setActionOptions([ + { key: 'create', text: formatMessage('Create a new Speech resource') }, + { key: 'handoff', text: formatMessage('Generate a resource request'), disabled: false }, + { key: 'choose', text: formatMessage('Choose from existing'), disabled: true }, + ]); + } else { + setNoKeys(false); + setKeys(keys); + setActionOptions([ + { key: 'create', text: formatMessage('Create a new Speech resource') }, + { key: 'handoff', text: formatMessage('Generate a resource request'), disabled: false }, + { key: 'choose', text: formatMessage('Choose from existing'), disabled: false }, + ]); + } + } + }; + + const fetchLocations = async (subscriptionId) => { + if (token) { + const tokenCredentials = new TokenCredentials(token); + const subscriptionClient = new SubscriptionClient(tokenCredentials); + const locations = await subscriptionClient.subscriptions.listLocations(subscriptionId); + setLocationList( + locations.map((location) => { + return { key: location.name || '', text: location.displayName || '' }; + }) + ); + } + }; + + const fetchResourceGroups = async (subscriptionId) => { + if (token) { + const tokenCredentials = new TokenCredentials(token); + const resourceClient = new ResourceManagementClient(tokenCredentials, subscriptionId); + const groups = await resourceClient.resourceGroups.list(); + + setResourceGroups([ + { + id: CREATE_NEW_KEY, + data: { icon: 'Add' }, + name: formatMessage('Create new'), + }, + ...groups, + ]); + } + }; + + const createResources = async () => { + let key = ''; + const serviceName = `${resourceName}`; + + if (token) { + setLoading(true); + const tokenCredentials = new TokenCredentials(token); + + const resourceGroupName = resourceGroupKey === CREATE_NEW_KEY ? newResourceGroupName : resourceGroup; + if (resourceGroupKey === CREATE_NEW_KEY) { + try { + const resourceClient = new ResourceManagementClient(tokenCredentials, subscriptionId); + await resourceClient.resourceGroups.createOrUpdate(resourceGroupName, { + location: localRegion, + }); + } catch (err) { + setOutcomeDescription( + formatMessage( + 'Due to the following error, we were unable to successfully add your selected Speech keys to your bot project:' + ) + ); + setOutcomeSummary(

{err.message}

); + setOutcomeError(true); + setCurrentPage(3); + setLoading(false); + return; + } + } + + try { + const cognitiveServicesManagementClient = new CognitiveServicesManagementClient( + tokenCredentials, + subscriptionId + ); + await cognitiveServicesManagementClient.accounts.create(resourceGroupName, serviceName, { + kind: 'SpeechServices', + sku: { + name: 'S0', + }, + location: localRegion, + }); + + const keys = await cognitiveServicesManagementClient.accounts.listKeys(resourceGroupName, serviceName); + if (!keys?.key1) { + throw new Error('No key found for newly created authoring resource'); + } else { + key = keys.key1; + setLocalKey(keys.key1); + } + } catch (err) { + setOutcomeDescription( + formatMessage( + 'Due to the following error, we were unable to successfully add your selected Speech keys to your bot project:' + ) + ); + setOutcomeSummary(

{err.message}

); + setOutcomeError(true); + setCurrentPage(3); + setLoading(false); + return; + } + + setOutcomeDescription( + formatMessage('The following Speech resource was successfully created and added to your bot project:') + ); + setOutcomeSummary( +
+

+ + {subscriptionId} +

+

+ + {resourceGroupName} +

+

+ + {localRegion} +

+

+ + {resourceName} +

+
+ ); + setOutcomeError(false); + + // ALL DONE! + // this will pass the new values back to the caller + // have to wait a second for the key to be available to use + // otherwise the ARM api will throw an "unknown error" + setTimeout(() => { + setLoading(false); + + props.onGetKey({ + subscriptionKey: key, + region: localRegion, + }); + + setCurrentPage(3); + }, 3000); + } + }; + + // allow a user to provide a subscription id if one is missing + const onChangeSubscription = async (_, opt) => { + // get list of keys for this subscription + setSubscriptionId(opt.key); + fetchLocations(opt.key); + fetchAccounts(opt.key); + fetchResourceGroups(opt.key); + }; + + const onChangeKey = async (_, opt) => { + setLocalKey(opt.key); + setLocalRegion(opt.data.region); + }; + + const onChangeAction = async (_, opt) => { + setNextAction(opt.key); + }; + + const onChangeResourceGroup = async (_, opt) => { + setResourceGroupKey(opt.key); + setResourceGroup(opt.text); + if (opt.key === CREATE_NEW_KEY) { + setCreateResourceGroup(true); + } else { + setCreateResourceGroup(false); + } + }; + + const performNextAction = () => { + if (nextAction === 'choose') { + props.onGetKey({ + subscriptionKey: localKey, + region: localRegion, + }); + setOutcomeDescription(formatMessage('The following Speech key has been successfully added to your bot project:')); + setOutcomeSummary( +
+

+ + {localKey} +

+

+ + {localRegion} +

+
+ ); + setOutcomeError(false); + + setCurrentPage(3); + } else if (nextAction === 'handoff') { + setShowHandoff(true); + props.onDismiss(); + } else { + setCurrentPage(2); + } + }; + + const onRenderOption = (option) => { + return ( +
+ {option.data?.icon &&
+ ); + }; + + return ( + + {showAuthDialog && ( + { + setShowAuthDialog(false); + }} + /> + )} + + ); +}; diff --git a/Composer/packages/client/src/components/ProjectTree/ProjectHeader.tsx b/Composer/packages/client/src/components/ProjectTree/ProjectHeader.tsx index 8318f52085..6a17eb65ad 100644 --- a/Composer/packages/client/src/components/ProjectTree/ProjectHeader.tsx +++ b/Composer/packages/client/src/components/ProjectTree/ProjectHeader.tsx @@ -18,11 +18,6 @@ import { isChildDialogLinkSelected, doesLinkMatch } from './helpers'; import { TreeItem } from './treeItem'; import { ProjectTreeOptions, TreeLink } from './types'; -const icons = { - BOT: 'CubeShape', - EXTERNAL_SKILL: 'Globe', -}; - const headerCSS = (label: string) => css` margin-top: -6px; width: 100%; @@ -159,10 +154,10 @@ export const ProjectHeader = (props: ProjectHeaderProps) => { = ({ > = ({ = ({ key={`${item.id}_${item.index}`} dialogName={dialog.displayName} extraSpace={INDENT_PER_LEVEL} - icon={icons.TRIGGER} isActive={doesLinkMatch(link, selectedLink)} isMenuOpen={isMenuOpen} + itemType={'trigger'} link={link} marginLeft={depth * INDENT_PER_LEVEL} menu={ @@ -440,6 +429,7 @@ export const ProjectTree: React.FC = ({ hasChildren isMenuOpen={isMenuOpen} isSubItemActive={false} + itemType={'trigger group'} link={link} menuOpenCallback={setMenuOpen} showErrors={options.showErrors} @@ -516,9 +506,9 @@ export const ProjectTree: React.FC = ({ = ({ = ({ = ({ = ({ aria-label={formatMessage( `{ dialogNum, plural, - =0 {No bots} - =1 {One bot} - other {# bots} - } have been found. + =0 {No bots have} + =1 {One bot has} + other {# bots have} + } been found. { dialogNum, select, 0 {} diff --git a/Composer/packages/client/src/components/ProjectTree/treeItem.tsx b/Composer/packages/client/src/components/ProjectTree/treeItem.tsx index 8dddea6909..8775ce8e38 100644 --- a/Composer/packages/client/src/components/ProjectTree/treeItem.tsx +++ b/Composer/packages/client/src/components/ProjectTree/treeItem.tsx @@ -225,6 +225,44 @@ const calloutRootStyle = css` padding: 11px; `; +type TreeObject = + | 'bot' + | 'dialog' + | 'trigger' // basic ProjectTree elements + | 'trigger group' + | 'form dialog' + | 'form field' + | 'form trigger' // used with form dialogs + | 'lg' + | 'lu' // used on other pages + | 'external skill'; // used with multi-bot authoring + +const icons: { [key in TreeObject]: string | null } = { + bot: 'CubeShape', + dialog: 'Org', + trigger: 'LightningBolt', + 'trigger group': null, + 'form dialog': 'Table', + 'form field': 'Variable2', // x in parentheses + 'form trigger': 'TriggerAuto', // lightning bolt with gear + lg: 'Robot', + lu: 'People', + 'external skill': 'Globe', +}; + +const objectNames: { [key in TreeObject]: () => string } = { + trigger: () => formatMessage('Trigger'), + dialog: () => formatMessage('Dialog'), + 'trigger group': () => formatMessage('Trigger group'), + 'form dialog': () => formatMessage('Form dialog'), + 'form field': () => formatMessage('Form field'), + 'form trigger': () => formatMessage('Form trigger'), + lg: () => formatMessage('LG'), + lu: () => formatMessage('LU'), + bot: () => formatMessage('Bot'), + 'external skill': () => formatMessage('External skill'), +}; + // -------------------- TreeItem -------------------- // type ITreeItemProps = { @@ -233,7 +271,7 @@ type ITreeItemProps = { isChildSelected?: boolean; isSubItemActive?: boolean; onSelect?: (link: TreeLink) => void; - icon?: string; + itemType: TreeObject; dialogName?: string; textWidth?: number; extraSpace?: number; @@ -372,7 +410,7 @@ export const TreeItem: React.FC = ({ link, isActive = false, isChildSelected = false, - icon, + itemType, dialogName, onSelect, textWidth = 100, @@ -387,7 +425,9 @@ export const TreeItem: React.FC = ({ role, }) => { const [thisItemSelected, setThisItemSelected] = useState(false); - const a11yLabel = `${dialogName ?? '$Root'}_${link.displayName}`; + + const ariaLabel = `${objectNames[itemType]()} ${link.displayName}`; + const dataTestId = `${dialogName ?? '$Root'}_${link.displayName}`; const overflowMenu = menu.map(renderTreeMenuItem(link)); @@ -422,16 +462,16 @@ export const TreeItem: React.FC = ({ return (
- {item.icon != null && ( + {item.itemType != null && icons[item.itemType] != null && ( = ({ return (overflowItems: IContextualMenuItem[] | undefined) => { if (overflowItems == null) return null; return ( - + = ({ return (
= ({ padLeft, marginLeft )} - data-testid={a11yLabel} + data-testid={dataTestId} role={role} tabIndex={0} onClick={() => { @@ -530,7 +575,7 @@ export const TreeItem: React.FC = ({ } }} > -
+
= ({ items={[ { key: linkString, - icon: isBroken ? 'RemoveLink' : icon, + icon: isBroken ? 'RemoveLink' : icons[itemType], + itemType, ...link, }, ]} diff --git a/Composer/packages/client/src/pages/botProject/adapters/ABSChannels.tsx b/Composer/packages/client/src/pages/botProject/adapters/ABSChannels.tsx index 6e2500e049..bef478ff85 100644 --- a/Composer/packages/client/src/pages/botProject/adapters/ABSChannels.tsx +++ b/Composer/packages/client/src/pages/botProject/adapters/ABSChannels.tsx @@ -41,8 +41,7 @@ import { columnSizes, } from '../styles'; import { TeamsManifestGeneratorModal } from '../../../components/Adapters/TeamsManifestGeneratorModal'; - -import ABSChannelSpeechModal from './ABSChannelSpeechModal'; +import { ManageSpeech } from '../../../components/ManageSpeech/ManageSpeech'; const teamsHelpLink = 'https://aka.ms/composer-channel-teams'; const webchatHelpLink = 'https://aka.ms/composer-channel-webchat'; @@ -370,7 +369,11 @@ export const ABSChannels: React.FC = (props) => { }; }; - const toggleSpeechOn = async (key: string, region: string, isDefault: boolean) => { + const toggleSpeechOn = async ( + settings: { subscriptionKey: string; region: string }, + isDefault: boolean, + attempt = 0 + ) => { setChannelStatus({ ...channelStatus, [CHANNELS.SPEECH]: { @@ -381,8 +384,8 @@ export const ABSChannels: React.FC = (props) => { try { await createChannelService(CHANNELS.SPEECH, { - cognitiveServiceSubscriptionKey: key, - cognitiveServiceRegion: region, + cognitiveServiceSubscriptionKey: settings.subscriptionKey, + cognitiveServiceRegion: settings.region, isDefaultBotForCogSvcAccount: isDefault, }); } catch (err) { @@ -395,8 +398,13 @@ export const ABSChannels: React.FC = (props) => { loading: false, }, }); - - if (err?.response?.data?.error.code === 'InvalidChannelData') { + if (err?.response?.data?.error.code === 'UnknownError' && attempt < 5) { + console.error(err); + console.log('Retrying...'); + setTimeout(() => { + toggleSpeechOn(settings, isDefault, attempt + 1); + }, 3000); + } else if (err?.response?.data?.error.code === 'InvalidChannelData') { const result = await OpenConfirmModal( formatMessage('Enable speech'), formatMessage( @@ -404,7 +412,7 @@ export const ABSChannels: React.FC = (props) => { ) ); if (result) { - toggleSpeechOn(key, region, false); + toggleSpeechOn(settings, false); } } else { setApplicationLevelError(err); @@ -539,12 +547,18 @@ export const ABSChannels: React.FC = (props) => { }} /> )} - { +