-
+
{children}
diff --git a/Composer/packages/client/src/components/TextFieldWithCustomButton.tsx b/Composer/packages/client/src/components/TextFieldWithCustomButton.tsx new file mode 100644 index 0000000000..a101b028e7 --- /dev/null +++ b/Composer/packages/client/src/components/TextFieldWithCustomButton.tsx @@ -0,0 +1,194 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx, css } from '@emotion/core'; +import React, { useState, useRef, Fragment, useEffect } from 'react'; +import { TextField, ITextField } from 'office-ui-fabric-react/lib/TextField'; +import { TooltipHost } from 'office-ui-fabric-react/lib/Tooltip'; +import { Icon } from 'office-ui-fabric-react/lib/Icon'; +import { ActionButton } from 'office-ui-fabric-react/lib/Button'; +import { SharedColors } from '@uifabric/fluent-theme'; +import { FontWeights } from 'office-ui-fabric-react/lib/Styling'; +import { FontSizes } from '@uifabric/fluent-theme'; +import { NeutralColors } from '@uifabric/fluent-theme'; + +const unknownIconStyle = (required) => { + return { + root: { + selectors: { + '&::before': { + content: required ? " '*'" : '', + color: SharedColors.red10, + paddingRight: 3, + }, + }, + }, + }; +}; + +const labelContainer = css` + display: flex; + flex-direction: row; +`; + +const customerLabel = css` + font-size: ${FontSizes.size12}; + margin-right: 5px; +`; + +const disabledTextFieldStyle = { + root: { + selectors: { + '.ms-TextField-field': { + background: '#ddf3db', + }, + 'p > span': { + width: '100%', + }, + }, + }, +}; + +const actionButtonStyle = { + root: { + fontSize: FontSizes.size12, + fontWeight: FontWeights.regular, + color: SharedColors.cyanBlue10, + marginLeft: 0, + marginTop: -12, + paddingLeft: 0, + }, +}; + +const errorContainer = css` + display: flex; + width: 100%; + height: 48px; + line-height: 48px; + background: #fed9cc; + color: ${NeutralColors.black}; +`; + +const errorIcon = { + root: { + color: '#A80000', + marginRight: 8, + paddingLeft: 12, + fontSize: FontSizes.size12, + }, +}; + +const errorTextStyle = css` + margin-bottom: 5px; +`; + +type TextFieldWithCustomButtonProps = { + label: string; + ariaLabelledby: string; + buttonText: string; + errorMessage; + placeholder: string; + placeholderOnDisable: string; + value: string; + onBlur?: (value) => void; + onChange?: (e, value) => void; + required: boolean; +}; + +const errorElement = (errorText: string) => { + if (!errorText) return ''; + return ( +
+ +
{errorText}
+
+ ); +}; + +const onRenderLabel = (props) => { + return ( +
+
{props.label}
+ + + +
+ ); +}; + +export const TextFieldWithCustomButton: React.FC = (props) => { + const { + label, + placeholder, + placeholderOnDisable, + onChange, + required, + ariaLabelledby, + value, + buttonText, + onBlur, + errorMessage, + } = props; + const [isDisabled, setDisabled] = useState(!value); + const textFieldComponentRef = useRef(null); + const [autoFoucsOnTextField, setAutoFoucsOnTextField] = useState(); + const [localValue, setLocalValue] = useState(value); + useEffect(() => { + if (autoFoucsOnTextField) { + textFieldComponentRef.current?.focus(); + } + }, [autoFoucsOnTextField]); + + useEffect(() => { + setLocalValue(value); + setDisabled(!value); + }, [value]); + + return ( + + {isDisabled ? ( + + ) : ( + { + if (!localValue) { + setDisabled(true); + } + onBlur && onBlur(localValue); + }} + onChange={(e, value) => { + setLocalValue(value ?? ''); + onChange && onChange(e, value); + }} + onRenderLabel={onRenderLabel} + /> + )} + + { + setDisabled(false); + setAutoFoucsOnTextField(true); + }} + > + {buttonText} + + + ); +}; diff --git a/Composer/packages/client/src/pages/botProject/AppIdAndPassword.tsx b/Composer/packages/client/src/pages/botProject/AppIdAndPassword.tsx new file mode 100644 index 0000000000..0c674291ea --- /dev/null +++ b/Composer/packages/client/src/pages/botProject/AppIdAndPassword.tsx @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import React, { useState, useEffect, useCallback } from 'react'; +import { jsx, css } from '@emotion/core'; +import { useRecoilValue } from 'recoil'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { TooltipHost } from 'office-ui-fabric-react/lib/Tooltip'; +import { Icon } from 'office-ui-fabric-react/lib/Icon'; +import formatMessage from 'format-message'; +import { mergeStyleSets } from '@uifabric/styling'; +import { FontSizes, FontWeights } from 'office-ui-fabric-react/lib/Styling'; +import { SharedColors } from '@uifabric/fluent-theme'; + +import { dispatcherState, settingsState } from '../../recoilModel'; +import { CollapsableWrapper } from '../../components/CollapsableWrapper'; +import { mergePropertiesManagedByRootBot } from '../../recoilModel/dispatchers/utils/project'; +import { rootBotProjectIdSelector } from '../../recoilModel/selectors/project'; +// -------------------- Styles -------------------- // + +const titleStyle = css` + font-size: ${FontSizes.medium}; + font-weight: ${FontWeights.semibold}; + margin-left: 22px; + margin-top: 6px; +`; + +const labelContainer = css` + display: flex; + flex-direction: row; +`; + +const customerLabel = css` + font-size: ${FontSizes.small}; + margin-right: 5px; +`; + +const unknownIconStyle = (required) => { + return { + root: { + selectors: { + '&::before': { + content: required ? " '*'" : '', + color: SharedColors.red10, + paddingRight: 3, + }, + }, + }, + }; +}; + +const appIdAndPasswordStyle = css` + display: flex; + flex-direction: column; +`; + +const customError = { + root: { + selectors: { + 'p > span': { + width: '100%', + }, + }, + }, +}; + +// -------------------- AppIdAndPassword -------------------- // + +type AppIdAndPasswordProps = { + projectId: string; +}; + +const onRenderLabel = (props) => { + return ( +
+
{props.label}
+ + + +
+ ); +}; + +export const AppIdAndPassword: React.FC = (props) => { + const { projectId } = props; + const { MicrosoftAppId, MicrosoftAppPassword } = useRecoilValue(settingsState(projectId)); + const [localMicrosoftAppId, setLocalMicrosoftAppId] = useState(''); + const [localMicrosoftAppPassword, setLocalMicrosoftAppPassword] = useState(''); + const { setSettings } = useRecoilValue(dispatcherState); + const rootBotProjectId = useRecoilValue(rootBotProjectIdSelector) || ''; + const settings = useRecoilValue(settingsState(projectId)); + const mergedSettings = mergePropertiesManagedByRootBot(projectId, rootBotProjectId, settings); + useEffect(() => { + setLocalMicrosoftAppId(MicrosoftAppId ?? ''); + setLocalMicrosoftAppPassword(MicrosoftAppPassword ?? ''); + }, [projectId]); + + const handleAppIdOnChange = (e, value) => { + setLocalMicrosoftAppId(value); + }; + + const handleAppPasswordOnChange = (e, value) => { + setLocalMicrosoftAppPassword(value); + }; + + const handleAppPasswordOnBlur = useCallback(() => { + setSettings(projectId, { + ...mergedSettings, + MicrosoftAppPassword: localMicrosoftAppPassword, + }); + }, [projectId, mergedSettings, localMicrosoftAppPassword]); + + const handleAppIdOnBlur = useCallback(() => { + setSettings(projectId, { + ...mergedSettings, + MicrosoftAppId: localMicrosoftAppId, + }); + }, [projectId, mergedSettings, localMicrosoftAppId]); + + return ( + +
+ + +
+
+ ); +}; diff --git a/Composer/packages/client/src/pages/botProject/BotLanguage.tsx b/Composer/packages/client/src/pages/botProject/BotLanguage.tsx new file mode 100644 index 0000000000..7cf9a137ce --- /dev/null +++ b/Composer/packages/client/src/pages/botProject/BotLanguage.tsx @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import React, { Fragment, useMemo } from 'react'; +import { jsx, css } from '@emotion/core'; +import { useRecoilValue } from 'recoil'; +import { ActionButton } from 'office-ui-fabric-react/lib/Button'; +import formatMessage from 'format-message'; +import cloneDeep from 'lodash/cloneDeep'; +import { FontSizes, FontWeights } from 'office-ui-fabric-react/lib/Styling'; +import { NeutralColors, SharedColors } from '@uifabric/fluent-theme'; + +import { dispatcherState, settingsState } from '../../recoilModel'; +import { CollapsableWrapper } from '../../components/CollapsableWrapper'; +import { languageListTemplates } from '../../components/MultiLanguage'; +import { localeState, showAddLanguageModalState } from '../../recoilModel/atoms'; +import { AddLanguageModal } from '../../components/MultiLanguage'; +import { mergePropertiesManagedByRootBot } from '../../recoilModel/dispatchers/utils/project'; +import { rootBotProjectIdSelector } from '../../recoilModel/selectors/project'; +// -------------------- Styles -------------------- // + +const titleStyle = css` + font-size: ${FontSizes.medium}; + font-weight: ${FontWeights.semibold}; + margin-left: 22px; + margin-top: 6px; +`; + +const botLanguageContainerStyle = css` + display: flex; + flex-direction: column; +`; + +const botLanguageDescriptionStyle = css` + font-size: ${FontSizes.small}; + color: ${NeutralColors.gray130}; +`; + +const botLanguageFieldStyle = css` + font-size: ${FontSizes.small}; + color: ${NeutralColors.black}; + overflow-y: auto; + max-height: 150px; + border: 1px solid #c4c4c4; + margin-top: 17px; + padding: 10px; +`; + +const manageBotLanguage = { + root: { + height: 30, + fontSize: FontSizes.smallPlus, + fontWeight: FontWeights.regular, + color: SharedColors.cyanBlue10, + paddingLeft: 0, + }, +}; + +const languageItem = css` + &:hover { + background: #ebebeb; + } +`; + +const languageRowContainer = css` + display: flex; + height: 30px; + line-height: 30px; +`; + +const languageItemContainer = css` + display: flex; + width: 100%; + justify-content: space-between; + &:hover .ms-Button { + visibility: visible; + } +`; + +const languageButton = { + root: { + fontSize: FontSizes.small, + fontWeight: FontWeights.regular, + color: SharedColors.cyanBlue10, + height: 30, + visibility: 'hidden', + }, +}; + +const defaultLanguageTextStyle = css` + color: #898989; + font-size: 8px; +`; + +const languageTextStyle = css` + color: ${NeutralColors.black}; + font-size: 12px; +`; + +const languageButtonContainer = css` + display: flex; + justify-content: space-between; + width: 240px; +`; + +// -------------------- BotLanguage -------------------- // + +type BotLanguageProps = { + projectId: string; +}; + +export const BotLanguage: React.FC = (props) => { + const { projectId } = props; + const { languages, defaultLanguage } = useRecoilValue(settingsState(projectId)); + const rootBotProjectId = useRecoilValue(rootBotProjectIdSelector) || ''; + const settings = useRecoilValue(settingsState(projectId)); + const mergedSettings = mergePropertiesManagedByRootBot(projectId, rootBotProjectId, settings); + const locale = useRecoilValue(localeState(projectId)); + const showAddLanguageModal = useRecoilValue(showAddLanguageModalState(projectId)); + const { + addLanguageDialogBegin, + setSettings, + deleteLanguages, + setLocale, + addLanguageDialogCancel, + addLanguages, + } = useRecoilValue(dispatcherState); + + const languageListOptions = useMemo(() => { + const languageList = languageListTemplates(languages, locale, defaultLanguage); + const enableLanguages = languageList.filter(({ isEnabled }) => !!isEnabled); + return enableLanguages.map((item) => { + const { language, locale } = item; + return { + key: locale, + title: locale, + text: language, + }; + }); + }, [languages]); + + const onAddLangModalSubmit = async (formData) => { + await addLanguages({ + ...formData, + projectId, + }); + }; + + const setDefaultLanguage = (language: string) => { + setLocale(language, projectId); + const updatedSetting = { ...cloneDeep(mergedSettings), defaultLanguage: language }; + if (updatedSetting?.luis?.defaultLanguage) { + updatedSetting.luis.defaultLanguage = language; + } + setSettings(projectId, updatedSetting); + }; + + const index = languageListOptions.findIndex((l) => l.key === defaultLanguage); + const dl = languageListOptions.splice(index, 1)[0]; + languageListOptions.unshift(dl); + + return ( + + +
+
+ {formatMessage( + 'List of languages that bot will be able to understand (User input) and respond to (Bot responses). To make this bot available in other languages, click ‘Manage bot languages’ to create a copy of the default language, and translate the content into the new language.' + )} +
+
+ {languageListOptions.map((l) => ( +
+ {l.key === defaultLanguage && ( +
+ {l.text} + {formatMessage('DEFAULT LANGUAGE')} +
+ )} + {l.key !== defaultLanguage && ( +
+
{l.text}
+
+ setDefaultLanguage(l.key)} + > + {formatMessage('Set it as default language')} + + deleteLanguages({ languages: [l.key], projectId: projectId })} + > + {formatMessage('Remove')} + +
+
+ )} +
+ ))} +
+ addLanguageDialogBegin(projectId, () => {})}> + {formatMessage('Manage bot languages')} + +
+
+ addLanguageDialogCancel(projectId)} + onSubmit={onAddLangModalSubmit} + /> +
+ ); +}; diff --git a/Composer/packages/client/src/pages/botProject/BotProjectSettings.tsx b/Composer/packages/client/src/pages/botProject/BotProjectSettings.tsx new file mode 100644 index 0000000000..e93fd75547 --- /dev/null +++ b/Composer/packages/client/src/pages/botProject/BotProjectSettings.tsx @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx, css } from '@emotion/core'; +import { useRecoilValue } from 'recoil'; +import React, { useMemo, useState, Suspense } from 'react'; +import formatMessage from 'format-message'; +import { RouteComponentProps } from '@reach/router'; +import { JsonEditor } from '@bfc/code-editor'; +import { Toggle } from 'office-ui-fabric-react/lib/Toggle'; +import { DialogSetting } from '@bfc/shared'; +import { FontSizes, FontWeights } from 'office-ui-fabric-react/lib/Styling'; +import { NeutralColors } from '@uifabric/fluent-theme'; + +import { LoadingSpinner } from '../../components/LoadingSpinner'; +import { INavTreeItem } from '../../components/NavTree'; +import { Page } from '../../components/Page'; +import { dispatcherState } from '../../recoilModel'; +import { settingsState, userSettingsState, schemasState } from '../../recoilModel/atoms'; +import { botProjectSpaceSelector, rootBotProjectIdSelector } from '../../recoilModel/selectors/project'; +import { navigateTo } from '../../utils/navigation'; +import { mergePropertiesManagedByRootBot } from '../../recoilModel/dispatchers/utils/project'; + +import BotProjectSettingsTableView from './BotProjectSettingsTableView'; + +// -------------------- Styles -------------------- // + +const header = css` + padding: 5px 20px; + display: flex; + flex-direction: column; + flex-shrink: 0; + justify-content: space-between; + label: PageHeader; +`; + +const container = css` + display: flex; + flex-direction: column; + max-width: 1000px; + height: 100%; +`; + +const botNameStyle = css` + font-size: ${FontSizes.xLarge}; + font-weight: ${FontWeights.semibold}; + color: ${NeutralColors.black}; +`; + +const mainContentHeader = css` + display: flex; + justify-content: space-between; + margin-bottom: 15px; +`; + +// -------------------- BotProjectSettings -------------------- // + +const BotProjectSettings: React.FC> = (props) => { + const botProjectsMetaData = useRecoilValue(botProjectSpaceSelector); + const rootBotProjectId = useRecoilValue(rootBotProjectIdSelector); + const userSettings = useRecoilValue(userSettingsState); + const projectId = (props['*'] === 'root' ? rootBotProjectId : props['*']) || ''; + const schemas = useRecoilValue(schemasState(projectId)); + const botProject = botProjectsMetaData.find((b) => b.projectId === projectId); + + const isRootBot = !!botProject?.isRootBot; + const botName = botProject?.name; + const settings = useRecoilValue(settingsState(projectId)); + const mergedSettings = mergePropertiesManagedByRootBot(projectId, rootBotProjectId, settings); + + const [isAdvancedSettingsEnabled, setAdvancedSettingsEnabled] = useState(false); + + const { setSettings } = useRecoilValue(dispatcherState); + + const navLinks: INavTreeItem[] = useMemo(() => { + const localBotProjects = botProjectsMetaData.filter((b) => !b.isRemote); + const newbotProjectLinks: INavTreeItem[] = localBotProjects.map((b) => { + return { + id: b.projectId, + name: b.name, + ariaLabel: formatMessage('bot'), + url: b.isRootBot + ? `/bot/${rootBotProjectId}/botProjectsSettings/root` + : `/bot/${rootBotProjectId}/botProjectsSettings/${b.projectId}`, + isRootBot: b.isRootBot, + }; + }); + const rootBotIndex = localBotProjects.findIndex((link) => link.isRootBot); + + if (rootBotIndex > -1) { + const rootBotLink = newbotProjectLinks.splice(rootBotIndex, 1)[0]; + newbotProjectLinks.splice(0, 0, rootBotLink); + } + return newbotProjectLinks; + }, [botProjectsMetaData]); + + const onRenderHeaderContent = () => { + return formatMessage( + 'This Page contains detailed information about your bot. For security reasons, they are hidden by default. To test your bot or publish to Azure, you may need to provide these settings' + ); + }; + + const saveChangeResult = (result: DialogSetting) => { + setSettings(projectId, result); + }; + + const handleChange = (result: any) => { + // prevent result was undefined, it will cause error + if (result && typeof result === 'object') { + saveChangeResult(result); + } + }; + + if (!botProject) { + navigateTo(`/bot/${rootBotProjectId}/botProjectsSettings/root`); + return null; + } + + return ( + + }> +
+
+
+ {`${botName} (${isRootBot ? formatMessage('Root Bot') : formatMessage('Skill')})`} +
+ setAdvancedSettingsEnabled(!isAdvancedSettingsEnabled)} + /> +
+ {isAdvancedSettingsEnabled ? ( + + ) : ( + + )} +
+
+
+ ); +}; + +export default BotProjectSettings; diff --git a/Composer/packages/client/src/pages/botProject/BotProjectSettingsTableView.tsx b/Composer/packages/client/src/pages/botProject/BotProjectSettingsTableView.tsx new file mode 100644 index 0000000000..b245fe5232 --- /dev/null +++ b/Composer/packages/client/src/pages/botProject/BotProjectSettingsTableView.tsx @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx, css } from '@emotion/core'; +import { useRecoilValue } from 'recoil'; +import React from 'react'; +import { RouteComponentProps } from '@reach/router'; + +import { botProjectSpaceSelector } from '../../recoilModel/selectors/project'; + +import { SkillHostEndPoint } from './SkillHostEndPoint'; +import { AppIdAndPassword } from './AppIdAndPassword'; +import { ExternalService } from './ExternalService'; +import { BotLanguage } from './BotLanguage'; +import { RuntimeSettings } from './RuntimeSettings'; +import { PublishTargets } from './PublishTargets'; +import { DeleteBotButton } from './DeleteBotButton'; + +// -------------------- Styles -------------------- // + +const container = css` + display: flex; + flex-direction: column; + max-width: 1000px; + height: 100%; +`; + +// -------------------- BotProjectSettingsTableView -------------------- // + +export const BotProjectSettingsTableView: React.FC> = (props) => { + const { projectId = '' } = props; + const botProjectsMetaData = useRecoilValue(botProjectSpaceSelector); + const botProject = botProjectsMetaData.find((b) => b.projectId === projectId); + const isRootBot = !!botProject?.isRootBot; + + return ( +
+ {isRootBot && } + + + + + + +
+ ); +}; + +export default BotProjectSettingsTableView; diff --git a/Composer/packages/client/src/pages/botProject/DeleteBotButton.tsx b/Composer/packages/client/src/pages/botProject/DeleteBotButton.tsx new file mode 100644 index 0000000000..54be5935c1 --- /dev/null +++ b/Composer/packages/client/src/pages/botProject/DeleteBotButton.tsx @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import React from 'react'; +import { jsx, css } from '@emotion/core'; +import { useRecoilValue } from 'recoil'; +import formatMessage from 'format-message'; +import { FontIcon } from 'office-ui-fabric-react/lib/Icon'; +import { Button } from 'office-ui-fabric-react/lib/Button'; +import { Text } from 'office-ui-fabric-react/lib/Text'; +import { FontWeights } from 'office-ui-fabric-react/lib/Styling'; +import { NeutralColors, SharedColors } from '@uifabric/fluent-theme'; + +import { OpenConfirmModal } from '../../components/Modal/ConfirmDialog'; +import { navigateTo } from '../../utils/navigation'; +import { dispatcherState } from '../../recoilModel'; + +// -------------------- Styles -------------------- // + +const marginBottom = css` + margin-bottom: 20px; +`; + +const deleteBotText = css` + font-weight: ${FontWeights.semibold}; + font-size: 12px; + margin-bottom: 20px; +`; + +const deleteBotButton = { + root: { + height: 32, + width: 82, + background: SharedColors.cyanBlue10, + color: NeutralColors.white, + }, + rootHovered: { + background: SharedColors.cyanBlue10, + color: NeutralColors.white, + }, +}; + +// -------------------- DeleteBotButton -------------------- // + +type DeleteBotButtonProps = { + projectId: string; +}; + +export const DeleteBotButton: React.FC = (props) => { + const { projectId } = props; + const { deleteBot } = useRecoilValue(dispatcherState); + const openDeleteBotModal = async () => { + const boldWarningText = formatMessage( + 'Warning: the action you are about to take cannot be undone. Going further will delete this bot and any related files in the bot project folder.' + ); + const warningText = formatMessage('External resources will not be changed.'); + const title = formatMessage('Delete Bot'); + const checkboxLabel = formatMessage('I want to delete this bot'); + const settings = { + onRenderContent: () => { + return ( +
+ +
+ + {boldWarningText} + + + {warningText} + +
+
+ ); + }, + disabled: true, + checkboxLabel, + confirmBtnText: formatMessage('Delete'), + }; + const res = await OpenConfirmModal(title, null, settings); + if (res) { + await deleteBot(projectId); + navigateTo('home'); + } + }; + + return ( +
+
{formatMessage('Delete this bot')}
+ +
+ ); +}; diff --git a/Composer/packages/client/src/pages/botProject/ExternalService.tsx b/Composer/packages/client/src/pages/botProject/ExternalService.tsx new file mode 100644 index 0000000000..09666ad996 --- /dev/null +++ b/Composer/packages/client/src/pages/botProject/ExternalService.tsx @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import React from 'react'; +import { jsx } from '@emotion/core'; +import { useRecoilValue } from 'recoil'; + +import { rootBotProjectIdSelector } from '../../recoilModel/selectors/project'; + +import { RootBotExternalService } from './RootBotExternalService'; +import { SkillBotExternalService } from './SkillBotExternalService'; + +// -------------------- ExternalService -------------------- // +type ExternalServiceProps = { + projectId: string; +}; + +export const ExternalService: React.FC = (props) => { + const { projectId } = props; + const rootBotProjectId = useRecoilValue(rootBotProjectIdSelector) || ''; + const isRootBot = rootBotProjectId === projectId; + return isRootBot ? ( + + ) : ( + + ); +}; diff --git a/Composer/packages/client/src/pages/botProject/PublishTargets.tsx b/Composer/packages/client/src/pages/botProject/PublishTargets.tsx new file mode 100644 index 0000000000..9594b9887e --- /dev/null +++ b/Composer/packages/client/src/pages/botProject/PublishTargets.tsx @@ -0,0 +1,245 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import React, { Fragment, useState, useCallback, useEffect } from 'react'; +import { jsx, css } from '@emotion/core'; +import { useRecoilValue } from 'recoil'; +import { PublishTarget } from '@bfc/shared'; +import formatMessage from 'format-message'; +import { ActionButton } from 'office-ui-fabric-react/lib/Button'; +import { DialogType } from 'office-ui-fabric-react/lib/Dialog'; +import { FontSizes, FontWeights } from 'office-ui-fabric-react/lib/Styling'; +import { NeutralColors, SharedColors } from '@uifabric/fluent-theme'; +import { DialogWrapper, DialogTypes } from '@bfc/ui-shared'; + +import { dispatcherState, settingsState, publishTypesState } from '../../recoilModel'; +import { CollapsableWrapper } from '../../components/CollapsableWrapper'; +import { CreatePublishTarget } from '../publish/createPublishTarget'; + +// -------------------- Styles -------------------- // + +const titleStyle = css` + font-size: ${FontSizes.medium}; + font-weight: ${FontWeights.semibold}; + margin-left: 22px; + margin-top: 6px; +`; + +const publishTargetsContainer = css` + display: flex; + flex-direction: column; +`; + +const publishTargetsHeader = css` + display: flex; + flex-direction: row; + height: 42px; +`; + +const publishTargetsHeaderText = css` + width: 200px; + font-size: ${FontSizes.medium}; + font-weight: ${FontWeights.semibold}; + border-bottom: 1px solid ${NeutralColors.gray30}; + padding-top: 10px; + padding-left: 10px; +`; + +const publishTargetsItem = css` + display: flex; + flex-direction: row; + height: 42px; +`; + +const publishTargetsItemText = css` + width: 200px; + font-size: ${FontSizes.medium}; + font-weight: ${FontWeights.regular}; + border-bottom: 1px solid ${NeutralColors.gray30}; + padding-top: 10px; + padding-left: 10px; +`; + +const addPublishProfile = { + root: { + fontSize: 12, + fontWeight: FontWeights.regular, + color: SharedColors.cyanBlue10, + paddingLeft: 0, + marginLeft: 5, + }, +}; + +const editPublishProfile = { + root: { + fontSize: 12, + fontWeight: FontWeights.regular, + color: SharedColors.cyanBlue10, + paddingLeft: 0, + paddingBottom: 5, + }, +}; + +const publishTargetsEditButton = css` + width: 200px; + font-size: ${FontSizes.medium}; + font-weight: ${FontWeights.regular}; + border-bottom: 1px solid ${NeutralColors.gray30}; + padding-top: 3px; + padding-left: 10px; +`; + +// -------------------- PublishTargets -------------------- // + +type PublishTargetsProps = { + projectId: string; +}; + +export const PublishTargets: React.FC = (props) => { + const { projectId } = props; + const { publishTargets } = useRecoilValue(settingsState(projectId)); + const { getPublishTargetTypes, setPublishTargets } = useRecoilValue(dispatcherState); + const publishTypes = useRecoilValue(publishTypesState(projectId)); + const [editTarget, setEditTarget] = useState<{ index: number; item: PublishTarget } | null>(null); + const [editDialogProps, setEditDialogProps] = useState({ + title: formatMessage('Title'), + type: DialogType.normal, + children: {}, + }); + + const [dialogProps, setDialogProps] = useState({ + title: formatMessage('Title'), + type: DialogType.normal, + children: {}, + }); + + const [addDialogHidden, setAddDialogHidden] = useState(true); + const [editDialogHidden, setEditDialogHidden] = useState(true); + + const onEdit = useCallback( + async (index: number, item: PublishTarget) => { + const newItem = { item: item, index: index }; + setEditTarget(newItem); + setEditDialogHidden(false); + }, + [publishTargets] + ); + + const updatePublishTarget = useCallback( + async (name: string, type: string, configuration: string) => { + if (!editTarget) { + return; + } + + const targets = publishTargets ? [...publishTargets] : []; + + targets[editTarget.index] = { + name, + type, + configuration, + }; + + await setPublishTargets(targets, projectId); + }, + [publishTargets, projectId, editTarget] + ); + + const savePublishTarget = useCallback( + async (name: string, type: string, configuration: string) => { + const targets = [...(publishTargets || []), { name, type, configuration }]; + await setPublishTargets(targets, projectId); + }, + [publishTargets, projectId] + ); + + useEffect(() => { + setDialogProps({ + title: formatMessage('Add a publish profile'), + type: DialogType.normal, + children: ( + setAddDialogHidden(true)} + current={null} + targets={publishTargets || []} + types={publishTypes} + updateSettings={savePublishTarget} + /> + ), + }); + }, [publishTypes, savePublishTarget, publishTargets]); + + useEffect(() => { + setEditDialogProps({ + title: formatMessage('Edit a publish profile'), + type: DialogType.normal, + children: ( + setEditDialogHidden(true)} + current={editTarget ? editTarget.item : null} + targets={(publishTargets || []).filter((item) => editTarget && item.name !== editTarget.item.name)} + types={publishTypes} + updateSettings={updatePublishTarget} + /> + ), + }); + }, [editTarget, publishTypes, updatePublishTarget]); + + useEffect(() => { + if (projectId) { + getPublishTargetTypes(projectId); + } + }, [projectId]); + + return ( + + +
+
+
{formatMessage('Name')}
+
{formatMessage('Type')}
+
+
+ {publishTargets?.map((p, index) => { + return ( +
+
{p.name}
+
{p.type}
+
+ await onEdit(index, p)}> + {formatMessage('Edit')} + +
+
+ ); + })} + setAddDialogHidden(false)} + > + {formatMessage('Add new publish profile')} + +
+
+ setAddDialogHidden(true)} + > + {dialogProps.children} + + setEditDialogHidden(true)} + > + {editDialogProps.children} + +
+ ); +}; diff --git a/Composer/packages/client/src/pages/botProject/RootBotExternalService.tsx b/Composer/packages/client/src/pages/botProject/RootBotExternalService.tsx new file mode 100644 index 0000000000..9db39cfbf2 --- /dev/null +++ b/Composer/packages/client/src/pages/botProject/RootBotExternalService.tsx @@ -0,0 +1,302 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import React, { useState, useEffect } from 'react'; +import { jsx } from '@emotion/core'; +import { mergeStyleSets } from '@uifabric/styling'; +import { useRecoilValue } from 'recoil'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { TooltipHost } from 'office-ui-fabric-react/lib/Tooltip'; +import { Icon } from 'office-ui-fabric-react/lib/Icon'; +import formatMessage from 'format-message'; +import get from 'lodash/get'; +import { css } from '@emotion/core'; +import { FontSizes, FontWeights } from 'office-ui-fabric-react/lib/Styling'; +import { NeutralColors, SharedColors } from '@uifabric/fluent-theme'; + +import { + dispatcherState, + settingsState, + luFilesState, + qnaFilesState, + validateDialogsSelectorFamily, +} from '../../recoilModel'; +import settingStorage from '../../utils/dialogSettingStorage'; +import { rootBotProjectIdSelector } from '../../recoilModel/selectors/project'; +import { CollapsableWrapper } from '../../components/CollapsableWrapper'; +import { isLUISMandatory, isQnAKeyMandatory } from '../../utils/dialogValidator'; +import { mergePropertiesManagedByRootBot } from '../../recoilModel/dispatchers/utils/project'; +// -------------------- Styles -------------------- // + +const titleStyle = css` + font-size: ${FontSizes.medium}; + font-weight: ${FontWeights.semibold}; + margin-left: 22px; + margin-top: 6px; +`; + +const labelContainer = css` + display: flex; + flex-direction: row; +`; + +const customerLabel = css` + font-size: ${FontSizes.small}; + margin-right: 5px; +`; + +const unknownIconStyle = (required) => { + return { + root: { + selectors: { + '&::before': { + content: required ? " '*'" : '', + color: SharedColors.red10, + paddingRight: 3, + }, + }, + }, + }; +}; + +const externalServiceContainerStyle = css` + display: flex; + flex-direction: column; +`; + +const errorContainer = css` + display: flex; + width: 100%; + height: 48px; + line-height: 48px; + background: #fed9cc; + color: ${NeutralColors.black}; +`; + +const customError = { + root: { + selectors: { + 'p > span': { + width: '100%', + }, + }, + }, +}; + +const errorIcon = { + root: { + color: '#A80000', + marginRight: 8, + paddingLeft: 12, + fontSize: FontSizes.mediumPlus, + }, +}; + +const errorTextStyle = css` + margin-bottom: 5px; +`; + +// -------------------- ExternalService -------------------- // + +type RootBotExternalServiceProps = { + projectId: string; +}; + +const onRenderLabel = (props) => { + return ( +
+
{props.label}
+ + + +
+ ); +}; + +const errorElement = (errorText: string) => { + if (!errorText) return ''; + return ( +
+ +
{errorText}
+
+ ); +}; + +export const RootBotExternalService: React.FC = (props) => { + const { projectId } = props; + const { setSettings, setQnASettings } = useRecoilValue(dispatcherState); + + const rootBotProjectId = useRecoilValue(rootBotProjectIdSelector) || ''; + const settings = useRecoilValue(settingsState(projectId)); + const mergedSettings = mergePropertiesManagedByRootBot(projectId, rootBotProjectId, settings); + const sensitiveGroupManageProperty = settingStorage.get(rootBotProjectId); + + const groupLUISAuthoringKey = get(sensitiveGroupManageProperty, 'luis.authoringKey', {}); + const rootLuisKey = groupLUISAuthoringKey.root; + const groupLUISRegion = get(sensitiveGroupManageProperty, 'luis.authoringRegion', {}); + const rootLuisRegion = groupLUISRegion.root; + const groupQnAKey = get(sensitiveGroupManageProperty, 'qna.subscriptionKey', {}); + const rootqnaKey = groupQnAKey.root; + + const dialogs = useRecoilValue(validateDialogsSelectorFamily(projectId)); + const luFiles = useRecoilValue(luFilesState(projectId)); + const qnaFiles = useRecoilValue(qnaFilesState(projectId)); + const isLUISKeyNeeded = isLUISMandatory(dialogs, luFiles); + const isQnAKeyNeeded = isQnAKeyMandatory(dialogs, qnaFiles); + + const [luisKeyErrorMsg, setLuisKeyErrorMsg] = useState(''); + const [luisRegionErrorMsg, setLuisRegionErrorMsg] = useState(''); + const [qnaKeyErrorMsg, setQnAKeyErrorMsg] = useState(''); + + const [localRootLuisKey, setLocalRootLuisKey] = useState(rootLuisKey ?? ''); + const [localRootQnAKey, setLocalRootQnAKey] = useState(rootqnaKey ?? ''); + const [localRootLuisRegion, setLocalRootLuisRegion] = useState(rootLuisRegion ?? ''); + + useEffect(() => { + if (!localRootLuisKey) { + setLuisKeyErrorMsg( + formatMessage('LUIS Key is required with the current recognizer setting to start your bot locally, and publish') + ); + } else { + setLuisKeyErrorMsg(''); + } + if (!localRootQnAKey) { + setQnAKeyErrorMsg(formatMessage('QnA Maker subscription Key is required to start your bot locally, and publish')); + } else { + setQnAKeyErrorMsg(''); + } + + if (isLUISKeyNeeded && !localRootLuisRegion) { + setLuisRegionErrorMsg(formatMessage('LUIS Region is required')); + } else { + setLuisRegionErrorMsg(''); + } + }, [projectId]); + + useEffect(() => { + setLocalRootLuisKey(rootLuisKey); + }, [rootLuisKey]); + + const handleRootLUISKeyOnChange = (e, value) => { + if (value) { + setLuisKeyErrorMsg(''); + setLocalRootLuisKey(value); + } else { + setLuisKeyErrorMsg( + formatMessage('LUIS Key is required with the current recognizer setting to start your bot locally, and publish') + ); + setLocalRootLuisKey(''); + } + }; + + const handleRootQnAKeyOnChange = (e, value) => { + if (value) { + setQnAKeyErrorMsg(''); + setLocalRootQnAKey(value); + } else { + setQnAKeyErrorMsg(formatMessage('QnA Maker subscription Key is required to start your bot locally, and publish')); + setLocalRootQnAKey(''); + } + }; + + const handleRootLuisRegionOnChange = (e, value) => { + if (value) { + setLuisRegionErrorMsg(''); + setLocalRootLuisRegion(value); + } else { + setLuisRegionErrorMsg(formatMessage('LUIS Region is required')); + setLocalRootLuisRegion(''); + } + }; + + const handleRootLuisRegionOnBlur = () => { + if (isLUISKeyNeeded && !localRootLuisRegion) { + setLuisRegionErrorMsg(formatMessage('LUIS Region is required')); + } + setSettings(projectId, { + ...mergedSettings, + luis: { ...mergedSettings.luis, authoringRegion: localRootLuisRegion }, + }); + }; + + const handleRootLuisKeyOnBlur = () => { + if (!localRootLuisKey) { + setLuisKeyErrorMsg( + formatMessage('LUIS Key is required with the current recognizer setting to start your bot locally, and publish') + ); + } + setSettings(projectId, { + ...mergedSettings, + luis: { ...mergedSettings.luis, authoringKey: localRootLuisKey }, + }); + }; + + const handleRootQnAKeyOnBlur = () => { + if (!localRootQnAKey) { + setQnAKeyErrorMsg(formatMessage('QnA Maker subscription Key is required to start your bot locally, and publish')); + } + submitQnASubscripionKey(localRootQnAKey); + }; + + const submitQnASubscripionKey = (key: string) => { + if (key) { + setSettings(projectId, { + ...mergedSettings, + qna: { ...mergedSettings.qna, subscriptionKey: key }, + }); + setQnASettings(projectId, key); + } else { + setSettings(projectId, { + ...mergedSettings, + qna: { ...mergedSettings.qna, subscriptionKey: '', endpointKey: '' }, + }); + } + }; + + return ( + +
+ + + +
+
+ ); +}; diff --git a/Composer/packages/client/src/pages/botProject/RuntimeSettings.tsx b/Composer/packages/client/src/pages/botProject/RuntimeSettings.tsx new file mode 100644 index 0000000000..18e166b7d0 --- /dev/null +++ b/Composer/packages/client/src/pages/botProject/RuntimeSettings.tsx @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import React from 'react'; +import { jsx, css } from '@emotion/core'; +import formatMessage from 'format-message'; +import { FontSizes, FontWeights } from 'office-ui-fabric-react/lib/Styling'; + +import { CollapsableWrapper } from '../../components/CollapsableWrapper'; + +import { RuntimeSettings as Runtime } from './runtime-settings/RuntimeSettings'; + +// -------------------- Styles -------------------- // + +const titleStyle = css` + font-size: ${FontSizes.medium}; + font-weight: ${FontWeights.semibold}; + margin-left: 22px; + margin-top: 6px; +`; + +// -------------------- RuntimeSettings -------------------- // + +type RuntimeSettingsProps = { + projectId: string; +}; + +export const RuntimeSettings: React.FC = (props) => { + const { projectId } = props; + + return ( + + + + ); +}; diff --git a/Composer/packages/client/src/pages/botProject/SkillBotExternalService.tsx b/Composer/packages/client/src/pages/botProject/SkillBotExternalService.tsx new file mode 100644 index 0000000000..69f4023c54 --- /dev/null +++ b/Composer/packages/client/src/pages/botProject/SkillBotExternalService.tsx @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import React from 'react'; +import { jsx } from '@emotion/core'; +import { useRecoilValue } from 'recoil'; +import formatMessage from 'format-message'; +import get from 'lodash/get'; +import { css } from '@emotion/core'; +import { FontSizes, FontWeights } from 'office-ui-fabric-react/lib/Styling'; + +import { isLUISMandatory, isQnAKeyMandatory } from '../../utils/dialogValidator'; +import { + dispatcherState, + settingsState, + luFilesState, + qnaFilesState, + validateDialogsSelectorFamily, +} from '../../recoilModel'; +import settingStorage from '../../utils/dialogSettingStorage'; +import { rootBotProjectIdSelector } from '../../recoilModel/selectors/project'; +import { CollapsableWrapper } from '../../components/CollapsableWrapper'; +import { TextFieldWithCustomButton } from '../../components/TextFieldWithCustomButton'; +import { mergePropertiesManagedByRootBot } from '../../recoilModel/dispatchers/utils/project'; +// -------------------- Styles -------------------- // + +const titleStyle = css` + font-size: ${FontSizes.medium}; + font-weight: ${FontWeights.semibold}; + margin-left: 22px; + margin-top: 6px; +`; + +const externalServiceContainerStyle = css` + display: flex; + flex-direction: column; +`; + +// -------------------- ExternalService -------------------- // + +type SkillBotExternalServiceProps = { + projectId: string; +}; + +export const SkillBotExternalService: React.FC = (props) => { + const { projectId } = props; + const { setSettings, setQnASettings } = useRecoilValue(dispatcherState); + const rootBotProjectId = useRecoilValue(rootBotProjectIdSelector) || ''; + const settings = useRecoilValue(settingsState(projectId)); + const mergedSettings = mergePropertiesManagedByRootBot(projectId, rootBotProjectId, settings); + const sensitiveGroupManageProperty = settingStorage.get(rootBotProjectId); + + const groupLUISAuthoringKey = get(sensitiveGroupManageProperty, 'luis.authoringKey', {}); + const rootLuisKey = groupLUISAuthoringKey.root; + const skillLuisKey = groupLUISAuthoringKey[projectId]; + const groupLUISRegion = get(sensitiveGroupManageProperty, 'luis.authoringRegion', {}); + const rootLuisRegion = groupLUISRegion.root; + const skillLuisRegion = groupLUISRegion[projectId]; + const groupQnAKey = get(sensitiveGroupManageProperty, 'qna.subscriptionKey', {}); + const rootqnaKey = groupQnAKey.root; + const skillqnaKey = groupQnAKey[projectId]; + + const dialogs = useRecoilValue(validateDialogsSelectorFamily(projectId)); + const luFiles = useRecoilValue(luFilesState(projectId)); + const qnaFiles = useRecoilValue(qnaFilesState(projectId)); + const isLUISKeyNeeded = isLUISMandatory(dialogs, luFiles); + const isQnAKeyNeeded = isQnAKeyMandatory(dialogs, qnaFiles); + + const handleSkillQnAKeyOnBlur = (key: string) => { + if (key) { + submitQnASubscripionKey(key); + } else { + submitQnASubscripionKey(rootqnaKey); + } + }; + + const submitQnASubscripionKey = (key: string) => { + if (key) { + setSettings(projectId, { + ...mergedSettings, + qna: { ...mergedSettings.qna, subscriptionKey: key }, + }); + setQnASettings(projectId, key); + } else { + setSettings(projectId, { + ...mergedSettings, + qna: { ...mergedSettings.qna, subscriptionKey: '', endpointKey: '' }, + }); + } + }; + + const handleLUISRegionOnBlur = (value) => { + setSettings(projectId, { + ...mergedSettings, + luis: { ...mergedSettings.luis, authoringRegion: value ? value : '' }, + }); + }; + + const handleLUISKeyOnBlur = (value) => { + setSettings(projectId, { + ...mergedSettings, + luis: { ...mergedSettings.luis, authoringKey: value ? value : '' }, + }); + }; + + return ( + +
+ "} + required={isLUISKeyNeeded} + value={skillLuisKey} + onBlur={handleLUISKeyOnBlur} + /> + "} + required={isLUISKeyNeeded} + value={skillLuisRegion} + onBlur={handleLUISRegionOnBlur} + /> + "} + required={isQnAKeyNeeded} + value={skillqnaKey} + onBlur={handleSkillQnAKeyOnBlur} + /> +
+
+ ); +}; diff --git a/Composer/packages/client/src/pages/botProject/SkillHostEndPoint.tsx b/Composer/packages/client/src/pages/botProject/SkillHostEndPoint.tsx new file mode 100644 index 0000000000..bceb453ca8 --- /dev/null +++ b/Composer/packages/client/src/pages/botProject/SkillHostEndPoint.tsx @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import React from 'react'; +import { jsx, css } from '@emotion/core'; +import { useRecoilValue } from 'recoil'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { TooltipHost } from 'office-ui-fabric-react/lib/Tooltip'; +import { Icon } from 'office-ui-fabric-react/lib/Icon'; +import formatMessage from 'format-message'; +import { FontSizes, FontWeights } from 'office-ui-fabric-react/lib/Styling'; +import { SharedColors } from '@uifabric/fluent-theme'; + +import { dispatcherState, settingsState } from '../../recoilModel'; +import { CollapsableWrapper } from '../../components/CollapsableWrapper'; +import { rootBotProjectIdSelector } from '../../recoilModel/selectors/project'; +import { mergePropertiesManagedByRootBot } from '../../recoilModel/dispatchers/utils/project'; +// -------------------- Styles -------------------- // + +const titleStyle = css` + font-size: ${FontSizes.medium}; + font-weight: ${FontWeights.semibold}; + margin-left: 22px; + margin-top: 6px; +`; + +const labelContainer = css` + display: flex; + flex-direction: row; +`; + +const customerLabel = css` + font-size: ${FontSizes.small}; + margin-right: 5px; +`; + +const unknownIconStyle = (required) => { + return { + root: { + selectors: { + '&::before': { + content: required ? " '*'" : '', + color: SharedColors.red10, + paddingRight: 3, + }, + }, + }, + }; +}; + +// -------------------- SkillHostEndPoint -------------------- // + +type SkillHostEndPointProps = { + projectId: string; +}; + +const onRenderLabel = (props) => { + return ( +
+
{props.label}
+ + + +
+ ); +}; + +export const SkillHostEndPoint: React.FC = (props) => { + const { projectId } = props; + const { setSettings } = useRecoilValue(dispatcherState); + const settings = useRecoilValue(settingsState(projectId)); + const rootBotProjectId = useRecoilValue(rootBotProjectIdSelector); + const mergedSettings = mergePropertiesManagedByRootBot(projectId, rootBotProjectId, settings); + const { skillHostEndpoint } = useRecoilValue(settingsState(projectId)); + + return ( + + { + setSettings(projectId, { + ...mergedSettings, + skillHostEndpoint: value, + }); + }} + onRenderLabel={onRenderLabel} + /> + + ); +}; diff --git a/Composer/packages/client/src/pages/setting/runtime-settings/RuntimeSettings.tsx b/Composer/packages/client/src/pages/botProject/runtime-settings/RuntimeSettings.tsx similarity index 83% rename from Composer/packages/client/src/pages/setting/runtime-settings/RuntimeSettings.tsx rename to Composer/packages/client/src/pages/botProject/runtime-settings/RuntimeSettings.tsx index 59557a652a..9a8d05ba64 100644 --- a/Composer/packages/client/src/pages/setting/runtime-settings/RuntimeSettings.tsx +++ b/Composer/packages/client/src/pages/botProject/runtime-settings/RuntimeSettings.tsx @@ -6,8 +6,10 @@ import { jsx } from '@emotion/core'; import { useState, Fragment, useEffect } from 'react'; import formatMessage from 'format-message'; import { Toggle } from 'office-ui-fabric-react/lib/Toggle'; -import { TextField } from 'office-ui-fabric-react/lib/TextField'; import { DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { TooltipHost } from 'office-ui-fabric-react/lib/Tooltip'; +import { Icon } from 'office-ui-fabric-react/lib/Icon'; import { Link } from 'office-ui-fabric-react/lib/Link'; import { RouteComponentProps } from '@reach/router'; import { useRecoilValue } from 'recoil'; @@ -26,7 +28,16 @@ import { LoadingSpinner } from '../../../components/LoadingSpinner'; import { EjectModal } from './ejectModal'; import { WorkingModal } from './workingModal'; -import { breathingSpace, runtimeSettingsStyle, runtimeControls, runtimeToggle, controlGroup } from './style'; +import { + breathingSpace, + runtimeSettingsStyle, + runtimeControls, + runtimeToggle, + labelContainer, + customerLabel, + iconStyle, + textOr, +} from './style'; export const RuntimeSettings: React.FC> = (props) => { const { projectId = '' } = props; @@ -54,10 +65,10 @@ export const RuntimeSettings: React.FC { // check the status of the boilerplate material and see if it requires an update if (projectId) getBoilerplateVersion(projectId); - }, []); + }, [projectId]); useEffect(() => { - setNeedsUpdate(boilerplateVersion.updateRequired || false); + setNeedsUpdate(!!boilerplateVersion.updateRequired); }, [boilerplateVersion.updateRequired]); useEffect(() => { @@ -67,7 +78,7 @@ export const RuntimeSettings: React.FC { + const toggleCustomRuntime = (_, isOn = false) => { setCustomRuntime(projectId, isOn); }; @@ -90,17 +101,17 @@ export const RuntimeSettings: React.FC (
-

{formatMessage('Configure Composer to start your bot using runtime code you can customize and control.')}

+ {formatMessage('Configure Composer to start your bot using runtime code you can customize and control.')}
); - const toggle = () => ( + const toggleOfCustomRuntime = () => (
); @@ -153,11 +164,22 @@ export const RuntimeSettings: React.FC { + return ( +
+
{props.label}
+ + + +
+ ); + }; + return botName ? (
{header()} - {toggle()} -
+ {toggleOfCustomRuntime()} +
- {formatMessage('Or: ')} + {formatMessage('Or: ')}

{needsUpdate && ( -
+

{formatMessage( 'A newer version of the provisioning scripts has been found, and this project can be updated to the latest.' @@ -208,8 +232,8 @@ export const RuntimeSettings: React.FC

)} -
) : ( diff --git a/Composer/packages/client/src/pages/setting/runtime-settings/ejectModal.tsx b/Composer/packages/client/src/pages/botProject/runtime-settings/ejectModal.tsx similarity index 77% rename from Composer/packages/client/src/pages/setting/runtime-settings/ejectModal.tsx rename to Composer/packages/client/src/pages/botProject/runtime-settings/ejectModal.tsx index f93ddf23c8..6aa35076a5 100644 --- a/Composer/packages/client/src/pages/setting/runtime-settings/ejectModal.tsx +++ b/Composer/packages/client/src/pages/botProject/runtime-settings/ejectModal.tsx @@ -3,22 +3,22 @@ /** @jsx jsx */ import { jsx } from '@emotion/core'; import { useEffect, useMemo, useState } from 'react'; -import { Dialog, DialogType } from 'office-ui-fabric-react/lib/Dialog'; import formatMessage from 'format-message'; import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; import { DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; import { ChoiceGroup, IChoiceGroupOption } from 'office-ui-fabric-react/lib/ChoiceGroup'; import { useRecoilValue } from 'recoil'; +import { DialogWrapper, DialogTypes } from '@bfc/ui-shared'; import { runtimeTemplatesState, dispatcherState } from '../../../recoilModel'; import { modalControlGroup } from './style'; -export interface EjectModalProps { +export type EjectModalProps = { ejectRuntime: (templateKey: string) => Promise; hidden: boolean; - closeModal: () => void; -} + onDismiss: () => void; +}; export const EjectModal: React.FC = (props) => { const [selectedTemplate, setSelectedTemplate] = useState(); @@ -52,27 +52,22 @@ export const EjectModal: React.FC = (props) => { }; return ( - + ); }; diff --git a/Composer/packages/client/src/pages/botProject/runtime-settings/style.ts b/Composer/packages/client/src/pages/botProject/runtime-settings/style.ts new file mode 100644 index 0000000000..b38a7f0236 --- /dev/null +++ b/Composer/packages/client/src/pages/botProject/runtime-settings/style.ts @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { css } from '@emotion/core'; +import { NeutralColors, SharedColors } from '@uifabric/fluent-theme'; +import { FontWeights, FontSizes } from 'office-ui-fabric-react/lib/Styling'; +export const runtimeSettingsStyle = css` + display: flex; + flex-direction: column; + box-sizing: border-box; + height: 200px; +`; + +export const runtimeControls = css` + color: ${NeutralColors.gray130}; + font-size: ${FontSizes.smallPlus}; + & > h1 { + margin-top: 0; + } +`; + +export const runtimeToggle = css` + display: flex; + margin-top: 10px; + & > * { + margin-right: 2rem; + } +`; + +export const modalControlGroup = css` + border: 1px solid rgb(237, 235, 233); + padding: 0.5rem 1rem 1rem 1rem; +`; + +export const runtimeControlsTitle = css` + font-size: ${FontSizes.xLarge}; + font-weight: ${FontWeights.semibold}; +`; + +export const breathingSpace = css` + margin-bottom: 1rem; + font-size: ${FontSizes.smallPlus}; +`; + +export const labelContainer = css` + display: flex; + flex-direction: row; +`; + +export const customerLabel = (disabled) => css` + font-size: ${FontSizes.small}; + margin-right: 5px; + color: ${disabled ? NeutralColors.gray90 : NeutralColors.gray160}; +`; + +export const iconStyle = (disabled) => { + return { + root: { + color: disabled ? NeutralColors.gray90 : NeutralColors.gray160, + selectors: { + '&::before': { + content: " '*'", + color: SharedColors.red10, + paddingRight: 3, + }, + }, + }, + }; +}; + +export const textOr = css` + font-size: ${FontSizes.smallPlus}; +`; diff --git a/Composer/packages/client/src/pages/setting/runtime-settings/workingModal.tsx b/Composer/packages/client/src/pages/botProject/runtime-settings/workingModal.tsx similarity index 55% rename from Composer/packages/client/src/pages/setting/runtime-settings/workingModal.tsx rename to Composer/packages/client/src/pages/botProject/runtime-settings/workingModal.tsx index d00e6dfaf3..4779f8e8dd 100644 --- a/Composer/packages/client/src/pages/setting/runtime-settings/workingModal.tsx +++ b/Composer/packages/client/src/pages/botProject/runtime-settings/workingModal.tsx @@ -3,31 +3,22 @@ /** @jsx jsx */ import { jsx } from '@emotion/core'; -import { Dialog, DialogType } from 'office-ui-fabric-react/lib/Dialog'; +import { DialogWrapper, DialogTypes } from '@bfc/ui-shared'; import { LoadingSpinner } from '../../../components/LoadingSpinner'; import { modalControlGroup } from './style'; -export interface WorkingModalProps { - hidden: boolean; +export type WorkingModalProps = { + isOpen: boolean; title: string; -} +}; export const WorkingModal: React.FC = (props) => { return ( - + ); }; diff --git a/Composer/packages/client/src/pages/setting/SettingsPage.tsx b/Composer/packages/client/src/pages/setting/SettingsPage.tsx index 37b61f2e77..8aa356d4d1 100644 --- a/Composer/packages/client/src/pages/setting/SettingsPage.tsx +++ b/Composer/packages/client/src/pages/setting/SettingsPage.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. /** @jsx jsx */ -import { jsx } from '@emotion/core'; +import { jsx, css } from '@emotion/core'; import { useMemo, useEffect } from 'react'; import formatMessage from 'format-message'; import { RouteComponentProps } from '@reach/router'; @@ -29,6 +29,15 @@ import { useProjectIdCache } from '../../utils/hooks'; import { SettingsRoutes } from './router'; +const header = css` + padding: 5px 20px; + display: flex; + flex-direction: column; + flex-shrink: 0; + justify-content: space-between; + label: PageHeader; +`; + const getProjectLink = (path: string, id?: string) => { return id ? `/settings/bot/${id}/${path}` : `/settings/${path}`; }; @@ -73,15 +82,7 @@ const SettingPage: React.FC = () => { }; const links: INavTreeItem[] = [ - { - id: 'dialog-settings', - name: settingLabels.botSettings, - url: getProjectLink('dialog-settings', projectId), - disabled: !projectId, - }, { id: 'application', name: settingLabels.appSettings, url: getProjectLink('application') }, - { id: 'runtime', name: settingLabels.runtime, url: getProjectLink('runtime', projectId), disabled: !projectId }, - { id: 'extensions', name: settingLabels.extensions, url: getProjectLink('extensions') }, { id: 'about', name: settingLabels.about, url: getProjectLink('about') }, ]; @@ -227,13 +228,21 @@ const SettingPage: React.FC = () => { return settingLabels.appSettings; }, [location.pathname]); + const onRenderHeaderContent = () => { + return formatMessage( + 'This Page contains detailed information about your bot. For security reasons, they are hidden by default. To test your bot or publish to Azure, you may need to provide these settings' + ); + }; + return ( = () => { return (
+
+

{formatMessage('Application Language settings')}

+ +

{formatMessage('Onboarding')}

= (props) => { - const { id, title, description, dropdownWidth, image, onChange, options, selected } = props; + const { id, title, onChange, options, selected } = props; const uniqueId = useId(kebabCase(title)); + const onRenderLabel = (props) => { + return ( +
+
{props.label}
+ + + +
+ ); + }; + return (
- -
- -

{description}

-
-
- onChange(option?.key?.toString() ?? '')} - /> -
+ onChange(option?.key?.toString() ?? '')} + onRenderLabel={onRenderLabel} + />
); }; diff --git a/Composer/packages/client/src/pages/setting/app-settings/styles.ts b/Composer/packages/client/src/pages/setting/app-settings/styles.ts index e1765f855f..3bbb448320 100644 --- a/Composer/packages/client/src/pages/setting/app-settings/styles.ts +++ b/Composer/packages/client/src/pages/setting/app-settings/styles.ts @@ -8,6 +8,7 @@ import { FontWeights } from '@uifabric/styling'; export const container = css` label: SettingsContainer; + width: 700px; `; export const section = css` @@ -38,14 +39,15 @@ export const link: ILinkStyles = { export const settingsContainer = css` display: flex; - margin-left: 48px; border-top: 1px solid ${NeutralColors.gray20}; padding: 20px 0px; + width: 100%; `; export const settingsContent = css` width: 245px; - margin: 0 32px; + margin-left: 32px; + margin-right: 300px; font-size: ${FontSizes.size14}; `; @@ -58,6 +60,22 @@ export const image = css` width: 86px; `; +export const labelContainer = css` + display: flex; + flex-direction: row; +`; + +export const customerLabel = css` + font-size: ${FontSizes.size12}; + margin-right: 5px; +`; + +export const icon = { + root: { + fontSize: FontSizes.size12, + }, +}; + export const featureFlagGroupContainer = css` margin-left: 166px; font-size: ${FontSizes.size12}; diff --git a/Composer/packages/client/src/pages/setting/router.tsx b/Composer/packages/client/src/pages/setting/router.tsx index 6236121241..bc67e14a81 100644 --- a/Composer/packages/client/src/pages/setting/router.tsx +++ b/Composer/packages/client/src/pages/setting/router.tsx @@ -11,7 +11,6 @@ import { About } from '../about/About'; import { DialogSettings } from './dialog-settings/DialogSettings'; import { AppSettings } from './app-settings/AppSettings'; -import { RuntimeSettings } from './runtime-settings/RuntimeSettings'; import { Extensions } from './extensions/Extensions'; export const SettingsRoutes = React.memo(({ projectId }: { projectId: string }) => { @@ -33,7 +32,6 @@ export const SettingsRoutes = React.memo(({ projectId }: { projectId: string }) - diff --git a/Composer/packages/client/src/pages/setting/runtime-settings/style.ts b/Composer/packages/client/src/pages/setting/runtime-settings/style.ts deleted file mode 100644 index b9b1577d10..0000000000 --- a/Composer/packages/client/src/pages/setting/runtime-settings/style.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { css } from '@emotion/core'; -import { FontWeights, FontSizes } from 'office-ui-fabric-react/lib/Styling'; -export const runtimeSettingsStyle = css` - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - padding: 1rem; - display: flex; - flex-direction: column; - box-sizing: border-box; -`; - -export const runtimeControls = css` - margin-bottom: 18px; - - & > h1 { - margin-top: 0; - } -`; - -export const runtimeToggle = css` - display: flex; - - & > * { - margin-right: 2rem; - } -`; - -export const controlGroup = css` - border: 1px solid rgb(237, 235, 233); - padding: 0.5rem 1rem 1rem 1rem; -`; - -export const modalControlGroup = css` - border: 1px solid rgb(237, 235, 233); - padding: 0.5rem 1rem 1rem 1rem; -`; - -export const runtimeControlsTitle = css` - font-size: ${FontSizes.xLarge}; - font-weight: ${FontWeights.semibold}; -`; - -export const breathingSpace = css` - margin-bottom: 1rem; -`; diff --git a/Composer/packages/client/src/recoilModel/dispatchers/botProjectFile.ts b/Composer/packages/client/src/recoilModel/dispatchers/botProjectFile.ts index f28eb4f356..1df1315091 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/botProjectFile.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/botProjectFile.ts @@ -12,7 +12,7 @@ import { botNameIdentifierState, botProjectFileState, locationState, settingsSta import { rootBotProjectIdSelector } from '../selectors'; import { dispatcherState } from '../DispatcherWrapper'; -import { setSettingState } from './setting'; +import { setRootBotSettingState } from './setting'; export const botProjectFileDispatcher = () => { const addLocalSkill = useRecoilCallback(({ set, snapshot }: CallbackInterface) => async (skillId: string) => { @@ -81,7 +81,7 @@ export const botProjectFileDispatcher = () => { delete draftState.skill[botNameIdentifier]; } }); - setSettingState(callbackHelpers, rootBotProjectId, updatedSettings); + setRootBotSettingState(callbackHelpers, rootBotProjectId, updatedSettings); } }); diff --git a/Composer/packages/client/src/recoilModel/dispatchers/multilang.ts b/Composer/packages/client/src/recoilModel/dispatchers/multilang.ts index ad03d0afdf..02d6cb7c45 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/multilang.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/multilang.ts @@ -131,6 +131,15 @@ export const multilangDispatcher = () => { set(showDelLanguageModalState(projectId), false); set(onDelLanguageDialogCompleteState(projectId), { func: undefined }); + + //use default language as active language if active language is deleted + const botName = await snapshot.getPromise(botDisplayNameState(projectId)); + const currentActiveLanguage = languageStorage.get(botName)?.locale; + if (languages.includes(currentActiveLanguage)) { + const defaultLanguage = settings.defaultLanguage; + set(localeState(projectId), defaultLanguage); + languageStorage.setLocale(botName, defaultLanguage); + } } ); diff --git a/Composer/packages/client/src/recoilModel/dispatchers/project.ts b/Composer/packages/client/src/recoilModel/dispatchers/project.ts index 98c1011c36..14760cffba 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/project.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/project.ts @@ -4,6 +4,8 @@ import formatMessage from 'format-message'; import findIndex from 'lodash/findIndex'; +import { RootBotManagedProperties } from '@bfc/shared'; +import get from 'lodash/get'; import { CallbackInterface, useRecoilCallback } from 'recoil'; import { BotStatus } from '../../constants'; @@ -203,6 +205,17 @@ export const projectDispatcher = () => { isRemote: false, }); projectIdCache.set(projectId); + + //migration on some sensitive property in browser local storage + for (const property of RootBotManagedProperties) { + const settings = settingStorage.get(projectId); + const value = get(settings, property, ''); + if (!value.root && value.root !== '') { + const newValue = { root: value }; + settingStorage.setField(projectId, property, newValue); + } + } + if (navigate) { navigateToBot(callbackHelpers, projectId, mainDialog); } diff --git a/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts b/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts index a22c0019ad..2ee753d36c 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts @@ -22,7 +22,7 @@ import { rootBotProjectIdSelector } from '../selectors'; import { BotStatus, Text } from './../../constants'; import httpClient from './../../utils/httpUtil'; import { logMessage, setError } from './shared'; -import { setSettingState } from './setting'; +import { setRootBotSettingState } from './setting'; const PUBLISH_SUCCESS = 200; const PUBLISH_PENDING = 202; @@ -89,7 +89,7 @@ export const publisherDispatcher = () => { ...settings, skillHostEndpoint: endpointURL + '/api/skills', }; - setSettingState(callbackHelpers, projectId, updatedSettings); + setRootBotSettingState(callbackHelpers, projectId, updatedSettings); } } set(botStatusState(projectId), BotStatus.connected); diff --git a/Composer/packages/client/src/recoilModel/dispatchers/setting.ts b/Composer/packages/client/src/recoilModel/dispatchers/setting.ts index adebbffb71..6b0771eb5a 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/setting.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/setting.ts @@ -3,37 +3,117 @@ /* eslint-disable react-hooks/rules-of-hooks */ import { CallbackInterface, useRecoilCallback } from 'recoil'; -import { SensitiveProperties, DialogSetting, PublishTarget } from '@bfc/shared'; +import { SensitiveProperties, RootBotManagedProperties, DialogSetting, PublishTarget } from '@bfc/shared'; import get from 'lodash/get'; +import set from 'lodash/set'; import has from 'lodash/has'; +import cloneDeep from 'lodash/cloneDeep'; import settingStorage from '../../utils/dialogSettingStorage'; import { settingsState } from '../atoms/botState'; +import { rootBotProjectIdSelector, botProjectSpaceSelector } from '../selectors/project'; import httpClient from './../../utils/httpUtil'; import { setError } from './shared'; -export const setSettingState = async ( +export const setRootBotSettingState = async ( callbackHelpers: CallbackInterface, projectId: string, settings: DialogSetting ) => { - const { set } = callbackHelpers; + const { set: recoilSet, snapshot } = callbackHelpers; + // set value in local storage + for (const property of SensitiveProperties) { + if (!RootBotManagedProperties.includes(property) && has(settings, property)) { + const propertyValue = get(settings, property, ''); + settingStorage.setField(projectId, property, propertyValue); + } + } + + const rootProjectId = await snapshot.getPromise(rootBotProjectIdSelector); + //store RootBotManagedProperties in browser localStorage + for (const property of RootBotManagedProperties) { + if (has(settings, property) && rootProjectId) { + const propertyValue = get(settings, property, ''); + const groupPropertyValue = get(settingStorage.get(rootProjectId), property, ''); + const newGroupPropertyValue = { ...groupPropertyValue, root: propertyValue }; + settingStorage.setField(rootProjectId, property, newGroupPropertyValue); + } + } + + //sync skill bots' RootBotManagedProperties with root bot + const botProjectSpaceData = await snapshot.getPromise(botProjectSpaceSelector); + for (let i = 0; i < botProjectSpaceData.length; i++) { + const botProject = botProjectSpaceData[i]; + if (!botProject.isRootBot && !botProject.isRemote && rootProjectId) { + const skillSettings = await snapshot.getPromise(settingsState(botProject.projectId)); + const newSkillSettings = cloneDeep(skillSettings); + const localStorageSettings = settingStorage.get(rootProjectId); + for (const property of RootBotManagedProperties) { + const propertyValue = get(settings, property, ''); + const skillBotValue = get(localStorageSettings, property, {})[botProject.projectId]; + const shouldUseRootProperty = !skillBotValue; + if (shouldUseRootProperty) { + set(newSkillSettings, property, propertyValue); + } else { + set(newSkillSettings, property, skillBotValue); + } + } + recoilSet(settingsState(botProject.projectId), newSkillSettings); + } + } + recoilSet(settingsState(projectId), settings); +}; +export const setSkillBotSettingState = async ( + callbackHelpers: CallbackInterface, + projectId: string, + settings: DialogSetting +) => { + const { set: recoilSet, snapshot } = callbackHelpers; // set value in local storage for (const property of SensitiveProperties) { - if (has(settings, property)) { + if (!RootBotManagedProperties.includes(property) && has(settings, property)) { const propertyValue = get(settings, property, ''); settingStorage.setField(projectId, property, propertyValue); } } - set(settingsState(projectId), settings); + + const rootProjectId = await snapshot.getPromise(rootBotProjectIdSelector); + //store RootBotManagedProperties in browser localStorage + for (const property of RootBotManagedProperties) { + if (has(settings, property) && rootProjectId) { + const propertyValue = get(settings, property, ''); + const groupPropertyValue = get(settingStorage.get(rootProjectId), property, ''); + const newGroupPropertyValue = { ...groupPropertyValue, [projectId]: propertyValue }; + settingStorage.setField(rootProjectId, property, newGroupPropertyValue); + } + } + + //Use root bot's RootBotManagedProperties value if those of the skill bot's are empty + if (rootProjectId) { + const rootSettings = await snapshot.getPromise(settingsState(rootProjectId)); + for (const property of RootBotManagedProperties) { + const propertyValue = get(settings, property, ''); + const rootPropertyValue = get(rootSettings, property, ''); + if (!propertyValue) { + set(settings, property, rootPropertyValue); + } + } + } + recoilSet(settingsState(projectId), settings); }; export const settingsDispatcher = () => { const setSettings = useRecoilCallback<[string, DialogSetting], Promise>( (callbackHelpers: CallbackInterface) => async (projectId: string, settings: DialogSetting) => { - setSettingState(callbackHelpers, projectId, settings); + const { snapshot } = callbackHelpers; + const rootBotProjectId = await snapshot.getPromise(rootBotProjectIdSelector); + if (projectId === rootBotProjectId) { + setRootBotSettingState(callbackHelpers, projectId, settings); + } else { + setSkillBotSettingState(callbackHelpers, projectId, settings); + } } ); @@ -98,6 +178,7 @@ export const settingsDispatcher = () => { } } ); + return { setSettings, setRuntimeSettings, diff --git a/Composer/packages/client/src/recoilModel/dispatchers/skill.ts b/Composer/packages/client/src/recoilModel/dispatchers/skill.ts index f494d24a84..02c6b4e735 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/skill.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/skill.ts @@ -16,7 +16,7 @@ import { botEndpointsState, } from '../atoms'; -import { setSettingState } from './setting'; +import { setRootBotSettingState } from './setting'; export const skillDispatcher = () => { // For endpoints in manifests the settings are updated immediately in SelectSkill. If "Composer Local" is chosen needs updating when rootbot is started. @@ -53,7 +53,7 @@ export const skillDispatcher = () => { }); } } - setSettingState(callbackHelpers, rootBotId, updatedSettings); + setRootBotSettingState(callbackHelpers, rootBotId, updatedSettings); }); const createSkillManifest = async (callbackHelpers: CallbackInterface, { id, content, projectId }) => { diff --git a/Composer/packages/client/src/recoilModel/dispatchers/utils/project.ts b/Composer/packages/client/src/recoilModel/dispatchers/utils/project.ts index 061c2ce3fa..bdec296055 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/utils/project.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/utils/project.ts @@ -18,16 +18,20 @@ import { LuFile, QnAFile, SensitiveProperties, + RootBotManagedProperties, defaultPublishConfig, } from '@bfc/shared'; import formatMessage from 'format-message'; import camelCase from 'lodash/camelCase'; import objectGet from 'lodash/get'; import objectSet from 'lodash/set'; +import cloneDeep from 'lodash/cloneDeep'; import { stringify } from 'query-string'; import { CallbackInterface } from 'recoil'; import { v4 as uuid } from 'uuid'; import isEmpty from 'lodash/isEmpty'; +import get from 'lodash/get'; +import set from 'lodash/set'; import { BotStatus, QnABotTemplateId } from '../../../constants'; import settingStorage from '../../../utils/dialogSettingStorage'; @@ -79,7 +83,8 @@ import { botRuntimeOperationsSelector, rootBotProjectIdSelector } from '../../se import { undoHistoryState } from '../../undo/history'; import UndoHistory from '../../undo/undoHistory'; import { logMessage, setError } from '../shared'; -import { setSettingState } from '../setting'; +import { setRootBotSettingState } from '../setting'; +import settingsStorage from '../../../utils/dialogSettingStorage'; import { crossTrainConfigState } from './../../atoms/botState'; import { recognizersSelectorFamily } from './../../selectors/recognizers'; @@ -129,6 +134,9 @@ const mergeLocalStorage = (projectId: string, settings: DialogSetting) => { const mergedSettings = { ...settings }; if (localSetting) { for (const property of SensitiveProperties) { + if (RootBotManagedProperties.includes(property)) { + continue; + } const value = objectGet(localSetting, property); if (value) { objectSet(mergedSettings, property, value); @@ -140,6 +148,41 @@ const mergeLocalStorage = (projectId: string, settings: DialogSetting) => { return mergedSettings; }; +export const mergePropertiesManagedByRootBot = (projectId: string, rootBotProjectId, settings: DialogSetting) => { + const localSetting = settingStorage.get(rootBotProjectId); + const mergedSettings = cloneDeep(settings); + if (localSetting) { + for (const property of RootBotManagedProperties) { + const rootValue = get(localSetting, property, {}).root; + if (projectId === rootBotProjectId) { + objectSet(mergedSettings, property, rootValue ?? ''); + } + if (projectId !== rootBotProjectId) { + const skillValue = get(localSetting, property, {})[projectId]; + objectSet(mergedSettings, property, skillValue ?? ''); + } + } + } + return mergedSettings; +}; + +export const getSensitiveProperties = (projectId: string, rootBotProjectId: string) => { + const rootBotLocalStorage = settingsStorage.get(rootBotProjectId); + const skillBotLocalStorage = settingsStorage.get(projectId); + const sensitiveProperties = {}; + for (const property of SensitiveProperties) { + if (!RootBotManagedProperties.includes(property)) { + const value = get(skillBotLocalStorage, property, ''); + set(sensitiveProperties, property, value); + } else { + const groupValue = get(rootBotLocalStorage, property, {}); + const value = get(groupValue, projectId, ''); + set(sensitiveProperties, property, value); + } + } + return sensitiveProperties; +}; + export const getMergedSettings = (projectId, settings): DialogSetting => { const mergedSettings = mergeLocalStorage(projectId, settings); if (Array.isArray(mergedSettings.skill)) { @@ -526,7 +569,7 @@ const openRootBotAndSkills = async (callbackHelpers: CallbackInterface, data, st mergedSettings.skill ); if (!isEmpty(skillSettings)) { - setSettingState(callbackHelpers, rootBotProjectId, { + setRootBotSettingState(callbackHelpers, rootBotProjectId, { ...mergedSettings, skill: skillSettings, }); diff --git a/Composer/packages/client/src/recoilModel/selectors/project.ts b/Composer/packages/client/src/recoilModel/selectors/project.ts index cbb56698f7..a1d4540a2d 100644 --- a/Composer/packages/client/src/recoilModel/selectors/project.ts +++ b/Composer/packages/client/src/recoilModel/selectors/project.ts @@ -14,7 +14,6 @@ import { botProjectIdsState, formDialogSchemaIdsState, formDialogSchemaState, - settingsState, luFilesState, lgFilesState, qnaFilesState, @@ -22,6 +21,7 @@ import { dialogSchemasState, jsonSchemaFilesState, projectMetaDataState, + settingsState, dialogIdsState, dialogState, } from '../atoms'; @@ -97,6 +97,7 @@ export const botProjectSpaceSelector = selector({ projectId, name, ...metaData, + setting, error: botError, diagnostics, botNameId, diff --git a/Composer/packages/client/src/router.tsx b/Composer/packages/client/src/router.tsx index 1be6c53bb7..aa7c6c8926 100644 --- a/Composer/packages/client/src/router.tsx +++ b/Composer/packages/client/src/router.tsx @@ -12,17 +12,28 @@ import { resolveToBasePath } from './utils/fileUtil'; import { data } from './styles'; import { NotFound } from './components/NotFound'; import { BASEPATH } from './constants'; -import { dispatcherState, schemasState, botProjectIdsState, botOpeningState, pluginPagesSelector } from './recoilModel'; +import { + dispatcherState, + schemasState, + botProjectIdsState, + botOpeningState, + pluginPagesSelector, + botProjectSpaceSelector, +} from './recoilModel'; +import { rootBotProjectIdSelector } from './recoilModel/selectors/project'; import { openAlertModal } from './components/Modal/AlertDialog'; import { dialogStyle } from './components/Modal/dialogStyle'; import { LoadingSpinner } from './components/LoadingSpinner'; import { PluginPageContainer } from './pages/plugin/PluginPageContainer'; +import { botProjectSpaceLoadedState } from './recoilModel/atoms'; +import { mergePropertiesManagedByRootBot } from './recoilModel/dispatchers/utils/project'; const DesignPage = React.lazy(() => import('./pages/design/DesignPage')); const LUPage = React.lazy(() => import('./pages/language-understanding/LUPage')); const QnAPage = React.lazy(() => import('./pages/knowledge-base/QnAPage')); const LGPage = React.lazy(() => import('./pages/language-generation/LGPage')); const SettingPage = React.lazy(() => import('./pages/setting/SettingsPage')); +const BotProjectSettings = React.lazy(() => import('./pages/botProject/BotProjectSettings')); const Diagnostics = React.lazy(() => import('./pages/diagnostics/Diagnostics')); const Publish = React.lazy(() => import('./pages/publish/Publish')); const BotCreationFlowRouter = React.lazy(() => import('./components/CreationFlow/CreationFlow')); @@ -48,6 +59,7 @@ const Routes = (props) => { /> + @@ -55,6 +67,7 @@ const Routes = (props) => { + @@ -106,8 +119,25 @@ const projectStyle = css` const ProjectRouter: React.FC> = (props) => { const { projectId = '' } = props; const schemas = useRecoilValue(schemasState(projectId)); - const { fetchProjectById } = useRecoilValue(dispatcherState); + const { fetchProjectById, setSettings } = useRecoilValue(dispatcherState); const botProjects = useRecoilValue(botProjectIdsState); + const botProjectsMetaData = useRecoilValue(botProjectSpaceSelector); + const botProjectSpaceLoaded = useRecoilValue(botProjectSpaceLoadedState); + const rootBotProjectId = useRecoilValue(rootBotProjectIdSelector); + + useEffect(() => { + if (botProjectSpaceLoaded && rootBotProjectId && botProjectsMetaData) { + for (let i = 0; i < botProjectsMetaData.length; i++) { + if (!botProjectsMetaData[i].isRemote) { + const id = botProjectsMetaData[i].projectId; + const setting = botProjectsMetaData[i].setting; + const mergedSettings = mergePropertiesManagedByRootBot(id, rootBotProjectId, setting); + setSettings(id, mergedSettings); + } + } + } + }, [botProjectSpaceLoaded, rootBotProjectId]); + useEffect(() => { if (props.projectId && !botProjects.includes(props.projectId)) { fetchProjectById(props.projectId); diff --git a/Composer/packages/client/src/utils/dialogValidator.ts b/Composer/packages/client/src/utils/dialogValidator.ts index fe7e850374..7b35c2cf94 100644 --- a/Composer/packages/client/src/utils/dialogValidator.ts +++ b/Composer/packages/client/src/utils/dialogValidator.ts @@ -1,11 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. import get from 'lodash/get'; -import { DialogInfo, ITrigger } from '@bfc/shared'; +import { DialogInfo, ITrigger, LuFile, QnAFile } from '@bfc/shared'; import { regexRecognizerKey, onChooseIntentKey, qnaMatcherKey } from '../utils/dialogUtil'; import { triggerNotSupportedWarning } from '../constants'; +import { getBaseName } from './fileUtil'; + export const isRegExRecognizerType = (dialog: DialogInfo | undefined) => { if (!dialog) return false; return get(dialog, 'content.recognizer.$kind', '') === regexRecognizerKey; @@ -36,3 +38,19 @@ export const triggerNotSupported = (dialog: DialogInfo | undefined, trigger: ITr } return ''; }; + +export const isLUISMandatory = (dialogs: DialogInfo[], luFiles: LuFile[]) => { + return dialogs.some((dialog) => { + const isDefault = isLUISnQnARecognizerType(dialog); + const luFile = luFiles.find((luFile) => getBaseName(luFile.id) === dialog.id); + return !!(isDefault && luFile?.content); + }); +}; + +export const isQnAKeyMandatory = (dialogs: DialogInfo[], qnaFiles: QnAFile[]) => { + return dialogs.some((dialog) => { + const isDefault = isLUISnQnARecognizerType(dialog); + const qnaFile = qnaFiles.find((qnaFile) => getBaseName(qnaFile.id) === dialog.id); + return !!(isDefault && qnaFile?.content); + }); +}; diff --git a/Composer/packages/client/src/utils/pageLinks.ts b/Composer/packages/client/src/utils/pageLinks.ts index c2016b993b..a8f04e1b03 100644 --- a/Composer/packages/client/src/utils/pageLinks.ts +++ b/Composer/packages/client/src/utils/pageLinks.ts @@ -71,6 +71,13 @@ export const topLinks = ( exact: true, disabled: !botLoaded, }, + { + to: `/bot/${projectId}/botProjectsSettings`, + iconName: 'BotProjectsSettings', + labelName: formatMessage('Bot Projects'), + exact: true, + disabled: !botLoaded, + }, ...(showFormDialog ? [ { diff --git a/Composer/packages/lib/shared/src/constant.ts b/Composer/packages/lib/shared/src/constant.ts index ac883acaa4..345dbfa0be 100644 --- a/Composer/packages/lib/shared/src/constant.ts +++ b/Composer/packages/lib/shared/src/constant.ts @@ -3,11 +3,14 @@ export const SensitiveProperties = [ 'MicrosoftAppPassword', - 'luis.authoringKey', 'luis.endpointKey', - 'qna.subscriptionKey', 'qna.endpointKey', + 'luis.authoringKey', + 'qna.subscriptionKey', ]; + +export const RootBotManagedProperties = ['luis.authoringKey', 'luis.authoringRegion', 'qna.subscriptionKey']; + export const FieldNames = { Events: 'triggers', Actions: 'actions', diff --git a/Composer/packages/lib/ui-shared/src/components/DialogWrapper.tsx b/Composer/packages/lib/ui-shared/src/components/DialogWrapper.tsx index 32bc7de3db..ee60a9f52d 100644 --- a/Composer/packages/lib/ui-shared/src/components/DialogWrapper.tsx +++ b/Composer/packages/lib/ui-shared/src/components/DialogWrapper.tsx @@ -11,6 +11,7 @@ import { IModalStyles } from 'office-ui-fabric-react/lib/Modal'; export enum DialogTypes { CreateFlow, DesignFlow, + Customer, } // -------------------- Styles -------------------- // @@ -61,14 +62,33 @@ const styles: { interface DialogWrapperProps extends Pick { isOpen: boolean; - title: string; - subText: string; + title?: string; + subText?: string; dialogType: DialogTypes; + customerStyle?: { + dialog?: Record; + modal?: Record; + }; + minWidth?: number; } export const DialogWrapper: React.FC = (props) => { - const { isOpen, onDismiss, title, subText, children, dialogType } = props; + const { + isOpen, + onDismiss, + title = '', + subText = '', + children, + dialogType, + customerStyle = { dialog: {}, modal: {} }, + minWidth, + } = props; const [currentStyle, setStyle] = useState(styles[dialogType]); + + if (dialogType === DialogTypes.Customer) { + styles[DialogTypes.Customer] = customerStyle; + } + useEffect(() => { if (dialogType) { setStyle(styles[dialogType]); @@ -88,6 +108,7 @@ export const DialogWrapper: React.FC = (props) => { styles: currentStyle.dialog, }} hidden={false} + minWidth={minWidth} modalProps={{ isBlocking: false, styles: currentStyle.modal,