Skip to content

Commit

Permalink
refactor: support lg templates cross-file copy during Visual Editor c…
Browse files Browse the repository at this point in the history
…opy / paste (#2236)

* dump real lg content before paste them

* implement lg resources walker

* update lg walker api

* split insertNodes from pasteNodes

* fix tslint

* change copyUtils ExtarnelAPI interface

* migrate to new api format

* create real lg template when pasting
  • Loading branch information
yeze322 authored Mar 12, 2020
1 parent 9293211 commit d91eaa4
Show file tree
Hide file tree
Showing 14 changed files with 167 additions and 62 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -44,9 +44,41 @@ export const ObiEditor: FC<ObiEditorProps> = ({
}): 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<string> = async (
actionId: string,
actionData: any,
lgFieldName: string,
lgText?: string
): Promise<string> => {
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<string> = 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
Expand Down Expand Up @@ -91,23 +123,7 @@ export const ObiEditor: FC<ObiEditorProps> = ({
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);
});
};
Expand All @@ -128,16 +144,18 @@ export const ObiEditor: FC<ObiEditorProps> = ({
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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand All @@ -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;
Expand All @@ -168,14 +168,21 @@ export function insert(inputDialog, path, position, $type) {
return dialog;
}

export function copyNodes(inputDialog, nodeIds: string[]): any[] {
type DereferenceLgHandler = ExternalResourceCopyHandlerAsync<string>;

export async function copyNodes(inputDialog, nodeIds: string[], dereferenceLg: DereferenceLgHandler): Promise<any[]> {
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 };
}
Expand All @@ -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;
}
Expand All @@ -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<string>
) {
// 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 || ''),
};
16 changes: 15 additions & 1 deletion Composer/packages/lib/shared/src/copyUtils/ExternalApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,21 @@

import { DesignerData } from '../types';

export type ExternalResourceCopyHandler<CopiedType> = (
actionId: string,
actionData: any,
resourceFieldName: string,
resourceValue?: CopiedType
) => CopiedType;

export type ExternalResourceCopyHandlerAsync<CopiedType> = (
actionId: string,
actionData: any,
resourceFieldName: string,
resourceValue?: CopiedType
) => Promise<CopiedType>;

export interface ExternalApi {
getDesignerId: (data?: DesignerData) => DesignerData;
copyLgTemplate: (lgTemplateName: string, newNodeId: string) => Promise<string>;
copyLgTemplate: ExternalResourceCopyHandlerAsync<string>;
}
9 changes: 5 additions & 4 deletions Composer/packages/lib/shared/src/copyUtils/copyInputDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,22 @@ import { shallowCopyAdaptiveAction } from './shallowCopyAdaptiveAction';
export const copyInputDialog = async (input: InputDialog, externalApi: ExternalApi): Promise<InputDialog> => {
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions Composer/packages/lib/shared/src/copyUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
// Licensed under the MIT License.

export { copyAdaptiveAction } from './copyAdaptiveAction';
export { ExternalResourceCopyHandlerAsync } from './ExternalApi';
2 changes: 2 additions & 0 deletions Composer/packages/lib/shared/src/deleteUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
21 changes: 16 additions & 5 deletions Composer/packages/lib/shared/src/dialogFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -108,16 +109,26 @@ export const seedDefaults = (type: string) => {
return assignDefaults(properties);
};

export const deepCopyAction = async (
data,
copyLgTemplateToNewNode: (lgTemplateName: string, newNodeId: string) => Promise<string>
) => {
export const deepCopyAction = async (data, copyLgTemplate: ExternalResourceCopyHandlerAsync<string>) => {
return await copyAdaptiveAction(data, {
getDesignerId,
copyLgTemplate: copyLgTemplateToNewNode,
copyLgTemplate,
});
};

export const deepCopyActions = async (actions: any[], copyLgTemplate: ExternalResourceCopyHandlerAsync<string>) => {
// 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,
Expand Down
2 changes: 2 additions & 0 deletions Composer/packages/lib/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ export * from './appschema';
export * from './types';
export * from './constant';
export * from './lgUtils';
export * from './walkerUtils';
export * from './copyUtils';
4 changes: 4 additions & 0 deletions Composer/packages/lib/shared/src/walkerUtils/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

export { walkLgResourcesInAction, walkLgResourcesInActionList } from './walkLgResources';
42 changes: 42 additions & 0 deletions Composer/packages/lib/shared/src/walkerUtils/walkLgResources.ts
Original file line number Diff line number Diff line change
@@ -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));
};

0 comments on commit d91eaa4

Please sign in to comment.