diff --git a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/renderers/EdgeMenu.test.tsx b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/renderers/EdgeMenu.test.tsx index 3c73a33342..83e51fce14 100644 --- a/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/renderers/EdgeMenu.test.tsx +++ b/Composer/packages/adaptive-flow/__tests__/adaptive-flow-editor/renderers/EdgeMenu.test.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { render } from '@botframework-composer/test-utils'; -import { DialogGroup } from '@bfc/shared'; +import { DialogGroup, SDKKinds } from '@bfc/shared'; import { EdgeMenu } from '../../../src/adaptive-flow-editor/renderers/EdgeMenu'; import { createActionMenu } from '../../../src/adaptive-flow-editor/renderers/EdgeMenu/createSchemaMenu'; @@ -16,17 +16,42 @@ describe('', () => { }); describe('createActionMenu()', () => { + fit('Should disable an action kind if its included in forceDisabledActions', () => { + const menuItems = createActionMenu( + () => null, + { isSelfHosted: false, enablePaste: true }, + [ + { + kind: SDKKinds.BeginSkill, + reason: 'Cannot call a skill from another skill', + }, + ], + { + [SDKKinds.BeginDialog]: { + label: 'Begin skill', + submenu: [SDKKinds.BeginSkill], + }, + } + ); + const sdkBeginSkill = menuItems.find((item) => item.key === SDKKinds.BeginSkill); + + expect(sdkBeginSkill).toBeDefined(); + if (sdkBeginSkill) { + expect(sdkBeginSkill.disabled).toBeTruthy(); + } + }); + it('options.enablePaste should control Paste button state.', () => { - const menuItems1 = createActionMenu(() => null, { isSelfHosted: false, enablePaste: true }); + const menuItems1 = createActionMenu(() => null, { isSelfHosted: false, enablePaste: true }, []); expect(menuItems1.findIndex((x) => x.key === 'Paste')).toEqual(0); expect(menuItems1[0].disabled).toBeFalsy(); - const menuItems2 = createActionMenu(() => null, { isSelfHosted: false, enablePaste: false }); + const menuItems2 = createActionMenu(() => null, { isSelfHosted: false, enablePaste: false }, []); expect(menuItems2[0].disabled).toBeTruthy(); }); it('should return builtin $kinds.', () => { - const menuItemsHosted = createActionMenu(() => null, { isSelfHosted: true, enablePaste: true }); + const menuItemsHosted = createActionMenu(() => null, { isSelfHosted: true, enablePaste: true }, []); expect(menuItemsHosted.findIndex((x) => x.key === DialogGroup.RESPONSE)).toBeTruthy(); }); @@ -34,6 +59,7 @@ describe('createActionMenu()', () => { const menuItemsWithoutCustomActions = createActionMenu( () => null, { isSelfHosted: false, enablePaste: false }, + [], {}, [] ); @@ -46,6 +72,7 @@ describe('createActionMenu()', () => { const withCustomActions = createActionMenu( () => null, { isSelfHosted: false, enablePaste: false }, + [], {}, customActions ); diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/renderers/EdgeMenu/EdgeMenu.tsx b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/renderers/EdgeMenu/EdgeMenu.tsx index 4480a54344..e75b18649d 100644 --- a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/renderers/EdgeMenu/EdgeMenu.tsx +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/renderers/EdgeMenu/EdgeMenu.tsx @@ -7,7 +7,7 @@ import { useContext, useState } from 'react'; import formatMessage from 'format-message'; import { DefinitionSummary } from '@bfc/shared'; import { TooltipHost, DirectionalHint } from 'office-ui-fabric-react/lib/Tooltip'; -import { useMenuConfig, MenuUISchema } from '@bfc/extension-client'; +import { useMenuConfig } from '@bfc/extension-client'; // TODO: leak of visual-sdk domain (EdgeAddButtonSize) import { EdgeAddButtonSize } from '../../../adaptive-flow-renderer/constants/ElementSizes'; @@ -51,7 +51,7 @@ export const EdgeMenu: React.FC = ({ id, onClick }) => { setMenuSelected(menuSelected); }; - const menuSchema: MenuUISchema = useMenuConfig(); + const { menuSchema, forceDisabledActions } = useMenuConfig(); const menuItems = createActionMenu( (item) => { if (!item) return; @@ -61,6 +61,7 @@ export const EdgeMenu: React.FC = ({ id, onClick }) => { isSelfHosted: selfHosted, enablePaste: Array.isArray(clipboardActions) && !!clipboardActions.length, }, + forceDisabledActions, menuSchema, // Custom Action 'oneOf' arrays from schema file customSchemas.map((x) => x.oneOf).filter((oneOf) => Array.isArray(oneOf) && oneOf.length) as DefinitionSummary[][] diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/renderers/EdgeMenu/createSchemaMenu.tsx b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/renderers/EdgeMenu/createSchemaMenu.tsx index 1215fad996..c208999d3a 100644 --- a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/renderers/EdgeMenu/createSchemaMenu.tsx +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/renderers/EdgeMenu/createSchemaMenu.tsx @@ -7,16 +7,30 @@ import { IContextualMenuItem, ContextualMenuItemType, } from 'office-ui-fabric-react/lib/components/ContextualMenu/ContextualMenu.types'; -import { SDKKinds, DefinitionSummary } from '@bfc/shared'; +import { NeutralColors } from '@uifabric/fluent-theme'; +import { SDKKinds, DefinitionSummary, DisabledMenuActions } from '@bfc/shared'; import { FontIcon } from 'office-ui-fabric-react/lib/Icon'; import formatMessage from 'format-message'; import { MenuUISchema, MenuOptions } from '@bfc/extension-client'; import set from 'lodash/set'; +import { ITooltipHostStyles, TooltipHost } from 'office-ui-fabric-react/lib/Tooltip'; import { MenuEventTypes } from '../../constants/MenuTypes'; import { menuOrderMap } from './defaultMenuOrder'; +const toolTipHostStyles: Partial = { + root: { + display: 'flex', + justifyContent: 'flex-start', + alignItems: 'center', + padding: '0px 7px', + height: '38px', + background: NeutralColors.gray30, + opacity: 0.3, + }, +}; + type ActionMenuItemClickHandler = (item?: IContextualMenuItem) => any; type ActionKindFilter = ($kind: SDKKinds) => boolean; @@ -25,6 +39,7 @@ type MenuTree = { [key: string]: SDKKinds | MenuTree }; const createBaseActionMenu = ( menuSchema: MenuUISchema, onClick: ActionMenuItemClickHandler, + forceDisabledActions: DisabledMenuActions[], filter?: ActionKindFilter ): IContextualMenuItem[] => { const menuTree: MenuTree = Object.entries(menuSchema).reduce((result, [$kind, options]) => { @@ -62,7 +77,7 @@ const createBaseActionMenu = ( return order1 - order2; }) .map((sublabelName) => buildMenuItemFromMenuTree(sublabelName, labelData[sublabelName])); - return createSubMenu(labelName, onClick, subMenuItems); + return createSubMenu(labelName, onClick, subMenuItems, forceDisabledActions); } }; @@ -174,13 +189,41 @@ interface ActionMenuOptions { const createSubMenu = ( label: string, onClick: ActionMenuItemClickHandler, - subItems: IContextualMenuItem[] + subItems: IContextualMenuItem[], + forceDisabledActions: DisabledMenuActions[] ): IContextualMenuItem => { + const subMenuItems = subItems.map((subMenuItem: IContextualMenuItem) => { + let additionalProps: Partial = {}; + const disabledAction = forceDisabledActions.find((action) => action.kind === subMenuItem.key); + if (disabledAction) { + additionalProps = { + title: disabledAction.reason, + disabled: true, + onRender: (item) => { + const tooltipId = `tooltip-${disabledAction.kind}`; + return ( + + {item.name} + + ); + }, + }; + } + return { + ...subMenuItem, + ...additionalProps, + }; + }); return { key: label, text: label, subMenuProps: { - items: subItems, + items: subMenuItems, onItemClick: (e, itemData) => onClick(itemData), }, }; @@ -189,6 +232,7 @@ const createSubMenu = ( export const createActionMenu = ( onClick: ActionMenuItemClickHandler, options: ActionMenuOptions, + forceDisabledActions: DisabledMenuActions[], menuSchema?: MenuUISchema, customActionGroups?: DefinitionSummary[][] ) => { @@ -199,6 +243,7 @@ export const createActionMenu = ( const baseMenuItems = createBaseActionMenu( menuOptions, onClick, + forceDisabledActions, options.isSelfHosted ? ($kind: SDKKinds) => $kind !== SDKKinds.LogAction : undefined ); resultItems.push(...baseMenuItems); @@ -217,7 +262,7 @@ export const createActionMenu = ( submenuWithDuplicatedName.subMenuProps?.items.push(...customActionItems); } else { // Otherwise create a new submenu named as 'Custom Actions'. - resultItems.push(createSubMenu(customActionGroupName, onClick, customActionItems)); + resultItems.push(createSubMenu(customActionGroupName, onClick, customActionItems, [])); } } } diff --git a/Composer/packages/client/src/shell/useShell.ts b/Composer/packages/client/src/shell/useShell.ts index 13c6d19a00..a9962e3bc0 100644 --- a/Composer/packages/client/src/shell/useShell.ts +++ b/Composer/packages/client/src/shell/useShell.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { useMemo, useRef } from 'react'; -import { ShellApi, ShellData, Shell, DialogSchemaFile, DialogInfo } from '@botframework-composer/types'; +import { ShellApi, ShellData, Shell, DialogSchemaFile, DialogInfo, SDKKinds } from '@botframework-composer/types'; import { useRecoilValue } from 'recoil'; import formatMessage from 'format-message'; @@ -27,6 +27,7 @@ import { lgFilesState, luFilesState, rateInfoState, + rootBotProjectIdSelector, } from '../recoilModel'; import { undoFunctionState } from '../recoilModel/undo/history'; @@ -78,6 +79,8 @@ export function useShell(source: EventSource, projectId: string): Shell { const botName = useRecoilValue(botDisplayNameState(projectId)); const settings = useRecoilValue(settingsState(projectId)); const flowZoomRate = useRecoilValue(rateInfoState); + const rootBotProjectId = useRecoilValue(rootBotProjectIdSelector); + const isRootBot = rootBotProjectId === projectId; const userSettings = useRecoilValue(userSettingsState); const clipboardActions = useRecoilValue(clipboardActionsState); @@ -271,6 +274,14 @@ export function useShell(source: EventSource, projectId: string): Shell { skills, skillsSettings: settings.skill || {}, flowZoomRate, + forceDisabledActions: isRootBot + ? [] + : [ + { + kind: SDKKinds.BeginSkill, + reason: formatMessage('You can only connect to a skill in the root bot.'), + }, + ], }; return { diff --git a/Composer/packages/extension-client/src/hooks/__tests__/useMenuConfig.test.tsx b/Composer/packages/extension-client/src/hooks/__tests__/useMenuConfig.test.tsx index bf78dbc363..865f45a2ba 100644 --- a/Composer/packages/extension-client/src/hooks/__tests__/useMenuConfig.test.tsx +++ b/Composer/packages/extension-client/src/hooks/__tests__/useMenuConfig.test.tsx @@ -42,7 +42,7 @@ describe('useMenuConfig', () => { it('returns a map of sdk kinds to their menu config', () => { const { result } = renderHook(() => useMenuConfig(), { wrapper }); - expect(result.current).toEqual({ + expect(result.current.menuSchema).toEqual({ foo: 'foo menu config', bar: 'bar menu config', }); diff --git a/Composer/packages/extension-client/src/hooks/useMenuConfig.ts b/Composer/packages/extension-client/src/hooks/useMenuConfig.ts index f51c4909b8..5ceea30fa5 100644 --- a/Composer/packages/extension-client/src/hooks/useMenuConfig.ts +++ b/Composer/packages/extension-client/src/hooks/useMenuConfig.ts @@ -3,15 +3,17 @@ import { useContext, useMemo } from 'react'; import mapValues from 'lodash/mapValues'; +import { DisabledMenuActions } from '@botframework-composer/types'; import { EditorExtensionContext } from '../EditorExtensionContext'; import { MenuUISchema } from '../types'; -export function useMenuConfig(): MenuUISchema { +export function useMenuConfig(): { menuSchema: MenuUISchema; forceDisabledActions: DisabledMenuActions[] } { const { plugins, shellData } = useContext(EditorExtensionContext); const uiSchema = plugins.uiSchema || {}; const sdkSchema = shellData.schemas?.sdk; const sdkDefinitions = sdkSchema?.content?.definitions || {}; + const forceDisabledActions = shellData.forceDisabledActions; return useMemo(() => { const menuSchema = mapValues(uiSchema, 'menu') as MenuUISchema; @@ -24,6 +26,6 @@ export function useMenuConfig(): MenuUISchema { } }); - return implementedMenuSchema; + return { menuSchema: implementedMenuSchema, forceDisabledActions }; }, [plugins.uiSchema, sdkSchema]); } diff --git a/Composer/packages/types/src/shell.ts b/Composer/packages/types/src/shell.ts index 6062fbc3ba..cd1bf9f8e5 100644 --- a/Composer/packages/types/src/shell.ts +++ b/Composer/packages/types/src/shell.ts @@ -4,7 +4,7 @@ import type { DialogInfo, LuFile, LgFile, QnAFile, LuIntentSection, LgTemplate, DialogSchemaFile } from './indexers'; import type { ILUFeaturesConfig, SkillSetting, UserSettings } from './settings'; -import type { JSONSchema7 } from './schema'; +import type { JSONSchema7, SDKKinds } from './schema'; import { MicrosoftIDialog } from './sdk'; /** Recursively marks all properties as optional. */ @@ -41,6 +41,11 @@ export type BotSchemas = { diagnostics?: any[]; }; +export type DisabledMenuActions = { + kind: SDKKinds; + reason: string; +}; + export type ApplicationContextApi = { navTo: (path: string, rest?: any) => void; updateUserSettings: (settings: AllPartial) => void; @@ -108,6 +113,7 @@ export type ProjectContext = { skills: any[]; skillsSettings: Record; schemas: BotSchemas; + forceDisabledActions: DisabledMenuActions[]; }; export type ActionContextApi = {