Skip to content

Commit

Permalink
feat: Add FieldToolbar to expression fields (#6573)
Browse files Browse the repository at this point in the history
* feat: Add field toolbar to expressions

* feat: Add FieldToolbar to expression fields

* changes

* update callout

* fixes

* re-factor and more

* deps

* format

* fix test

Co-authored-by: Soroush <[email protected]>
Co-authored-by: Soroush <[email protected]>
  • Loading branch information
3 people authored Mar 26, 2021
1 parent 1b44b16 commit 236ceac
Show file tree
Hide file tree
Showing 25 changed files with 445 additions and 347 deletions.
18 changes: 6 additions & 12 deletions Composer/packages/adaptive-form/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,6 @@
},
"license": "MIT",
"peerDependencies": {
"@bfc/code-editor": "*",
"@bfc/extension-client": "*",
"@bfc/intellisense": "*",
"@uifabric/fluent-theme": "^7.1.4",
"@uifabric/icons": "^7.3.0",
"@uifabric/styling": "^7.7.4",
Expand All @@ -30,20 +27,17 @@
"react-dom": "16.13.1"
},
"devDependencies": {
"@bfc/code-editor": "*",
"@bfc/extension-client": "*",
"@bfc/intellisense": "*",
"@botframework-composer/test-utils": "*",
"@types/lodash": "^4.14.149",
"@types/react": "16.9.23",
"format-message": "^6.2.3",
"react": "16.13.1",
"react-dom": "16.13.1"
"@types/react": "16.9.23"
},
"dependencies": {
"@bfc/built-in-functions": "*",
"@bfc/code-editor": "*",
"@bfc/extension-client": "*",
"@bfc/intellisense": "*",
"@emotion/core": "^10.0.27",
"lodash": "^4.17.19",
"react-error-boundary": "^1.2.5",
"@bfc/built-in-functions": "*"
"react-error-boundary": "^1.2.5"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { DirectionalHint } from 'office-ui-fabric-react/lib/ContextualMenu';
import React from 'react';
import { Callout } from 'office-ui-fabric-react/lib/Callout';
import { FieldToolbar } from '@bfc/code-editor';
import { useShellApi } from '@bfc/extension-client';

const inputs = ['input', 'textarea'];

type Props = {
container: HTMLDivElement | null;
target: HTMLInputElement | HTMLTextAreaElement | null;
value?: string;
onChange: (expression: string) => void;
onClearTarget: () => void;
};

const jsFieldToolbarMenuClassName = 'js-field-toolbar-menu';

export const ExpressionFieldToolbar = (props: Props) => {
const { onClearTarget, container, target, value = '', onChange } = props;
const { projectId, shellApi } = useShellApi();

const [memoryVariables, setMemoryVariables] = React.useState<string[] | undefined>();

React.useEffect(() => {
const abortController = new AbortController();
(async () => {
try {
const variables = await shellApi.getMemoryVariables(projectId, { signal: abortController.signal });
setMemoryVariables(variables);
} catch (e) {
// error can be due to abort
}
})();
}, [projectId]);

React.useEffect(() => {
const keyDownHandler = (e: KeyboardEvent) => {
if (
e.key === 'Escape' &&
(!document.activeElement || inputs.includes(document.activeElement.tagName.toLowerCase()))
) {
onClearTarget();
}
};

const focusHandler = (e: FocusEvent) => {
if (container?.contains(e.target as Node)) {
return;
}

if (
!e
.composedPath()
.filter((n) => n instanceof Element)
.map((n) => (n as Element).className)
.some((c) => c.indexOf(jsFieldToolbarMenuClassName) !== -1)
) {
onClearTarget();
}
};

document.addEventListener('focusin', focusHandler);
document.addEventListener('keydown', keyDownHandler);

return () => {
document.removeEventListener('focusin', focusHandler);
document.removeEventListener('keydown', keyDownHandler);
};
}, [container, onClearTarget]);

const onSelectToolbarMenuItem = React.useCallback(
(text: string) => {
if (typeof target?.selectionStart === 'number') {
const start = target.selectionStart;
const end = typeof target?.selectionEnd === 'number' ? target.selectionEnd : target.selectionStart;

const updatedItem = [value.slice(0, start), text, value.slice(end)].join('');
onChange(updatedItem);

setTimeout(() => {
target.setSelectionRange(updatedItem.length, updatedItem.length);
}, 0);
}

target?.focus();
},
[target, value, onChange]
);
return target ? (
<Callout
doNotLayer
directionalHint={DirectionalHint.topLeftEdge}
gapSpace={2}
isBeakVisible={false}
target={target}
>
<FieldToolbar
key="field-toolbar"
dismissHandlerClassName={jsFieldToolbarMenuClassName}
excludedToolbarItems={['template']}
properties={memoryVariables}
onSelectToolbarMenuItem={onSelectToolbarMenuItem}
/>
</Callout>
) : null;
};

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import React, { useRef, useState } from 'react';

import { getIntellisenseUrl } from '../../utils/getIntellisenseUrl';
import { ExpressionSwitchWindow } from '../expressions/ExpressionSwitchWindow';
import { ExpressionsListMenu } from '../expressions/ExpressionsListMenu';
import { ExpressionFieldToolbar } from '../expressions/ExpressionFieldToolbar';

import { JsonField } from './JsonField';
import { NumberField } from './NumberField';
Expand Down Expand Up @@ -68,23 +68,25 @@ export const IntellisenseExpressionField: React.FC<FieldProps<string>> = (props)
const scopes = ['expressions', 'user-variables'];
const intellisenseServerUrlRef = useRef(getIntellisenseUrl());

const [expressionsListContainerElements, setExpressionsListContainerElements] = useState<HTMLDivElement[]>([]);
const [containerElm, setContainerElm] = useState<HTMLDivElement | null>(null);
const [toolbarTargetElm, setToolbarTargetElm] = useState<HTMLInputElement | HTMLTextAreaElement | null>(null);

const focus = React.useCallback(
(id: string, value?: string, event?: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (event?.target) {
event.stopPropagation();
setToolbarTargetElm(event.target as HTMLInputElement | HTMLTextAreaElement);
}
},
[]
);

const completionListOverrideResolver = (value: string) => {
return value === '=' ? (
<ExpressionsListMenu
onExpressionSelected={(expression: string) => onChange(expression)}
onMenuMount={(refs) => {
setExpressionsListContainerElements(refs);
}}
/>
) : null;
};
const onClearTarget = React.useCallback(() => {
setToolbarTargetElm(null);
}, []);

return (
<Intellisense
completionListOverrideContainerElements={expressionsListContainerElements}
completionListOverrideResolver={completionListOverrideResolver}
focused={defaultFocused}
id={`intellisense-${id}`}
scopes={scopes}
Expand All @@ -102,18 +104,28 @@ export const IntellisenseExpressionField: React.FC<FieldProps<string>> = (props)
onKeyUpTextField,
onClickTextField,
}) => (
<StringField
{...props}
cursorPosition={cursorPosition}
focused={focused}
id={id}
value={textFieldValue}
onBlur={noop} // onBlur managed by Intellisense
onChange={(newValue) => onValueChanged(newValue || '')}
onClick={onClickTextField}
onKeyDown={onKeyDownTextField}
onKeyUp={onKeyUpTextField}
/>
<div ref={setContainerElm}>
<StringField
{...props}
cursorPosition={cursorPosition}
focused={focused}
id={id}
value={textFieldValue}
onBlur={noop} // onBlur managed by Intellisense
onChange={(newValue) => onValueChanged(newValue || '')}
onClick={onClickTextField}
onFocus={focus}
onKeyDown={onKeyDownTextField}
onKeyUp={onKeyUpTextField}
/>
<ExpressionFieldToolbar
container={containerElm}
target={toolbarTargetElm}
value={textFieldValue}
onChange={onChange}
onClearTarget={onClearTarget}
/>
</div>
)}
</Intellisense>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export const StringField: React.FC<FieldProps<string>> = function StringField(pr
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
if (typeof onFocus === 'function') {
e.stopPropagation();
onFocus(id, value);
onFocus(id, value, e);
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe('<StringField />', () => {
const input = getByLabelText('a label');

fireEvent.focus(input);
expect(onFocus).toHaveBeenCalledWith('string field', 'string value');
expect(onFocus).toHaveBeenCalledWith('string field', 'string value', expect.any(Object));

fireEvent.blur(input);
expect(onBlur).toHaveBeenCalledWith('string field', 'string value');
Expand Down
2 changes: 1 addition & 1 deletion Composer/packages/extension-client/src/types/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export interface FieldProps<T = any> {
cursorPosition?: number;

onChange: ChangeHandler<T>;
onFocus?: (id: string, value?: T) => void;
onFocus?: (id: string, value?: T, event?: React.FocusEvent<any>) => void;
onBlur?: (id: string, value?: T) => void;

onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
Expand Down
11 changes: 4 additions & 7 deletions Composer/packages/intellisense/src/components/Intellisense.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export const Intellisense = React.memo(
if (didComplete.current) {
didComplete.current = false;
} else {
if (completionItems && completionItems.length) {
if (completionItems?.length) {
setShowCompletionList(true);
} else {
setShowCompletionList(false);
Expand All @@ -93,24 +93,21 @@ export const Intellisense = React.memo(
shouldBlur = false;
}

if (
completionListOverrideContainerElements &&
completionListOverrideContainerElements.some((item) => !checkIsOutside(x, y, item))
) {
if (completionListOverrideContainerElements?.some((item) => !checkIsOutside(x, y, item))) {
shouldBlur = false;
}

if (shouldBlur) {
setShowCompletionList(false);
setCursorPosition(-1);
onBlur && onBlur(id);
onBlur?.(id);
}
};

const keydownHandler = (event: KeyboardEvent) => {
if ((event.key === 'Escape' || event.key === 'Tab') && focused) {
setShowCompletionList(false);
onBlur && onBlur(id);
onBlur?.(id);
}
};

Expand Down
Loading

0 comments on commit 236ceac

Please sign in to comment.