Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: support lg templates cross-file copy during Visual Editor copy / paste #2236

Merged
merged 11 commits into from
Mar 12, 2020
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>;
}
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));
};