Skip to content

Commit

Permalink
feat: $designer.id based URL (#4242)
Browse files Browse the repository at this point in the history
* mark out contribution points

* impl designer path encoder/decoder

* use double quotation

* apply encoder / decoder to navigation logic

* fix tslint error

* fix import order

* remove comments

* fix tslint error

* encode breadcrumb path

Co-authored-by: Chris Whitten <christopher.whitten@microsoft.com>
  • Loading branch information
yeze322 and cwhitten committed Sep 30, 2020
1 parent 1d272a8 commit cd05173
Show file tree
Hide file tree
Showing 5 changed files with 213 additions and 10 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { SDKKinds } from '@bfc/shared';

import {
decodeDesignerPathToArrayPath,
encodeArrayPathToDesignerPath,
} from '../../../src/utils/convertUtils/designerPathEncoder';

const dialog = {
triggers: [
{
$kind: SDKKinds.OnIntent,
$designer: { id: '1234' },
actions: [
{
$kind: SDKKinds.SendActivity,
$designer: { id: '5678' },
},
{
$kind: SDKKinds.SendActivity,
},
],
},
],
};

describe('encodeArrayPathToDesignerPath()', () => {
it('can handle empty input.', () => {
expect(encodeArrayPathToDesignerPath(dialog, '')).toEqual('');
expect(encodeArrayPathToDesignerPath(undefined, '')).toEqual('');
});

it('should transform valid array path.', () => {
expect(encodeArrayPathToDesignerPath(dialog, 'triggers[0].actions[0]')).toEqual(`triggers["1234"].actions["5678"]`);
});

it('can handle subdata without $designer.id.', () => {
expect(encodeArrayPathToDesignerPath(dialog, 'triggers[0].actions[1]')).toEqual(`triggers["1234"].actions[1]`);
});

it('can recover from invalid array path.', () => {
expect(encodeArrayPathToDesignerPath(dialog, 'triggers[0].actions[99]')).toEqual('triggers[0].actions[99]');
});
});

describe('decodeDesignerPathToArrayPath()', () => {
it('should transform valid designer path.', () => {
expect(decodeDesignerPathToArrayPath(dialog, `triggers["1234"].actions["5678"]`)).toEqual('triggers[0].actions[0]');
});

it('can handle valid designer path.', () => {
expect(decodeDesignerPathToArrayPath(dialog, `triggers["1234"].actions["9999"]`)).toEqual(
`triggers["1234"].actions["9999"]`
);
expect(decodeDesignerPathToArrayPath(dialog, `triggers["5678"].actions["1234"]`)).toEqual(
`triggers["5678"].actions["1234"]`
);
expect(decodeDesignerPathToArrayPath(dialog, `dialogs["1234"].actions["5678"]`)).toEqual(
`dialogs["1234"].actions["5678"]`
);
});

it('can handle empty input.', () => {
expect(decodeDesignerPathToArrayPath(dialog, '')).toEqual('');
expect(decodeDesignerPathToArrayPath(undefined, '')).toEqual('');
});
});
11 changes: 8 additions & 3 deletions Composer/packages/client/src/pages/design/DesignPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
import ImportQnAFromUrlModal from '../knowledge-base/ImportQnAFromUrlModal';
import { triggerNotSupported } from '../../utils/dialogValidator';
import { undoFunctionState, undoVersionState } from '../../recoilModel/undo/history';
import { decodeDesignerPathToArrayPath } from '../../utils/convertUtils/designerPathEncoder';

import { WarningMessage } from './WarningMessage';
import {
Expand Down Expand Up @@ -142,7 +143,10 @@ const DesignPage: React.FC<RouteComponentProps<{ dialogId: string; projectId: st
} = useRecoilValue(dispatcherState);

const params = new URLSearchParams(location?.search);
const selected = params.get('selected') || '';
const selected = decodeDesignerPathToArrayPath(
dialogs.find((x) => x.id === props.dialogId)?.content,
params.get('selected') || ''
);
const [triggerModalVisible, setTriggerModalVisibility] = useState(false);
const [dialogJsonVisible, setDialogJsonVisibility] = useState(false);
const [importQnAModalVisibility, setImportQnAModalVisibility] = useState(false);
Expand Down Expand Up @@ -185,8 +189,9 @@ const DesignPage: React.FC<RouteComponentProps<{ dialogId: string; projectId: st
const { dialogId, projectId } = props;
const params = new URLSearchParams(location.search);
const dialogMap = dialogs.reduce((acc, { content, id }) => ({ ...acc, [id]: content }), {});
const selected = params.get('selected') ?? '';
const focused = params.get('focused') ?? '';
const dialogData = getDialogData(dialogMap, dialogId);
const selected = decodeDesignerPathToArrayPath(dialogData, params.get('selected') ?? '');
const focused = decodeDesignerPathToArrayPath(dialogData, params.get('focused') ?? '');

//make sure focusPath always valid
const data = getDialogData(dialogMap, dialogId, getFocusPath(selected, focused));
Expand Down
24 changes: 19 additions & 5 deletions Composer/packages/client/src/recoilModel/dispatchers/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { PromptTab, SDKKinds } from '@bfc/shared';
import cloneDeep from 'lodash/cloneDeep';

import { currentProjectIdState } from '../atoms';
import { encodeArrayPathToDesignerPath } from '../../utils/convertUtils/designerPathEncoder';

import { createSelectedPath, getSelected } from './../../utils/dialogUtil';
import { BreadcrumbItem } from './../../recoilModel/types';
Expand Down Expand Up @@ -64,6 +65,7 @@ export const navigationDispatcher = () => {

if (typeof beginDialogIndex !== 'undefined' && beginDialogIndex >= 0) {
path = createSelectedPath(beginDialogIndex);
path = encodeArrayPathToDesignerPath(currentDialog?.content, path);
updatedBreadcrumb.push({ dialogId, selected: '', focused: '' });
}
}
Expand All @@ -88,7 +90,10 @@ export const navigationDispatcher = () => {

if (!dialogId) dialogId = 'Main';

const currentUri = convertPathToUrl(projectId, dialogId, selectPath);
const dialogs = await snapshot.getPromise(dialogsState(projectId));
const currentDialog = dialogs.find(({ id }) => id === dialogId);
const encodedSelectPath = encodeArrayPathToDesignerPath(currentDialog?.content, selectPath);
const currentUri = convertPathToUrl(projectId, dialogId, encodedSelectPath);

if (checkUrl(currentUri, projectId, designPageLocation)) return;
navigateTo(currentUri, { state: { breadcrumb: updateBreadcrumb(breadcrumb, BreadcrumbUpdateType.Selected) } });
Expand All @@ -106,12 +111,16 @@ export const navigationDispatcher = () => {
let currentUri = `/bot/${projectId}/dialogs/${dialogId}`;

if (focusPath) {
const targetSelected = getSelected(focusPath);
const dialogs = await snapshot.getPromise(dialogsState(projectId));
const currentDialog = dialogs.find(({ id }) => id === dialogId);
const encodedFocusPath = encodeArrayPathToDesignerPath(currentDialog?.content, focusPath);

const targetSelected = getSelected(encodedFocusPath);
if (targetSelected !== selected) {
updatedBreadcrumb = updateBreadcrumb(breadcrumb, BreadcrumbUpdateType.Selected);
updatedBreadcrumb.push({ dialogId, selected: targetSelected, focused: '' });
}
currentUri = `${currentUri}?selected=${targetSelected}&focused=${focusPath}`;
currentUri = `${currentUri}?selected=${targetSelected}&focused=${encodedFocusPath}`;
updatedBreadcrumb = updateBreadcrumb(breadcrumb, BreadcrumbUpdateType.Focused);
} else {
currentUri = `${currentUri}?selected=${selected}`;
Expand All @@ -135,10 +144,15 @@ export const navigationDispatcher = () => {
breadcrumb: BreadcrumbItem[] = []
) => {
set(currentProjectIdState, projectId);
const search = getUrlSearch(selectPath, focusPath);

const dialogs = await snapshot.getPromise(dialogsState(projectId));
const currentDialog = dialogs.find(({ id }) => id === dialogId)?.content;
const encodedSelectPath = encodeArrayPathToDesignerPath(currentDialog, selectPath);
const encodedFocusPath = encodeArrayPathToDesignerPath(currentDialog, focusPath);
const search = getUrlSearch(encodedSelectPath, encodedFocusPath);
const designPageLocation = await snapshot.getPromise(designPageLocationState(projectId));
if (search) {
const currentUri = `/bot/${projectId}/dialogs/${dialogId}${getUrlSearch(selectPath, focusPath)}`;
const currentUri = `/bot/${projectId}/dialogs/${dialogId}${search}`;

if (checkUrl(currentUri, projectId, designPageLocation)) return;
navigateTo(currentUri, { state: { breadcrumb } });
Expand Down
115 changes: 115 additions & 0 deletions Composer/packages/client/src/utils/convertUtils/designerPathEncoder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import get from 'lodash/get';

const ArrayPathPattern = /^(\w+)\[(\d+)\]$/;

const parseArrayPath = (subpath: string) => {
const matchResults = ArrayPathPattern.exec(subpath);
if (matchResults?.length !== 3) return null;

const [, prefix, indexStr] = matchResults;
return {
prefix,
index: parseInt(indexStr),
};
};

/**
* Inputs `triggers[0].actions[0]`, outputs `triggers['12345'].actions['67890']`
* @param dialog Current active Adaptive dialog json
* @param path Current focus path in index format
*/
export const encodeArrayPathToDesignerPath = (dialog, path: string): string => {
if (!path || !dialog) return path;

const subpaths = path.split('.');
const transformedSubpaths: string[] = [];

let rootData = dialog;
for (const p of subpaths) {
const pathInfo = parseArrayPath(p);
if (!pathInfo) {
// For invalid input, fallback to origin array path.
return path;
}

const subData = get(rootData, p);
if (!subData) {
// For invalid data, fallback to origin array path.
return path;
}

const { prefix, index } = pathInfo;
// For subdata without designer.id, fallback to array index.
const designerId: string | number = get(subData, '$designer.id', index);
const designerIdStr = typeof designerId === 'string' ? `"${designerId}"` : designerId;

const designerSubpath = `${prefix}[${designerIdStr}]`;
transformedSubpaths.push(designerSubpath);

// descent to subData
rootData = subData;
}

const designerPath = transformedSubpaths.join('.');
return designerPath;
};

const DesignerPathPattern = /^(\w+)\["(\w+)"\]$/;

const parseDesignerPath = (subpath: string) => {
const matchResults = DesignerPathPattern.exec(subpath);
if (matchResults?.length !== 3) return null;

const [, prefix, designerId] = matchResults;
return {
prefix,
designerId,
};
};

/**
* Inputs `triggers['12345'].actions['67890']`, outputs `triggers[0].actions[0]`
* @param dialog Current active Adaptive dialog json
* @param path Current focus path in designer format
*/
export const decodeDesignerPathToArrayPath = (dialog, path: string): string => {
if (!path || !dialog) return path;

const subpaths = path.split('.');
const transformedSubpaths: string[] = [];

let rootData = dialog;
for (const p of subpaths) {
const pathInfo = parseDesignerPath(p);
if (!pathInfo) {
// For invalid input path, fallback to origin designer path
return path;
}

const { prefix: arrayName, designerId } = pathInfo;

const arrayData = get(rootData, arrayName);
if (!Array.isArray(arrayData)) {
// For invalid data, fallback to origin designer path
return path;
}

const arrayIndex = arrayData.findIndex((x) => get(x, '$designer.id') === designerId);
if (arrayIndex === -1) {
// Can't find given designer id, fallback to input path.
return path;
}

const arraySubpath = `${arrayName}[${arrayIndex}]`;
transformedSubpaths.push(arraySubpath);

// descent to subData
rootData = arrayData[arrayIndex];
}

const indexPath = transformedSubpaths.join('.');
return indexPath;
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
export function parsePathToSelected(path: string): string {
//path is like main.triggers[0].actions[0]

const triggerPattern = /triggers\[(\d+)\]/g;
const triggerPattern = /triggers\[(.+?)\]/g;
const matchTriggers = triggerPattern.exec(path);

const trigger = matchTriggers ? `triggers[${+matchTriggers[1]}]` : '';
const trigger = matchTriggers ? `triggers[${matchTriggers[1]}]` : '';

return trigger;
}

0 comments on commit cd05173

Please sign in to comment.