diff --git a/doc/new_boilerplate_migration.md b/doc/new_boilerplate_migration.md index c1face774..51eaf94bc 100644 --- a/doc/new_boilerplate_migration.md +++ b/doc/new_boilerplate_migration.md @@ -781,42 +781,117 @@ UiFactory Foo = uiFunction( ); ``` -#### With Consumed Props +#### With Prop Forwarding (fka Consumed Props) -Because functional components have no instance that track consumed props, the syntax for passing unconsumed -props changes within functional components. +Because functional components have no instance that track consumed props, the syntax for forwarding props +changes within functional components. -`UiProps` exposes a field `staticMeta` that can be used to generate an iterable containing props meta for specific mixins. -This is similar to accessing `propsMeta` within a class based component. Using the iterable returned from `staticMeta`'s -APIs (such as `forMixins`), we can generate unconsumed props and pass them to a child component. +`UiProps` exposes 2 APIs `getPropsToForward` & `addPropsToForward` that can be used to forward props +that have not been used to a child component. -This is done like so: +##### getPropsToForward +`getPropsToForward` will return a `Map` of props removing the props found in the `exclude` argument. +`exclude` is optional and will default to a `Set` with the type that `props` is statically typed as, +this only works with `mixin .. on UiProps` types. If your function component uses a Props `class` then +you must include an `exclude` argument. + +Component with a single props mixin: ```dart mixin FooPropsMixin on UiProps { - String passedProp; + String foo; } -mixin BarPropsMixin on UiProps { - String nonPassedProp; +UiFactory Foo = uiFunction((props) { + return (Bar() + // Filter out props declared in FooPropsMixin + // (used as the default for `exclude` since that's what `props` is statically typed as) + // when forwarding to Bar. + ..addAll(props.getPropsToForward()) + )(); +}); +``` + +Component with a more than one props mixin: +```dart +mixin FooPropsMixin on UiProps { + String foo; } -class FooBarProps = UiProps with BarPropsMixin, FooPropsMixin; +class FooProps = UiProps with BarProps, FooPropsMixin; -UiFactory FooBar = uiFunction( - (props) { - final consumedProps = props.staticMeta.forMixins({BarPropsMixin}); +UiFactory Foo = uiFunction((props) { + return (Bar() + // Filter out props declared in FooPropsMixin when forwarding to Bar. + ..addAll(props.getPropsToForward(exclude: {FooPropsMixin})) + )(); +}); +``` - return (Foo()..addUnconsumedProps(props, consumedProps))(); - }, - _$FooBarConfig, // ignore: undefined_identifier -); +`domOnly` - to forward DOM props only: +```dart +mixin FooPropsMixin on UiProps { + String foo; +} -UiFactory Foo = uiFunction( - (props) { - return 'foo: ${props.passedProp}'; - }, - _$FooConfig, // ignore: undefined_identifier -); +UiFactory Foo = uiFunction((props) { + return (Dom.div() + // Forward only DOM based props & Filter out props declared in FooPropsMixin + // (used as the default for `exclude` since that's what `props` is statically typed as) + // when forwarding to Bar. + ..addAll(props.getPropsToForward(domOnly: true)) + )(); +}); +``` + +##### addPropsToForward +`addPropsToForward` has the same function signature as `getPropsToForward` but is meant to be used with the `UiProps` method `modifyProps`. + +Component with a single props mixin: +```dart +mixin FooPropsMixin on UiProps { + String foo; +} + +UiFactory Foo = uiFunction((props) { + return (Bar() + // Filter out props declared in FooPropsMixin + // (used as the default for `exclude` since that's what `props` is statically typed as) + // when forwarding to Bar. + ..modifyProps(props.addPropsToForward()) + )(); +}); +``` + +Component with a more than one props mixin: +```dart +mixin FooPropsMixin on UiProps { + String foo; +} + +class FooProps = UiProps with BarProps, FooPropsMixin; + +UiFactory Foo = uiFunction((props) { + return (Bar() + // Filter out props declared in FooPropsMixin when forwarding to Bar. + ..modifyProps(props.addPropsToForward(exclude: {FooPropsMixin})) + )(); +}); +``` + +`domOnly` - to forward DOM props only: +```dart +mixin FooPropsMixin on UiProps { + String foo; +} + +UiFactory Foo = uiFunction((props) { + return (Dom.div() + // Forward only DOM based props & Filter out props declared in FooPropsMixin + // (used as the default for `exclude` since that's what `props` is statically typed as) + // when forwarding to Bar. + ..modifyProps(props.addPropsToForward(domOnly: true)) + )(); +}); ``` #### With UiProps diff --git a/doc/props_mixin_component_composition.md b/doc/props_mixin_component_composition.md index dbd671674..3cafd056f 100644 --- a/doc/props_mixin_component_composition.md +++ b/doc/props_mixin_component_composition.md @@ -2,9 +2,9 @@ In our core `UiProps` documentation, the pattern of [composing multiple props mixins into a single component props API](../README.md#with-other-mixins) is introduced. -This example builds on that, showing a lightweight example a common use-case for such composition. +This example builds on that, showing a lightweight example a common use-case for such composition. -We'll show three components +We'll show three components 1. A `Foo` component that has its own props API - and default rendering behavior when rendered standalone. 1. A `FooBar` component that has its own props API, in addition to the `Foo` props API. This allows consumers to set props declared in `FooPropsMixin`, which will be forwarded to the `Foo` component it renders. @@ -16,7 +16,7 @@ import 'package:over_react/over_react.dart'; part 'foo.over_react.g.dart'; -UiFactory Foo = +UiFactory Foo = castUiFactory(_$Foo); // ignore: undefined_identifier mixin FooPropsMixin on UiProps { @@ -35,7 +35,7 @@ class FooComponent extends UiComponent2 { ..modifyProps(addUnconsumedDomProps) ..className = (forwardingClassNameBuilder()..add('foo')) )( - 'Qux: ', + 'Qux: ', props.qux.map((n) => n), props.children, ); @@ -58,7 +58,7 @@ import 'foo.dart'; part 'foo_bar.over_react.g.dart'; -UiFactory FooBar = +UiFactory FooBar = castUiFactory(_$FooBar); // ignore: undefined_identifier mixin BarPropsMixin on UiProps { @@ -69,7 +69,7 @@ mixin BarPropsMixin on UiProps { class FooBarProps = UiProps with BarPropsMixin, FooPropsMixin; class FooBarComponent extends UiComponent2 { - // Only consume the props found within BarPropsMixin, so that any prop values + // Only consume the props found within BarPropsMixin, so that any prop values // found in FooPropsMixin get forwarded to the child Foo component via `addUnconsumedProps`. @override get consumedProps => propsMeta.forMixins({BarPropsMixin}); @@ -122,7 +122,7 @@ Produces the following HTML:
Qux: 234
- Bizzles: + Bizzles:
  1. a
  2. b
  3. @@ -150,12 +150,9 @@ class FooBazProps = UiProps with BarPropsMixin, FooPropsMixin; UiFactory FooBaz = uiFunction( (props) { - // Only consume the props found within BarPropsMixin, so that any prop values - // found in FooPropsMixin get forwarded to the child Foo component via `addUnconsumedProps`. - final consumedProps = props.staticMeta.forMixins({BarPropsMixin}); - return (Foo() - ..addUnconsumedProps(props, consumedProps) + // Only forward the props not belonging to BarPropsMixin to the child Foo component. + ..addAll(props.getPropsToForward(exclude: {BarPropsMixin})) ..className = (forwardingClassNameBuilder()..add('foo__baz')) )( (Dom.div()..className = 'foo__baz__bizzles')( @@ -165,7 +162,7 @@ UiFactory FooBaz = uiFunction( ), ), ); - + ReactElement _renderBizzleItem(String bizzle) { return (Dom.li()..key = bizzle)(bizzle); } @@ -201,7 +198,7 @@ Produces the following HTML:
    Qux: 234
    - Bizzles: + Bizzles:
    1. a
    2. b
    3. diff --git a/example/builder/src/functional_consumed_props.dart b/example/builder/src/functional_consumed_props.dart index fcb2ac455..3c6d164f7 100644 --- a/example/builder/src/functional_consumed_props.dart +++ b/example/builder/src/functional_consumed_props.dart @@ -28,14 +28,12 @@ mixin SharedPropsMixin on UiProps { class SomeParentProps = UiProps with ParentOnlyPropsMixin, SharedPropsMixin; UiFactory SomeParent = uiFunction((props) { - final consumedProps = props.staticMeta.forMixins({ParentOnlyPropsMixin}); - return ( Dom.div()( Dom.div()( 'The parent prop is: ${props.aParentProp}', ), - (SomeChild()..addUnconsumedProps(props, consumedProps))(), + (SomeChild()..addAll(props.getPropsToForward(exclude: {ParentOnlyPropsMixin})))(), ) ); }, diff --git a/example/builder/src/new_class_consumed_props.dart b/example/builder/src/new_class_consumed_props.dart index 4309b1fd9..f41ccd5c7 100644 --- a/example/builder/src/new_class_consumed_props.dart +++ b/example/builder/src/new_class_consumed_props.dart @@ -32,14 +32,12 @@ class SomeParentProps = UiProps with ParentOnlyPropsMixin, SharedPropsMixin; class SomeClassParentComponent extends UiComponent2 { @override render() { - final meta = props.staticMeta.forMixins({ParentOnlyPropsMixin}); - return ( Dom.div()( Dom.div()( 'The parent prop is: ${props.aParentProp}', ), - (SomeClassChild()..addUnconsumedProps(props, meta))(), + (SomeClassChild()..modifyProps(props.addPropsToForward(exclude: {ParentOnlyPropsMixin})))(), ) ); } @@ -58,4 +56,4 @@ class SomeClassChildComponent extends UiComponent2 { ) ); } -} \ No newline at end of file +} diff --git a/lib/src/component_declaration/builder_helpers.dart b/lib/src/component_declaration/builder_helpers.dart index 6c22a3b28..82cd5066f 100644 --- a/lib/src/component_declaration/builder_helpers.dart +++ b/lib/src/component_declaration/builder_helpers.dart @@ -134,6 +134,122 @@ abstract class UiProps extends component_base.UiProps with GeneratedClass { @toBeGenerated PropsMetaCollection get staticMeta => throw UngeneratedError(member: #meta); } +/// Helper static extension methods to make forwarding props easier. +extension PropsToForward on T { + + /// Returns a copy of this instance's props excluding the keys found in [exclude]. + /// + /// [exclude] should be a `Set` of PropsMixin `Type`s. + /// If [exclude] is not set, it defaults to using the current instance's Type. + /// + /// __Example:__ + /// + /// Component with a single props mixin: + /// ```dart + /// mixin FooPropsMixin on UiProps { + /// String foo; + /// } + /// + /// UiFactory Foo = uiFunction((props) { + /// return (Bar() + /// // Filter out props declared in FooPropsMixin + /// // (used as the default for `exclude` since that's what `props` is statically typed as) + /// // when forwarding to Bar. + /// ..addAll(props.getPropsToForward()) + /// )(); + /// }); + /// ``` + /// + /// Component with a more than one props mixin: + /// ```dart + /// mixin FooPropsMixin on UiProps { + /// String foo; + /// } + /// class FooProps = UiProps with BarProps, FooPropsMixin; + /// + /// UiFactory Foo = uiFunction((props) { + /// return (Bar() + /// // Filter out props declared in FooPropsMixin when forwarding to Bar. + /// ..addAll(props.getPropsToForward(exclude: {FooPropsMixin})) + /// )(); + /// }); + /// ``` + /// + /// To only add DOM props, use the [domOnly] named argument. + /// + /// Related: `UiComponent2`'s `addUnconsumedProps` + Map getPropsToForward({Set exclude, bool domOnly = false}) { + return _propsToForward(exclude: exclude, domOnly: domOnly, propsToUpdate: {}); + } + + /// A utility function to be used with `modifyProps` to add props excluding the keys found in [exclude]. + /// + /// [exclude] should be a `Set` of PropsMixin `Type`s. + /// If [exclude] is not set, it defaults to using the current instance's Type. + /// + /// __Example:__ + /// + /// Component with a single props mixin: + /// ```dart + /// mixin FooPropsMixin on UiProps { + /// String foo; + /// } + /// + /// UiFactory Foo = uiFunction((props) { + /// return (Bar() + /// // Filter out props declared in FooPropsMixin + /// // (used as the default for `exclude` since that's what `props` is statically typed as) + /// // when forwarding to Bar. + /// ..modifyProps(props.addPropsToForward()) + /// )(); + /// }); + /// ``` + /// + /// Component with a more than one props mixin: + /// ```dart + /// mixin FooPropsMixin on UiProps { + /// String foo; + /// } + /// class FooProps = UiProps with BarProps, FooPropsMixin; + /// + /// UiFactory Foo = uiFunction((props) { + /// return (Bar() + /// // Filter out props declared in FooPropsMixin when forwarding to Bar. + /// ..modifyProps(props.addPropsToForward(exclude: {FooPropsMixin})) + /// )(); + /// }); + /// ``` + /// + /// To only add DOM props, use the [domOnly] named argument. + /// + /// Related: `UiComponent2`'s `addUnconsumedProps` + PropsModifier addPropsToForward({Set exclude, bool domOnly = false}) { + return (Map props) { + _propsToForward(exclude: exclude, domOnly: domOnly, propsToUpdate: props); + }; + } + + Map _propsToForward({Set exclude, bool domOnly = false, Map propsToUpdate}) { + Iterable consumedProps = []; + try { + consumedProps = staticMeta.forMixins(exclude ?? {T}).toList(); + } catch(_) { + assert(exclude != null, ArgumentError('Could not find props meta for type $T.' + ' If this is not a props mixin, you need to specify its mixins as the second argument. For example:' + '\n ..addAll(props.getPropsToForward(exclude: {${T}Mixin})').message); + rethrow; + } + final consumedPropKeys = consumedProps?.map((consumedProps) => consumedProps.keys); + forwardUnconsumedPropsV2( + props, + propsToUpdate: propsToUpdate, + keySetsToOmit: consumedPropKeys, + onlyCopyDomProps: domOnly, + ); + return propsToUpdate; + } +} + /// A [dart.collection.MapView]-like class with strongly-typed getters/setters for React state. /// /// To be used with the over_react builder to generate concrete state implementations diff --git a/test/over_react/component_declaration/builder_integration_tests/new_boilerplate/function_component_test.dart b/test/over_react/component_declaration/builder_integration_tests/new_boilerplate/function_component_test.dart index fb0df94a4..de08bc4c6 100644 --- a/test/over_react/component_declaration/builder_integration_tests/new_boilerplate/function_component_test.dart +++ b/test/over_react/component_declaration/builder_integration_tests/new_boilerplate/function_component_test.dart @@ -290,6 +290,9 @@ void functionComponentTestHelper(UiFactory factory, }); }); + testPropsToForward(factory: factory, modifyProps: true); + testPropsToForward(factory: factory, modifyProps: false); + group('using `addUnconsumedDomProps`', () { TestProps initialProps; @@ -325,6 +328,109 @@ void functionComponentTestHelper(UiFactory factory, }); } +testPropsToForward({UiFactory factory, bool modifyProps = false}) { + group(modifyProps ? 'using `modifyProps(props.addPropsToForward)`' : 'using `getPropsToForwardProps`', () { + TestProps initialProps; + TestPropsMixin secondProps; + const stringProp = 'stringProp'; + const anotherProp = 'anotherProp'; + const idAttributeValue = 'idAttributeValue'; + const aRandomDataAttributeValue = 'aRandomDataAttributeValue'; + const anAriaLabelPropValue = 'anAriaLabelPropValue'; + + setUp(() { + initialProps = (factory() + ..stringProp = stringProp + ..anotherProp = anotherProp + ..aRandomDataAttribute = aRandomDataAttributeValue + ..anAriaLabelAlias = anAriaLabelPropValue + ..id = idAttributeValue + ); + + secondProps = initialProps; + }); + + test('by default excludes props mixin type that it is invoked on', () { + TestProps unconsumedProps; + if (modifyProps == true) { + unconsumedProps = factory()..modifyProps(secondProps.addPropsToForward()); + } else { + unconsumedProps = factory(secondProps.getPropsToForward()); + } + + expect(unconsumedProps.anotherProp, anotherProp); + expect(unconsumedProps.stringProp, isNull); + expect(unconsumedProps.id, idAttributeValue); + }); + + group('and props are correctly filtered', () { + test('for an empty set', () { + var unconsumedProps = _propsToForward(exclude: {}, props: initialProps, factory: factory, modifyProps: modifyProps); + + expect(unconsumedProps.stringProp, stringProp); + expect(unconsumedProps.anotherProp, anotherProp); + expect(unconsumedProps.id, idAttributeValue); + }); + + test('for a single value in set', () { + var unconsumedProps = _propsToForward(exclude: {ASecondPropsMixin}, props: initialProps, factory: factory, modifyProps: modifyProps); + + expect(unconsumedProps.stringProp, stringProp); + expect(unconsumedProps.anotherProp, isNull); + expect(unconsumedProps.id, idAttributeValue); + }); + + test('for multiple values in set', () { + var unconsumedProps = _propsToForward(exclude: {ASecondPropsMixin, TestPropsMixin}, props: initialProps, factory: factory, modifyProps: modifyProps); + + expect(unconsumedProps.stringProp, isNull); + expect(unconsumedProps.anotherProp, isNull); + expect(unconsumedProps.id, idAttributeValue); + }); + + test('excludes dom attributes that are part of a mixin with `@Accessor` annotations ', () { + var unconsumedProps = _propsToForward(exclude: {DomAccessorPropsMixin}, props: initialProps, factory: factory, modifyProps: modifyProps); + + expect(unconsumedProps.stringProp, stringProp); + expect(unconsumedProps.anotherProp, anotherProp); + expect(unconsumedProps.aRandomDataAttribute, isNull); + expect(unconsumedProps.anAriaLabelAlias, isNull); + expect(unconsumedProps.id, idAttributeValue); + }); + + test('for dom only ', () { + var unconsumedProps = _propsToForward(exclude: {}, domOnly: true, props: initialProps, factory: factory, modifyProps: modifyProps); + + expect(unconsumedProps.stringProp, isNull); + expect(unconsumedProps.anotherProp, isNull); + expect(unconsumedProps.aRandomDataAttribute, aRandomDataAttributeValue); + expect(unconsumedProps.anAriaLabelAlias, anAriaLabelPropValue); + expect(unconsumedProps.id, idAttributeValue); + }); + }); + + test('which throws an error when not providing an exclude argument and the props class is NOT a mixin and `domOnly` is NOT `true`', () { + expect(() => _propsToForward(exclude: null, props: initialProps, factory: factory, modifyProps: modifyProps), + throwsA( + isA() + .having( + (e) => e.toString(), + 'toString value', + contains('If this is not a props mixin, you need to specify its mixins as the second argument') + ), + ), + ); + }, tags: 'ddc'); + }); +} + +TestProps _propsToForward({UiFactory factory, T props, bool modifyProps = false, Set exclude, bool domOnly = false }) { + if (modifyProps == true) { + return factory()..modifyProps(props.addPropsToForward(exclude: exclude, domOnly: domOnly)); + } + return factory(props.getPropsToForward(exclude: exclude, domOnly: domOnly)); +} + UiFactory BasicUiForwardRef = uiForwardRef( (props, ref) { return (Dom.div() @@ -491,4 +597,12 @@ mixin AThirdPropsMixin on UiProps { String aPropsFromAThirdMixin; } -class TestProps = UiProps with TestPropsMixin, ASecondPropsMixin, AThirdPropsMixin; +mixin DomAccessorPropsMixin on UiProps { + @Accessor(key: 'data-random', keyNamespace: '') + String aRandomDataAttribute; + + @Accessor(key: 'aria-label', keyNamespace: '') + String anAriaLabelAlias; +} + +class TestProps = UiProps with TestPropsMixin, ASecondPropsMixin, AThirdPropsMixin, DomAccessorPropsMixin;