Skip to content

Commit

Permalink
feat: Move (#2206)
Browse files Browse the repository at this point in the history
* Implement Move

* Rmemove dependency on query string

* fix: sort action ids correctly (#2217)

* sort actionIds by tree path order

* filter invalid ids and update test cases

* fix: make MoveSelection work (not target master) (#2234)

* make MoveSelection work

* add comments

* initialDialogShape as a function

* feat: visual/move with lgapi (#2258)

* 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

* renaming

* update walkLgResources

* create lgTemplates for moved actions

* hack the debounce issue #2247

* Fix build

* Fix lint

Co-authored-by: Ze Ye <zeye@microsoft.com>
Co-authored-by: zeye <2295905420@qq.com>

Co-authored-by: zeye <2295905420@qq.com>
Co-authored-by: Ze Ye <zeye@microsoft.com>
  • Loading branch information
3 people committed Mar 12, 2020
1 parent d91eaa4 commit a9f7e66
Show file tree
Hide file tree
Showing 25 changed files with 252 additions and 50 deletions.
4 changes: 2 additions & 2 deletions Composer/packages/client/src/ShellApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,9 +361,9 @@ export const ShellApi: React.FC = () => {
apiClient.registerApi('onSelect', onSelect);
apiClient.registerApi('onCopy', onCopy);
apiClient.registerApi('isExpression', ({ expression }) => isExpression(expression));
apiClient.registerApi('createDialog', () => {
apiClient.registerApi('createDialog', actionsSeed => {
return new Promise(resolve => {
actions.createDialogBegin((newDialog: string | null) => {
actions.createDialogBegin(actionsSeed, (newDialog: string | null) => {
resolve(newDialog);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,12 @@ const shellApi: ShellApi = {
return apiClient.apiCall('removeLuIntent', { id, intentName });
},

updateRegExIntent: (id, intentName, pattern) => {
return apiClient.apiCall('updateRegExIntent', { id, intentName, pattern });
createDialog: actions => {
return apiClient.apiCall('createDialog', { actions });
},

createDialog: () => {
return apiClient.apiCall('createDialog');
updateRegExIntent: (id, intentName, pattern) => {
return apiClient.apiCall('updateRegExIntent', { id, intentName, pattern });
},

validateExpression: expression => {
Expand Down
1 change: 1 addition & 0 deletions Composer/packages/client/src/messenger/FrameAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const VisualEditorAPI = (() => {
hasElementSelected: () => visualEditorFrameAPI.invoke('hasElementSelected').catch(() => false),
copySelection: () => visualEditorFrameAPI.invoke('copySelection'),
cutSelection: () => visualEditorFrameAPI.invoke('cutSelection'),
moveSelection: () => visualEditorFrameAPI.invoke('moveSelection'),
deleteSelection: () => visualEditorFrameAPI.invoke('deleteSelection'),
};
})();
24 changes: 21 additions & 3 deletions Composer/packages/client/src/pages/design/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import formatMessage from 'format-message';
import { globalHistory } from '@reach/router';
import get from 'lodash/get';
import { PromptTab } from '@bfc/shared';
import { getNewDesigner, seedNewDialog } from '@bfc/shared';
import { seedNewDialog, SDKTypes } from '@bfc/shared';
import { DialogInfo } from '@bfc/indexers';

import { VisualEditorAPI } from '../../messenger/FrameAPI';
Expand Down Expand Up @@ -255,6 +255,18 @@ function DesignPage(props) {
align: 'left',
disabled: !nodeOperationAvailable,
},
{
type: 'action',
text: formatMessage('Move'),
buttonProps: {
iconProps: {
iconName: 'Share',
},
onClick: () => VisualEditorAPI.moveSelection(),
},
align: 'left',
disabled: !nodeOperationAvailable,
},
{
type: 'action',
text: formatMessage('Delete'),
Expand Down Expand Up @@ -312,8 +324,14 @@ function DesignPage(props) {
}, [dialogs, breadcrumb]);

async function onSubmit(data: { name: string; description: string }) {
const content = { ...getNewDesigner(data.name, data.description), generator: `${data.name}.lg` };
const seededContent = seedNewDialog('Microsoft.AdaptiveDialog', content.$designer, content);
const seededContent = seedNewDialog(
SDKTypes.AdaptiveDialog,
{ name: data.name, description: data.description },
{
generator: `${data.name}.lg`,
},
state.actionsSeed || []
);
await actions.createDialog({ id: data.name, content: seededContent });
}

Expand Down
3 changes: 2 additions & 1 deletion Composer/packages/client/src/store/action/dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,11 @@ export const updateDialog: ActionCreator = undoable(
updateDialogBase
);

export const createDialogBegin: ActionCreator = ({ dispatch }, onComplete) => {
export const createDialogBegin: ActionCreator = ({ dispatch }, { actions }, onComplete) => {
dispatch({
type: ActionTypes.CREATE_DIALOG_BEGIN,
payload: {
actionsSeed: actions,
onComplete,
},
});
Expand Down
1 change: 1 addition & 0 deletions Composer/packages/client/src/store/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const initialState: State = {
lgFiles: [],
schemas: { editor: {} },
luFiles: [],
actionsSeed: [],
designPageLocation: {
dialogId: '',
focused: '',
Expand Down
4 changes: 3 additions & 1 deletion Composer/packages/client/src/store/reducer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,9 @@ const removeDialog: ReducerFunc = (state, { response }) => {
return state;
};

const createDialogBegin: ReducerFunc = (state, { onComplete }) => {
const createDialogBegin: ReducerFunc = (state, { actionsSeed, onComplete }) => {
state.showCreateDialogModal = true;
state.actionsSeed = actionsSeed;
state.onCreateDialogComplete = onComplete;
return state;
};
Expand All @@ -105,6 +106,7 @@ const createDialogSuccess: ReducerFunc = (state, { response }) => {
state.luFiles = response.data.luFiles;
state.lgFiles = response.data.lgFiles;
state.showCreateDialogModal = false;
state.actionsSeed = [];
delete state.onCreateDialogComplete;
return state;
};
Expand Down
1 change: 1 addition & 0 deletions Composer/packages/client/src/store/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export interface State {
showCreateDialogModal: boolean;
isEnvSettingUpdated: boolean;
settings: DialogSetting;
actionsSeed: any;
onCreateDialogComplete?: (dialogId: string | null) => void;
toStartBot: boolean;
currentUser: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const DialogSelectWidget: React.FC<BFDWidgetProps> = props => {
if (option) {
if (option.key === ADD_DIALOG) {
setComboboxTitle(formatMessage('Create a new dialog'));
formContext.shellApi.createDialog().then(newDialog => {
formContext.shellApi.createDialog({ actions: [] }).then(newDialog => {
if (newDialog) {
onChange(newDialog);
setTimeout(() => formContext.shellApi.navTo(newDialog), 500);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { normalizeSelection, sortActionIds } from '../../src/utils/normalizeSelection';

describe('normalizeSelection', () => {
it('should filter out child ids', () => {
const selectedIds1 = ['actions[0]', 'actions[0].actions[0]', 'actions[0].actions[1]'];
expect(normalizeSelection(selectedIds1)).toEqual(['actions[0]']);

const selectedIds2 = ['actions[0]', 'actions[0].actions[0]', 'actions[0].actions[1]', 'actions[1]', 'actions[1].a'];
expect(normalizeSelection(selectedIds2)).toEqual(['actions[0]', 'actions[1]']);
});

it('should keep orphan child ids', () => {
const selectedIds = ['actions[0]', 'actions[0].actions[0]', 'actions[1].actions[0]'];
expect(normalizeSelection(selectedIds)).toEqual(['actions[0]', 'actions[1].actions[0]']);
});

it('should throw invalid ids', () => {
const selectedIds = ['action[0].a', 'actions[0].diamond', 'actions', 'actions[0].ifelse'];
expect(normalizeSelection(selectedIds)).toEqual([]);
});
});

describe('sortActionIds', () => {
it('can sort input ids at same level', () => {
const actionIds = ['actions[10]', 'actions[1]', 'actions[3]', 'actions[2]'];
expect(sortActionIds(actionIds)).toEqual(['actions[1]', 'actions[2]', 'actions[3]', 'actions[10]']);
});

it('can sort input ids with children', () => {
const actionIds = ['actions[3]', 'actions[2]', 'actions[1].actions[0]', 'actions[1].elseActions[0]', 'actions[1]'];
expect(sortActionIds(actionIds)).toEqual([
'actions[1]',
'actions[1].actions[0]',
'actions[1].elseActions[0]',
'actions[2]',
'actions[3]',
]);
});

it('can sort ids with orphan children', () => {
const actionIds = ['actions[3]', 'actions[2]', 'actions[1].actions[0]', 'actions[1].elseActions[0]'];
expect(sortActionIds(actionIds)).toEqual([
'actions[1].actions[0]',
'actions[1].elseActions[0]',
'actions[2]',
'actions[3]',
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export enum NodeEventTypes {
InsertEvent = 'event.data.insert-event',
CopySelection = 'event.data.copy-selection',
CutSelection = 'event.data.cut-selection',
MoveSelection = 'event.data.move-selection',
DeleteSelection = 'event.data.delete-selection',
AppendSelection = 'event.data.paste-selection--keyboard',
InsertSelection = 'event.data.paste-selection--menu',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,16 @@
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, ExternalResourceCopyHandlerAsync } from '@bfc/shared';
import {
deleteAction,
deleteActions,
LgTemplateRef,
LgMetaData,
seedNewDialog,
ExternalResourceHandlerAsync,
walkLgResourcesInActionList,
} from '@bfc/shared';
import { SDKTypes } from '@bfc/shared';

import { NodeEventTypes } from '../constants/NodeEventTypes';
import { KeyboardCommandTypes, KeyboardPrimaryTypes } from '../constants/KeyboardCommandTypes';
Expand All @@ -20,6 +29,7 @@ import {
appendNodesAfter,
pasteNodes,
deleteNodes,
insertAction,
} from '../utils/jsonTracker';
import { moveCursor, querySelectableElements, SelectorElement } from '../utils/cursorTracker';
import { NodeIndexGenerator } from '../utils/NodeIndexGetter';
Expand All @@ -37,6 +47,7 @@ export const ObiEditor: FC<ObiEditorProps> = ({
onClipboardChange,
onOpen,
onChange,
onCreateDialog,
onSelect,
undo,
redo,
Expand All @@ -54,7 +65,7 @@ export const ObiEditor: FC<ObiEditorProps> = ({
removeLuIntent,
} = useContext(NodeRendererContext);

const dereferenceLg: ExternalResourceCopyHandlerAsync<string> = async (
const dereferenceLg: ExternalResourceHandlerAsync<string> = async (
actionId: string,
actionData: any,
lgFieldName: string,
Expand All @@ -72,7 +83,7 @@ export const ObiEditor: FC<ObiEditorProps> = ({
return targetTemplate ? targetTemplate.body : lgText;
};

const buildLgReference: ExternalResourceCopyHandlerAsync<string> = async (nodeId, data, fieldName, fieldText) => {
const buildLgReference: ExternalResourceHandlerAsync<string> = async (nodeId, data, fieldName, fieldText) => {
if (!fieldText) return '';
const newLgTemplateName = new LgMetaData(fieldName, nodeId).toString();
const newLgTemplateRefStr = new LgTemplateRef(newLgTemplateName).toString();
Expand Down Expand Up @@ -158,6 +169,57 @@ export const ObiEditor: FC<ObiEditorProps> = ({
});
};
break;
case NodeEventTypes.MoveSelection:
handler = e => {
if (!Array.isArray(e.actionIds) || !e.actionIds.length) return;

// Using copy-paste-delete pattern here is safer than using cut-paste
// since create new dialog may be cancelled or failed
copyNodes(data, e.actionIds, dereferenceLg)
.then(copiedActions => {
const lgTemplatesToBeCreated: { name: string; body: string }[] = [];
walkLgResourcesInActionList(copiedActions, (designerId, actionData, fieldName, lgStr) => {
if (!lgStr) return '';

const lgName = new LgMetaData(fieldName, designerId).toString();
const refString = new LgTemplateRef(lgName).toString();

lgTemplatesToBeCreated.push({ name: lgName, body: lgStr });
actionData[fieldName] = refString;
return refString;
});
return onCreateDialog(copiedActions).then(dialogName => ({ dialogName, lgTemplatesToBeCreated }));
})
.then(async ({ dialogName: newDialog, lgTemplatesToBeCreated }) => {
// defense modal cancellation
if (!newDialog) return;

// create lg templates for actions in new dialog
for (const { name, body } of lgTemplatesToBeCreated) {
await updateLgTemplate(newDialog, name, body);
}

// delete old actions (they are already moved to new dialog)

// HACK: https://github.com/microsoft/BotFramework-Composer/issues/2247
const postponedDeleteLgTemplates = templates => setTimeout(() => deleteLgTemplates(templates), 501);
const deleteResult = deleteNodes(data, e.actionIds, nodes =>
deleteActions(nodes, postponedDeleteLgTemplates, deleteLuIntents)
);

// insert a BeginDialog action points to newly created dialog
const indexes = e.actionIds[0].match(/^(.+)\[(\d+)\]$/);
if (indexes === null || indexes.length !== 3) return;

const [, arrayPath, actionIndexStr] = indexes;
const startIndex = parseInt(actionIndexStr);
const placeholderAction = seedNewDialog(SDKTypes.BeginDialog, undefined, { dialog: newDialog });
const insertResult = insertAction(deleteResult, arrayPath, startIndex, placeholderAction);
onChange(insertResult);
});
onFocusSteps([]);
};
break;
case NodeEventTypes.DeleteSelection:
handler = e => {
const dialog = deleteNodes(data, e.actionIds, nodes =>
Expand Down Expand Up @@ -257,6 +319,8 @@ export const ObiEditor: FC<ObiEditorProps> = ({
dispatchEvent(NodeEventTypes.CopySelection, { actionIds: getClipboardTargetsFromContext() });
(window as any).cutSelection = () =>
dispatchEvent(NodeEventTypes.CutSelection, { actionIds: getClipboardTargetsFromContext() });
(window as any).moveSelection = () =>
dispatchEvent(NodeEventTypes.MoveSelection, { actionIds: getClipboardTargetsFromContext() });
(window as any).deleteSelection = () =>
dispatchEvent(NodeEventTypes.DeleteSelection, { actionIds: getClipboardTargetsFromContext() });

Expand Down Expand Up @@ -370,6 +434,7 @@ interface ObiEditorProps {
focusedEvent: string;
onFocusEvent: (eventId: string) => any;
onClipboardChange: (actions: any[]) => void;
onCreateDialog: (actions: any[]) => Promise<string>;
onOpen: (calleeDialog: string, callerId: string) => any;
onChange: (newDialog: any) => any;
onSelect: (ids: string[]) => any;
Expand Down
2 changes: 2 additions & 0 deletions Composer/packages/extensions/visual-designer/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const VisualDesigner: React.FC<VisualDesignerProps> = ({
onSelect,
onCopy,
saveData,
createDialog,
updateLgTemplate,
getLgTemplates,
copyLgTemplate,
Expand Down Expand Up @@ -97,6 +98,7 @@ const VisualDesigner: React.FC<VisualDesignerProps> = ({
focusedEvent={focusedEvent}
onFocusEvent={onFocusEvent}
onClipboardChange={onCopy}
onCreateDialog={createDialog}
onOpen={x => navTo(x)}
onChange={x => saveData(x)}
onSelect={onSelect}
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, deepCopyActions, generateSDKTitle, ExternalResourceCopyHandlerAsync } from '@bfc/shared';
import { seedNewDialog, deepCopyActions, generateSDKTitle, ExternalResourceHandlerAsync } from '@bfc/shared';

function parseSelector(path: string): null | string[] {
if (!path) return null;
Expand Down Expand Up @@ -152,23 +152,27 @@ export function deleteNodes(inputDialog, nodeIds: string[], callbackOnRemovedNod
}

export function insert(inputDialog, path, position, $type) {
const dialog = cloneDeep(inputDialog);
const current = get(dialog, path, []);
const newStep = {
$type,
...seedNewDialog($type, { name: generateSDKTitle({ $type }) }),
};
return insertAction(inputDialog, path, position, newStep);
}

export function insertAction(inputDialog, arrayPath: string, position: number, newAction) {
const dialog = cloneDeep(inputDialog);
const current = get(dialog, arrayPath, []);

const insertAt = typeof position === 'undefined' ? current.length : position;

current.splice(insertAt, 0, newStep);
current.splice(insertAt, 0, newAction);

set(dialog, path, current);
set(dialog, arrayPath, current);

return dialog;
}

type DereferenceLgHandler = ExternalResourceCopyHandlerAsync<string>;
type DereferenceLgHandler = ExternalResourceHandlerAsync<string>;

export async function copyNodes(inputDialog, nodeIds: string[], dereferenceLg: DereferenceLgHandler): Promise<any[]> {
const nodes = nodeIds.map(id => queryNode(inputDialog, id)).filter(x => x !== null);
Expand Down Expand Up @@ -224,7 +228,7 @@ export async function pasteNodes(
arrayPath: string,
arrayIndex: number,
clipboardNodes: any[],
handleLgField: ExternalResourceCopyHandlerAsync<string>
handleLgField: ExternalResourceHandlerAsync<string>
) {
// Considering a scenario that copy one time but paste multiple times,
// it requires seeding all $designer.id again by invoking deepCopy.
Expand Down
Loading

0 comments on commit a9f7e66

Please sign in to comment.