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'),
};
})();
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 }) }),
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;
}

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