From b5ec41e0805c226250bf8bd757938be7010c1430 Mon Sep 17 00:00:00 2001 From: Dongyu Zhao Date: Tue, 21 Aug 2018 07:14:25 +0000 Subject: [PATCH] Merged PR 740936: Support PeoplePicker Support PeoplePicker --- dist/Components/Basic/Touchable.js | 12 +- dist/Components/Inputs/Contact.js | 54 +++ dist/Components/Inputs/InputBox.js | 2 +- dist/Components/Inputs/LabelInput.js | 166 ++++++++ dist/Contexts/CardContext.js | 2 + dist/Contexts/FormContext.js | 28 +- dist/Contexts/HostContext.js | 6 + dist/Contexts/MediaContext.js | 30 -- dist/Schema/Abstract/ActionElement.js | 1 + dist/Schema/Actions/SelectAction.js | 26 ++ dist/Schema/Factories/ActionFactory.js | 4 + dist/Utils/ElementUtils.js | 4 + dist/Views/Factories/ContentFactory.js | 3 + dist/Views/Inputs/DateInput.js | 2 +- dist/Views/Inputs/PeoplePicker.js | 99 +++++ dist/Views/Inputs/TimeInput.js | 2 +- dist/Views/Root.js | 22 +- .../Components/Basic/Touchable.js | 12 +- .../Components/Inputs/Contact.js | 54 +++ .../Components/Inputs/InputBox.js | 2 +- .../Components/Inputs/LabelInput.js | 166 ++++++++ .../AdaptiveCards/Contexts/CardContext.js | 2 + .../AdaptiveCards/Contexts/FormContext.js | 28 +- .../AdaptiveCards/Contexts/HostContext.js | 6 + .../AdaptiveCards/Contexts/MediaContext.js | 30 -- .../Schema/Abstract/ActionElement.js | 1 + .../Schema/Actions/SelectAction.js | 26 ++ .../Schema/Factories/ActionFactory.js | 4 + examples/AdaptiveCards/Utils/ElementUtils.js | 4 + .../Views/Factories/ContentFactory.js | 3 + .../AdaptiveCards/Views/Inputs/DateInput.js | 2 +- .../Views/Inputs/PeoplePicker.js | 99 +++++ .../AdaptiveCards/Views/Inputs/TimeInput.js | 2 +- examples/AdaptiveCards/Views/Root.js | 22 +- examples/App.js | 66 +-- examples/mockData/index.js | 4 +- examples/mockData/peopleSuggestion.json | 397 ++++++++++++++++++ package.json | 24 +- src/Components/Basic/Touchable.tsx | 16 +- src/Components/Inputs/Contact.tsx | 94 +++++ src/Components/Inputs/InputBox.tsx | 2 +- src/Components/Inputs/LabelInput.tsx | 260 ++++++++++++ src/Contexts/CardContext.ts | 3 + src/Contexts/FormContext.ts | 30 +- src/Contexts/HostContext.ts | 9 + src/Contexts/MediaContext.ts | 40 -- src/Schema/Abstract/ActionElement.ts | 1 + src/Schema/Actions/SelectAction.ts | 39 ++ src/Schema/Cards/Card.ts | 1 + src/Schema/Containers/ColumnSet.ts | 1 + src/Schema/Factories/ActionFactory.ts | 4 + src/Schema/Interfaces/IScope.ts | 2 +- src/Utils/ElementUtils.ts | 5 + src/Views/Factories/ContentFactory.tsx | 11 + src/Views/Inputs/DateInput.tsx | 2 +- src/Views/Inputs/PeoplePicker.tsx | 141 +++++++ src/Views/Inputs/TimeInput.tsx | 2 +- src/Views/Root.tsx | 26 +- yarn.lock | 35 +- 59 files changed, 1869 insertions(+), 272 deletions(-) create mode 100644 dist/Components/Inputs/Contact.js create mode 100644 dist/Components/Inputs/LabelInput.js create mode 100644 dist/Contexts/CardContext.js delete mode 100644 dist/Contexts/MediaContext.js create mode 100644 dist/Schema/Actions/SelectAction.js create mode 100644 dist/Views/Inputs/PeoplePicker.js create mode 100644 examples/AdaptiveCards/Components/Inputs/Contact.js create mode 100644 examples/AdaptiveCards/Components/Inputs/LabelInput.js create mode 100644 examples/AdaptiveCards/Contexts/CardContext.js delete mode 100644 examples/AdaptiveCards/Contexts/MediaContext.js create mode 100644 examples/AdaptiveCards/Schema/Actions/SelectAction.js create mode 100644 examples/AdaptiveCards/Views/Inputs/PeoplePicker.js create mode 100644 examples/mockData/peopleSuggestion.json create mode 100644 src/Components/Inputs/Contact.tsx create mode 100644 src/Components/Inputs/LabelInput.tsx create mode 100644 src/Contexts/CardContext.ts delete mode 100644 src/Contexts/MediaContext.ts create mode 100644 src/Schema/Actions/SelectAction.ts create mode 100644 src/Views/Inputs/PeoplePicker.tsx diff --git a/dist/Components/Basic/Touchable.js b/dist/Components/Basic/Touchable.js index e07d743..247c2db 100644 --- a/dist/Components/Basic/Touchable.js +++ b/dist/Components/Basic/Touchable.js @@ -13,28 +13,26 @@ import { Guid } from '../../Shared/Guid'; export class Touchable extends React.Component { constructor(props) { super(props); + this.testId = this.props.testId + Guid.newGuid(); } componentDidMount() { if (Platform.OS === 'android') { - DeviceEventEmitter.addListener('KeyEnter' + this.props.testId, this.props.onPress); + DeviceEventEmitter.addListener('KeyEnter' + this.testId, this.props.onPress); } } componentWillUnmount() { if (Platform.OS === 'android') { - DeviceEventEmitter.removeListener('KeyEnter' + this.props.testId, this.props.onPress); + DeviceEventEmitter.removeListener('KeyEnter' + this.testId, this.props.onPress); } } render() { const _a = this.props, { onPress, onLongPress, disabled, accessibilityLabel, accessibilityTraits, accessibilityComponentType, activeOpacity, hitSlop, style } = _a, otherProps = __rest(_a, ["onPress", "onLongPress", "disabled", "accessibilityLabel", "accessibilityTraits", "accessibilityComponentType", "activeOpacity", "hitSlop", "style"]); if (Platform.OS === 'android') { - return (React.createElement(TouchableNativeFeedback, { disabled: disabled, onPress: onPress, onLongPress: onLongPress, accessible: true, testID: this.uniqueTestId, useForeground: true, hitSlop: hitSlop, background: TouchableNativeFeedback.SelectableBackground(), accessibilityLabel: accessibilityLabel }, + return (React.createElement(TouchableNativeFeedback, { disabled: disabled, onPress: onPress, onLongPress: onLongPress, accessible: true, testID: this.testId, useForeground: true, hitSlop: hitSlop, background: TouchableNativeFeedback.SelectableBackground(), accessibilityLabel: accessibilityLabel }, React.createElement(View, Object.assign({ style: style, onLayout: this.props.onLayout }, otherProps)))); } else { - return (React.createElement(TouchableOpacity, { disabled: disabled, onPress: onPress, onLongPress: onLongPress, accessible: true, testID: this.uniqueTestId, activeOpacity: activeOpacity, style: style, hitSlop: hitSlop, accessibilityLabel: accessibilityLabel, onLayout: this.props.onLayout }, otherProps.children)); + return (React.createElement(TouchableOpacity, { disabled: disabled, onPress: onPress, onLongPress: onLongPress, accessible: true, testID: this.testId, activeOpacity: activeOpacity, style: style, hitSlop: hitSlop, accessibilityLabel: accessibilityLabel, onLayout: this.props.onLayout }, otherProps.children)); } } - get uniqueTestId() { - return this.props.testId + Guid.newGuid(); - } } diff --git a/dist/Components/Inputs/Contact.js b/dist/Components/Inputs/Contact.js new file mode 100644 index 0000000..2527762 --- /dev/null +++ b/dist/Components/Inputs/Contact.js @@ -0,0 +1,54 @@ +import * as React from 'react'; +import { Text, View } from 'react-native'; +import { StyleManager } from '../../Styles/StyleManager'; +import { ImageBlock } from '../Basic/ImageBlock'; +import { Touchable } from '../Basic/Touchable'; +export class Contact extends React.Component { + constructor() { + super(...arguments); + this.onPress = () => { + if (this.props.onSelect) { + this.props.onSelect(this.props.hiddenFields); + } + }; + } + render() { + if (this.props.onSelect) { + return this.renderTouchableBlock(); + } + else { + return this.renderNonTouchableBlock(); + } + } + renderTouchableBlock() { + return (React.createElement(Touchable, { onPress: this.onPress, style: { + alignSelf: 'stretch', + flexDirection: 'row' + } }, this.renderContent())); + } + renderNonTouchableBlock() { + return (React.createElement(View, { alignSelf: 'stretch', flexDirection: 'row' }, this.renderContent())); + } + renderContent() { + return [ + React.createElement(ImageBlock, { url: this.props.avatar, mode: 'avatar', width: StyleManager.getImageSize('medium'), height: StyleManager.getImageSize('medium') }), + React.createElement(View, null, + React.createElement(Text, { accessible: true, style: { + color: StyleManager.getColor('default', this.props.theme, false), + fontSize: StyleManager.getFontSize('default'), + fontWeight: StyleManager.getFontWeight('default'), + backgroundColor: 'transparent', + textAlign: StyleManager.getTextAlign('left'), + flexWrap: StyleManager.getWrap(true), + } }, this.props.mainInfo), + React.createElement(Text, { accessible: true, style: { + color: StyleManager.getColor('default', this.props.theme, true), + fontSize: StyleManager.getFontSize('small'), + fontWeight: StyleManager.getFontWeight('default'), + backgroundColor: 'transparent', + textAlign: StyleManager.getTextAlign('left'), + flexWrap: StyleManager.getWrap(true), + } }, this.props.subInfo)) + ]; + } +} diff --git a/dist/Components/Inputs/InputBox.js b/dist/Components/Inputs/InputBox.js index c3bacc1..3c5aa10 100644 --- a/dist/Components/Inputs/InputBox.js +++ b/dist/Components/Inputs/InputBox.js @@ -87,7 +87,7 @@ export class InputBox extends React.Component { return 1; } get height() { - return this.fontSize * this.numberOfLine + this.paddingVertical * 2; + return this.fontSize * this.numberOfLine + this.paddingVertical * 2 + 2; } get color() { if (this.state.focused) { diff --git a/dist/Components/Inputs/LabelInput.js b/dist/Components/Inputs/LabelInput.js new file mode 100644 index 0000000..69b0b49 --- /dev/null +++ b/dist/Components/Inputs/LabelInput.js @@ -0,0 +1,166 @@ +import * as React from 'react'; +import { ScrollView, Text, TextInput, View } from 'react-native'; +import { StyleManager } from '../../Styles/StyleManager'; +import { SeparateLine } from '../Basic/SeparateLine'; +export class LabelInput extends React.Component { + constructor(props) { + super(props); + this.onValueChange = (value) => { + if (this.props.onRequestSuggestion) { + this.props.onRequestSuggestion(value); + } + }; + this.onBlur = () => { + this.setState({ focused: false }, () => { + this.validateInput(); + if (this.props.onBlur) { + this.props.onBlur(); + } + }); + }; + this.onFocus = () => { + this.setState({ + focused: true + }, () => { + if (this.props.onFocus) { + this.props.onFocus(); + } + }); + }; + this.state = { + focused: this.props.focused, + }; + } + componentDidUpdate(prevProps, prevState) { + if (!prevState.focused && this.props.focused) { + this.setState({ + focused: true, + }, () => { + if (this.inputBox) { + this.inputBox.focus(); + } + }); + } + } + render() { + return (React.createElement(View, { style: { + flex: this.props.flex, + } }, + this.renderInputArea(), + this.renderSuggestions())); + } + renderInputArea() { + return (React.createElement(View, { style: { + alignSelf: 'stretch', + flexDirection: 'row', + flexWrap: 'wrap', + backgroundColor: this.backgroundColor, + borderColor: this.borderColor, + borderWidth: 1, + borderRadius: 4, + width: this.props.width, + marginTop: this.props.marginTop, + marginRight: this.props.marginRight, + marginBottom: this.props.marginBottom, + marginLeft: this.props.marginLeft, + } }, + this.renderLabels(), + this.renderInputBox())); + } + renderLabels() { + if (this.props.labels) { + return this.props.labels.map((label, index) => { + return (React.createElement(Text, { key: 'Label' + index, style: { + fontSize: this.fontSize, + fontWeight: this.fontWeight, + color: this.backgroundColor, + backgroundColor: this.color, + paddingTop: this.paddingVertical - 6, + paddingBottom: this.paddingVertical - 6, + borderRadius: 4, + paddingLeft: 6, + paddingRight: 6, + marginTop: 6, + marginBottom: 6, + marginLeft: 6, + } }, label.title)); + }); + } + return undefined; + } + renderInputBox() { + return (React.createElement(TextInput, { ref: ref => this.inputBox = ref, style: [ + { + flex: 1, + color: this.color, + fontSize: this.fontSize, + fontWeight: this.fontWeight, + paddingTop: this.paddingVertical, + paddingRight: this.paddingHorizontal, + paddingBottom: this.paddingVertical, + paddingLeft: this.paddingHorizontal, + }, + this.props.style + ], multiline: this.isMultiLine, numberOfLines: this.props.numberOfLines, keyboardType: this.props.keyboardType, blurOnSubmit: !this.isMultiLine, placeholder: this.props.placeholder, value: this.props.value, returnKeyType: this.props.returnKeyType, underlineColorAndroid: 'transparent', importantForAccessibility: 'no-hide-descendants', onChangeText: this.onValueChange, onFocus: this.onFocus, onBlur: this.onBlur })); + } + renderSuggestions() { + if (this.props.suggestionView) { + return [ + React.createElement(SeparateLine, { key: 0 }), + React.createElement(ScrollView, { key: 1, style: { + maxHeight: 200 + } }, this.props.suggestionView) + ]; + } + return undefined; + } + validateInput() { + if (this.props.validateInput) { + if (this.props.validateInput(this.props.value)) { + console.log('Input: valid'); + } + else { + console.log('Input: invalid'); + } + } + } + get isMultiLine() { + return this.props.numberOfLines && this.props.numberOfLines > 1; + } + get fontSize() { + return StyleManager.getFontSize('default'); + } + get fontWeight() { + return StyleManager.getFontWeight('default'); + } + get paddingVertical() { + return 12; + } + get paddingHorizontal() { + return 12; + } + get color() { + if (this.state.focused) { + return StyleManager.getInputFocusColor(this.props.theme); + } + else { + return StyleManager.getInputColor(this.props.theme); + } + } + get backgroundColor() { + if (this.state.focused) { + return StyleManager.getInputFocusBackgroundColor(this.props.theme); + } + else { + return StyleManager.getInputBackgroundColor(this.props.theme); + } + } + get borderColor() { + if (this.state.focused) { + return StyleManager.getInputFocusBorderColor(this.props.theme); + } + else { + return StyleManager.getInputBorderColor(this.props.theme); + } + } +} diff --git a/dist/Contexts/CardContext.js b/dist/Contexts/CardContext.js new file mode 100644 index 0000000..dbec3aa --- /dev/null +++ b/dist/Contexts/CardContext.js @@ -0,0 +1,2 @@ +export class CardContext { +} diff --git a/dist/Contexts/FormContext.js b/dist/Contexts/FormContext.js index 899af6f..abfd6bb 100644 --- a/dist/Contexts/FormContext.js +++ b/dist/Contexts/FormContext.js @@ -1,6 +1,7 @@ export class FormContext { constructor() { this.formFields = {}; + this.fieldListeners = {}; } static getInstance() { if (FormContext.sharedInstance === undefined) { @@ -14,6 +15,7 @@ export class FormContext { value: value, validate: validate }; + this.getFieldListeners(id).forEach((listener) => listener(value)); } } getField(id) { @@ -54,17 +56,6 @@ export class FormContext { } return {}; } - getCallbackParamData(params) { - if (params) { - return Object.keys(params).reduce((prev, current) => { - let formIndex = params[current]; - console.log(formIndex); - prev[current] = this.getFieldValue(formIndex); - return prev; - }, {}); - } - return {}; - } validateField(id) { let field = this.getField(id); if (field) { @@ -80,4 +71,19 @@ export class FormContext { } return true; } + registerFieldListener(id, listener) { + if (!this.fieldListeners[id]) { + this.fieldListeners[id] = [listener]; + } + else { + this.fieldListeners[id].push(listener); + } + } + getFieldListeners(id) { + let result = this.fieldListeners[id]; + if (!result) { + result = []; + } + return result; + } } diff --git a/dist/Contexts/HostContext.js b/dist/Contexts/HostContext.js index 8d9e86d..197bcbf 100644 --- a/dist/Contexts/HostContext.js +++ b/dist/Contexts/HostContext.js @@ -38,6 +38,9 @@ export class HostContext { registerCallbackHandler(handler) { this.onCallback = handler; } + registerSelectActionHandler(handler) { + this.onSelectAction = handler; + } applyConfig(configJson) { this.config.combine(new HostConfig(configJson)); } @@ -59,6 +62,9 @@ export class HostContext { case ActionType.Submit: callback = this.onSubmit; break; + case ActionType.Select: + callback = this.onSelectAction; + break; case 'focus': callback = this.onFocus; break; diff --git a/dist/Contexts/MediaContext.js b/dist/Contexts/MediaContext.js deleted file mode 100644 index 8801ef1..0000000 --- a/dist/Contexts/MediaContext.js +++ /dev/null @@ -1,30 +0,0 @@ -import { Image } from 'react-native'; -export class MediaContext { - constructor() { - this.mediaSizes = {}; - } - static getInstance() { - if (MediaContext.sharedInstance === undefined) { - MediaContext.sharedInstance = new MediaContext(); - } - return MediaContext.sharedInstance; - } - fetchImageSize(url, onSuccess, onFailure) { - let cache = this.getSize(url); - if (cache) { - onSuccess(cache.width, cache.height); - } - else { - Image.getSize(url, (width, height) => { - this.cacheSize(url, { width: width, height: height }); - onSuccess(width, height); - }, onFailure); - } - } - cacheSize(url, size) { - this.mediaSizes[url] = size; - } - getSize(url) { - return this.mediaSizes[url]; - } -} diff --git a/dist/Schema/Abstract/ActionElement.js b/dist/Schema/Abstract/ActionElement.js index 855ef06..d6b3bf7 100644 --- a/dist/Schema/Abstract/ActionElement.js +++ b/dist/Schema/Abstract/ActionElement.js @@ -2,6 +2,7 @@ import { AbstractElement } from './AbstractElement'; export var ActionType; (function (ActionType) { ActionType["OpenUrl"] = "Action.OpenUrl"; + ActionType["Select"] = "Action.Select"; ActionType["Submit"] = "Action.Submit"; ActionType["ShowCard"] = "Action.ShowCard"; ActionType["Callback"] = "Action.Callback"; diff --git a/dist/Schema/Actions/SelectAction.js b/dist/Schema/Actions/SelectAction.js new file mode 100644 index 0000000..7259dfc --- /dev/null +++ b/dist/Schema/Actions/SelectAction.js @@ -0,0 +1,26 @@ +import { ElementUtils } from '../../Utils/ElementUtils'; +import { ActionElement } from '../Abstract/ActionElement'; +export class SelectActionElement extends ActionElement { + constructor(json, parent) { + super(json, parent); + this.children = []; + if (this.isValid) { + this.title = json.selectedTextTitle; + this.subTitle = json.selectedTextSubTitle; + this.data = json.data; + } + } + get targetFormField() { + let targetInput = this.ancestorsAndSelf.find(element => ElementUtils.isSelectActionTarget(element.type)); + if (targetInput) { + return targetInput.id; + } + return undefined; + } + get scope() { + return this.ancestorsAndSelf.find(element => element.parent === undefined); + } + get requiredProperties() { + return ['type', 'selectedTextTitle', 'selectedTextSubTitle', 'data']; + } +} diff --git a/dist/Schema/Factories/ActionFactory.js b/dist/Schema/Factories/ActionFactory.js index 9cc9ebd..2937d44 100644 --- a/dist/Schema/Factories/ActionFactory.js +++ b/dist/Schema/Factories/ActionFactory.js @@ -1,5 +1,6 @@ import { ActionType } from '../Abstract/ActionElement'; import { OpenUrlActionElement } from '../Actions/OpenUrlAction'; +import { SelectActionElement } from '../Actions/SelectAction'; import { ShowCardActionElement } from '../Actions/ShowCardAction'; import { SubmitActionElement } from '../Actions/SubmitAction'; export class ActionFactory { @@ -18,6 +19,9 @@ export class ActionFactory { case ActionType.ShowCard: action = new ShowCardActionElement(json, parent); break; + case ActionType.Select: + action = new SelectActionElement(json, parent); + break; default: action = undefined; break; diff --git a/dist/Utils/ElementUtils.js b/dist/Utils/ElementUtils.js index 92a53d9..d0429f9 100644 --- a/dist/Utils/ElementUtils.js +++ b/dist/Utils/ElementUtils.js @@ -5,6 +5,10 @@ export class ElementUtils { static isValue(type) { return ElementUtils.valueTypes.indexOf(type) >= 0; } + static isSelectActionTarget(type) { + return ElementUtils.selectActionTargetTypes.indexOf(type) >= 0; + } } ElementUtils.inputTypes = ['Input.Text', 'Input.Number', 'Input.Date', 'Input.Time', 'Input.Toggle', 'Input.ChoiceSet']; ElementUtils.valueTypes = ['Fact', 'Input.Choice']; +ElementUtils.selectActionTargetTypes = ['Input.PeoplePicker']; diff --git a/dist/Views/Factories/ContentFactory.js b/dist/Views/Factories/ContentFactory.js index a63e152..2f8d9b8 100644 --- a/dist/Views/Factories/ContentFactory.js +++ b/dist/Views/Factories/ContentFactory.js @@ -11,6 +11,7 @@ import { FactSetView } from '../Containers/FactSet'; import { ImageSetView } from '../Containers/ImageSet'; import { DateInputView } from '../Inputs/DateInput'; import { NumberInputView } from '../Inputs/NumberInput'; +import { PeoplePickerView } from '../Inputs/PeoplePicker'; import { TextInputView } from '../Inputs/TextInput'; import { TimeInputView } from '../Inputs/TimeInput'; export class ContentFactory { @@ -61,6 +62,8 @@ export class ContentFactory { return (React.createElement(DateInputView, { key: 'DateInputView' + index, element: element, index: index, theme: theme })); case ContentElementType.TimeInput: return (React.createElement(TimeInputView, { key: 'TimeInputView' + index, element: element, index: index, theme: theme })); + case ContentElementType.PeoplePicker: + return (React.createElement(PeoplePickerView, { key: 'PeoplePickerView' + index, element: element, index: index, theme: theme })); default: return null; } diff --git a/dist/Views/Inputs/DateInput.js b/dist/Views/Inputs/DateInput.js index 9346dd5..ec8a3db 100644 --- a/dist/Views/Inputs/DateInput.js +++ b/dist/Views/Inputs/DateInput.js @@ -75,7 +75,7 @@ export class DateInputView extends React.Component { return 1; } get height() { - return this.fontSize * this.numberOfLine + this.paddingVertical * 2; + return this.fontSize * this.numberOfLine + this.paddingVertical * 2 + 2; } get color() { if (this.state.focused) { diff --git a/dist/Views/Inputs/PeoplePicker.js b/dist/Views/Inputs/PeoplePicker.js new file mode 100644 index 0000000..1c710d7 --- /dev/null +++ b/dist/Views/Inputs/PeoplePicker.js @@ -0,0 +1,99 @@ +import * as React from 'react'; +import { LabelInput } from '../../Components/Inputs/LabelInput'; +import { ActionContext } from '../../Contexts/ActionContext'; +import { FormContext } from '../../Contexts/FormContext'; +import { ActionType } from '../../Schema/Abstract/ActionElement'; +import { CardElement } from '../../Schema/Cards/Card'; +import { ContentFactory } from '../Factories/ContentFactory'; +import { DebugOutputFactory } from '../Factories/DebugOutputFactory'; +export class PeoplePickerView extends React.Component { + constructor(props) { + super(props); + this.onBlur = () => { + this.setState({ inputFocused: false }); + }; + this.onFocus = () => { + this.setState({ inputFocused: true }); + }; + this.onSuggestionCallback = (data) => { + this.setState({ + suggestionCard: new CardElement(data, this.props.element), + }); + }; + this.onRequestSuggestion = (input) => { + this.setState({ + value: input, + }, () => { + const { element } = this.props; + if (element.callback) { + let callback = ActionContext.getGlobalInstance().getActionEventHandler(element.callback, this.onSuggestionCallback); + if (callback) { + callback({ + actionType: ActionType.Callback, + func: this.populateApiParams, + name: 'populateApiParams' + }); + } + } + }); + }; + this.populateApiParams = (args) => { + if (args && args.formValidate) { + args.formData = this.extractParamData(); + } + return args; + }; + this.extractParamData = () => { + const { element } = this.props; + if (element.callback) { + const params = element.callback.parameters; + if (params) { + return Object.keys(params).reduce((prev, current) => { + let formIndex = params[current]; + if (formIndex === element.id) { + prev[current] = this.state.value; + } + else { + prev[current] = FormContext.getInstance().getFieldValue(formIndex); + } + return prev; + }, {}); + } + } + return {}; + }; + this.storeListener = (value) => { + this.setState({ + selected: JSON.parse(value), + suggestionCard: undefined, + inputFocused: true, + value: '', + }); + }; + const { element } = this.props; + if (element && element.isValid) { + this.state = { + value: '', + inputFocused: false, + selected: [], + suggestionCard: undefined, + }; + FormContext.getInstance().updateField(element.id, JSON.stringify([]), true); + FormContext.getInstance().registerFieldListener(element.id, this.storeListener); + } + } + render() { + const { element, theme } = this.props; + if (!element || !element.isValid) { + return DebugOutputFactory.createDebugOutputBanner(element.type + '>>' + element.id + ' is not valid', theme, 'error'); + } + return (React.createElement(LabelInput, { placeholder: element.placeholder, value: this.state.value, focused: this.state.inputFocused, labels: this.labels, suggestionView: ContentFactory.createElement(this.state.suggestionCard, 0, theme), onRequestSuggestion: this.onRequestSuggestion, onFocus: this.onFocus, onBlur: this.onBlur })); + } + get labels() { + return this.state.selected.map((contact) => { + return { + title: contact.Name + }; + }); + } +} diff --git a/dist/Views/Inputs/TimeInput.js b/dist/Views/Inputs/TimeInput.js index fb47946..28be876 100644 --- a/dist/Views/Inputs/TimeInput.js +++ b/dist/Views/Inputs/TimeInput.js @@ -75,7 +75,7 @@ export class TimeInputView extends React.Component { return 1; } get height() { - return this.fontSize * this.numberOfLine + this.paddingVertical * 2; + return this.fontSize * this.numberOfLine + this.paddingVertical * 2 + 2; } get color() { if (this.state.focused) { diff --git a/dist/Views/Root.js b/dist/Views/Root.js index 34e1304..a539ee5 100644 --- a/dist/Views/Root.js +++ b/dist/Views/Root.js @@ -50,6 +50,15 @@ export class CardRootView extends React.PureComponent { } } }; + this.onSelectAction = (args) => { + if (args) { + let currentValue = JSON.parse(FormContext.getInstance().getFieldValue(args.action.targetFormField)); + if (currentValue) { + currentValue.push(args.formData); + FormContext.getInstance().updateField(args.action.targetFormField, JSON.stringify(currentValue), true); + } + } + }; this.validateForm = (args) => { if (args) { args.formValidate = args.action.scope.validateScope(); @@ -68,9 +77,9 @@ export class CardRootView extends React.PureComponent { } return args; }; - this.populateCallbackParamData = (args) => { - if (args && args.formValidate) { - args.formData = FormContext.getInstance().getCallbackParamData(args.action.parameters); + this.populateSelectActionData = (args) => { + if (args) { + args.formData = Object.assign({}, (args.action.data || {})); } return args; }; @@ -80,6 +89,7 @@ export class CardRootView extends React.PureComponent { hostContext.registerOpenUrlHandler(this.onOpenUrl); hostContext.registerSubmitHandler(this.onSubmit); hostContext.registerCallbackHandler(this.onCallback); + hostContext.registerSelectActionHandler(this.onSelectAction); hostContext.registerFocusHandler(this.props.onFocus); hostContext.registerBlurHandler(this.props.onBlur); hostContext.registerErrorHandler(this.props.onError); @@ -102,9 +112,9 @@ export class CardRootView extends React.PureComponent { actionType: ActionType.Submit }); actionContext.registerHook({ - func: this.populateCallbackParamData, - name: 'populateCallbackParamData', - actionType: ActionType.Callback + func: this.populateSelectActionData, + name: 'populateSelectActionData', + actionType: ActionType.Select }); } render() { diff --git a/examples/AdaptiveCards/Components/Basic/Touchable.js b/examples/AdaptiveCards/Components/Basic/Touchable.js index e07d743..247c2db 100644 --- a/examples/AdaptiveCards/Components/Basic/Touchable.js +++ b/examples/AdaptiveCards/Components/Basic/Touchable.js @@ -13,28 +13,26 @@ import { Guid } from '../../Shared/Guid'; export class Touchable extends React.Component { constructor(props) { super(props); + this.testId = this.props.testId + Guid.newGuid(); } componentDidMount() { if (Platform.OS === 'android') { - DeviceEventEmitter.addListener('KeyEnter' + this.props.testId, this.props.onPress); + DeviceEventEmitter.addListener('KeyEnter' + this.testId, this.props.onPress); } } componentWillUnmount() { if (Platform.OS === 'android') { - DeviceEventEmitter.removeListener('KeyEnter' + this.props.testId, this.props.onPress); + DeviceEventEmitter.removeListener('KeyEnter' + this.testId, this.props.onPress); } } render() { const _a = this.props, { onPress, onLongPress, disabled, accessibilityLabel, accessibilityTraits, accessibilityComponentType, activeOpacity, hitSlop, style } = _a, otherProps = __rest(_a, ["onPress", "onLongPress", "disabled", "accessibilityLabel", "accessibilityTraits", "accessibilityComponentType", "activeOpacity", "hitSlop", "style"]); if (Platform.OS === 'android') { - return (React.createElement(TouchableNativeFeedback, { disabled: disabled, onPress: onPress, onLongPress: onLongPress, accessible: true, testID: this.uniqueTestId, useForeground: true, hitSlop: hitSlop, background: TouchableNativeFeedback.SelectableBackground(), accessibilityLabel: accessibilityLabel }, + return (React.createElement(TouchableNativeFeedback, { disabled: disabled, onPress: onPress, onLongPress: onLongPress, accessible: true, testID: this.testId, useForeground: true, hitSlop: hitSlop, background: TouchableNativeFeedback.SelectableBackground(), accessibilityLabel: accessibilityLabel }, React.createElement(View, Object.assign({ style: style, onLayout: this.props.onLayout }, otherProps)))); } else { - return (React.createElement(TouchableOpacity, { disabled: disabled, onPress: onPress, onLongPress: onLongPress, accessible: true, testID: this.uniqueTestId, activeOpacity: activeOpacity, style: style, hitSlop: hitSlop, accessibilityLabel: accessibilityLabel, onLayout: this.props.onLayout }, otherProps.children)); + return (React.createElement(TouchableOpacity, { disabled: disabled, onPress: onPress, onLongPress: onLongPress, accessible: true, testID: this.testId, activeOpacity: activeOpacity, style: style, hitSlop: hitSlop, accessibilityLabel: accessibilityLabel, onLayout: this.props.onLayout }, otherProps.children)); } } - get uniqueTestId() { - return this.props.testId + Guid.newGuid(); - } } diff --git a/examples/AdaptiveCards/Components/Inputs/Contact.js b/examples/AdaptiveCards/Components/Inputs/Contact.js new file mode 100644 index 0000000..2527762 --- /dev/null +++ b/examples/AdaptiveCards/Components/Inputs/Contact.js @@ -0,0 +1,54 @@ +import * as React from 'react'; +import { Text, View } from 'react-native'; +import { StyleManager } from '../../Styles/StyleManager'; +import { ImageBlock } from '../Basic/ImageBlock'; +import { Touchable } from '../Basic/Touchable'; +export class Contact extends React.Component { + constructor() { + super(...arguments); + this.onPress = () => { + if (this.props.onSelect) { + this.props.onSelect(this.props.hiddenFields); + } + }; + } + render() { + if (this.props.onSelect) { + return this.renderTouchableBlock(); + } + else { + return this.renderNonTouchableBlock(); + } + } + renderTouchableBlock() { + return (React.createElement(Touchable, { onPress: this.onPress, style: { + alignSelf: 'stretch', + flexDirection: 'row' + } }, this.renderContent())); + } + renderNonTouchableBlock() { + return (React.createElement(View, { alignSelf: 'stretch', flexDirection: 'row' }, this.renderContent())); + } + renderContent() { + return [ + React.createElement(ImageBlock, { url: this.props.avatar, mode: 'avatar', width: StyleManager.getImageSize('medium'), height: StyleManager.getImageSize('medium') }), + React.createElement(View, null, + React.createElement(Text, { accessible: true, style: { + color: StyleManager.getColor('default', this.props.theme, false), + fontSize: StyleManager.getFontSize('default'), + fontWeight: StyleManager.getFontWeight('default'), + backgroundColor: 'transparent', + textAlign: StyleManager.getTextAlign('left'), + flexWrap: StyleManager.getWrap(true), + } }, this.props.mainInfo), + React.createElement(Text, { accessible: true, style: { + color: StyleManager.getColor('default', this.props.theme, true), + fontSize: StyleManager.getFontSize('small'), + fontWeight: StyleManager.getFontWeight('default'), + backgroundColor: 'transparent', + textAlign: StyleManager.getTextAlign('left'), + flexWrap: StyleManager.getWrap(true), + } }, this.props.subInfo)) + ]; + } +} diff --git a/examples/AdaptiveCards/Components/Inputs/InputBox.js b/examples/AdaptiveCards/Components/Inputs/InputBox.js index c3bacc1..3c5aa10 100644 --- a/examples/AdaptiveCards/Components/Inputs/InputBox.js +++ b/examples/AdaptiveCards/Components/Inputs/InputBox.js @@ -87,7 +87,7 @@ export class InputBox extends React.Component { return 1; } get height() { - return this.fontSize * this.numberOfLine + this.paddingVertical * 2; + return this.fontSize * this.numberOfLine + this.paddingVertical * 2 + 2; } get color() { if (this.state.focused) { diff --git a/examples/AdaptiveCards/Components/Inputs/LabelInput.js b/examples/AdaptiveCards/Components/Inputs/LabelInput.js new file mode 100644 index 0000000..69b0b49 --- /dev/null +++ b/examples/AdaptiveCards/Components/Inputs/LabelInput.js @@ -0,0 +1,166 @@ +import * as React from 'react'; +import { ScrollView, Text, TextInput, View } from 'react-native'; +import { StyleManager } from '../../Styles/StyleManager'; +import { SeparateLine } from '../Basic/SeparateLine'; +export class LabelInput extends React.Component { + constructor(props) { + super(props); + this.onValueChange = (value) => { + if (this.props.onRequestSuggestion) { + this.props.onRequestSuggestion(value); + } + }; + this.onBlur = () => { + this.setState({ focused: false }, () => { + this.validateInput(); + if (this.props.onBlur) { + this.props.onBlur(); + } + }); + }; + this.onFocus = () => { + this.setState({ + focused: true + }, () => { + if (this.props.onFocus) { + this.props.onFocus(); + } + }); + }; + this.state = { + focused: this.props.focused, + }; + } + componentDidUpdate(prevProps, prevState) { + if (!prevState.focused && this.props.focused) { + this.setState({ + focused: true, + }, () => { + if (this.inputBox) { + this.inputBox.focus(); + } + }); + } + } + render() { + return (React.createElement(View, { style: { + flex: this.props.flex, + } }, + this.renderInputArea(), + this.renderSuggestions())); + } + renderInputArea() { + return (React.createElement(View, { style: { + alignSelf: 'stretch', + flexDirection: 'row', + flexWrap: 'wrap', + backgroundColor: this.backgroundColor, + borderColor: this.borderColor, + borderWidth: 1, + borderRadius: 4, + width: this.props.width, + marginTop: this.props.marginTop, + marginRight: this.props.marginRight, + marginBottom: this.props.marginBottom, + marginLeft: this.props.marginLeft, + } }, + this.renderLabels(), + this.renderInputBox())); + } + renderLabels() { + if (this.props.labels) { + return this.props.labels.map((label, index) => { + return (React.createElement(Text, { key: 'Label' + index, style: { + fontSize: this.fontSize, + fontWeight: this.fontWeight, + color: this.backgroundColor, + backgroundColor: this.color, + paddingTop: this.paddingVertical - 6, + paddingBottom: this.paddingVertical - 6, + borderRadius: 4, + paddingLeft: 6, + paddingRight: 6, + marginTop: 6, + marginBottom: 6, + marginLeft: 6, + } }, label.title)); + }); + } + return undefined; + } + renderInputBox() { + return (React.createElement(TextInput, { ref: ref => this.inputBox = ref, style: [ + { + flex: 1, + color: this.color, + fontSize: this.fontSize, + fontWeight: this.fontWeight, + paddingTop: this.paddingVertical, + paddingRight: this.paddingHorizontal, + paddingBottom: this.paddingVertical, + paddingLeft: this.paddingHorizontal, + }, + this.props.style + ], multiline: this.isMultiLine, numberOfLines: this.props.numberOfLines, keyboardType: this.props.keyboardType, blurOnSubmit: !this.isMultiLine, placeholder: this.props.placeholder, value: this.props.value, returnKeyType: this.props.returnKeyType, underlineColorAndroid: 'transparent', importantForAccessibility: 'no-hide-descendants', onChangeText: this.onValueChange, onFocus: this.onFocus, onBlur: this.onBlur })); + } + renderSuggestions() { + if (this.props.suggestionView) { + return [ + React.createElement(SeparateLine, { key: 0 }), + React.createElement(ScrollView, { key: 1, style: { + maxHeight: 200 + } }, this.props.suggestionView) + ]; + } + return undefined; + } + validateInput() { + if (this.props.validateInput) { + if (this.props.validateInput(this.props.value)) { + console.log('Input: valid'); + } + else { + console.log('Input: invalid'); + } + } + } + get isMultiLine() { + return this.props.numberOfLines && this.props.numberOfLines > 1; + } + get fontSize() { + return StyleManager.getFontSize('default'); + } + get fontWeight() { + return StyleManager.getFontWeight('default'); + } + get paddingVertical() { + return 12; + } + get paddingHorizontal() { + return 12; + } + get color() { + if (this.state.focused) { + return StyleManager.getInputFocusColor(this.props.theme); + } + else { + return StyleManager.getInputColor(this.props.theme); + } + } + get backgroundColor() { + if (this.state.focused) { + return StyleManager.getInputFocusBackgroundColor(this.props.theme); + } + else { + return StyleManager.getInputBackgroundColor(this.props.theme); + } + } + get borderColor() { + if (this.state.focused) { + return StyleManager.getInputFocusBorderColor(this.props.theme); + } + else { + return StyleManager.getInputBorderColor(this.props.theme); + } + } +} diff --git a/examples/AdaptiveCards/Contexts/CardContext.js b/examples/AdaptiveCards/Contexts/CardContext.js new file mode 100644 index 0000000..dbec3aa --- /dev/null +++ b/examples/AdaptiveCards/Contexts/CardContext.js @@ -0,0 +1,2 @@ +export class CardContext { +} diff --git a/examples/AdaptiveCards/Contexts/FormContext.js b/examples/AdaptiveCards/Contexts/FormContext.js index 899af6f..abfd6bb 100644 --- a/examples/AdaptiveCards/Contexts/FormContext.js +++ b/examples/AdaptiveCards/Contexts/FormContext.js @@ -1,6 +1,7 @@ export class FormContext { constructor() { this.formFields = {}; + this.fieldListeners = {}; } static getInstance() { if (FormContext.sharedInstance === undefined) { @@ -14,6 +15,7 @@ export class FormContext { value: value, validate: validate }; + this.getFieldListeners(id).forEach((listener) => listener(value)); } } getField(id) { @@ -54,17 +56,6 @@ export class FormContext { } return {}; } - getCallbackParamData(params) { - if (params) { - return Object.keys(params).reduce((prev, current) => { - let formIndex = params[current]; - console.log(formIndex); - prev[current] = this.getFieldValue(formIndex); - return prev; - }, {}); - } - return {}; - } validateField(id) { let field = this.getField(id); if (field) { @@ -80,4 +71,19 @@ export class FormContext { } return true; } + registerFieldListener(id, listener) { + if (!this.fieldListeners[id]) { + this.fieldListeners[id] = [listener]; + } + else { + this.fieldListeners[id].push(listener); + } + } + getFieldListeners(id) { + let result = this.fieldListeners[id]; + if (!result) { + result = []; + } + return result; + } } diff --git a/examples/AdaptiveCards/Contexts/HostContext.js b/examples/AdaptiveCards/Contexts/HostContext.js index 8d9e86d..197bcbf 100644 --- a/examples/AdaptiveCards/Contexts/HostContext.js +++ b/examples/AdaptiveCards/Contexts/HostContext.js @@ -38,6 +38,9 @@ export class HostContext { registerCallbackHandler(handler) { this.onCallback = handler; } + registerSelectActionHandler(handler) { + this.onSelectAction = handler; + } applyConfig(configJson) { this.config.combine(new HostConfig(configJson)); } @@ -59,6 +62,9 @@ export class HostContext { case ActionType.Submit: callback = this.onSubmit; break; + case ActionType.Select: + callback = this.onSelectAction; + break; case 'focus': callback = this.onFocus; break; diff --git a/examples/AdaptiveCards/Contexts/MediaContext.js b/examples/AdaptiveCards/Contexts/MediaContext.js deleted file mode 100644 index 8801ef1..0000000 --- a/examples/AdaptiveCards/Contexts/MediaContext.js +++ /dev/null @@ -1,30 +0,0 @@ -import { Image } from 'react-native'; -export class MediaContext { - constructor() { - this.mediaSizes = {}; - } - static getInstance() { - if (MediaContext.sharedInstance === undefined) { - MediaContext.sharedInstance = new MediaContext(); - } - return MediaContext.sharedInstance; - } - fetchImageSize(url, onSuccess, onFailure) { - let cache = this.getSize(url); - if (cache) { - onSuccess(cache.width, cache.height); - } - else { - Image.getSize(url, (width, height) => { - this.cacheSize(url, { width: width, height: height }); - onSuccess(width, height); - }, onFailure); - } - } - cacheSize(url, size) { - this.mediaSizes[url] = size; - } - getSize(url) { - return this.mediaSizes[url]; - } -} diff --git a/examples/AdaptiveCards/Schema/Abstract/ActionElement.js b/examples/AdaptiveCards/Schema/Abstract/ActionElement.js index 855ef06..d6b3bf7 100644 --- a/examples/AdaptiveCards/Schema/Abstract/ActionElement.js +++ b/examples/AdaptiveCards/Schema/Abstract/ActionElement.js @@ -2,6 +2,7 @@ import { AbstractElement } from './AbstractElement'; export var ActionType; (function (ActionType) { ActionType["OpenUrl"] = "Action.OpenUrl"; + ActionType["Select"] = "Action.Select"; ActionType["Submit"] = "Action.Submit"; ActionType["ShowCard"] = "Action.ShowCard"; ActionType["Callback"] = "Action.Callback"; diff --git a/examples/AdaptiveCards/Schema/Actions/SelectAction.js b/examples/AdaptiveCards/Schema/Actions/SelectAction.js new file mode 100644 index 0000000..7259dfc --- /dev/null +++ b/examples/AdaptiveCards/Schema/Actions/SelectAction.js @@ -0,0 +1,26 @@ +import { ElementUtils } from '../../Utils/ElementUtils'; +import { ActionElement } from '../Abstract/ActionElement'; +export class SelectActionElement extends ActionElement { + constructor(json, parent) { + super(json, parent); + this.children = []; + if (this.isValid) { + this.title = json.selectedTextTitle; + this.subTitle = json.selectedTextSubTitle; + this.data = json.data; + } + } + get targetFormField() { + let targetInput = this.ancestorsAndSelf.find(element => ElementUtils.isSelectActionTarget(element.type)); + if (targetInput) { + return targetInput.id; + } + return undefined; + } + get scope() { + return this.ancestorsAndSelf.find(element => element.parent === undefined); + } + get requiredProperties() { + return ['type', 'selectedTextTitle', 'selectedTextSubTitle', 'data']; + } +} diff --git a/examples/AdaptiveCards/Schema/Factories/ActionFactory.js b/examples/AdaptiveCards/Schema/Factories/ActionFactory.js index 9cc9ebd..2937d44 100644 --- a/examples/AdaptiveCards/Schema/Factories/ActionFactory.js +++ b/examples/AdaptiveCards/Schema/Factories/ActionFactory.js @@ -1,5 +1,6 @@ import { ActionType } from '../Abstract/ActionElement'; import { OpenUrlActionElement } from '../Actions/OpenUrlAction'; +import { SelectActionElement } from '../Actions/SelectAction'; import { ShowCardActionElement } from '../Actions/ShowCardAction'; import { SubmitActionElement } from '../Actions/SubmitAction'; export class ActionFactory { @@ -18,6 +19,9 @@ export class ActionFactory { case ActionType.ShowCard: action = new ShowCardActionElement(json, parent); break; + case ActionType.Select: + action = new SelectActionElement(json, parent); + break; default: action = undefined; break; diff --git a/examples/AdaptiveCards/Utils/ElementUtils.js b/examples/AdaptiveCards/Utils/ElementUtils.js index 92a53d9..d0429f9 100644 --- a/examples/AdaptiveCards/Utils/ElementUtils.js +++ b/examples/AdaptiveCards/Utils/ElementUtils.js @@ -5,6 +5,10 @@ export class ElementUtils { static isValue(type) { return ElementUtils.valueTypes.indexOf(type) >= 0; } + static isSelectActionTarget(type) { + return ElementUtils.selectActionTargetTypes.indexOf(type) >= 0; + } } ElementUtils.inputTypes = ['Input.Text', 'Input.Number', 'Input.Date', 'Input.Time', 'Input.Toggle', 'Input.ChoiceSet']; ElementUtils.valueTypes = ['Fact', 'Input.Choice']; +ElementUtils.selectActionTargetTypes = ['Input.PeoplePicker']; diff --git a/examples/AdaptiveCards/Views/Factories/ContentFactory.js b/examples/AdaptiveCards/Views/Factories/ContentFactory.js index a63e152..2f8d9b8 100644 --- a/examples/AdaptiveCards/Views/Factories/ContentFactory.js +++ b/examples/AdaptiveCards/Views/Factories/ContentFactory.js @@ -11,6 +11,7 @@ import { FactSetView } from '../Containers/FactSet'; import { ImageSetView } from '../Containers/ImageSet'; import { DateInputView } from '../Inputs/DateInput'; import { NumberInputView } from '../Inputs/NumberInput'; +import { PeoplePickerView } from '../Inputs/PeoplePicker'; import { TextInputView } from '../Inputs/TextInput'; import { TimeInputView } from '../Inputs/TimeInput'; export class ContentFactory { @@ -61,6 +62,8 @@ export class ContentFactory { return (React.createElement(DateInputView, { key: 'DateInputView' + index, element: element, index: index, theme: theme })); case ContentElementType.TimeInput: return (React.createElement(TimeInputView, { key: 'TimeInputView' + index, element: element, index: index, theme: theme })); + case ContentElementType.PeoplePicker: + return (React.createElement(PeoplePickerView, { key: 'PeoplePickerView' + index, element: element, index: index, theme: theme })); default: return null; } diff --git a/examples/AdaptiveCards/Views/Inputs/DateInput.js b/examples/AdaptiveCards/Views/Inputs/DateInput.js index 9346dd5..ec8a3db 100644 --- a/examples/AdaptiveCards/Views/Inputs/DateInput.js +++ b/examples/AdaptiveCards/Views/Inputs/DateInput.js @@ -75,7 +75,7 @@ export class DateInputView extends React.Component { return 1; } get height() { - return this.fontSize * this.numberOfLine + this.paddingVertical * 2; + return this.fontSize * this.numberOfLine + this.paddingVertical * 2 + 2; } get color() { if (this.state.focused) { diff --git a/examples/AdaptiveCards/Views/Inputs/PeoplePicker.js b/examples/AdaptiveCards/Views/Inputs/PeoplePicker.js new file mode 100644 index 0000000..1c710d7 --- /dev/null +++ b/examples/AdaptiveCards/Views/Inputs/PeoplePicker.js @@ -0,0 +1,99 @@ +import * as React from 'react'; +import { LabelInput } from '../../Components/Inputs/LabelInput'; +import { ActionContext } from '../../Contexts/ActionContext'; +import { FormContext } from '../../Contexts/FormContext'; +import { ActionType } from '../../Schema/Abstract/ActionElement'; +import { CardElement } from '../../Schema/Cards/Card'; +import { ContentFactory } from '../Factories/ContentFactory'; +import { DebugOutputFactory } from '../Factories/DebugOutputFactory'; +export class PeoplePickerView extends React.Component { + constructor(props) { + super(props); + this.onBlur = () => { + this.setState({ inputFocused: false }); + }; + this.onFocus = () => { + this.setState({ inputFocused: true }); + }; + this.onSuggestionCallback = (data) => { + this.setState({ + suggestionCard: new CardElement(data, this.props.element), + }); + }; + this.onRequestSuggestion = (input) => { + this.setState({ + value: input, + }, () => { + const { element } = this.props; + if (element.callback) { + let callback = ActionContext.getGlobalInstance().getActionEventHandler(element.callback, this.onSuggestionCallback); + if (callback) { + callback({ + actionType: ActionType.Callback, + func: this.populateApiParams, + name: 'populateApiParams' + }); + } + } + }); + }; + this.populateApiParams = (args) => { + if (args && args.formValidate) { + args.formData = this.extractParamData(); + } + return args; + }; + this.extractParamData = () => { + const { element } = this.props; + if (element.callback) { + const params = element.callback.parameters; + if (params) { + return Object.keys(params).reduce((prev, current) => { + let formIndex = params[current]; + if (formIndex === element.id) { + prev[current] = this.state.value; + } + else { + prev[current] = FormContext.getInstance().getFieldValue(formIndex); + } + return prev; + }, {}); + } + } + return {}; + }; + this.storeListener = (value) => { + this.setState({ + selected: JSON.parse(value), + suggestionCard: undefined, + inputFocused: true, + value: '', + }); + }; + const { element } = this.props; + if (element && element.isValid) { + this.state = { + value: '', + inputFocused: false, + selected: [], + suggestionCard: undefined, + }; + FormContext.getInstance().updateField(element.id, JSON.stringify([]), true); + FormContext.getInstance().registerFieldListener(element.id, this.storeListener); + } + } + render() { + const { element, theme } = this.props; + if (!element || !element.isValid) { + return DebugOutputFactory.createDebugOutputBanner(element.type + '>>' + element.id + ' is not valid', theme, 'error'); + } + return (React.createElement(LabelInput, { placeholder: element.placeholder, value: this.state.value, focused: this.state.inputFocused, labels: this.labels, suggestionView: ContentFactory.createElement(this.state.suggestionCard, 0, theme), onRequestSuggestion: this.onRequestSuggestion, onFocus: this.onFocus, onBlur: this.onBlur })); + } + get labels() { + return this.state.selected.map((contact) => { + return { + title: contact.Name + }; + }); + } +} diff --git a/examples/AdaptiveCards/Views/Inputs/TimeInput.js b/examples/AdaptiveCards/Views/Inputs/TimeInput.js index fb47946..28be876 100644 --- a/examples/AdaptiveCards/Views/Inputs/TimeInput.js +++ b/examples/AdaptiveCards/Views/Inputs/TimeInput.js @@ -75,7 +75,7 @@ export class TimeInputView extends React.Component { return 1; } get height() { - return this.fontSize * this.numberOfLine + this.paddingVertical * 2; + return this.fontSize * this.numberOfLine + this.paddingVertical * 2 + 2; } get color() { if (this.state.focused) { diff --git a/examples/AdaptiveCards/Views/Root.js b/examples/AdaptiveCards/Views/Root.js index 34e1304..a539ee5 100644 --- a/examples/AdaptiveCards/Views/Root.js +++ b/examples/AdaptiveCards/Views/Root.js @@ -50,6 +50,15 @@ export class CardRootView extends React.PureComponent { } } }; + this.onSelectAction = (args) => { + if (args) { + let currentValue = JSON.parse(FormContext.getInstance().getFieldValue(args.action.targetFormField)); + if (currentValue) { + currentValue.push(args.formData); + FormContext.getInstance().updateField(args.action.targetFormField, JSON.stringify(currentValue), true); + } + } + }; this.validateForm = (args) => { if (args) { args.formValidate = args.action.scope.validateScope(); @@ -68,9 +77,9 @@ export class CardRootView extends React.PureComponent { } return args; }; - this.populateCallbackParamData = (args) => { - if (args && args.formValidate) { - args.formData = FormContext.getInstance().getCallbackParamData(args.action.parameters); + this.populateSelectActionData = (args) => { + if (args) { + args.formData = Object.assign({}, (args.action.data || {})); } return args; }; @@ -80,6 +89,7 @@ export class CardRootView extends React.PureComponent { hostContext.registerOpenUrlHandler(this.onOpenUrl); hostContext.registerSubmitHandler(this.onSubmit); hostContext.registerCallbackHandler(this.onCallback); + hostContext.registerSelectActionHandler(this.onSelectAction); hostContext.registerFocusHandler(this.props.onFocus); hostContext.registerBlurHandler(this.props.onBlur); hostContext.registerErrorHandler(this.props.onError); @@ -102,9 +112,9 @@ export class CardRootView extends React.PureComponent { actionType: ActionType.Submit }); actionContext.registerHook({ - func: this.populateCallbackParamData, - name: 'populateCallbackParamData', - actionType: ActionType.Callback + func: this.populateSelectActionData, + name: 'populateSelectActionData', + actionType: ActionType.Select }); } render() { diff --git a/examples/App.js b/examples/App.js index 3272a79..67a30e0 100644 --- a/examples/App.js +++ b/examples/App.js @@ -31,67 +31,7 @@ export default class App extends React.Component { padding: 10, backgroundColor: 'white', }}> - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - - {this.renderGap()} - + ); } @@ -99,4 +39,8 @@ export default class App extends React.Component { renderGap() { return ; }; + + onCallback = (url, data) => { + return Promise.resolve(mockData.peopleSuggestion); + } } diff --git a/examples/mockData/index.js b/examples/mockData/index.js index 7705fd2..c6e57e1 100644 --- a/examples/mockData/index.js +++ b/examples/mockData/index.js @@ -19,7 +19,8 @@ var searchEmail = require('./skills/search_email.json'); var emailSent = require('./skills/email_sent.json'); var sendText = require('./skills/send_text.json'); var sendTextContact = require('./skills/send_text_contact_disam.json'); -var peoplePicker = require('./peoplepicker.json'); +var peoplePicker = require('./peoplePicker.json'); +var peopleSuggestion = require('./peopleSuggestion.json'); var showVideo = require('./showVideo.json'); var fact = require('./fact.json'); var vocabulary = require('./vocabulary.json'); @@ -52,4 +53,5 @@ exports["default"] = { vocabulary: vocabulary, dinning: dinning, bingMap: bingMap, + peopleSuggestion: peopleSuggestion, }; diff --git a/examples/mockData/peopleSuggestion.json b/examples/mockData/peopleSuggestion.json new file mode 100644 index 0000000..c3449eb --- /dev/null +++ b/examples/mockData/peopleSuggestion.json @@ -0,0 +1,397 @@ +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.0", + "body": [ + { + "type": "Container", + "id": "peoplePickerContent", + "items": [ + { + "type": "ColumnSet", + "separator": true, + "columns": [ + { + "type": "Column", + "width": "Auto", + "items": [ + { + "type": "Image", + "size": "medium", + "style": "person", + "url": "/cortana/autosuggest/people/photo?provider=Office365&uid=Richard.Zhao%40microsoft.com", + "altText": "RZ" + } + ] + }, + { + "type": "Column", + "width": "Stretch", + "items": [ + { + "type": "TextBlock", + "weight": "bolder", + "text": "Richard Zhao", + "wrap": true + }, + { + "type": "TextBlock", + "isSubtle": true, + "text": "Richard.Zhao@microsoft.com", + "wrap": true, + "spacing": "none", + "separation": "none" + } + ] + } + ], + "selectAction": { + "type": "Action.Select", + "selectedTextTitle": "Richard Zhao", + "selectedTextSubTitle": "@microsoft.com", + "data": { + "Name": "Richard Zhao", + "Address": "Richard.Zhao@microsoft.com" + } + } + }, + { + "type": "ColumnSet", + "separator": true, + "columns": [ + { + "type": "Column", + "width": "Auto", + "items": [ + { + "type": "Image", + "size": "medium", + "style": "person", + "url": "/cortana/autosuggest/people/photo?provider=Office365&uid=quanliu%40microsoft.com", + "altText": "RL" + } + ] + }, + { + "type": "Column", + "width": "Stretch", + "items": [ + { + "type": "TextBlock", + "weight": "bolder", + "text": "Richard Liu", + "wrap": true + }, + { + "type": "TextBlock", + "isSubtle": true, + "text": "quanliu@microsoft.com", + "wrap": true, + "spacing": "none", + "separation": "none" + } + ] + } + ], + "selectAction": { + "type": "Action.Select", + "selectedTextTitle": "Richard Liu", + "selectedTextSubTitle": "@microsoft.com", + "data": { + "Name": "Richard Liu", + "Address": "quanliu@microsoft.com" + } + } + }, + { + "type": "ColumnSet", + "separator": true, + "columns": [ + { + "type": "Column", + "width": "Auto", + "items": [ + { + "type": "Image", + "size": "medium", + "style": "person", + "url": "/cortana/autosuggest/people/photo?provider=Office365&uid=richzdir%40microsoft.com", + "altText": "RD" + } + ] + }, + { + "type": "Column", + "width": "Stretch", + "items": [ + { + "type": "TextBlock", + "weight": "bolder", + "text": "RichZ's directs", + "wrap": true + }, + { + "type": "TextBlock", + "isSubtle": true, + "text": "richzdir@microsoft.com", + "wrap": true, + "spacing": "none", + "separation": "none" + } + ] + } + ], + "selectAction": { + "type": "Action.Select", + "selectedTextTitle": "RichZ's directs", + "selectedTextSubTitle": "@microsoft.com", + "data": { + "Name": "RichZ's directs", + "Address": "richzdir@microsoft.com" + } + } + }, + { + "type": "ColumnSet", + "separator": true, + "columns": [ + { + "type": "Column", + "width": "Auto", + "items": [ + { + "type": "Image", + "size": "medium", + "style": "person", + "url": "/cortana/autosuggest/people/photo?provider=Office365&uid=jordir%40microsoft.com", + "altText": "JR" + } + ] + }, + { + "type": "Column", + "width": "Stretch", + "items": [ + { + "type": "TextBlock", + "weight": "bolder", + "text": "Jordi Ribas", + "wrap": true + }, + { + "type": "TextBlock", + "isSubtle": true, + "text": "jordir@microsoft.com", + "wrap": true, + "spacing": "none", + "separation": "none" + } + ] + } + ], + "selectAction": { + "type": "Action.Select", + "selectedTextTitle": "Jordi Ribas", + "selectedTextSubTitle": "@microsoft.com", + "data": { + "Name": "Jordi Ribas", + "Address": "jordir@microsoft.com" + } + } + }, + { + "type": "ColumnSet", + "separator": true, + "columns": [ + { + "type": "Column", + "width": "Auto", + "items": [ + { + "type": "Image", + "size": "medium", + "style": "person", + "url": "/cortana/autosuggest/people/photo?provider=Office365&uid=richq%40microsoft.com", + "altText": "RQ" + } + ] + }, + { + "type": "Column", + "width": "Stretch", + "items": [ + { + "type": "TextBlock", + "weight": "bolder", + "text": "Richard Qian", + "wrap": true + }, + { + "type": "TextBlock", + "isSubtle": true, + "text": "richq@microsoft.com", + "wrap": true, + "spacing": "none", + "separation": "none" + } + ] + } + ], + "selectAction": { + "type": "Action.Select", + "selectedTextTitle": "Richard Qian", + "selectedTextSubTitle": "@microsoft.com", + "data": { + "Name": "Richard Qian", + "Address": "richq@microsoft.com" + } + } + }, + { + "type": "ColumnSet", + "separator": true, + "columns": [ + { + "type": "Column", + "width": "Auto", + "items": [ + { + "type": "Image", + "size": "medium", + "style": "person", + "url": "/cortana/autosuggest/people/photo?provider=Office365&uid=richard.zhao%40outlook.com", + "altText": "RZ" + } + ] + }, + { + "type": "Column", + "width": "Stretch", + "items": [ + { + "type": "TextBlock", + "weight": "bolder", + "text": "Richard Zhao", + "wrap": true + }, + { + "type": "TextBlock", + "isSubtle": true, + "text": "richard.zhao@outlook.com", + "wrap": true, + "spacing": "none", + "separation": "none" + } + ] + } + ], + "selectAction": { + "type": "Action.Select", + "selectedTextTitle": "Richard Zhao", + "selectedTextSubTitle": "@outlook.com", + "data": { + "Name": "Richard Zhao", + "Address": "richard.zhao@outlook.com" + } + } + }, + { + "type": "ColumnSet", + "separator": true, + "columns": [ + { + "type": "Column", + "width": "Auto", + "items": [ + { + "type": "Image", + "size": "medium", + "style": "person", + "url": "/cortana/autosuggest/people/photo?provider=Office365&uid=Eric.Carter%40microsoft.com", + "altText": "EC" + } + ] + }, + { + "type": "Column", + "width": "Stretch", + "items": [ + { + "type": "TextBlock", + "weight": "bolder", + "text": "Eric Carter", + "wrap": true + }, + { + "type": "TextBlock", + "isSubtle": true, + "text": "Eric.Carter@microsoft.com", + "wrap": true, + "spacing": "none", + "separation": "none" + } + ] + } + ], + "selectAction": { + "type": "Action.Select", + "selectedTextTitle": "Eric Carter", + "selectedTextSubTitle": "@microsoft.com", + "data": { + "Name": "Eric Carter", + "Address": "Eric.Carter@microsoft.com" + } + } + }, + { + "type": "ColumnSet", + "separator": true, + "columns": [ + { + "type": "Column", + "width": "Auto", + "items": [ + { + "type": "Image", + "size": "medium", + "style": "person", + "url": "/cortana/autosuggest/people/photo?provider=Office365&uid=eryang%40microsoft.com", + "altText": "EY" + } + ] + }, + { + "type": "Column", + "width": "Stretch", + "items": [ + { + "type": "TextBlock", + "weight": "bolder", + "text": "Eric Yang", + "wrap": true + }, + { + "type": "TextBlock", + "isSubtle": true, + "text": "eryang@microsoft.com", + "wrap": true, + "spacing": "none", + "separation": "none" + } + ] + } + ], + "selectAction": { + "type": "Action.Select", + "selectedTextTitle": "Eric Yang", + "selectedTextSubTitle": "@microsoft.com", + "data": { + "Name": "Eric Yang", + "Address": "eryang@microsoft.com" + } + } + } + ] + } + ] +} diff --git a/package.json b/package.json index 0837332..e2f44df 100644 --- a/package.json +++ b/package.json @@ -23,20 +23,20 @@ "silent": true }, "devDependencies": { - "@types/lodash": "^4.14.116", - "@types/react": "^16.4.8", - "@types/react-native": "^0.56.6", - "del": "^3.0.0", - "gulp": "^3.9.1", - "gulp-imagemin": "^4.1.0", - "gulp-tslint": "^8.1.3", - "gulp-typescript": "^5.0.0-alpha.3", + "@types/lodash": "latest", + "@types/react": "latest", + "@types/react-native": "latest", + "del": "latest", + "gulp": "latest", + "gulp-imagemin": "latest", + "gulp-tslint": "latest", + "gulp-typescript": "latest", "react-devtools": "latest", - "run-sequence": "^2.2.1", - "tslint": "^5.11.0", - "typescript": "^3.0.1" + "run-sequence": "latest", + "tslint": "latest", + "typescript": "latest" }, "dependencies": { - "lodash": "^4.17.10" + "lodash": "latest" } } diff --git a/src/Components/Basic/Touchable.tsx b/src/Components/Basic/Touchable.tsx index 50e5b20..2a961d1 100644 --- a/src/Components/Basic/Touchable.tsx +++ b/src/Components/Basic/Touchable.tsx @@ -25,19 +25,23 @@ interface IProps { } export class Touchable extends React.Component { + private testId: string; + constructor(props: IProps) { super(props); + + this.testId = this.props.testId + Guid.newGuid(); } public componentDidMount() { if (Platform.OS === 'android') { - DeviceEventEmitter.addListener('KeyEnter' + this.props.testId, this.props.onPress); + DeviceEventEmitter.addListener('KeyEnter' + this.testId, this.props.onPress); } } public componentWillUnmount() { if (Platform.OS === 'android') { - DeviceEventEmitter.removeListener('KeyEnter' + this.props.testId, this.props.onPress); + DeviceEventEmitter.removeListener('KeyEnter' + this.testId, this.props.onPress); } } @@ -62,7 +66,7 @@ export class Touchable extends React.Component { onPress={onPress} onLongPress={onLongPress} accessible={true} - testID={this.uniqueTestId} + testID={this.testId} useForeground={true} hitSlop={hitSlop} background={TouchableNativeFeedback.SelectableBackground()} @@ -81,7 +85,7 @@ export class Touchable extends React.Component { onPress={onPress} onLongPress={onLongPress} accessible={true} - testID={this.uniqueTestId} + testID={this.testId} activeOpacity={activeOpacity} style={style} hitSlop={hitSlop} @@ -93,8 +97,4 @@ export class Touchable extends React.Component { ); } } - - private get uniqueTestId() { - return this.props.testId + Guid.newGuid(); - } } diff --git a/src/Components/Inputs/Contact.tsx b/src/Components/Inputs/Contact.tsx new file mode 100644 index 0000000..7493993 --- /dev/null +++ b/src/Components/Inputs/Contact.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; +import { Text, View } from 'react-native'; +import { StyleManager } from '../../Styles/StyleManager'; +import { ImageBlock } from '../Basic/ImageBlock'; +import { Touchable } from '../Basic/Touchable'; + +interface IProps { + avatar: string; + mainInfo: string; + subInfo: string; + hiddenFields: any; + theme: 'default' | 'emphasis'; + onSelect?: (data: any) => void; +} + +export class Contact extends React.Component { + public render() { + if (this.props.onSelect) { + return this.renderTouchableBlock(); + } else { + return this.renderNonTouchableBlock(); + } + } + + private renderTouchableBlock() { + return ( + + {this.renderContent()} + + ); + } + + private renderNonTouchableBlock() { + return ( + + {this.renderContent()} + + ); + } + + private renderContent() { + return [ + , + + + {this.props.mainInfo} + + + {this.props.subInfo} + + + ]; + } + + private onPress = () => { + if (this.props.onSelect) { + this.props.onSelect(this.props.hiddenFields); + } + } +} diff --git a/src/Components/Inputs/InputBox.tsx b/src/Components/Inputs/InputBox.tsx index 2b53331..3343289 100644 --- a/src/Components/Inputs/InputBox.tsx +++ b/src/Components/Inputs/InputBox.tsx @@ -147,7 +147,7 @@ export class InputBox extends React.Component { } private get height() { - return this.fontSize * this.numberOfLine + this.paddingVertical * 2; + return this.fontSize * this.numberOfLine + this.paddingVertical * 2 + 2; } private get color() { diff --git a/src/Components/Inputs/LabelInput.tsx b/src/Components/Inputs/LabelInput.tsx new file mode 100644 index 0000000..2122987 --- /dev/null +++ b/src/Components/Inputs/LabelInput.tsx @@ -0,0 +1,260 @@ +import * as React from 'react'; +import { + KeyboardTypeOptions, + ReturnKeyTypeOptions, + ScrollView, + StyleProp, + Text, + TextInput, + TextStyle, + View +} from 'react-native'; +import { StyleManager } from '../../Styles/StyleManager'; +import { SeparateLine } from '../Basic/SeparateLine'; + +interface IProps { + placeholder: string; + value: string; + labels: Array<{ title: string, }>; + suggestionView: JSX.Element; + focused: boolean; + keyboardType?: KeyboardTypeOptions; + returnKeyType?: ReturnKeyTypeOptions; + numberOfLines?: number; + theme?: 'default' | 'emphasis'; + flex?: number; + width?: number; + marginTop?: number; + marginBottom?: number; + marginLeft?: number; + marginRight?: number; + style?: StyleProp; + onRequestSuggestion?: (input: string) => void; + onFocus?: () => void; + onBlur?: () => void; + validateInput?: (input: string) => boolean; +} + +interface IState { + focused: boolean; +} + +export class LabelInput extends React.Component { + private inputBox: TextInput; + constructor(props: IProps) { + super(props); + + this.state = { + focused: this.props.focused, + }; + } + + public componentDidUpdate(prevProps: IProps, prevState: IState) { + if (!prevState.focused && this.props.focused) { + this.setState({ + focused: true, + }, () => { + if (this.inputBox) { + this.inputBox.focus(); + } + }); + } + } + + public render() { + return ( + + {this.renderInputArea()} + {this.renderSuggestions()} + + ); + } + + private renderInputArea() { + return ( + + {this.renderLabels()} + {this.renderInputBox()} + + ); + } + + private renderLabels() { + if (this.props.labels) { + return this.props.labels.map((label, index) => { + return ( + + {label.title} + + ); + }); + } + return undefined; + } + + private renderInputBox() { + return ( + this.inputBox = ref} + style={[ + { + flex: 1, + color: this.color, + fontSize: this.fontSize, + fontWeight: this.fontWeight, + paddingTop: this.paddingVertical, + paddingRight: this.paddingHorizontal, + paddingBottom: this.paddingVertical, + paddingLeft: this.paddingHorizontal, + }, + this.props.style + ]} + multiline={this.isMultiLine} + numberOfLines={this.props.numberOfLines} + keyboardType={this.props.keyboardType} + blurOnSubmit={!this.isMultiLine} + placeholder={this.props.placeholder} + value={this.props.value} + returnKeyType={this.props.returnKeyType} + underlineColorAndroid={'transparent'} + importantForAccessibility={'no-hide-descendants'} + onChangeText={this.onValueChange} + onFocus={this.onFocus} + onBlur={this.onBlur} + /> + ); + } + + private renderSuggestions() { + if (this.props.suggestionView) { + return [ + , + + {this.props.suggestionView} + + ]; + } + return undefined; + } + + private onValueChange = (value: string) => { + if (this.props.onRequestSuggestion) { + this.props.onRequestSuggestion(value); + } + } + + private onBlur = () => { + this.setState({ focused: false }, () => { + this.validateInput(); + if (this.props.onBlur) { + this.props.onBlur(); + } + }); + } + + private onFocus = () => { + this.setState({ + focused: true + }, () => { + if (this.props.onFocus) { + this.props.onFocus(); + } + }); + } + + private validateInput() { + if (this.props.validateInput) { + if (this.props.validateInput(this.props.value)) { + console.log('Input: valid'); + } else { + console.log('Input: invalid'); + } + } + } + + private get isMultiLine() { + return this.props.numberOfLines && this.props.numberOfLines > 1; + } + + private get fontSize() { + return StyleManager.getFontSize('default'); + } + + private get fontWeight() { + return StyleManager.getFontWeight('default'); + } + + private get paddingVertical() { + return 12; + } + + private get paddingHorizontal() { + return 12; + } + + private get color() { + if (this.state.focused) { + return StyleManager.getInputFocusColor(this.props.theme); + } else { + return StyleManager.getInputColor(this.props.theme); + } + } + + private get backgroundColor() { + if (this.state.focused) { + return StyleManager.getInputFocusBackgroundColor(this.props.theme); + } else { + return StyleManager.getInputBackgroundColor(this.props.theme); + } + } + + private get borderColor() { + if (this.state.focused) { + return StyleManager.getInputFocusBorderColor(this.props.theme); + } else { + return StyleManager.getInputBorderColor(this.props.theme); + } + } +} diff --git a/src/Contexts/CardContext.ts b/src/Contexts/CardContext.ts new file mode 100644 index 0000000..f56648c --- /dev/null +++ b/src/Contexts/CardContext.ts @@ -0,0 +1,3 @@ +export class CardContext { + +} diff --git a/src/Contexts/FormContext.ts b/src/Contexts/FormContext.ts index 823fbe8..2944f43 100644 --- a/src/Contexts/FormContext.ts +++ b/src/Contexts/FormContext.ts @@ -5,6 +5,7 @@ export interface FormField { export class FormContext { private formFields: { [id: string]: FormField } = {}; + private fieldListeners: { [id: string]: Array<(value: string) => void> } = {}; private static sharedInstance: FormContext; @@ -23,6 +24,7 @@ export class FormContext { value: value, validate: validate }; + this.getFieldListeners(id).forEach((listener) => listener(value)); } } @@ -67,18 +69,6 @@ export class FormContext { return {}; } - public getCallbackParamData(params: { [key: string]: string }): { [id: string]: string } { - if (params) { - return Object.keys(params).reduce((prev, current) => { - let formIndex = params[current]; - console.log(formIndex); - prev[current] = this.getFieldValue(formIndex); - return prev; - }, {} as { [key: string]: string }); - } - return {}; - } - public validateField(id: string): boolean { let field = this.getField(id); if (field) { @@ -95,4 +85,20 @@ export class FormContext { } return true; } + + public registerFieldListener(id: string, listener: (value: string) => void) { + if (!this.fieldListeners[id]) { + this.fieldListeners[id] = [listener]; + } else { + this.fieldListeners[id].push(listener); + } + } + + public getFieldListeners(id: string) { + let result = this.fieldListeners[id]; + if (!result) { + result = []; + } + return result; + } } diff --git a/src/Contexts/HostContext.ts b/src/Contexts/HostContext.ts index 54e230f..65ce2ae 100644 --- a/src/Contexts/HostContext.ts +++ b/src/Contexts/HostContext.ts @@ -2,6 +2,7 @@ import { ConfigManager } from '../Config/ConfigManager'; import { HostConfig } from '../Config/Types'; import { ActionType } from '../Schema/Abstract/ActionElement'; import { OpenUrlActionElement } from '../Schema/Actions/OpenUrlAction'; +import { SelectActionElement } from '../Schema/Actions/SelectAction'; import { ShowCardActionElement } from '../Schema/Actions/ShowCardAction'; import { SubmitActionElement } from '../Schema/Actions/SubmitAction'; import { IElement } from '../Schema/Interfaces/IElement'; @@ -19,6 +20,7 @@ export class HostContext { private onShowCard: (args?: ActionEventHandlerArgs) => void; private onSubmit: (args?: ActionEventHandlerArgs) => void; private onCallback: (args?: ActionEventHandlerArgs) => void; + private onSelectAction: (args?: ActionEventHandlerArgs) => void; private static sharedInstance: HostContext; @@ -69,6 +71,10 @@ export class HostContext { this.onCallback = handler; } + public registerSelectActionHandler(handler: (args?: ActionEventHandlerArgs) => void) { + this.onSelectAction = handler; + } + public applyConfig(configJson: any) { this.config.combine(new HostConfig(configJson)); } @@ -92,6 +98,9 @@ export class HostContext { case ActionType.Submit: callback = this.onSubmit; break; + case ActionType.Select: + callback = this.onSelectAction; + break; case 'focus': callback = this.onFocus; break; diff --git a/src/Contexts/MediaContext.ts b/src/Contexts/MediaContext.ts deleted file mode 100644 index c3a97ae..0000000 --- a/src/Contexts/MediaContext.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Image } from 'react-native'; -import { Dimension } from '../Shared/Types'; - -export class MediaContext { - private mediaSizes: { [url: string]: Dimension } = {}; - private static sharedInstance: MediaContext; - - private constructor() { } - - public static getInstance() { - if (MediaContext.sharedInstance === undefined) { - MediaContext.sharedInstance = new MediaContext(); - } - return MediaContext.sharedInstance; - } - - public fetchImageSize(url: string, onSuccess: (width: number, height: number) => void, onFailure: (error: any) => void) { - let cache = this.getSize(url); - if (cache) { - onSuccess(cache.width, cache.height); - } else { - Image.getSize( - url, - (width, height) => { - this.cacheSize(url, { width: width, height: height }); - onSuccess(width, height); - }, - onFailure - ); - } - } - - public cacheSize(url: string, size: Dimension) { - this.mediaSizes[url] = size; - } - - public getSize(url: string) { - return this.mediaSizes[url]; - } -} diff --git a/src/Schema/Abstract/ActionElement.ts b/src/Schema/Abstract/ActionElement.ts index 0afad24..fa8da93 100644 --- a/src/Schema/Abstract/ActionElement.ts +++ b/src/Schema/Abstract/ActionElement.ts @@ -5,6 +5,7 @@ import { AbstractElement } from './AbstractElement'; export enum ActionType { OpenUrl = 'Action.OpenUrl', + Select = 'Action.Select', Submit = 'Action.Submit', ShowCard = 'Action.ShowCard', Callback = 'Action.Callback', diff --git a/src/Schema/Actions/SelectAction.ts b/src/Schema/Actions/SelectAction.ts new file mode 100644 index 0000000..8b19f51 --- /dev/null +++ b/src/Schema/Actions/SelectAction.ts @@ -0,0 +1,39 @@ +import { ElementUtils } from '../../Utils/ElementUtils'; +import { ActionElement } from '../Abstract/ActionElement'; +import { IElement } from '../Interfaces/IElement'; +import { IInput } from '../Interfaces/IInput'; +import { IScope } from '../Interfaces/IScope'; + +export class SelectActionElement extends ActionElement { + // Required + public readonly title: string; + public readonly subTitle: string; + public readonly data: any; + public readonly children: IElement[] = []; + + constructor(json: any, parent: IElement) { + super(json, parent); + + if (this.isValid) { + this.title = json.selectedTextTitle; + this.subTitle = json.selectedTextSubTitle; + this.data = json.data; + } + } + + public get targetFormField() { + let targetInput = this.ancestorsAndSelf.find(element => ElementUtils.isSelectActionTarget(element.type)) as IInput; + if (targetInput) { + return targetInput.id; + } + return undefined; + } + + public get scope(): IScope { + return this.ancestorsAndSelf.find(element => element.parent === undefined) as IScope; + } + + public get requiredProperties() { + return ['type', 'selectedTextTitle', 'selectedTextSubTitle', 'data']; + } +} diff --git a/src/Schema/Cards/Card.ts b/src/Schema/Cards/Card.ts index b3836c4..1d93241 100644 --- a/src/Schema/Cards/Card.ts +++ b/src/Schema/Cards/Card.ts @@ -15,6 +15,7 @@ export class CardElement extends ScopeElement { public readonly actions?: IAction[]; public readonly body?: IContent[]; public readonly backgroundImage?: string; + public readonly cardSource?: string; constructor(json: any, parent: IElement) { super(json, parent); diff --git a/src/Schema/Containers/ColumnSet.ts b/src/Schema/Containers/ColumnSet.ts index a192f04..b277635 100644 --- a/src/Schema/Containers/ColumnSet.ts +++ b/src/Schema/Containers/ColumnSet.ts @@ -11,6 +11,7 @@ export class ColumnSetElement extends ScopeElement { if (this.isValid) { this.columns = []; + if (json.columns) { json.columns.forEach((item: any) => { let column: ColumnElement = new ColumnElement(item, this); diff --git a/src/Schema/Factories/ActionFactory.ts b/src/Schema/Factories/ActionFactory.ts index 1d9c64e..22e2480 100644 --- a/src/Schema/Factories/ActionFactory.ts +++ b/src/Schema/Factories/ActionFactory.ts @@ -1,5 +1,6 @@ import { ActionElement, ActionType } from '../Abstract/ActionElement'; import { OpenUrlActionElement } from '../Actions/OpenUrlAction'; +import { SelectActionElement } from '../Actions/SelectAction'; import { ShowCardActionElement } from '../Actions/ShowCardAction'; import { SubmitActionElement } from '../Actions/SubmitAction'; import { IAction } from '../Interfaces/IAction'; @@ -23,6 +24,9 @@ export class ActionFactory { case ActionType.ShowCard: action = new ShowCardActionElement(json, parent); break; + case ActionType.Select: + action = new SelectActionElement(json, parent); + break; default: action = undefined; break; diff --git a/src/Schema/Interfaces/IScope.ts b/src/Schema/Interfaces/IScope.ts index 3884b5e..55cedbe 100644 --- a/src/Schema/Interfaces/IScope.ts +++ b/src/Schema/Interfaces/IScope.ts @@ -2,7 +2,7 @@ import { IAction } from './IAction'; import { IContent } from './IContent'; export interface IScope extends IContent { - readonly selectAction?: IContent; + readonly selectAction?: IAction; readonly backgroundImage?: string | { url: string }; readonly action: IAction; readonly inputFields: string[]; diff --git a/src/Utils/ElementUtils.ts b/src/Utils/ElementUtils.ts index e68c2ec..0f4fe2e 100644 --- a/src/Utils/ElementUtils.ts +++ b/src/Utils/ElementUtils.ts @@ -1,6 +1,7 @@ export class ElementUtils { private static inputTypes = ['Input.Text', 'Input.Number', 'Input.Date', 'Input.Time', 'Input.Toggle', 'Input.ChoiceSet']; private static valueTypes = ['Fact', 'Input.Choice']; + private static selectActionTargetTypes = ['Input.PeoplePicker']; public static isInput(type: string) { return ElementUtils.inputTypes.indexOf(type) >= 0; @@ -9,4 +10,8 @@ export class ElementUtils { public static isValue(type: string) { return ElementUtils.valueTypes.indexOf(type) >= 0; } + + public static isSelectActionTarget(type: string) { + return ElementUtils.selectActionTargetTypes.indexOf(type) >= 0; + } } diff --git a/src/Views/Factories/ContentFactory.tsx b/src/Views/Factories/ContentFactory.tsx index 8b9ebc1..faa5e14 100644 --- a/src/Views/Factories/ContentFactory.tsx +++ b/src/Views/Factories/ContentFactory.tsx @@ -11,6 +11,7 @@ import { FactSetElement } from '../../Schema/Containers/FactSet'; import { ImageSetElement } from '../../Schema/Containers/ImageSet'; import { DateInputElement } from '../../Schema/Inputs/DateInput'; import { NumberInputElement } from '../../Schema/Inputs/NumberInput'; +import { PeoplePickerElement } from '../../Schema/Inputs/PeoplePicker'; import { TextInputElement } from '../../Schema/Inputs/TextInput'; import { TimeInputElement } from '../../Schema/Inputs/TimeInput'; import { ImageView } from '../CardElements/Image'; @@ -22,6 +23,7 @@ import { FactSetView } from '../Containers/FactSet'; import { ImageSetView } from '../Containers/ImageSet'; import { DateInputView } from '../Inputs/DateInput'; import { NumberInputView } from '../Inputs/NumberInput'; +import { PeoplePickerView } from '../Inputs/PeoplePicker'; import { TextInputView } from '../Inputs/TextInput'; import { TimeInputView } from '../Inputs/TimeInput'; @@ -160,6 +162,15 @@ export class ContentFactory { theme={theme} /> ); + case ContentElementType.PeoplePicker: + return ( + + ); default: return null; } diff --git a/src/Views/Inputs/DateInput.tsx b/src/Views/Inputs/DateInput.tsx index 6849060..0e0f23f 100644 --- a/src/Views/Inputs/DateInput.tsx +++ b/src/Views/Inputs/DateInput.tsx @@ -137,7 +137,7 @@ export class DateInputView extends React.Component { } private get height() { - return this.fontSize * this.numberOfLine + this.paddingVertical * 2; + return this.fontSize * this.numberOfLine + this.paddingVertical * 2 + 2; } private get color() { diff --git a/src/Views/Inputs/PeoplePicker.tsx b/src/Views/Inputs/PeoplePicker.tsx new file mode 100644 index 0000000..e062d70 --- /dev/null +++ b/src/Views/Inputs/PeoplePicker.tsx @@ -0,0 +1,141 @@ +import * as React from 'react'; +import { LabelInput } from '../../Components/Inputs/LabelInput'; +import { ActionContext } from '../../Contexts/ActionContext'; +import { FormContext } from '../../Contexts/FormContext'; +import { ActionType } from '../../Schema/Abstract/ActionElement'; +import { CardElement } from '../../Schema/Cards/Card'; +import { PeoplePickerElement } from '../../Schema/Inputs/PeoplePicker'; +import { CallbackAction } from '../../Schema/Internal/CallbackAction'; +import { ActionEventHandlerArgs } from '../../Shared/Types'; +import { ContentFactory } from '../Factories/ContentFactory'; +import { DebugOutputFactory } from '../Factories/DebugOutputFactory'; + +interface IProps { + index: number; + element: PeoplePickerElement; + theme: 'default' | 'emphasis'; +} + +interface IState { + value: string; + selected: Array<{ Name: string, Address: string }>; + inputFocused: boolean; + suggestionCard: CardElement; +} + +export class PeoplePickerView extends React.Component { + constructor(props: IProps) { + super(props); + + const { element } = this.props; + + if (element && element.isValid) { + this.state = { + value: '', + inputFocused: false, + selected: [], + suggestionCard: undefined, + }; + FormContext.getInstance().updateField(element.id, JSON.stringify([]), true); + FormContext.getInstance().registerFieldListener(element.id, this.storeListener); + } + } + + public render() { + const { element, theme } = this.props; + + if (!element || !element.isValid) { + return DebugOutputFactory.createDebugOutputBanner(element.type + '>>' + element.id + ' is not valid', theme, 'error'); + } + + return ( + + ); + } + + private onBlur = () => { + this.setState({ inputFocused: false }); + } + + private onFocus = () => { + this.setState({ inputFocused: true }); + } + + private onSuggestionCallback = (data: any) => { + this.setState({ + suggestionCard: new CardElement(data, this.props.element), + }); + } + + private onRequestSuggestion = (input: string) => { + this.setState({ + value: input, + }, () => { + const { element } = this.props; + + if (element.callback) { + let callback = ActionContext.getGlobalInstance().getActionEventHandler(element.callback, this.onSuggestionCallback); + if (callback) { + callback({ + actionType: ActionType.Callback, + func: this.populateApiParams, + name: 'populateApiParams' + }); + } + } + }); + } + + private populateApiParams = (args: ActionEventHandlerArgs) => { + if (args && args.formValidate) { + args.formData = this.extractParamData(); + } + return args; + } + + private extractParamData = () => { + const { element } = this.props; + + if (element.callback) { + const params = element.callback.parameters; + if (params) { + return Object.keys(params).reduce((prev, current) => { + let formIndex = params[current]; + if (formIndex === element.id) { + prev[current] = this.state.value; + } else { + prev[current] = FormContext.getInstance().getFieldValue(formIndex); + } + return prev; + }, {} as { [key: string]: string }); + } + } + return {}; + } + + private storeListener = (value: string) => { + this.setState({ + selected: JSON.parse(value), + suggestionCard: undefined, + inputFocused: true, + value: '', + }); + } + + private get labels() { + return this.state.selected.map((contact) => { + return { + title: contact.Name + }; + }); + } +} diff --git a/src/Views/Inputs/TimeInput.tsx b/src/Views/Inputs/TimeInput.tsx index ae1fda7..2f6a8ab 100644 --- a/src/Views/Inputs/TimeInput.tsx +++ b/src/Views/Inputs/TimeInput.tsx @@ -137,7 +137,7 @@ export class TimeInputView extends React.Component { } private get height() { - return this.fontSize * this.numberOfLine + this.paddingVertical * 2; + return this.fontSize * this.numberOfLine + this.paddingVertical * 2 + 2; } private get color() { diff --git a/src/Views/Root.tsx b/src/Views/Root.tsx index 58a483d..1a524fc 100644 --- a/src/Views/Root.tsx +++ b/src/Views/Root.tsx @@ -9,6 +9,7 @@ import { FormContext } from '../Contexts/FormContext'; import { HostContext } from '../Contexts/HostContext'; import { ActionType } from '../Schema/Abstract/ActionElement'; import { OpenUrlActionElement } from '../Schema/Actions/OpenUrlAction'; +import { SelectActionElement } from '../Schema/Actions/SelectAction'; import { SubmitActionElement } from '../Schema/Actions/SubmitAction'; import { CardElement } from '../Schema/Cards/Card'; import { CallbackAction } from '../Schema/Internal/CallbackAction'; @@ -47,6 +48,7 @@ export class CardRootView extends React.PureComponent { hostContext.registerOpenUrlHandler(this.onOpenUrl); hostContext.registerSubmitHandler(this.onSubmit); hostContext.registerCallbackHandler(this.onCallback); + hostContext.registerSelectActionHandler(this.onSelectAction); hostContext.registerFocusHandler(this.props.onFocus); hostContext.registerBlurHandler(this.props.onBlur); @@ -76,9 +78,9 @@ export class CardRootView extends React.PureComponent { }); actionContext.registerHook({ - func: this.populateCallbackParamData, - name: 'populateCallbackParamData', - actionType: ActionType.Callback + func: this.populateSelectActionData, + name: 'populateSelectActionData', + actionType: ActionType.Select }); } @@ -140,6 +142,16 @@ export class CardRootView extends React.PureComponent { } } + private onSelectAction = (args: ActionEventHandlerArgs) => { + if (args) { + let currentValue = JSON.parse(FormContext.getInstance().getFieldValue(args.action.targetFormField)) as Array; + if (currentValue) { + currentValue.push(args.formData); + FormContext.getInstance().updateField(args.action.targetFormField, JSON.stringify(currentValue), true); + } + } + } + private validateForm = (args: ActionEventHandlerArgs) => { if (args) { args.formValidate = args.action.scope.validateScope(); @@ -164,9 +176,11 @@ export class CardRootView extends React.PureComponent { return args; } - private populateCallbackParamData = (args: ActionEventHandlerArgs) => { - if (args && args.formValidate) { - args.formData = FormContext.getInstance().getCallbackParamData(args.action.parameters); + private populateSelectActionData = (args: ActionEventHandlerArgs) => { + if (args) { + args.formData = { + ...(args.action.data || {}), + }; } return args; } diff --git a/yarn.lock b/yarn.lock index c863bf0..f5c28c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6,7 +6,7 @@ version "1.3.0" resolved "https://registry.yarnpkg.com/@types/fancy-log/-/fancy-log-1.3.0.tgz#a61ab476e5e628cd07a846330df53b85e05c8ce0" -"@types/lodash@^4.14.116": +"@types/lodash@latest": version "4.14.116" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.116.tgz#5ccf215653e3e8c786a58390751033a9adca0eb9" @@ -20,19 +20,26 @@ dependencies: "@types/react" "*" -"@types/react-native@^0.56.6": - version "0.56.6" - resolved "https://registry.yarnpkg.com/@types/react-native/-/react-native-0.56.6.tgz#d9c60e1df95ec0402fa956225558c8a48f37f23f" +"@types/react-native@latest": + version "0.56.9" + resolved "https://registry.yarnpkg.com/@types/react-native/-/react-native-0.56.9.tgz#87e5877b98aed2b1dcd1def6f69927b24c2bd7e5" dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^16.4.8": +"@types/react@*": version "16.4.8" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.4.8.tgz#ff0440429783df0927bdcd430fa1225f7c08cf36" dependencies: "@types/prop-types" "*" csstype "^2.2.0" +"@types/react@latest": + version "16.4.11" + resolved "https://registry.yarnpkg.com/@types/react/-/react-16.4.11.tgz#330f3d864300f71150dc2d125e48644c098f8770" + dependencies: + "@types/prop-types" "*" + csstype "^2.2.0" + ajv@^5.1.0: version "5.5.2" resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.5.2.tgz#73b5eeca3fab653e3d3f9422b341ad42205dc965" @@ -825,7 +832,7 @@ define-property@^2.0.2: is-descriptor "^1.0.2" isobject "^3.0.1" -del@^3.0.0: +del@latest: version "3.0.0" resolved "https://registry.yarnpkg.com/del/-/del-3.0.0.tgz#53ecf699ffcbcb39637691ab13baf160819766e5" dependencies: @@ -1598,7 +1605,7 @@ gulp-decompress@^1.2.0: gulp-util "^3.0.1" readable-stream "^2.0.2" -gulp-imagemin@^4.1.0: +gulp-imagemin@latest: version "4.1.0" resolved "https://registry.yarnpkg.com/gulp-imagemin/-/gulp-imagemin-4.1.0.tgz#5ce347f1d1706fed3cc8f1777ca9094a583b50b7" dependencies: @@ -1629,7 +1636,7 @@ gulp-sourcemaps@1.6.0: through2 "^2.0.0" vinyl "^1.0.0" -gulp-tslint@^8.1.3: +gulp-tslint@latest: version "8.1.3" resolved "https://registry.yarnpkg.com/gulp-tslint/-/gulp-tslint-8.1.3.tgz#a89ed144038ae861ee7bfea9528272d126a93da1" dependencies: @@ -1640,7 +1647,7 @@ gulp-tslint@^8.1.3: plugin-error "1.0.1" through "~2.3.8" -gulp-typescript@^5.0.0-alpha.3: +gulp-typescript@latest: version "5.0.0-alpha.3" resolved "https://registry.yarnpkg.com/gulp-typescript/-/gulp-typescript-5.0.0-alpha.3.tgz#c49a306cbbb8c97f5fe8a79208671b6642ef9861" dependencies: @@ -1674,7 +1681,7 @@ gulp-util@^3.0.0, gulp-util@^3.0.1: through2 "^2.0.0" vinyl "^0.5.0" -gulp@^3.9.1: +gulp@latest: version "3.9.1" resolved "https://registry.yarnpkg.com/gulp/-/gulp-3.9.1.tgz#571ce45928dd40af6514fc4011866016c13845b4" dependencies: @@ -2437,7 +2444,7 @@ lodash.templatesettings@^3.0.0: lodash._reinterpolate "^3.0.0" lodash.escape "^3.0.0" -lodash@^4.17.10: +lodash@latest: version "4.17.10" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.10.tgz#1b7793cf7259ea38fb3661d4d38b3260af8ae4e7" @@ -3309,7 +3316,7 @@ rimraf@^2.2.6, rimraf@^2.2.8, rimraf@^2.5.4: dependencies: glob "^7.0.5" -run-sequence@^2.2.1: +run-sequence@latest: version "2.2.1" resolved "https://registry.yarnpkg.com/run-sequence/-/run-sequence-2.2.1.tgz#1ce643da36fd8c7ea7e1a9329da33fc2b8898495" dependencies: @@ -3857,7 +3864,7 @@ tslib@^1.8.0, tslib@^1.8.1: version "1.9.3" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286" -tslint@^5.11.0: +tslint@latest: version "5.11.0" resolved "https://registry.yarnpkg.com/tslint/-/tslint-5.11.0.tgz#98f30c02eae3cde7006201e4c33cb08b48581eed" dependencies: @@ -3898,7 +3905,7 @@ typedarray@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" -typescript@^3.0.1: +typescript@latest: version "3.0.1" resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.0.1.tgz#43738f29585d3a87575520a4b93ab6026ef11fdb"