diff --git a/Composer/packages/extensions/visual-designer/src/editors/ObiEditor.tsx b/Composer/packages/extensions/visual-designer/src/editors/ObiEditor.tsx index 8c1c35a255..7004490b90 100644 --- a/Composer/packages/extensions/visual-designer/src/editors/ObiEditor.tsx +++ b/Composer/packages/extensions/visual-designer/src/editors/ObiEditor.tsx @@ -5,7 +5,7 @@ import { jsx } from '@emotion/core'; import { useContext, FC, useEffect, useState, useRef } from 'react'; import { MarqueeSelection, Selection } from 'office-ui-fabric-react/lib/MarqueeSelection'; -import { deleteAction, deleteActions, LgTemplateRef, LgMetaData } from '@bfc/shared'; +import { deleteAction, deleteActions, LgTemplateRef, LgMetaData, ExternalResourceCopyHandlerAsync } from '@bfc/shared'; import { NodeEventTypes } from '../constants/NodeEventTypes'; import { KeyboardCommandTypes, KeyboardPrimaryTypes } from '../constants/KeyboardCommandTypes'; @@ -44,9 +44,41 @@ export const ObiEditor: FC = ({ }): JSX.Element | null => { let divRef; - const { focusedId, focusedEvent, clipboardActions, copyLgTemplate, removeLgTemplates, removeLuIntent } = useContext( - NodeRendererContext - ); + const { + focusedId, + focusedEvent, + clipboardActions, + getLgTemplates, + updateLgTemplate, + removeLgTemplates, + removeLuIntent, + } = useContext(NodeRendererContext); + + const dereferenceLg: ExternalResourceCopyHandlerAsync = async ( + actionId: string, + actionData: any, + lgFieldName: string, + lgText?: string + ): Promise => { + if (!lgText) return ''; + + const inputLgRef = LgTemplateRef.parse(lgText); + if (!inputLgRef) return lgText; + + const lgTemplates = await getLgTemplates(inputLgRef.name); + if (!Array.isArray(lgTemplates) || !lgTemplates.length) return lgText; + + const targetTemplate = lgTemplates.find(x => x.name === inputLgRef.name); + return targetTemplate ? targetTemplate.body : lgText; + }; + + const buildLgReference: ExternalResourceCopyHandlerAsync = async (nodeId, data, fieldName, fieldText) => { + if (!fieldText) return ''; + const newLgTemplateName = new LgMetaData(fieldName, nodeId).toString(); + const newLgTemplateRefStr = new LgTemplateRef(newLgTemplateName).toString(); + await updateLgTemplate(path, newLgTemplateName, fieldText); + return newLgTemplateRefStr; + }; const deleteLgTemplates = (lgTemplates: string[]) => { const normalizedLgTemplates = lgTemplates @@ -91,23 +123,7 @@ export const ObiEditor: FC = ({ case NodeEventTypes.Insert: if (eventData.$type === 'PASTE') { handler = e => { - // TODO: clean this along with node deletion. - const copyLgTemplateToNewNode = async (lgText: string, newNodeId: string) => { - const inputLgRef = LgTemplateRef.parse(lgText); - if (!inputLgRef) return lgText; - - const inputLgMetaData = LgMetaData.parse(inputLgRef.name); - if (!inputLgMetaData) return lgText; - - inputLgMetaData.designerId = newNodeId; - const newLgName = inputLgMetaData.toString(); - const newLgTemplateRefString = new LgTemplateRef(newLgName).toString(); - - const lgFileId = path; - await copyLgTemplate(lgFileId, inputLgRef.name, newLgName); - return newLgTemplateRefString; - }; - pasteNodes(data, e.id, e.position, clipboardActions, copyLgTemplateToNewNode).then(dialog => { + pasteNodes(data, e.id, e.position, clipboardActions, buildLgReference).then(dialog => { onChange(dialog); }); }; @@ -128,16 +144,18 @@ export const ObiEditor: FC = ({ break; case NodeEventTypes.CopySelection: handler = e => { - const copiedActions = copyNodes(data, e.actionIds); - onClipboardChange(copiedActions); + copyNodes(data, e.actionIds, dereferenceLg).then(copiedNodes => onClipboardChange(copiedNodes)); }; break; case NodeEventTypes.CutSelection: handler = e => { - const { dialog, cutData } = cutNodes(data, e.actionIds); - onChange(dialog); - onFocusSteps([]); - onClipboardChange(cutData); + cutNodes(data, e.actionIds, dereferenceLg, nodes => + deleteActions(nodes, deleteLgTemplates, deleteLuIntents) + ).then(({ dialog, cutData }) => { + onChange(dialog); + onFocusSteps([]); + onClipboardChange(cutData); + }); }; break; case NodeEventTypes.DeleteSelection: diff --git a/Composer/packages/extensions/visual-designer/src/utils/jsonTracker.ts b/Composer/packages/extensions/visual-designer/src/utils/jsonTracker.ts index e6ab0647fa..96e010e425 100644 --- a/Composer/packages/extensions/visual-designer/src/utils/jsonTracker.ts +++ b/Composer/packages/extensions/visual-designer/src/utils/jsonTracker.ts @@ -4,7 +4,7 @@ import cloneDeep from 'lodash/cloneDeep'; import get from 'lodash/get'; import set from 'lodash/set'; -import { seedNewDialog, deepCopyAction, generateSDKTitle } from '@bfc/shared'; +import { seedNewDialog, deepCopyActions, generateSDKTitle, ExternalResourceCopyHandlerAsync } from '@bfc/shared'; function parseSelector(path: string): null | string[] { if (!path) return null; @@ -117,7 +117,7 @@ export function deleteNode(inputDialog, path, callbackOnRemovedData?: (removedDa return dialog; } -export function deleteNodes(inputDialog, nodeIds: string[], callbackOnRemovedData?: (removedData: any) => any) { +export function deleteNodes(inputDialog, nodeIds: string[], callbackOnRemovedNodes?: (nodes: any[]) => any) { const dialog = cloneDeep(inputDialog); const nodeLocations = nodeIds.map(id => locateNode(dialog, id)); @@ -144,8 +144,8 @@ export function deleteNodes(inputDialog, nodeIds: string[], callbackOnRemovedDat }); // invoke callback handler - if (callbackOnRemovedData && typeof callbackOnRemovedData === 'function') { - deletedNodes.forEach(x => callbackOnRemovedData(x)); + if (callbackOnRemovedNodes && typeof callbackOnRemovedNodes === 'function') { + callbackOnRemovedNodes(deletedNodes); } return dialog; @@ -168,14 +168,21 @@ export function insert(inputDialog, path, position, $type) { return dialog; } -export function copyNodes(inputDialog, nodeIds: string[]): any[] { +type DereferenceLgHandler = ExternalResourceCopyHandlerAsync; + +export async function copyNodes(inputDialog, nodeIds: string[], dereferenceLg: DereferenceLgHandler): Promise { const nodes = nodeIds.map(id => queryNode(inputDialog, id)).filter(x => x !== null); - return JSON.parse(JSON.stringify(nodes)); + return deepCopyActions(nodes, dereferenceLg); } -export function cutNodes(inputDialog, nodeIds: string[]) { - const nodesData = copyNodes(inputDialog, nodeIds); - const newDialog = deleteNodes(inputDialog, nodeIds); +export async function cutNodes( + inputDialog, + nodeIds: string[], + dereferenceLg: DereferenceLgHandler, + callbackOnCutNodes?: (nodes: any[]) => any +) { + const nodesData = await copyNodes(inputDialog, nodeIds, dereferenceLg); + const newDialog = deleteNodes(inputDialog, nodeIds, callbackOnCutNodes); return { dialog: newDialog, cutData: nodesData }; } @@ -196,7 +203,7 @@ export function appendNodesAfter(inputDialog, targetId, newNodes) { return dialog; } -export async function pasteNodes(inputDialog, arrayPath, arrayIndex, newNodes, copyLgTemplate) { +function insertNodes(inputDialog, arrayPath: string, arrayIndex: number, newNodes: any[]) { if (!Array.isArray(newNodes) || newNodes.length === 0) { return inputDialog; } @@ -208,16 +215,19 @@ export async function pasteNodes(inputDialog, arrayPath, arrayIndex, newNodes, c return inputDialog; } - // NOTES: underlying lg api for writing new lg template to file is not concurrency-safe, - // so we have to call them sequentially - // TODO: copy them parralleled via Promise.all() after optimizing lg api. - const copiedNodes: any[] = []; - for (const node of newNodes) { - // Deep copy nodes with external resources - const copy = await deepCopyAction(node, copyLgTemplate); - copiedNodes.push(copy); - } - - targetArray.currentData.splice(arrayIndex, 0, ...copiedNodes); + targetArray.currentData.splice(arrayIndex, 0, ...newNodes); return dialog; } + +export async function pasteNodes( + inputDialog, + arrayPath: string, + arrayIndex: number, + clipboardNodes: any[], + handleLgField: ExternalResourceCopyHandlerAsync +) { + // Considering a scenario that copy one time but paste multiple times, + // it requires seeding all $designer.id again by invoking deepCopy. + const newNodes = await deepCopyActions(clipboardNodes, handleLgField); + return insertNodes(inputDialog, arrayPath, arrayIndex, newNodes); +} diff --git a/Composer/packages/lib/shared/__tests__/copyUtils/copyInputDialog.test.ts b/Composer/packages/lib/shared/__tests__/copyUtils/copyInputDialog.test.ts index 918ef04eaf..1e0dd78bfd 100644 --- a/Composer/packages/lib/shared/__tests__/copyUtils/copyInputDialog.test.ts +++ b/Composer/packages/lib/shared/__tests__/copyUtils/copyInputDialog.test.ts @@ -8,7 +8,7 @@ import { externalApiStub as externalApi } from '../jestMocks/externalApiStub'; describe('shallowCopyAdaptiveAction', () => { const externalApiWithLgCopy: ExternalApi = { ...externalApi, - copyLgTemplate: (templateName, newNodeId) => Promise.resolve(templateName + '(copy)'), + copyLgTemplate: (id, data, field, value) => Promise.resolve(value + '(copy)'), }; it('can copy TextInput', async () => { diff --git a/Composer/packages/lib/shared/__tests__/copyUtils/copySendActivity.test.ts b/Composer/packages/lib/shared/__tests__/copyUtils/copySendActivity.test.ts index 8a30e66c53..12c7682f89 100644 --- a/Composer/packages/lib/shared/__tests__/copyUtils/copySendActivity.test.ts +++ b/Composer/packages/lib/shared/__tests__/copyUtils/copySendActivity.test.ts @@ -8,7 +8,7 @@ import { externalApiStub as externalApi } from '../jestMocks/externalApiStub'; describe('copySendActivity', () => { const externalApiWithLgCopy: ExternalApi = { ...externalApi, - copyLgTemplate: (templateName, newNodeId) => Promise.resolve(templateName + '(copy)'), + copyLgTemplate: (id, data, fieldName, fieldValue) => Promise.resolve(fieldValue + '(copy)'), }; it('can copy SendActivity', async () => { diff --git a/Composer/packages/lib/shared/__tests__/jestMocks/externalApiStub.ts b/Composer/packages/lib/shared/__tests__/jestMocks/externalApiStub.ts index c8e297b99d..7aa455f302 100644 --- a/Composer/packages/lib/shared/__tests__/jestMocks/externalApiStub.ts +++ b/Composer/packages/lib/shared/__tests__/jestMocks/externalApiStub.ts @@ -5,5 +5,5 @@ import { ExternalApi } from '../../src/copyUtils/ExternalApi'; export const externalApiStub: ExternalApi = { getDesignerId: () => ({ id: '5678' }), - copyLgTemplate: (lgTemplateName: string, targetNodeId: string) => Promise.resolve(lgTemplateName), + copyLgTemplate: (id, data, fieldName, fieldValue) => Promise.resolve(fieldValue || ''), }; diff --git a/Composer/packages/lib/shared/src/copyUtils/ExternalApi.ts b/Composer/packages/lib/shared/src/copyUtils/ExternalApi.ts index 2f86b9aa01..fe586e5924 100644 --- a/Composer/packages/lib/shared/src/copyUtils/ExternalApi.ts +++ b/Composer/packages/lib/shared/src/copyUtils/ExternalApi.ts @@ -3,7 +3,21 @@ import { DesignerData } from '../types'; +export type ExternalResourceCopyHandler = ( + actionId: string, + actionData: any, + resourceFieldName: string, + resourceValue?: CopiedType +) => CopiedType; + +export type ExternalResourceCopyHandlerAsync = ( + actionId: string, + actionData: any, + resourceFieldName: string, + resourceValue?: CopiedType +) => Promise; + export interface ExternalApi { getDesignerId: (data?: DesignerData) => DesignerData; - copyLgTemplate: (lgTemplateName: string, newNodeId: string) => Promise; + copyLgTemplate: ExternalResourceCopyHandlerAsync; } diff --git a/Composer/packages/lib/shared/src/copyUtils/copyInputDialog.ts b/Composer/packages/lib/shared/src/copyUtils/copyInputDialog.ts index 5371b69e04..71eda21774 100644 --- a/Composer/packages/lib/shared/src/copyUtils/copyInputDialog.ts +++ b/Composer/packages/lib/shared/src/copyUtils/copyInputDialog.ts @@ -9,21 +9,22 @@ import { shallowCopyAdaptiveAction } from './shallowCopyAdaptiveAction'; export const copyInputDialog = async (input: InputDialog, externalApi: ExternalApi): Promise => { const copy = shallowCopyAdaptiveAction(input, externalApi); const nodeId = copy.$designer ? copy.$designer.id : ''; + const copyLgField = (data, fieldName: string) => externalApi.copyLgTemplate(nodeId, data, fieldName, data[fieldName]); if (input.prompt !== undefined) { - copy.prompt = await externalApi.copyLgTemplate(input.prompt, nodeId); + copy.prompt = await copyLgField(copy, 'prompt'); } if (input.unrecognizedPrompt !== undefined) { - copy.unrecognizedPrompt = await externalApi.copyLgTemplate(input.unrecognizedPrompt, nodeId); + copy.unrecognizedPrompt = await copyLgField(copy, 'unrecognizedPrompt'); } if (input.invalidPrompt !== undefined) { - copy.invalidPrompt = await externalApi.copyLgTemplate(input.invalidPrompt, nodeId); + copy.invalidPrompt = await copyLgField(copy, 'invalidPrompt'); } if (input.defaultValueResponse !== undefined) { - copy.defaultValueResponse = await externalApi.copyLgTemplate(input.defaultValueResponse, nodeId); + copy.defaultValueResponse = await copyLgField(copy, 'defaultValueResponse'); } return copy; diff --git a/Composer/packages/lib/shared/src/copyUtils/copySendActivity.ts b/Composer/packages/lib/shared/src/copyUtils/copySendActivity.ts index 54c22113c4..929201c322 100644 --- a/Composer/packages/lib/shared/src/copyUtils/copySendActivity.ts +++ b/Composer/packages/lib/shared/src/copyUtils/copySendActivity.ts @@ -11,7 +11,7 @@ export const copySendActivity = async (input: SendActivity, externalApi: Externa const nodeId = copy.$designer ? copy.$designer.id : ''; if (input.activity !== undefined) { - copy.activity = await externalApi.copyLgTemplate(input.activity, nodeId); + copy.activity = await externalApi.copyLgTemplate(nodeId, copy, 'activity', copy.activity); } return copy; diff --git a/Composer/packages/lib/shared/src/copyUtils/index.ts b/Composer/packages/lib/shared/src/copyUtils/index.ts index c04dd59fa5..d6bd0088a7 100644 --- a/Composer/packages/lib/shared/src/copyUtils/index.ts +++ b/Composer/packages/lib/shared/src/copyUtils/index.ts @@ -2,3 +2,4 @@ // Licensed under the MIT License. export { copyAdaptiveAction } from './copyAdaptiveAction'; +export { ExternalResourceCopyHandlerAsync } from './ExternalApi'; diff --git a/Composer/packages/lib/shared/src/deleteUtils/index.ts b/Composer/packages/lib/shared/src/deleteUtils/index.ts index 14d10ddfec..c237ba35fc 100644 --- a/Composer/packages/lib/shared/src/deleteUtils/index.ts +++ b/Composer/packages/lib/shared/src/deleteUtils/index.ts @@ -6,6 +6,7 @@ import { MicrosoftIDialog, SDKTypes } from '../types'; import { walkAdaptiveAction } from './walkAdaptiveAction'; import { walkAdaptiveActionList } from './walkAdaptiveActionList'; +// TODO: (ze) considering refactoring it with the `walkLgResources` util const collectLgTemplates = (action: any, outputTemplates: string[]) => { if (typeof action === 'string') return; if (!action || !action.$type) return; @@ -25,6 +26,7 @@ const collectLgTemplates = (action: any, outputTemplates: string[]) => { } }; +// TODO: (ze) considering refactoring it by implementing a new `walkLuResources` util const collectLuIntents = (action: any, outputTemplates: string[]) => { if (typeof action === 'string') return; if (!action || !action.$type) return; diff --git a/Composer/packages/lib/shared/src/dialogFactory.ts b/Composer/packages/lib/shared/src/dialogFactory.ts index 585b5a6aed..7ed461a0f0 100644 --- a/Composer/packages/lib/shared/src/dialogFactory.ts +++ b/Composer/packages/lib/shared/src/dialogFactory.ts @@ -9,6 +9,7 @@ import { copyAdaptiveAction } from './copyUtils'; import { deleteAdaptiveAction, deleteAdaptiveActionList } from './deleteUtils'; import { MicrosoftIDialog } from './types'; import { SDKTypes } from './types'; +import { ExternalResourceCopyHandlerAsync } from './copyUtils/ExternalApi'; interface DesignerAttributes { name: string; description: string; @@ -108,16 +109,26 @@ export const seedDefaults = (type: string) => { return assignDefaults(properties); }; -export const deepCopyAction = async ( - data, - copyLgTemplateToNewNode: (lgTemplateName: string, newNodeId: string) => Promise -) => { +export const deepCopyAction = async (data, copyLgTemplate: ExternalResourceCopyHandlerAsync) => { return await copyAdaptiveAction(data, { getDesignerId, - copyLgTemplate: copyLgTemplateToNewNode, + copyLgTemplate, }); }; +export const deepCopyActions = async (actions: any[], copyLgTemplate: ExternalResourceCopyHandlerAsync) => { + // NOTES: underlying lg api for writing new lg template to file is not concurrency-safe, + // so we have to call them sequentially + // TODO: copy them parralleled via Promise.all() after optimizing lg api. + const copiedActions: any[] = []; + for (const action of actions) { + // Deep copy nodes with external resources + const copy = await deepCopyAction(action, copyLgTemplate); + copiedActions.push(copy); + } + return copiedActions; +}; + export const deleteAction = ( data: MicrosoftIDialog, deleteLgTemplates: (templates: string[]) => any, diff --git a/Composer/packages/lib/shared/src/index.ts b/Composer/packages/lib/shared/src/index.ts index 0c0d75b91e..df4c90189c 100644 --- a/Composer/packages/lib/shared/src/index.ts +++ b/Composer/packages/lib/shared/src/index.ts @@ -13,3 +13,5 @@ export * from './appschema'; export * from './types'; export * from './constant'; export * from './lgUtils'; +export * from './walkerUtils'; +export * from './copyUtils'; diff --git a/Composer/packages/lib/shared/src/walkerUtils/index.tsx b/Composer/packages/lib/shared/src/walkerUtils/index.tsx new file mode 100644 index 0000000000..5538f89aed --- /dev/null +++ b/Composer/packages/lib/shared/src/walkerUtils/index.tsx @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export { walkLgResourcesInAction, walkLgResourcesInActionList } from './walkLgResources'; diff --git a/Composer/packages/lib/shared/src/walkerUtils/walkLgResources.ts b/Composer/packages/lib/shared/src/walkerUtils/walkLgResources.ts new file mode 100644 index 0000000000..faf75eb066 --- /dev/null +++ b/Composer/packages/lib/shared/src/walkerUtils/walkLgResources.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { walkAdaptiveAction } from '../deleteUtils/walkAdaptiveAction'; +import { SDKTypes } from '../types'; +import { walkAdaptiveActionList } from '../deleteUtils/walkAdaptiveActionList'; + +type LgFieldHandler = (action, lgFieldName: string, lgString: string) => any; + +const findLgFields = (action: any, handleLgField: LgFieldHandler) => { + if (typeof action === 'string') return; + if (!action || !action.$type) return; + + const onFound = (fieldName: string) => { + action[fieldName] && handleLgField(action, fieldName, action[fieldName]); + }; + + switch (action.$type) { + case SDKTypes.SendActivity: + onFound('activity'); + break; + case SDKTypes.AttachmentInput: + case SDKTypes.ChoiceInput: + case SDKTypes.ConfirmInput: + case SDKTypes.DateTimeInput: + case SDKTypes.NumberInput: + case SDKTypes.TextInput: + onFound('prompt'); + onFound('unrecognizedPrompt'); + onFound('invalidPrompt'); + onFound('defaultValueResponse'); + break; + } +}; + +export const walkLgResourcesInAction = (action, handleLgResource: LgFieldHandler) => { + walkAdaptiveAction(action, action => findLgFields(action, handleLgResource)); +}; + +export const walkLgResourcesInActionList = (actioList: any[], handleLgResource: LgFieldHandler) => { + walkAdaptiveActionList(actioList, action => findLgFields(action, handleLgResource)); +};