diff --git a/Composer/packages/adaptive-form/src/components/fields/ArrayField.tsx b/Composer/packages/adaptive-form/src/components/fields/ArrayField.tsx index d7a6c6f244..3576cc460a 100644 --- a/Composer/packages/adaptive-form/src/components/fields/ArrayField.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/ArrayField.tsx @@ -3,7 +3,6 @@ /** @jsx jsx */ import { jsx } from '@emotion/core'; import React, { useState } from 'react'; -import { TextField } from 'office-ui-fabric-react/lib/TextField'; import { IconButton } from 'office-ui-fabric-react/lib/Button'; import { TooltipHost } from 'office-ui-fabric-react/lib/Tooltip'; import { SharedColors, NeutralColors, FontSizes } from '@uifabric/fluent-theme'; @@ -16,6 +15,7 @@ import { FieldLabel } from '../FieldLabel'; import { arrayField } from './styles'; import { ArrayFieldItem } from './ArrayFieldItem'; import { UnsupportedField } from './UnsupportedField'; +import { TextField } from './TextField/TextField'; const ArrayField: React.FC> = (props) => { const { @@ -82,12 +82,12 @@ const ArrayField: React.FC> = (props) => { diff --git a/Composer/packages/adaptive-form/src/components/fields/EditableField.tsx b/Composer/packages/adaptive-form/src/components/fields/EditableField.tsx index 623a3c29d1..6151e36809 100644 --- a/Composer/packages/adaptive-form/src/components/fields/EditableField.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/EditableField.tsx @@ -2,11 +2,13 @@ // Licensed under the MIT License. import React, { useState, useEffect } from 'react'; -import { TextField, ITextFieldStyles } from 'office-ui-fabric-react/lib/TextField'; +import { ITextFieldStyles } from 'office-ui-fabric-react/lib/TextField'; import { NeutralColors } from '@uifabric/fluent-theme'; import { mergeStyleSets } from '@uifabric/styling'; import { FieldProps } from '@bfc/extension-client'; +import { TextField } from './TextField/TextField'; + interface EditableFieldProps extends Omit { fontSize?: string; styles?: Partial; @@ -67,6 +69,7 @@ const EditableField: React.FC = (props) => { = (props) => { styles ) as Partial } - value={localValue} onBlur={handleCommit} onChange={handleChange} onFocus={() => setHasFocus(true)} diff --git a/Composer/packages/adaptive-form/src/components/fields/ExpressionField/ExpressionEditor.tsx b/Composer/packages/adaptive-form/src/components/fields/ExpressionField/ExpressionEditor.tsx index bddc4a630d..a3e93d1074 100644 --- a/Composer/packages/adaptive-form/src/components/fields/ExpressionField/ExpressionEditor.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/ExpressionField/ExpressionEditor.tsx @@ -4,10 +4,10 @@ import { FieldProps, useShellApi } from '@bfc/extension-client'; import { IntellisenseTextField } from '@bfc/intellisense'; import { Icon } from 'office-ui-fabric-react/lib/Icon'; -import { TextField } from 'office-ui-fabric-react/lib/TextField'; import React from 'react'; import { getIntellisenseUrl } from '../../../utils/getIntellisenseUrl'; +import { TextField } from '../TextField/TextField'; const ExpressionEditor: React.FC = (props) => { const { id, value = '', onChange, disabled, placeholder, readonly, error } = props; @@ -26,6 +26,7 @@ const ExpressionEditor: React.FC = (props) => { {(textFieldValue, onValueChanged, onKeyDownTextField, onKeyUpTextField, onClickTextField) => ( = (props) => { root: { width: '100%' }, errorMessage: { display: 'none' }, }} - value={textFieldValue} onChange={(_e, newValue) => onValueChanged(newValue || '')} onClick={onClickTextField} onKeyDown={onKeyDownTextField} diff --git a/Composer/packages/adaptive-form/src/components/fields/IntellisenseField.tsx b/Composer/packages/adaptive-form/src/components/fields/IntellisenseField.tsx index 39d8adec9b..2b1f6f8bb6 100644 --- a/Composer/packages/adaptive-form/src/components/fields/IntellisenseField.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/IntellisenseField.tsx @@ -3,12 +3,13 @@ import { FieldProps, useShellApi } from '@bfc/extension-client'; import { IntellisenseTextField } from '@bfc/intellisense'; -import { TextField } from 'office-ui-fabric-react/lib/TextField'; import React from 'react'; import { getIntellisenseUrl } from '../../utils/getIntellisenseUrl'; import { FieldLabel } from '../FieldLabel'; +import { TextField } from './TextField/TextField'; + export const IntellisenseField: React.FC> = function IntellisenseField(props) { const { id, value = '', onChange, label, description, uiOptions, required } = props; @@ -29,8 +30,8 @@ export const IntellisenseField: React.FC> = function Intellis {(textFieldValue, onValueChanged, onKeyDownTextField, onKeyUpTextField, onClickTextField) => ( onValueChanged(newValue || '')} onClick={onClickTextField} onKeyDown={onKeyDownTextField} diff --git a/Composer/packages/adaptive-form/src/components/fields/ObjectArrayField.tsx b/Composer/packages/adaptive-form/src/components/fields/ObjectArrayField.tsx index 169c0a7ef9..a6491ea0f0 100644 --- a/Composer/packages/adaptive-form/src/components/fields/ObjectArrayField.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/ObjectArrayField.tsx @@ -8,7 +8,7 @@ import { FieldProps, useShellApi } from '@bfc/extension-client'; import { DefaultButton } from 'office-ui-fabric-react/lib/Button'; import { JSONSchema7 } from 'json-schema'; import { IconButton } from 'office-ui-fabric-react/lib/Button'; -import { TextField, ITextField } from 'office-ui-fabric-react/lib/TextField'; +import { ITextField } from 'office-ui-fabric-react/lib/TextField'; import { TooltipHost } from 'office-ui-fabric-react/lib/Tooltip'; import { FontSizes, NeutralColors, SharedColors } from '@uifabric/fluent-theme'; import formatMessage from 'format-message'; @@ -20,6 +20,7 @@ import { FieldLabel } from '../FieldLabel'; import { objectArrayField } from './styles'; import { ArrayFieldItem } from './ArrayFieldItem'; import { UnsupportedField } from './UnsupportedField'; +import { TextField } from './TextField/TextField'; const getNewPlaceholder = (props: FieldProps, propertyName: string): string | undefined => { const { uiOptions } = props; @@ -156,6 +157,7 @@ const ObjectArrayField: React.FC> = (props) => { ariaLabel={lastField ? END_OF_ROW_LABEL : INSIDE_ROW_LABEL} autoComplete="off" componentRef={index === 0 ? firstNewFieldRef : undefined} + defaultValue={newObject[property] || ''} iconProps={{ ...(lastField ? { @@ -169,7 +171,6 @@ const ObjectArrayField: React.FC> = (props) => { }} placeholder={getNewPlaceholder(props, property)} styles={{ field: { padding: '0 24px 0 8px' } }} - value={newObject[property] || ''} onChange={handleNewObjectChange(property)} onKeyDown={handleKeyDown} /> diff --git a/Composer/packages/adaptive-form/src/components/fields/OpenObjectField/OpenObjectField.tsx b/Composer/packages/adaptive-form/src/components/fields/OpenObjectField/OpenObjectField.tsx index 46bfd8e484..7615fe3a5c 100644 --- a/Composer/packages/adaptive-form/src/components/fields/OpenObjectField/OpenObjectField.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/OpenObjectField/OpenObjectField.tsx @@ -6,13 +6,14 @@ import React, { useState, useRef } from 'react'; import { DefaultButton } from 'office-ui-fabric-react/lib/Button'; import { FontSizes, NeutralColors, SharedColors } from '@uifabric/fluent-theme'; import { IconButton } from 'office-ui-fabric-react/lib/Button'; -import { TextField, ITextField } from 'office-ui-fabric-react/lib/TextField'; +import { ITextField } from 'office-ui-fabric-react/lib/TextField'; import { TooltipHost } from 'office-ui-fabric-react/lib/Tooltip'; import { FieldProps } from '@bfc/extension-client'; import formatMessage from 'format-message'; import { FieldLabel } from '../../FieldLabel'; import { getPropertyItemProps, useObjectItems } from '../../../utils/objectUtils'; +import { TextField } from '../TextField/TextField'; import * as styles from './styles'; import { ObjectItem } from './ObjectItem'; @@ -99,11 +100,11 @@ const OpenObjectField: React.FC setName(newValue || '')} onKeyDown={handleKeyDown} /> @@ -112,6 +113,7 @@ const OpenObjectField: React.FC setNewValue(newValue || '')} onKeyDown={handleKeyDown} /> diff --git a/Composer/packages/adaptive-form/src/components/fields/StringField.tsx b/Composer/packages/adaptive-form/src/components/fields/StringField.tsx index 2ffbbc4bce..5242ee9505 100644 --- a/Composer/packages/adaptive-form/src/components/fields/StringField.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/StringField.tsx @@ -4,11 +4,12 @@ import React from 'react'; import { FieldProps } from '@bfc/extension-client'; import { NeutralColors } from '@uifabric/fluent-theme'; -import { TextField } from 'office-ui-fabric-react/lib/TextField'; import formatMessage from 'format-message'; import { FieldLabel } from '../FieldLabel'; +import { TextField } from './TextField/TextField'; + export const borderStyles = (transparentBorder: boolean, error: boolean) => transparentBorder ? { @@ -65,6 +66,7 @@ export const StringField: React.FC> = function StringField(pr > = function StringField(pr root: { width: '100%' }, errorMessage: { display: 'none' }, }} - value={value} onBlur={handleBlur} onChange={handleChange} onFocus={handleFocus} diff --git a/Composer/packages/adaptive-form/src/components/fields/TextField/TextField.base.tsx b/Composer/packages/adaptive-form/src/components/fields/TextField/TextField.base.tsx new file mode 100644 index 0000000000..2b63e10b02 --- /dev/null +++ b/Composer/packages/adaptive-form/src/components/fields/TextField/TextField.base.tsx @@ -0,0 +1,620 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as React from 'react'; +import { Icon } from 'office-ui-fabric-react/lib/Icon'; +import { IProcessedStyleSet } from '@uifabric/merge-styles/lib/IStyleSet'; +import { Label, ILabelStyleProps, ILabelStyles } from 'office-ui-fabric-react/lib/Label'; +import { + Async, + DelayedRender, + IStyleFunctionOrObject, + classNamesFunction, + getId, + getNativeProps, + initializeComponentRef, + inputProperties, + isControlled, + textAreaProperties, + warn, + warnControlledUsage, + warnMutuallyExclusive, +} from '@uifabric/utilities'; +import { + ITextField, + ITextFieldProps, + ITextFieldStyleProps, + ITextFieldStyles, +} from 'office-ui-fabric-react/lib/TextField'; +import debounce from 'lodash/debounce'; + +const getClassNames = classNamesFunction(); + +/** @internal */ +export interface ITextFieldState { + /** Is true when the control has focus. */ + isFocused?: boolean; + + /** + * Dynamic error message returned by `onGetErrorMessage`. + * Use `this._errorMessage` to get the actual current error message. + */ + errorMessage: string | JSX.Element; +} + +/** @internal */ +export interface ITextFieldSnapshot { + /** + * If set, the text field is changing between single- and multi-line, so we'll need to reset + * selection/cursor after the change completes. + */ + selection?: [number | null, number | null]; +} + +const DEFAULT_STATE_VALUE = ''; +const COMPONENT_NAME = 'TextField'; + +export class TextFieldBase extends React.Component + implements ITextField { + public static defaultProps: ITextFieldProps = { + resizable: true, + deferredValidationTime: 200, + validateOnLoad: true, + }; + + /** Fallback ID if none is provided in props. Access proper value via `this.componentId`. */ + private _fallbackId: string; + private _descriptionId: string; + private _labelId: string; + private _delayedValidate: (value: string | undefined) => void; + private _lastValidation: number; + private _latestValidateValue: string | undefined; + private _hasWarnedNullValue: boolean | undefined; + private _textElement = React.createRef(); + private _async: Async; + /** Most recent value from a change or input event, to help avoid processing events twice */ + private _lastChangeValue: string | undefined; + + private _syncData; + + public constructor(props: ITextFieldProps) { + super(props); + + initializeComponentRef(this); + this._async = new Async(this); + + if (process.env.NODE_ENV !== 'production') { + warnMutuallyExclusive(COMPONENT_NAME, props, { + errorMessage: 'onGetErrorMessage', + }); + } + + this._fallbackId = getId(COMPONENT_NAME); + this._descriptionId = getId(COMPONENT_NAME + 'Description'); + this._labelId = getId(COMPONENT_NAME + 'Label'); + + this._warnControlledUsage(); + + let { defaultValue = DEFAULT_STATE_VALUE } = props; + if (typeof defaultValue === 'number') { + // This isn't allowed per the props, but happens anyway. + defaultValue = String(defaultValue); + } + this.state = { + isFocused: false, + errorMessage: '', + }; + + /*-----updated by composer-----*/ + // this is only use to sync the data from the other data source + // we want to use uncontrolled feature but we still need to sync data. + this._delayedValidate = this._async.debounce(this._validate, this.props.deferredValidationTime); + this._lastValidation = 0; + + this._syncData = debounce((defaultValue: string | undefined) => { + if (this._textElement.current && defaultValue !== undefined && defaultValue !== this._textElement.current.value) { + this._textElement.current.value = defaultValue; + } + }, 300); + /*-----updated by composer-----*/ + } + + /** + * Gets the current value of the text field. + */ + public get value(): string | undefined { + return getValue(this.props); + } + + public componentDidMount(): void { + this._adjustInputHeight(); + + if (this.props.validateOnLoad) { + this._validate(this.value); + } + } + + public componentWillUnmount() { + this._async.dispose(); + } + + public getSnapshotBeforeUpdate(prevProps: ITextFieldProps, prevState: ITextFieldState): ITextFieldSnapshot | null { + return { + selection: [this.selectionStart, this.selectionEnd], + }; + } + + public componentDidUpdate( + prevProps: ITextFieldProps, + prevState: ITextFieldState, + snapshot: ITextFieldSnapshot + ): void { + const props = this.props; + const { selection = [null, null] } = snapshot || {}; + const [start, end] = selection; + + if (!!prevProps.multiline !== !!props.multiline && prevState.isFocused) { + // The text field has just changed between single- and multi-line, so we need to reset focus + // and selection/cursor. + this.focus(); + if (start !== null && end !== null && start >= 0 && end >= 0) { + this.setSelectionRange(start, end); + } + } + + !this.isComponentControlled && this._syncData(this.props.defaultValue); + const prevValue = getValue(prevProps); + const value = this.value; + if (prevValue !== value) { + // Handle controlled/uncontrolled warnings and status + this._warnControlledUsage(prevProps); + + // Clear error message if needed + // TODO: is there any way to do this without an extra render? + if (this.state.errorMessage && !props.errorMessage) { + this.setState({ errorMessage: '' }); + } + + // Adjust height if needed based on new value + this._adjustInputHeight(); + + // Reset the record of the last value seen by a change/input event + this._lastChangeValue = undefined; + + // TODO: #5875 added logic to trigger validation in componentWillReceiveProps and other places. + // This seems a bit odd and hard to integrate with the new approach. + // (Starting to think we should just put the validation logic in a separate wrapper component...?) + if (shouldValidateAllChanges(props)) { + this._delayedValidate(value); + } + } + } + + public render(): JSX.Element { + const { + iconProps, + multiline, + prefix, + suffix, + onRenderPrefix = this._onRenderPrefix, + onRenderSuffix = this._onRenderSuffix, + onRenderLabel = this._onRenderLabel, + onRenderDescription = this._onRenderDescription, + } = this.props; + const errorMessage = this.errorComponentMessage; + + const classNames = this.componentClassNames; + + return ( +
+
+ {onRenderLabel(this.props, this._onRenderLabel)} +
+ {(prefix !== undefined || this.props.onRenderPrefix) && ( +
{onRenderPrefix(this.props, this._onRenderPrefix)}
+ )} + {multiline ? this._renderTextArea() : this._renderInput()} + {iconProps && } + {(suffix !== undefined || this.props.onRenderSuffix) && ( +
{onRenderSuffix(this.props, this._onRenderSuffix)}
+ )} +
+
+ {this.isDescriptionAvailable && ( + + {onRenderDescription(this.props, this._onRenderDescription)} + {errorMessage && ( +
+ +

+ {errorMessage} +

+
+
+ )} +
+ )} +
+ ); + } + + /** + * Sets focus on the text field + */ + public focus() { + if (this._textElement.current) { + this._textElement.current.focus(); + } + } + + /** + * Blurs the text field. + */ + public blur() { + if (this._textElement.current) { + this._textElement.current.blur(); + } + } + + /** + * Selects the text field + */ + public select() { + if (this._textElement.current) { + this._textElement.current.select(); + } + } + + /** + * Sets the selection start of the text field to a specified value + */ + public setSelectionStart(value: number): void { + if (this._textElement.current) { + this._textElement.current.selectionStart = value; + } + } + + /** + * Sets the selection end of the text field to a specified value + */ + public setSelectionEnd(value: number): void { + if (this._textElement.current) { + this._textElement.current.selectionEnd = value; + } + } + + /** + * Gets the selection start of the text field + */ + public get selectionStart(): number | null { + return this._textElement.current ? this._textElement.current.selectionStart : -1; + } + + /** + * Gets the selection end of the text field + */ + public get selectionEnd(): number | null { + return this._textElement.current ? this._textElement.current.selectionEnd : -1; + } + + /** + * Sets the start and end positions of a selection in a text field. + * @param start - Index of the start of the selection. + * @param end - Index of the end of the selection. + */ + public setSelectionRange(start: number, end: number): void { + if (this._textElement.current) { + (this._textElement.current as HTMLInputElement).setSelectionRange(start, end); + } + } + + private _warnControlledUsage = (prevProps?: ITextFieldProps): void => { + // Show warnings if props are being used in an invalid way + warnControlledUsage({ + componentId: this.componentId, + componentName: COMPONENT_NAME, + props: this.props, + oldProps: prevProps, + valueProp: 'value', + defaultValueProp: 'defaultValue', + onChangeProp: 'onChange', + readOnlyProp: 'readOnly', + }); + + if (this.props.value === null && !this._hasWarnedNullValue) { + this._hasWarnedNullValue = true; + warn( + `Warning: 'value' prop on '${COMPONENT_NAME}' should not be null. Consider using an ` + + 'empty string to clear the component or undefined to indicate an uncontrolled component.' + ); + } + }; + + /** Returns `props.id` if available, or a fallback if not. */ + private get componentId(): string { + return this.props.id || this._fallbackId; + } + + private get isComponentControlled(): boolean { + return isControlled(this.props, 'value'); + } + + private _onFocus = (ev: React.FocusEvent): void => { + if (this.props.onFocus) { + this.props.onFocus(ev); + } + + this.setState({ isFocused: true }, () => { + if (this.props.validateOnFocusIn) { + this._validate(this.value); + } + }); + }; + + private _onBlur = (ev: React.FocusEvent): void => { + if (this.props.onBlur) { + this.props.onBlur(ev); + } + + this.setState({ isFocused: false }, () => { + if (this.props.validateOnFocusOut) { + this._validate(this.value); + } + }); + }; + + private _onRenderLabel = (props: ITextFieldProps, rest: any): JSX.Element | null => { + const { label, required } = props; + // IProcessedStyleSet definition requires casting for what Label expects as its styles prop + const labelStyles = this.componentClassNames.subComponentStyles + ? (this.componentClassNames.subComponentStyles.label as IStyleFunctionOrObject) + : undefined; + + if (label) { + return ( + + ); + } + return null; + }; + + private _onRenderDescription = (props: ITextFieldProps, rest: any): JSX.Element | null => { + if (props.description) { + return {props.description}; + } + return null; + }; + + private _onRenderPrefix = (props: ITextFieldProps, rest: any): JSX.Element => { + const { prefix } = props; + return {prefix}; + }; + + private _onRenderSuffix = (props: ITextFieldProps, rest: any): JSX.Element => { + const { suffix } = props; + return {suffix}; + }; + + /** + * Current error message from either `props.errorMessage` or the result of `props.onGetErrorMessage`. + * + * - If there is no validation error or we have not validated the input value, errorMessage is an empty string. + * - If we have done the validation and there is validation error, errorMessage is the validation error message. + */ + private get errorComponentMessage(): string | JSX.Element { + const { errorMessage = this.state.errorMessage } = this.props; + return errorMessage || ''; + } + + /** + * If a custom description render function is supplied then treat description as always available. + * Otherwise defer to the presence of description or error message text. + */ + private get isDescriptionAvailable(): boolean { + const props = this.props; + return !!(props.onRenderDescription || props.description || this.errorComponentMessage); + } + + private get componentClassNames(): IProcessedStyleSet { + const { + borderless, + className, + disabled, + iconProps, + inputClassName, + label, + multiline, + required, + underlined, + resizable, + theme, + styles, + autoAdjustHeight, + } = this.props; + const { isFocused } = this.state; + const errorMessage = this.errorComponentMessage; + + return getClassNames(styles!, { + theme: theme!, + className, + disabled, + focused: isFocused, + required, + multiline, + hasLabel: !!label, + hasErrorMessage: !!errorMessage, + borderless, + resizable, + hasIcon: !!iconProps, + underlined, + inputClassName, + autoAdjustHeight, + }); + } + + private _renderTextArea = (): React.ReactElement> => { + const textAreaProps = getNativeProps>( + this.props, + textAreaProperties, + ['value'] + ); + + const ariaLabelledBy = this.props['aria-labelledby'] || (this.props.label ? this._labelId : undefined); + /*-----added by composer-----*/ + // use the default value to replace the state value + const valueProps = this.isComponentControlled ? { value: this.props.value } : {}; + /*-----added by composer-----*/ + return ( +