diff --git a/.travis.yml b/.travis.yml index 3f83b83e..6ea6888f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,6 +17,7 @@ jobs: dart: stable script: - dartanalyzer . + - dartfmt -n --set-exit-if-changed . - pub run dependency_validator -i build_runner,build_test,build_web_compilers - pub run test -p chrome - pub run build_runner test -- -p chrome diff --git a/lib/react_client.dart b/lib/react_client.dart index 574206ee..7fbe0ebd 100644 --- a/lib/react_client.dart +++ b/lib/react_client.dart @@ -16,6 +16,7 @@ import "package:react/react_client/js_interop_helpers.dart"; import 'package:react/react_client/react_interop.dart'; import "package:react/react_dom.dart"; import "package:react/react_dom_server.dart"; +import "package:react/src/react_client/event_prop_key_to_event_factory.dart"; import "package:react/src/react_client/synthetic_event_wrappers.dart" as events; import 'package:react/src/typedefs.dart'; import 'package:react/src/ddc_emulated_function_name_bug.dart' @@ -510,6 +511,32 @@ _convertBoundValues(Map args) { /// to the original Dart functions (the input of [_convertEventHandlers]). final Expando _originalEventHandlers = new Expando(); +/// Returns the props for a [ReactElement] or composite [ReactComponent] [instance], +/// shallow-converted to a Dart Map for convenience. +/// +/// If `style` is specified in props, then it too is shallow-converted and included +/// in the returned Map. +/// +/// Any JS event handlers included in the props for the given [instance] will be +/// unconverted such that the original JS handlers are returned instead of their +/// Dart synthetic counterparts. +Map unconvertJsProps(/* ReactElement|ReactComponent */ instance) { + var props = _dartifyJsMap(instance.props); + eventPropKeyToEventFactory.keys.forEach((key) { + if (props.containsKey(key)) { + props[key] = unconvertJsEventHandler(props[key]) ?? props[key]; + } + }); + + // Convert the nested style map so it can be read by Dart code. + var style = props['style']; + if (style != null) { + props['style'] = _dartifyJsMap(style); + } + + return props; +} + /// Returns the original Dart handler function that, within [_convertEventHandlers], /// was converted/wrapped into the function [jsConvertedEventHandler] to be passed to the JS. /// @@ -529,7 +556,7 @@ Function unconvertJsEventHandler(Function jsConvertedEventHandler) { _convertEventHandlers(Map args) { var zone = Zone.current; args.forEach((propKey, value) { - var eventFactory = _eventPropKeyToEventFactory[propKey]; + var eventFactory = eventPropKeyToEventFactory[propKey]; if (eventFactory != null && value != null) { // Apply allowInterop here so that the function we store in [_originalEventHandlers] // is the same one we'll retrieve from the JS props. @@ -544,72 +571,11 @@ _convertEventHandlers(Map args) { }); } -/// A mapping from event prop keys to their respective event factories. -/// -/// Used in [_convertEventHandlers] for efficient event handler conversion. -final Map _eventPropKeyToEventFactory = (() { - var eventPropKeyToEventFactory = { - // SyntheticClipboardEvent - 'onCopy': syntheticClipboardEventFactory, - 'onCut': syntheticClipboardEventFactory, - 'onPaste': syntheticClipboardEventFactory, - - // SyntheticKeyboardEvent - 'onKeyDown': syntheticKeyboardEventFactory, - 'onKeyPress': syntheticKeyboardEventFactory, - 'onKeyUp': syntheticKeyboardEventFactory, - - // SyntheticFocusEvent - 'onFocus': syntheticFocusEventFactory, - 'onBlur': syntheticFocusEventFactory, - - // SyntheticFormEvent - 'onChange': syntheticFormEventFactory, - 'onInput': syntheticFormEventFactory, - 'onSubmit': syntheticFormEventFactory, - 'onReset': syntheticFormEventFactory, - - // SyntheticMouseEvent - 'onClick': syntheticMouseEventFactory, - 'onContextMenu': syntheticMouseEventFactory, - 'onDoubleClick': syntheticMouseEventFactory, - 'onDrag': syntheticMouseEventFactory, - 'onDragEnd': syntheticMouseEventFactory, - 'onDragEnter': syntheticMouseEventFactory, - 'onDragExit': syntheticMouseEventFactory, - 'onDragLeave': syntheticMouseEventFactory, - 'onDragOver': syntheticMouseEventFactory, - 'onDragStart': syntheticMouseEventFactory, - 'onDrop': syntheticMouseEventFactory, - 'onMouseDown': syntheticMouseEventFactory, - 'onMouseEnter': syntheticMouseEventFactory, - 'onMouseLeave': syntheticMouseEventFactory, - 'onMouseMove': syntheticMouseEventFactory, - 'onMouseOut': syntheticMouseEventFactory, - 'onMouseOver': syntheticMouseEventFactory, - 'onMouseUp': syntheticMouseEventFactory, - - // SyntheticTouchEvent - 'onTouchCancel': syntheticTouchEventFactory, - 'onTouchEnd': syntheticTouchEventFactory, - 'onTouchMove': syntheticTouchEventFactory, - 'onTouchStart': syntheticTouchEventFactory, - - // SyntheticUIEvent - 'onScroll': syntheticUIEventFactory, - - // SyntheticWheelEvent - 'onWheel': syntheticWheelEventFactory, - }; - - // Add support for capturing variants; e.g., onClick/onClickCapture - for (var key in eventPropKeyToEventFactory.keys.toList()) { - eventPropKeyToEventFactory[key + 'Capture'] = - eventPropKeyToEventFactory[key]; - } - - return eventPropKeyToEventFactory; -})(); +/// Returns a Dart Map copy of the JS property key-value pairs in [jsMap]. +Map _dartifyJsMap(jsMap) { + return new Map.fromIterable(_objectKeys(jsMap), + value: (key) => getProperty(jsMap, key)); +} /// Wrapper for [SyntheticEvent]. SyntheticEvent syntheticEventFactory(events.SyntheticEvent e) { @@ -712,7 +678,8 @@ SyntheticFormEvent syntheticFormEventFactory(events.SyntheticFormEvent e) { } /// Wrapper for [SyntheticDataTransfer]. -SyntheticDataTransfer syntheticDataTransferFactory(events.SyntheticDataTransfer dt) { +SyntheticDataTransfer syntheticDataTransferFactory( + events.SyntheticDataTransfer dt) { if (dt == null) return null; List files = []; if (dt.files != null) { diff --git a/lib/src/react_client/event_prop_key_to_event_factory.dart b/lib/src/react_client/event_prop_key_to_event_factory.dart new file mode 100644 index 00000000..769d2ce0 --- /dev/null +++ b/lib/src/react_client/event_prop_key_to_event_factory.dart @@ -0,0 +1,68 @@ +import 'package:react/react_client.dart'; + +/// A mapping from event prop keys to their respective event factories. +/// +/// Used in [_convertEventHandlers] for efficient event handler conversion. +final Map eventPropKeyToEventFactory = (() { + var _eventPropKeyToEventFactory = { + // SyntheticClipboardEvent + 'onCopy': syntheticClipboardEventFactory, + 'onCut': syntheticClipboardEventFactory, + 'onPaste': syntheticClipboardEventFactory, + + // SyntheticKeyboardEvent + 'onKeyDown': syntheticKeyboardEventFactory, + 'onKeyPress': syntheticKeyboardEventFactory, + 'onKeyUp': syntheticKeyboardEventFactory, + + // SyntheticFocusEvent + 'onFocus': syntheticFocusEventFactory, + 'onBlur': syntheticFocusEventFactory, + + // SyntheticFormEvent + 'onChange': syntheticFormEventFactory, + 'onInput': syntheticFormEventFactory, + 'onSubmit': syntheticFormEventFactory, + 'onReset': syntheticFormEventFactory, + + // SyntheticMouseEvent + 'onClick': syntheticMouseEventFactory, + 'onContextMenu': syntheticMouseEventFactory, + 'onDoubleClick': syntheticMouseEventFactory, + 'onDrag': syntheticMouseEventFactory, + 'onDragEnd': syntheticMouseEventFactory, + 'onDragEnter': syntheticMouseEventFactory, + 'onDragExit': syntheticMouseEventFactory, + 'onDragLeave': syntheticMouseEventFactory, + 'onDragOver': syntheticMouseEventFactory, + 'onDragStart': syntheticMouseEventFactory, + 'onDrop': syntheticMouseEventFactory, + 'onMouseDown': syntheticMouseEventFactory, + 'onMouseEnter': syntheticMouseEventFactory, + 'onMouseLeave': syntheticMouseEventFactory, + 'onMouseMove': syntheticMouseEventFactory, + 'onMouseOut': syntheticMouseEventFactory, + 'onMouseOver': syntheticMouseEventFactory, + 'onMouseUp': syntheticMouseEventFactory, + + // SyntheticTouchEvent + 'onTouchCancel': syntheticTouchEventFactory, + 'onTouchEnd': syntheticTouchEventFactory, + 'onTouchMove': syntheticTouchEventFactory, + 'onTouchStart': syntheticTouchEventFactory, + + // SyntheticUIEvent + 'onScroll': syntheticUIEventFactory, + + // SyntheticWheelEvent + 'onWheel': syntheticWheelEventFactory, + }; + + // Add support for capturing variants; e.g., onClick/onClickCapture + for (var key in _eventPropKeyToEventFactory.keys.toList()) { + _eventPropKeyToEventFactory[key + 'Capture'] = + _eventPropKeyToEventFactory[key]; + } + + return _eventPropKeyToEventFactory; +})(); diff --git a/test/react_client_test.dart b/test/react_client_test.dart index 5ef52994..5685536a 100644 --- a/test/react_client_test.dart +++ b/test/react_client_test.dart @@ -1,11 +1,154 @@ @TestOn('browser') library react_test_utils_test; +import 'dart:html' show DivElement; + +import 'package:js/js.dart'; import 'package:test/test.dart'; +import 'package:react/react.dart' as react show div; import 'package:react/react_client.dart'; +import 'package:react/react_client/react_interop.dart' + show React, ReactClassConfig, ReactComponent; +import 'package:react/react_client/js_interop_helpers.dart'; +import 'package:react/react_dom.dart' as react_dom; +import 'package:react/src/react_client/event_prop_key_to_event_factory.dart'; main() { + setClientConfiguration(); + + group('unconvertJsProps', () { + const List testChildren = const ['child1', 'child2']; + const Map testStyle = const {'background': 'white'}; + + test('returns props for a composite JS component ReactElement', () { + ReactElement instance = testJsComponentFactory({ + 'jsProp': 'js', + 'style': testStyle, + }, testChildren); + + expect( + unconvertJsProps(instance), + equals({ + 'jsProp': 'js', + 'style': testStyle, + 'children': testChildren, + }), + ); + }); + + test('returns props for a composite JS ReactComponent', () { + var mountNode = new DivElement(); + ReactComponent renderedInstance = react_dom.render( + testJsComponentFactory({ + 'jsProp': 'js', + 'style': testStyle, + }, testChildren), + mountNode); + + expect( + unconvertJsProps(renderedInstance), + equals({ + 'jsProp': 'js', + 'style': testStyle, + 'children': testChildren, + }), + ); + }); + + test( + 'returns props for a composite JS ReactComponent, even when the props change', + () { + var mountNode = new DivElement(); + ReactComponent renderedInstance = react_dom.render( + testJsComponentFactory({ + 'jsProp': 'js', + 'style': testStyle, + }, testChildren), + mountNode); + + expect( + unconvertJsProps(renderedInstance), + equals({ + 'jsProp': 'js', + 'style': testStyle, + 'children': testChildren, + }), + ); + + renderedInstance = react_dom.render( + testJsComponentFactory({ + 'jsProp': 'other js', + 'style': testStyle, + }, testChildren), + mountNode); + + expect( + unconvertJsProps(renderedInstance), + equals({ + 'jsProp': 'other js', + 'style': testStyle, + 'children': testChildren, + })); + }); + + test('returns props for a DOM component ReactElement', () { + ReactElement instance = react.div({ + 'domProp': 'dom', + 'style': testStyle, + }, testChildren); + + expect( + unconvertJsProps(instance), + equals({ + 'domProp': 'dom', + 'style': testStyle, + 'children': testChildren, + })); + }); + + group('should unconvert JS event handlers', () { + Function createHandler(eventKey) => (_) { + print(eventKey); + }; + Map originalHandlers; + Map props; + + setUp(() { + originalHandlers = {}; + props = {}; + + for (final key in eventPropKeyToEventFactory.keys) { + props[key] = originalHandlers[key] = createHandler(key); + } + }); + + test('for a DOM element', () { + var component = react.div(props); + var jsProps = unconvertJsProps(component); + for (final key in eventPropKeyToEventFactory.keys) { + expect(jsProps[key], isNotNull, + reason: 'JS event handler prop should not be null'); + expect(jsProps[key], same(originalHandlers[key]), + reason: 'JS event handler prop was not unconverted'); + } + }); + + test( + ', except for a JS composite component (handlers should already be unconverted)', + () { + var component = testJsComponentFactory(props); + var jsProps = unconvertJsProps(component); + for (final key in eventPropKeyToEventFactory.keys) { + expect(jsProps[key], isNotNull, + reason: 'JS event handler prop should not be null'); + expect(jsProps[key], same(allowInterop(originalHandlers[key])), + reason: 'JS event handler prop was unexpectedly modified'); + } + }); + }); + }); + group('unconvertJsEventHandler', () { test('returns null when the input is null', () { var result; @@ -17,3 +160,16 @@ main() { }); }); } + +/// A factory for a JS composite component, for use in testing. +final Function testJsComponentFactory = (() { + var componentClass = React.createClass(new ReactClassConfig( + displayName: 'testJsComponent', + render: allowInterop(() => react.div({}, 'test js component')))); + + var reactFactory = React.createFactory(componentClass); + + return ([props = const {}, children]) { + return reactFactory(jsify(props), listifyChildren(children)); + }; +})(); diff --git a/test/react_client_test.html b/test/react_client_test.html new file mode 100644 index 00000000..55d37bf6 --- /dev/null +++ b/test/react_client_test.html @@ -0,0 +1,14 @@ + + + + + + + + + + + + + +