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

feat: Move #2206

Merged
merged 10 commits into from
Mar 12, 2020
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'),
};
})();
25 changes: 22 additions & 3 deletions Composer/packages/client/src/pages/design/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ 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 set from 'lodash/set';

import { VisualEditorAPI } from '../../messenger/FrameAPI';
import { TestController } from '../../TestController';
Expand Down Expand Up @@ -255,6 +256,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 +325,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,8 @@
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, seedNewDialog } from '@bfc/shared';
import { SDKTypes } from '@bfc/shared';

import { NodeEventTypes } from '../constants/NodeEventTypes';
import { KeyboardCommandTypes, KeyboardPrimaryTypes } from '../constants/KeyboardCommandTypes';
Expand All @@ -20,6 +21,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 +39,7 @@ export const ObiEditor: FC<ObiEditorProps> = ({
onClipboardChange,
onOpen,
onChange,
onCreateDialog,
onSelect,
undo,
redo,
Expand Down Expand Up @@ -140,6 +143,35 @@ export const ObiEditor: FC<ObiEditorProps> = ({
onClipboardChange(cutData);
};
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
const copiedActions = copyNodes(data, e.actionIds);
onCreateDialog(copiedActions).then(newDialog => {
// defense modal cancellation
if (!newDialog) return;

// delete old actions (they are already moved to new dialog)
const deleteResult = deleteNodes(data, e.actionIds, nodes =>
deleteActions(nodes, deleteLgTemplates, 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 @@ -239,6 +271,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 @@ -352,6 +386,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 @@ -152,18 +152,22 @@ export function deleteNodes(inputDialog, nodeIds: string[], callbackOnRemovedDat
}

export function insert(inputDialog, path, position, $type) {
const dialog = cloneDeep(inputDialog);
const current = get(dialog, path, []);
const newStep = {
$type,
...seedNewDialog($type, { name: generateSDKTitle({ $type }) }),
a-b-r-o-w-n marked this conversation as resolved.
Show resolved Hide resolved
};
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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@

export const normalizeSelection = (selectedIds: string[]): string[] => {
if (!Array.isArray(selectedIds)) return [];

// filter invalid ids such as 'actions[0].diamond'
const validIds = selectedIds.filter(id => id.match(/.*\w+\[\d+\]$/));
// events[0] < events[0].actions[0] < events[1] < events[1].actions[0]
const ascendingIds = [...selectedIds].sort();
const ascendingIds = sortActionIds(validIds);

for (let i = 0; i < ascendingIds.length; i++) {
const parentId = ascendingIds[i];
if (!parentId) continue;
for (let j = i + 1; j < ascendingIds.length; j++) {
if (ascendingIds[j].startsWith(parentId)) {
ascendingIds[j] = '';
Expand All @@ -17,3 +21,40 @@ export const normalizeSelection = (selectedIds: string[]): string[] => {

return ascendingIds.filter(id => id);
};

export const sortActionIds = (actionIds: string[]): string[] => {
const parsedActionIds = actionIds.map(id => ({
id,
paths: id
.split('.')
.map(x => x.replace(/\w+\[(\d+)\]/, '$1'))
.map(x => parseInt(x) || 0),
}));
const sorted = parsedActionIds.sort((a, b) => {
const aPaths = a.paths;
const bPaths = b.paths;

let diffIndex = 0;
while (diffIndex < aPaths.length && diffIndex < bPaths.length && aPaths[diffIndex] === bPaths[diffIndex]) {
diffIndex++;
}

const flag = (aPaths[diffIndex] === undefined ? '0' : '1') + (bPaths[diffIndex] === undefined ? '0' : '1');
switch (flag) {
case '00':
// a equal b ('actions[0]', 'actions[0]')
return 0;
case '01':
// a is b's parent, a < b ('actions[0]', 'actions[0].actions[0]')
return -1;
case '10':
// a is b's child, a > b ('actions[0].actions[0]', 'actions[0]')
return 1;
case '11':
return aPaths[diffIndex] - bPaths[diffIndex];
default:
return 0;
}
});
return sorted.map(x => x.id);
};
Loading