diff --git a/extensions/azurePublish/package.json b/extensions/azurePublish/package.json index b3757ae220..28cbcb09cd 100644 --- a/extensions/azurePublish/package.json +++ b/extensions/azurePublish/package.json @@ -58,11 +58,13 @@ "@azure/ms-rest-js": "^2.0.7", "@azure/ms-rest-nodeauth": "3.0.3", "@bfc/built-in-functions": "../../Composer/packages/tools/built-in-functions", - "@bfc/ui-shared": "../../Composer/packages/lib/ui-shared", "@bfc/code-editor": "../../Composer/packages/lib/code-editor", "@bfc/extension-client": "file:../../Composer/packages/extension-client", "@bfc/indexers": "../../Composer/packages/lib/indexers", "@bfc/shared": "../../Composer/packages/lib/shared", + "@bfc/ui-shared": "../../Composer/packages/lib/ui-shared", + "@emotion/core": "^10.0.27", + "@emotion/styled": "^10.0.27", "adal-node": "0.2.1", "archiver": "^5.0.2", "axios": "^0.21.1", diff --git a/extensions/azurePublish/src/components/ChooseResourcesList.tsx b/extensions/azurePublish/src/components/ChooseResourcesList.tsx new file mode 100644 index 0000000000..aa7c70e1cf --- /dev/null +++ b/extensions/azurePublish/src/components/ChooseResourcesList.tsx @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as React from 'react'; +import styled from '@emotion/styled'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { Text } from 'office-ui-fabric-react/lib/Text'; +import { Checkbox } from 'office-ui-fabric-react/lib/Checkbox'; +import { FluentTheme, NeutralColors } from '@uifabric/fluent-theme'; +import { FocusZone, FocusZoneDirection } from 'office-ui-fabric-react/lib/FocusZone'; +import { List } from 'office-ui-fabric-react/lib/List'; + +import { ResourcesItem } from '../types'; + +// ---------- Styles ---------- // + +const ItemCheckbox = styled(Checkbox)` + margin: 4px 10px 0 0; + padding: 5px; +`; + +const checkBoxStyle = { + label: { + alignItems: 'flex-start', + }, +}; + +const ItemLabel = styled(Stack)` + margin-left: 15px !important; +`; + +const ItemHeader = styled(Stack)``; + +const ImageIcon = styled.img` + width: 16px; + height: 16px; + margin: 4px 0 0 0; + user-select: none; +`; + +const ImageIconPlacholder = styled.div` + width: 16px; + height: 16px; + margin: 4px 0 0 0; + user-select: none; +`; + +const ItemText = styled(Text)` + font-size: ${FluentTheme.fonts.mediumPlus.fontSize}; + margin-left: 4px !important; +`; + +const ItemTier = styled(Text)` + font-size: ${FluentTheme.fonts.small.fontSize}; + margin: 4px 0 0 22px; + color: ${NeutralColors.gray130}; +`; + +const ItemDescription = styled(Text)` + font-size: ${FluentTheme.fonts.medium.fontSize}; + margin: 4px 2px 0 22px; + color: ${NeutralColors.gray190}; + max-width: 500px; +`; + +// ---------- ChooseResourcesList ---------- // + +type ResourceListItem = ResourcesItem & { icon?: string }; + +type Props = { + /** + * The resources to list in order. + */ + items: ResourceListItem[]; + /** + * The keys of the resources that should be selected. + */ + selectedKeys?: string[]; + /** + * Raised whenever the selection of keys changes. + */ + onSelectionChanged?: (selectedKeys: string[]) => void; +}; + +/** + * Provides a selectable list control of resources. + * Displays the text, tier, description, and optional icon. + * Raises a callback of the selected keys. + * Allows for uncontrolled or controlled selected keys. + */ +export const ChooseResourcesList = (props: Props) => { + const { items, selectedKeys: controlledSelectedKeys, onSelectionChanged } = props; + + // ----- Hooks + + const getInitialSelectedKeys = () => { + return controlledSelectedKeys || items.filter((item) => item.required).map((item) => item.key); + }; + + const [selectedKeys, setSelectedKeys] = React.useState(getInitialSelectedKeys); + + // When the items or controlled selection changes, update selection state. + React.useEffect(() => { + setSelectedKeys(getInitialSelectedKeys()); + }, [items, controlledSelectedKeys]); + + // ----- Handlers + + const onCheckboxChanged = (ev: React.FormEvent, checked: boolean, item: ResourceListItem): void => { + let newSelectedKeys = undefined; + if (item.required || checked) { + if (!selectedKeys.includes(item.ke)) { + newSelectedKeys = [...selectedKeys, item.key]; + } + } else { + newSelectedKeys = selectedKeys.filter((i: string) => i !== item.key); + } + + setSelectedKeys(newSelectedKeys); + + if (onSelectionChanged) { + onSelectionChanged(newSelectedKeys); + } + }; + + // ----- Render + + const renderItemLabel = (item: ResourceListItem) => { + return ( + + + {item.icon ? : } + {item.text} + + {item.tier} + {item.description} + + ); + }; + + const renderItem = (item: ResourceListItem) => { + const checked = item.required || !!selectedKeys.includes(item.key); + return ( + onCheckboxChanged(e, c, item)} + onRenderLabel={() => renderItemLabel(item)} + /> + ); + }; + + return ( + + + + ); +}; diff --git a/extensions/azurePublish/src/components/api.tsx b/extensions/azurePublish/src/components/api.tsx index 1c021e9875..55bf56d9e3 100644 --- a/extensions/azurePublish/src/components/api.tsx +++ b/extensions/azurePublish/src/components/api.tsx @@ -389,10 +389,19 @@ export const getResourceList = async (projectId: string, type: string): Promise< } }; +/** + * A resource item that could be chosen by the user for provisioning. + */ +export type PreviewResourcesItem = { + name: string; + icon: string; + key: string; +}; + /** * Get preview and description of resources */ -export const getPreview = (hostname: string) => { +export const getPreview = (hostname: string): PreviewResourcesItem[] => { const azureWebAppName = `${hostname}`; const azureServicePlanName = `${hostname}`; const botServiceName = `${hostname}`; diff --git a/extensions/azurePublish/src/components/azureProvisionDialog.tsx b/extensions/azurePublish/src/components/azureProvisionDialog.tsx index 19f8235e65..d996e54360 100644 --- a/extensions/azurePublish/src/components/azureProvisionDialog.tsx +++ b/extensions/azurePublish/src/components/azureProvisionDialog.tsx @@ -1,14 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import formatMessage from 'format-message'; import * as React from 'react'; +import formatMessage from 'format-message'; +import styled from '@emotion/styled'; import { useState, useMemo, useEffect, Fragment, useCallback, useRef, Suspense } from 'react'; import { Dropdown, IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; import { DefaultButton, PrimaryButton } from 'office-ui-fabric-react/lib/Button'; import { logOut, usePublishApi, getTenants, getARMTokenForTenant, useLocalStorage } from '@bfc/extension-client'; import { Subscription } from '@azure/arm-subscriptions/esm/models'; import { DeployLocation } from '@botframework-composer/types'; -import { NeutralColors } from '@uifabric/fluent-theme'; +import { FluentTheme, NeutralColors } from '@uifabric/fluent-theme'; import { ScrollablePane, ScrollbarVisibility, @@ -26,8 +27,9 @@ import { Persona, IPersonaProps, PersonaSize, - Selection, SelectionMode, + Stack, + Text, } from 'office-ui-fabric-react'; import { JsonEditor } from '@bfc/code-editor'; import { SharedColors } from '@uifabric/fluent-theme'; @@ -42,8 +44,15 @@ import { getLuisAuthoringRegions, CheckWebAppNameAvailability, } from './api'; +import { ChooseResourcesList } from './ChooseResourcesList'; import { getExistResources, removePlaceholder, decodeToken, defaultExtensionState } from './util'; +// ---------- Styles ---------- // + +const AddResourcesSectionName = styled(Text)` + font-size: ${FluentTheme.fonts.mediumPlus.fontSize}; +`; + const iconStyle = (required) => { return { root: { @@ -117,53 +126,6 @@ const onRenderLabel = (props) => { ); }; -const columns: IColumn[] = [ - { - key: 'Icon', - name: 'File Type', - isIconOnly: true, - fieldName: 'name', - minWidth: 16, - maxWidth: 16, - onRender: (item: ResourcesItem & { name; icon }) => { - return ; - }, - }, - { - key: 'Name', - name: formatMessage('Name'), - className: 'name', - fieldName: 'name', - minWidth: 300, - isRowHeader: true, - data: 'string', - onRender: (item: ResourcesItem & { name; icon }) => { - return ( -
-
{item.text}
-
{item.tier}
-
- ); - }, - isPadded: true, - }, - { - key: 'Description', - name: formatMessage('Description'), - className: 'description', - fieldName: 'description', - minWidth: 380, - isRowHeader: true, - data: 'string', - onRender: (item: ResourcesItem & { name; icon }) => { - return ( -
{item.description}
- ); - }, - isPadded: true, - }, -]; - const reviewCols: IColumn[] = [ { key: 'Icon', @@ -287,11 +249,18 @@ export const AzureProvisionDialog: React.FC = () => { const [importConfig, setImportConfig] = useState(); const [page, setPage] = useState(PageTypes.ConfigProvision); - const [group, setGroup] = useState(); - const [listItems, setListItem] = useState<(ResourcesItem & { name; icon })[]>(); + const [listItems, setListItems] = useState<(ResourcesItem & { icon?: string })[]>(); const [reviewListItems, setReviewListItems] = useState([]); + const isMounted = useRef(); - const timerRef = useRef(); + const timerRef = useRef(); + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + }; + }, []); useEffect(() => { setTitle(DialogTitle.CONFIG_RESOURCES); @@ -313,7 +282,7 @@ export const AzureProvisionDialog: React.FC = () => { } else { if (!getTenantIdFromCache()) { getTenants().then((tenants) => { - if (tenants?.length > 0) { + if (isMounted.current && tenants?.length > 0) { // set tenantId in cache. setTenantId(tenants[0].tenantId); getARMTokenForTenant(tenants[0].tenantId) @@ -337,15 +306,17 @@ export const AzureProvisionDialog: React.FC = () => { } else { getARMTokenForTenant(getTenantIdFromCache()) .then((token) => { - setToken(token); - const decoded = decodeToken(token); - setCurrentUser({ - token: token, - email: decoded.upn, - name: decoded.name, - expiration: (decoded.exp || 0) * 1000, // convert to ms, - sessionExpired: false, - }); + if (isMounted.current) { + setToken(token); + const decoded = decodeToken(token); + setCurrentUser({ + token: token, + email: decoded.upn, + name: decoded.name, + expiration: (decoded.exp || 0) * 1000, // convert to ms, + sessionExpired: false, + }); + } }) .catch((err) => { setCurrentUser(undefined); @@ -373,13 +344,6 @@ export const AzureProvisionDialog: React.FC = () => { } }, [currentConfig]); - useEffect(() => { - if (token) { - getSubscriptions(token).then(setSubscriptions); - getResources(); - } - }, [token]); - const getResources = async () => { try { const resources = await getResourceList(currentProjectId(), publishType); @@ -390,6 +354,17 @@ export const AzureProvisionDialog: React.FC = () => { } }; + useEffect(() => { + if (token) { + getSubscriptions(token).then((data) => { + if (isMounted.current) { + setSubscriptions(data); + } + }); + getResources(); + } + }, [token]); + const subscriptionOption = useMemo(() => { return subscriptions?.map((t) => ({ key: t.subscriptionId, text: t.displayName })); }, [subscriptions]); @@ -422,10 +397,12 @@ export const AzureProvisionDialog: React.FC = () => { if (currentSubscription && publishType === 'azurePublish') { // check app name whether exist or not CheckWebAppNameAvailability(token, newName, currentSubscription).then((value) => { - if (!value.nameAvailable) { - setErrorHostName(value.message); - } else { - setErrorHostName(''); + if (isMounted.current) { + if (!value.nameAvailable) { + setErrorHostName(value.message); + } else { + setErrorHostName(''); + } } }); } @@ -492,10 +469,12 @@ export const AzureProvisionDialog: React.FC = () => { if (currentSubscription && token) { // get resource group under subscription getDeployLocations(token, currentSubscription).then((data: DeployLocation[]) => { - setDeployLocations(data); - const luRegions = getLuisAuthoringRegions(); - const region = data.filter((item) => luRegions.includes(item.name)); - setLuisLocations(region); + if (isMounted.current) { + setDeployLocations(data); + const luRegions = getLuisAuthoringRegions(); + const region = data.filter((item) => luRegions.includes(item.name)); + setLuisLocations(region); + } }); } }, [currentSubscription, token]); @@ -520,26 +499,12 @@ export const AzureProvisionDialog: React.FC = () => { } // set review list - const groups: IGroup[] = []; const requireList = result.filter((item) => item.required); setRequireResources(requireList); - const externalList = result.filter((item) => !item.required); - groups.push({ - key: 'required', - name: 'Required', - startIndex: 0, - count: requireList.length, - }); - groups.push({ - key: 'optional', - name: 'Optional', - startIndex: requireList.length, - count: externalList.length, - }); - const items = requireList.concat(externalList); - - setGroup(groups); - setListItem(items); + const optionalList = result.filter((item) => !item.required); + setEnabledResources(optionalList); + const items = requireList.concat(optionalList); + setListItems(items); setPage(PageTypes.AddResources); setTitle(DialogTitle.ADD_RESOURCES); @@ -736,43 +701,45 @@ export const AzureProvisionDialog: React.FC = () => { } }, [listItems]); - const selection = useMemo(() => { - const s = new Selection({ - onSelectionChanged: () => { - const list = s.getSelection(); - setEnabledResources(list); - }, - canSelectItem: (item, index) => { - return item.required === false; - }, - }); - if (s && listItems) { - s.setItems(listItems, false); - s.setAllSelected(true); - } - return s; - }, [listItems]); + const PageAddResources = () => { + if (listItems) { + const requiredListItems = listItems.filter((item) => item.required); + const optionalListItems = listItems.filter((item) => !item.required); + const selectedResourceKeys = enabledResources.map((r) => r.key); - const PageAddResources = useMemo(() => { - return ( - - - item.key} - groups={group} - items={listItems} - layoutMode={DetailsListLayoutMode.justified} - selection={selection} - selectionMode={SelectionMode.multiple} - setKey="none" - /> + return ( + + + {requiredListItems.length > 0 && ( + + {formatMessage('Required')} + + + )} + {optionalListItems.length > 0 && ( + + {formatMessage('Optional')} + { + const newSelection = listItems.filter((item) => item.required === true || keys.includes(item.key)); + setEnabledResources(newSelection); + }} + /> + + )} + - - ); - }, [group, listItems, selection]); + ); + } else { + return ; + } + }; const PageReview = ( @@ -874,7 +841,7 @@ export const AzureProvisionDialog: React.FC = () => { onClick={() => { setPage(PageTypes.ReviewResource); setTitle(DialogTitle.REVIEW); - let selectedResources = requireResources.concat(enabledResources); + let selectedResources = enabledResources.slice(); selectedResources = selectedResources.map((item) => { let region = currentConfig?.region || currentLocation; if (item.key.includes('luis')) { @@ -967,7 +934,7 @@ export const AzureProvisionDialog: React.FC = () => { return (
{page === PageTypes.ConfigProvision && PageFormConfig} - {page === PageTypes.AddResources && PageAddResources} + {page === PageTypes.AddResources && PageAddResources()} {page === PageTypes.ReviewResource && PageReview} {page === PageTypes.EditJson && (