From f486808dbcad0a04610ce5822643f1970e3e4c76 Mon Sep 17 00:00:00 2001 From: zeye <2295905420@qq.com> Date: Fri, 1 May 2020 02:46:07 +0800 Subject: [PATCH] feat: support creating actions from ejected schema in Visual Editor (#2806) * bring 'Custom Actions' menu * move `createStepMenu` into visual editor * remove EventMenu (outdated) * refactor: EdgeMenu util * define 'oneOf' type * connect to global customSchemas * load customSchema files * create a schema hook * merge custom schemas in FormEditor * add comments * rename variable customSchemas * fix lint * revert some changes * calculate schema diff as custom schema * fix lint Co-authored-by: Chris Whitten --- .../src/components/menus/EdgeMenu.tsx | 105 ++-------- .../src/components/menus/EventMenu.tsx | 34 --- .../src/components/menus/createSchemaMenu.tsx | 197 ++++++++++++++++++ .../src/editors/EventsEditor.tsx | 5 +- .../extensions/visual-designer/src/index.tsx | 21 +- .../src/store/NodeRendererContext.ts | 4 +- .../src/utils/getCustomSchema.ts | 37 ++++ .../packages/lib/shared/src/types/schema.ts | 7 + .../packages/lib/shared/src/types/shell.ts | 2 + Composer/packages/lib/shared/src/viewUtils.ts | 105 ---------- .../server/src/models/bot/botProject.ts | 1 + 11 files changed, 285 insertions(+), 233 deletions(-) delete mode 100644 Composer/packages/extensions/visual-designer/src/components/menus/EventMenu.tsx create mode 100644 Composer/packages/extensions/visual-designer/src/components/menus/createSchemaMenu.tsx create mode 100644 Composer/packages/extensions/visual-designer/src/utils/getCustomSchema.ts diff --git a/Composer/packages/extensions/visual-designer/src/components/menus/EdgeMenu.tsx b/Composer/packages/extensions/visual-designer/src/components/menus/EdgeMenu.tsx index 7065463c66..b89118b961 100644 --- a/Composer/packages/extensions/visual-designer/src/components/menus/EdgeMenu.tsx +++ b/Composer/packages/extensions/visual-designer/src/components/menus/EdgeMenu.tsx @@ -2,13 +2,11 @@ // Licensed under the MIT License. /** @jsx jsx */ -import { jsx, css } from '@emotion/core'; +import { jsx } from '@emotion/core'; import { useCallback, useContext, useState } from 'react'; import classnames from 'classnames'; import formatMessage from 'format-message'; -import { createStepMenu, DialogGroup, SDKKinds } from '@bfc/shared'; -import { IContextualMenu, ContextualMenuItemType } from 'office-ui-fabric-react/lib/ContextualMenu'; -import { FontIcon } from 'office-ui-fabric-react/lib/Icon'; +import { DefinitionSummary } from '@bfc/shared'; import { EdgeAddButtonSize } from '../../constants/ElementSizes'; import { NodeRendererContext } from '../../store/NodeRendererContext'; @@ -19,6 +17,7 @@ import { MenuTypes } from '../../constants/MenuTypes'; import { ObiColors } from '../../constants/ElementColors'; import { IconMenu } from './IconMenu'; +import { createActionMenu } from './createSchemaMenu'; interface EdgeMenuProps { id: string; @@ -26,85 +25,8 @@ interface EdgeMenuProps { addCoachMarkRef?: (ref: { [key: string]: HTMLDivElement }) => void; } -const buildEdgeMenuItemsFromClipboardContext = ( - context, - onClick, - filter?: (t: SDKKinds) => boolean -): IContextualMenu[] => { - const { clipboardActions } = context; - const menuItems = createStepMenu( - [ - DialogGroup.RESPONSE, - DialogGroup.INPUT, - DialogGroup.BRANCHING, - DialogGroup.LOOPING, - DialogGroup.STEP, - DialogGroup.MEMORY, - DialogGroup.CODE, - DialogGroup.LOG, - ], - true, - (e, item) => onClick(item ? item.data.$kind : null), - context.dialogFactory, - filter - ); - - const enablePaste = Array.isArray(clipboardActions) && clipboardActions.length > 0; - const menuItemCount = menuItems.length; - menuItems.unshift( - { - key: 'Paste', - name: 'Paste', - ariaLabel: 'Paste', - disabled: !enablePaste, - onRender: () => { - return ( - - ); - }, - }, - { - key: 'divider', - itemType: ContextualMenuItemType.Divider, - } - ); - - return menuItems; -}; - export const EdgeMenu: React.FC = ({ id, addCoachMarkRef, onClick, ...rest }) => { - const nodeContext = useContext(NodeRendererContext); + const { clipboardActions, customSchemas } = useContext(NodeRendererContext); const selfHosted = useContext(SelfHostContext); const { selectedIds } = useContext(SelectionContext); const nodeSelected = selectedIds.includes(`${id}${MenuTypes.EdgeMenu}`); @@ -124,6 +46,19 @@ export const EdgeMenu: React.FC = ({ id, addCoachMarkRef, onClick const handleMenuShow = menuSelected => { setMenuSelected(menuSelected); }; + + const menuItems = createActionMenu( + item => { + if (!item) return; + onClick(item.key); + }, + { + isSelfHosted: selfHosted, + enablePaste: Array.isArray(clipboardActions) && !!clipboardActions.length, + }, + // Custom Action 'oneOf' arrays from schema file + customSchemas.map(x => x.oneOf).filter(oneOf => Array.isArray(oneOf) && oneOf.length) as DefinitionSummary[][] + ); return (
= ({ id, addCoachMarkRef, onClick }} iconSize={7} nodeSelected={nodeSelected} - menuItems={buildEdgeMenuItemsFromClipboardContext( - nodeContext, - onClick, - selfHosted ? x => x !== SDKKinds.LogAction : undefined - )} + menuItems={menuItems} label={formatMessage('Add')} handleMenuShow={handleMenuShow} {...rest} diff --git a/Composer/packages/extensions/visual-designer/src/components/menus/EventMenu.tsx b/Composer/packages/extensions/visual-designer/src/components/menus/EventMenu.tsx deleted file mode 100644 index 7067aebeb2..0000000000 --- a/Composer/packages/extensions/visual-designer/src/components/menus/EventMenu.tsx +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import React, { useContext } from 'react'; -import { createStepMenu, DialogGroup } from '@bfc/shared'; - -import { NodeRendererContext } from '../../store/NodeRendererContext'; - -import { IconMenu } from './IconMenu'; - -interface EventMenuProps { - label?: string; - onClick: (item: string | null) => void; -} - -export const EventMenu: React.FC = ({ label, onClick, ...rest }): JSX.Element => { - const { dialogFactory } = useContext(NodeRendererContext); - const eventMenuItems = createStepMenu( - [DialogGroup.EVENTS], - false, - (e, item): any => onClick(item ? item.data.$kind : null), - dialogFactory - ); - - return ( - - ); -}; diff --git a/Composer/packages/extensions/visual-designer/src/components/menus/createSchemaMenu.tsx b/Composer/packages/extensions/visual-designer/src/components/menus/createSchemaMenu.tsx new file mode 100644 index 0000000000..6fc67fc17b --- /dev/null +++ b/Composer/packages/extensions/visual-designer/src/components/menus/createSchemaMenu.tsx @@ -0,0 +1,197 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx, css } from '@emotion/core'; +import { + IContextualMenuItem, + ContextualMenuItemType, +} from 'office-ui-fabric-react/lib/components/ContextualMenu/ContextualMenu.types'; +import { ConceptLabels, DialogGroup, dialogGroups, SDKKinds, DefinitionSummary } from '@bfc/shared'; +import { FontIcon } from 'office-ui-fabric-react/lib/Icon'; +import formatMessage from 'format-message'; + +const resolveMenuTitle = ($kind: SDKKinds): string => { + const conceptLabel = ConceptLabels[$kind]; + return conceptLabel?.title || $kind; +}; + +type ActionMenuItemClickHandler = (item?: IContextualMenuItem) => any; +type ActionKindFilter = ($kind: SDKKinds) => boolean; + +const createBaseActionMenu = ( + onClick: ActionMenuItemClickHandler, + filter?: ActionKindFilter +): IContextualMenuItem[] => { + const pickedGroups: DialogGroup[] = [ + DialogGroup.RESPONSE, + DialogGroup.INPUT, + DialogGroup.BRANCHING, + DialogGroup.LOOPING, + DialogGroup.STEP, + DialogGroup.MEMORY, + DialogGroup.CODE, + DialogGroup.LOG, + ]; + const stepMenuItems = pickedGroups + .map(key => dialogGroups[key]) + .filter(groupItem => groupItem && Array.isArray(groupItem.types) && groupItem.types.length) + .map(({ label, types: actionKinds }) => { + const subMenuItems: IContextualMenuItem[] = actionKinds + .filter($kind => (filter ? filter($kind) : true)) + .map($kind => ({ + key: $kind, + name: resolveMenuTitle($kind), + onClick: (e, itemData) => onClick(itemData), + })); + + if (subMenuItems.length === 1) { + // hoists the only item to upper level + return subMenuItems[0]; + } + return createSubMenu(label, onClick, subMenuItems); + }); + return stepMenuItems; +}; + +const createDivider = () => ({ + key: 'divider', + itemType: ContextualMenuItemType.Divider, +}); + +const get$kindFrom$ref = ($ref: string): SDKKinds => { + return $ref.replace('#/definitions/', '') as SDKKinds; +}; + +const createCustomActionSubMenu = ( + customizedActionGroups: DefinitionSummary[][], + onClick: ActionMenuItemClickHandler +): IContextualMenuItem[] => { + if (!Array.isArray(customizedActionGroups) || customizedActionGroups.length === 0) { + return []; + } + + const itemGroups: IContextualMenuItem[][] = customizedActionGroups + .filter(actionGroup => Array.isArray(actionGroup) && actionGroup.length) + .map(actionGroup => { + return actionGroup.map( + ({ title, $ref }) => + ({ + key: get$kindFrom$ref($ref), + name: title, + onClick: (e, itemData) => onClick(itemData), + } as IContextualMenuItem) + ); + }); + + const flatMenuItems: IContextualMenuItem[] = itemGroups.reduce((resultItems, currentGroup, currentIndex) => { + if (currentIndex !== 0) { + // push a sep line ahead. + resultItems.push(createDivider()); + } + resultItems.push(...currentGroup); + return resultItems; + }, []); + + return flatMenuItems; +}; + +const createPasteButtonItem = ( + menuItemCount: number, + disabled: boolean, + onClick: ActionMenuItemClickHandler +): IContextualMenuItem => { + return { + key: 'Paste', + name: 'Paste', + ariaLabel: 'Paste', + disabled: disabled, + onRender: () => { + return ( + + ); + }, + }; +}; + +interface ActionMenuOptions { + isSelfHosted: boolean; + enablePaste: boolean; +} + +const createSubMenu = ( + label: string, + onClick: ActionMenuItemClickHandler, + subItems: IContextualMenuItem[] +): IContextualMenuItem => { + return { + key: label, + text: label, + subMenuProps: { + items: subItems, + onItemClick: (e, itemData) => onClick(itemData), + }, + }; +}; + +export const createActionMenu = ( + onClick: ActionMenuItemClickHandler, + options: ActionMenuOptions, + customActionGroups?: DefinitionSummary[][] +) => { + const resultItems: IContextualMenuItem[] = []; + + // base SDK menu + const baseMenuItems = createBaseActionMenu( + onClick, + options.isSelfHosted ? ($kind: SDKKinds) => $kind !== SDKKinds.LogAction : undefined + ); + resultItems.push(...baseMenuItems); + + // Append a 'Custom Actions' item conditionally. + if (customActionGroups) { + const customActionItems = createCustomActionSubMenu(customActionGroups, onClick); + if (customActionItems.length) { + const customActionTitle = formatMessage('Custom Actions'); + resultItems.push(createSubMenu(customActionTitle, onClick, customActionItems)); + } + } + + // paste button + const pasteButtonDisabled = !options.enablePaste; + const pasteButton = createPasteButtonItem(resultItems.length, pasteButtonDisabled, onClick); + resultItems.unshift(pasteButton, createDivider()); + + return resultItems; +}; diff --git a/Composer/packages/extensions/visual-designer/src/editors/EventsEditor.tsx b/Composer/packages/extensions/visual-designer/src/editors/EventsEditor.tsx index 380553aa20..1765e3c1bd 100644 --- a/Composer/packages/extensions/visual-designer/src/editors/EventsEditor.tsx +++ b/Composer/packages/extensions/visual-designer/src/editors/EventsEditor.tsx @@ -5,7 +5,6 @@ import React, { FC } from 'react'; import { Panel } from '../components/lib/Panel'; import { RuleGroup, CollapsedRuleGroup } from '../components/groups'; -import { EventMenu } from '../components/menus/EventMenu'; import { NodeEventTypes } from '../constants/NodeEventTypes'; import { EditorProps } from './editorProps'; @@ -14,8 +13,6 @@ export const EventsEditor: FC = ({ id, data, onEvent }): JSX.Elemen const ruleCount = data.children.length; const title = `Events (${ruleCount})`; - const onClick = $kind => onEvent(NodeEventTypes.Insert, { id, $kind, position: ruleCount }); - return ( = ({ id, data, onEvent }): JSX.Elemen onEvent(NodeEventTypes.FocusEvent, ''); }} collapsedItems={} - addMenu={} + addMenu={null} > diff --git a/Composer/packages/extensions/visual-designer/src/index.tsx b/Composer/packages/extensions/visual-designer/src/index.tsx index 906149c786..0e7313021d 100644 --- a/Composer/packages/extensions/visual-designer/src/index.tsx +++ b/Composer/packages/extensions/visual-designer/src/index.tsx @@ -4,7 +4,7 @@ /** @jsx jsx */ import { jsx, css, CacheProvider } from '@emotion/core'; import createCache from '@emotion/cache'; -import React, { useRef } from 'react'; +import React, { useRef, useMemo } from 'react'; import isEqual from 'lodash/isEqual'; import formatMessage from 'format-message'; import { DialogFactory } from '@bfc/shared'; @@ -16,6 +16,7 @@ import { SelfHostContext } from './store/SelfHostContext'; import { FlowSchemaContext } from './store/FlowSchemaContext'; import { FlowSchemaProvider } from './schema/flowSchemaProvider'; import { mergePluginConfig } from './utils/mergePluginConfig'; +import { getCustomSchema } from './utils/getCustomSchema'; formatMessage.setup({ missingTranslation: 'ignore', @@ -41,7 +42,16 @@ export interface VisualDesignerProps { } const VisualDesigner: React.FC = ({ schema }): JSX.Element => { const { shellApi, plugins, ...shellData } = useShellApi(); - const { dialogId, focusedEvent, focusedActions, focusedTab, clipboardActions, data: inputData, hosted } = shellData; + const { + dialogId, + focusedEvent, + focusedActions, + focusedTab, + clipboardActions, + data: inputData, + hosted, + schemas, + } = shellData; const dataCache = useRef({}); @@ -77,6 +87,12 @@ const VisualDesigner: React.FC = ({ schema }): JSX.Element const focusedId = Array.isArray(focusedActions) && focusedActions[0] ? focusedActions[0] : ''; + // Compute schema diff + const customSchema = useMemo(() => getCustomSchema(schemas?.default, schemas?.sdk?.content), [ + schemas?.sdk?.content, + schemas?.default, + ]); + const nodeContext: NodeRendererContextValue = { focusedId, focusedEvent, @@ -89,6 +105,7 @@ const VisualDesigner: React.FC = ({ schema }): JSX.Element removeLgTemplates, removeLuIntent, dialogFactory: new DialogFactory(schema), + customSchemas: customSchema ? [customSchema] : [], }; const visualEditorConfig = mergePluginConfig(...plugins); diff --git a/Composer/packages/extensions/visual-designer/src/store/NodeRendererContext.ts b/Composer/packages/extensions/visual-designer/src/store/NodeRendererContext.ts index 4e7aadd7be..0d45eee0c9 100644 --- a/Composer/packages/extensions/visual-designer/src/store/NodeRendererContext.ts +++ b/Composer/packages/extensions/visual-designer/src/store/NodeRendererContext.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import React from 'react'; -import { ShellApi, DialogFactory } from '@bfc/shared'; +import { ShellApi, DialogFactory, OBISchema } from '@bfc/shared'; type ShellApiFuncs = | 'getLgTemplates' @@ -18,6 +18,7 @@ export interface NodeRendererContextValue extends Pick focusedTab?: string; clipboardActions: any[]; dialogFactory: DialogFactory; + customSchemas: OBISchema[]; } export const NodeRendererContext = React.createContext({ @@ -32,4 +33,5 @@ export const NodeRendererContext = React.createContext updateLgTemplate: () => Promise.resolve(), removeLuIntent: () => Promise.resolve(), dialogFactory: new DialogFactory(), + customSchemas: [], }); diff --git a/Composer/packages/extensions/visual-designer/src/utils/getCustomSchema.ts b/Composer/packages/extensions/visual-designer/src/utils/getCustomSchema.ts new file mode 100644 index 0000000000..dd6dac5737 --- /dev/null +++ b/Composer/packages/extensions/visual-designer/src/utils/getCustomSchema.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { OBISchema } from '@bfc/shared'; + +export const getCustomSchema = (baseSchema?: OBISchema, ejectedSchema?: OBISchema): OBISchema | undefined => { + if (!baseSchema || !ejectedSchema) return; + + const baseDefinitions = baseSchema.definitions; + const baseKindHash = Object.keys(baseDefinitions).reduce((hash, $kind) => { + hash[$kind] = true; + return hash; + }, {}); + + const ejectedDefinitions = ejectedSchema.definitions; + const diffKinds = Object.keys(ejectedDefinitions).filter($kind => !baseKindHash[$kind]); + if (diffKinds.length === 0) return; + + const diffSchema = diffKinds.reduce( + (schema, $kind) => { + const definition = ejectedDefinitions[$kind]; + schema.definitions[$kind] = definition; + schema.oneOf?.push({ + title: definition.title || $kind, + description: definition.description || '', + $ref: `#/definitions/${$kind}`, + }); + return schema; + }, + { + oneOf: [], + definitions: {}, + } as OBISchema + ); + + return diffSchema; +}; diff --git a/Composer/packages/lib/shared/src/types/schema.ts b/Composer/packages/lib/shared/src/types/schema.ts index a1aeedae7e..12ed04d3fc 100644 --- a/Composer/packages/lib/shared/src/types/schema.ts +++ b/Composer/packages/lib/shared/src/types/schema.ts @@ -121,6 +121,12 @@ export enum SDKRoles { // union_*_ = 'union(*)', } +export interface DefinitionSummary { + title: string; + description: string; + $ref: string; +} + export interface OBISchema extends JSONSchema6 { $schema?: string; $role?: string; @@ -132,6 +138,7 @@ export interface OBISchema extends JSONSchema6 { [key: string]: any; }; description?: string; + oneOf?: DefinitionSummary[]; definitions?: any; title?: string; __additional_property?: boolean; diff --git a/Composer/packages/lib/shared/src/types/shell.ts b/Composer/packages/lib/shared/src/types/shell.ts index ce276bb98b..b2c8af700c 100644 --- a/Composer/packages/lib/shared/src/types/shell.ts +++ b/Composer/packages/lib/shared/src/types/shell.ts @@ -4,6 +4,7 @@ import { DialogInfo, LuFile, LgFile, LuIntentSection, LgTemplate } from './indexers'; import { UserSettings } from './settings'; +import { OBISchema } from './schema'; /** Recursively marks all properties as optional. */ type AllPartial = { @@ -18,6 +19,7 @@ export interface EditorSchema { } export interface BotSchemas { + default?: OBISchema; sdk?: any; diagnostics?: any[]; } diff --git a/Composer/packages/lib/shared/src/viewUtils.ts b/Composer/packages/lib/shared/src/viewUtils.ts index 81df4043ad..97224f1a6e 100644 --- a/Composer/packages/lib/shared/src/viewUtils.ts +++ b/Composer/packages/lib/shared/src/viewUtils.ts @@ -1,15 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { - IContextualMenuItem, - IContextualMenuProps, -} from 'office-ui-fabric-react/lib/components/ContextualMenu/ContextualMenu.types'; import get from 'lodash/get'; import { SDKKinds } from './types'; import { ConceptLabels } from './labelMap'; -import { DialogFactory } from './dialogFactory'; export const PROMPT_TYPES = [ SDKKinds.AttachmentInput, @@ -161,106 +156,6 @@ export const dialogGroups: DialogGroupsMap = { }, }; -const menuItemHandler = ( - factory: DialogFactory, - handleType: ( - e: React.MouseEvent | React.KeyboardEvent | undefined, - item: IContextualMenuItem - ) => void -) => ( - e: React.MouseEvent | React.KeyboardEvent | undefined, - item: IContextualMenuItem | undefined -) => { - if (item) { - const name = - ConceptLabels[item.$kind] && ConceptLabels[item.$kind].title ? ConceptLabels[item.$kind].title : item.$kind; - item = { - ...item, - data: { - ...factory.create(item.$kind, { - $designer: { name }, - }), - }, - }; - return handleType(e, item); - } -}; - -export const createStepMenu = ( - stepLabels: DialogGroup[], - subMenu = true, - handleType: ( - e: React.MouseEvent | React.KeyboardEvent | undefined, - item: IContextualMenuItem - ) => void, - factory: DialogFactory, - filter?: (x: SDKKinds) => boolean -): IContextualMenuItem[] => { - if (subMenu) { - const stepMenuItems = stepLabels.map(x => { - const item = dialogGroups[x]; - if (item.types.length === 1) { - const conceptLabel = ConceptLabels[item.types[0]]; - return { - key: item.types[0], - name: conceptLabel && conceptLabel.title ? conceptLabel.title : item.types[0], - $kind: item.types[0], - onClick: menuItemHandler(factory, handleType), - }; - } - const subMenu: IContextualMenuProps = { - items: item.types.filter(filter || (() => true)).map($kind => { - const conceptLabel = ConceptLabels[$kind]; - - return { - key: $kind, - name: conceptLabel && conceptLabel.title ? conceptLabel.title : $kind, - $kind: $kind, - }; - }), - onItemClick: menuItemHandler(factory, handleType), - }; - - const menuItem: IContextualMenuItem = { - key: item.label, - text: item.label, - name: item.label, - subMenuProps: subMenu, - }; - return menuItem; - }); - - return stepMenuItems; - } else { - const stepMenuItems = dialogGroups[stepLabels[0]].types.map(item => { - const conceptLabel = ConceptLabels[item]; - const name = conceptLabel && conceptLabel.title ? conceptLabel.title : item; - const menuItem: IContextualMenuItem = { - key: item, - text: name, - name: name, - $kind: item, - ...factory.create(item, { - $designer: { name }, - }), - data: { - $kind: item, - ...factory.create(item, { - $designer: { name }, - }), - }, - onClick: (e, item: IContextualMenuItem | undefined) => { - if (item) { - return handleType(e, item); - } - }, - }; - return menuItem; - }); - return stepMenuItems; - } -}; - export function getDialogGroupByType(type) { let dialogType = DialogGroup.OTHER; diff --git a/Composer/packages/server/src/models/bot/botProject.ts b/Composer/packages/server/src/models/bot/botProject.ts index 622f27943d..d987f1380d 100644 --- a/Composer/packages/server/src/models/bot/botProject.ts +++ b/Composer/packages/server/src/models/bot/botProject.ts @@ -178,6 +178,7 @@ export class BotProject { sdk: { content: sdkSchema, }, + default: this.defaultSDKSchema, diagnostics, }; };