gcanti / tcomb-form-native

Forms library for react-native
MIT License
3.15k stars 458 forks source link

How to set default props? #240

Open compojoom opened 8 years ago

compojoom commented 8 years ago

Hey there!

thanks for the component. Apart from some initial difficulties and the styling for android - it's awesome :)

Contrary to my initial expectations, creating a factory turned out to be really easy (it's a react.component at the end) and that made abstracting my code very easy.

I've created a factory that renders a google map and a marker. This way the user could move the marker around and update the latitude/longitude for this position.

Here is the code in case someone else needs it. (I could create a repository with it later, once I clean up some things)

import React, {PropTypes} from 'react';

import {
    View,
    StyleSheet,
    Dimensions
} from 'react-native';

import MapView from 'react-native-maps';

var t = require('tcomb-form-native');

var Component = t.form.Component;

const { width, height } = Dimensions.get('window');

const ASPECT_RATIO = width / height;
const LATITUDE = 51.023033;
const LONGITUDE = 10.3621663;
const LATITUDE_DELTA = 0.0922;
const LONGITUDE_DELTA = LATITUDE_DELTA * ASPECT_RATIO;

class PositionOnMap extends Component {

    constructor (props) {
        super(props);

        this.state.region = {
            latitude: LATITUDE,
            longitude: LONGITUDE,
            latitudeDelta: LATITUDE_DELTA,
            longitudeDelta: LONGITUDE_DELTA
        };

        if(props.options.region)
        {
            this.state.region = {
                ...this.state.region,
                ...props.options.region
            }
        }

        this.state.marker = null;

        if(props.value)
        {
            this.state.region.latitude = props.value.latitude;
            this.state.region.longitude = props.value.longitude;

            this.state.marker = {
                coordinate: {
                    latitude: props.value.latitude,
                    longitude: props.value.longitude
                }
            }
        }

        this.onMapPress.bind(this)
    }

    /**
     * Add our marker on the map
     *
     * @param e  - the onClick event from the map
     */
    onMapPress(e) {
        this.setState({
            marker: {
                coordinate: e.nativeEvent.coordinate
            },
            value: e.nativeEvent.coordinate
        });
    }

    /**
     * Update our marker and value state when we drag the marker
     *
     * @param e  - the event
     */
    onMarkerDragEnd(e) {
        this.setState({
            marker: {
                coordinate: e.nativeEvent.coordinate,
            },
            value: e.nativeEvent.coordinate
        });
    }

    /**
     * Get the user's position and update the region state if necessary
     */
    componentDidMount() {
        if(this.props.options.userPosition)
        {
            navigator.geolocation.getCurrentPosition(
                (position) => {
                    // If the user has set a marker already, there is no need to change the region position
                    if(!this.state.marker)
                    {
                        this.setState(
                            {
                                region: {
                                    latitude: position.coords.latitude,
                                    longitude: position.coords.longitude,
                                    latitudeDelta: 0.0922,
                                    longitudeDelta: 0.0421,
                                }
                            }
                        );
                    }
                },
                (error) => console.log(error),
                {enableHighAccuracy: false, timeout: 20000, maximumAge: 1000}
            );
        }
    }

    shouldComponentUpdate(nextProps, nextState) {
        var should = super.shouldComponentUpdate(nextProps, nextState)

        if (should)
        {
            return true;
        }

        should = (
            nextState.region !== this.state.region
        );

        return should;
    }

    getTemplate () {
        let self = this;
        return (locals) => {
            return (
                <View style={styles.mapContainer}>
                    <MapView style={styles.map}
                             region={this.state.region}
                             onRegionChange={(region) => this.setState({region})}

                             onPress={(e) => this.onMapPress(e)}
                    >
                        {(() => {
                            if (this.state.marker) {
                                return <MapView.Marker
                                    key={this.state.marker.key}
                                    onDragEnd={(e) => this.onMarkerDragEnd(e)}
                                    coordinate={this.state.marker.coordinate}
                                    pinColor={this.state.marker.color}
                                    draggable
                                />
                            }
                        })()}
                    </MapView>
                </View>
            );
        }
    }
}

var styles = StyleSheet.create({
    mapContainer: {
        height: 200,
        justifyContent: 'flex-end',
        alignItems: 'stretch',
    },
    map: {
        ...StyleSheet.absoluteFillObject,
        backgroundColor: '#000'
    },
});

export default PositionOnMap;

My question is - how do you pass props and define default values for them? To use the component I do:

var Position = t.struct({ latitude: t.Number, longitude: t.Number });

// here we are: define your domain model var Whatever = t.struct({ position: Position, });

var options = { fields: { position: { factory: PositionOnMap, userPosition: true } },

};

Now when I access props in my constructor - userPosition is passed to the options object. It's not a standalone value in the props. So using the propTypes and defaultProps to define what the component expects doesn't work?

As you can see in componentDidMount() I'm using this.props.options.userPosition to find the user's position. However I don't want to pass userPosition: true in the field options. I would rather prefer that userPosition is per default set to true and only when we don't need it pass userPosition: false.

Thanks in advance for any guidelines.

alvaromb commented 8 years ago

Hello @compojoom!

I'm not sure I follow completely, but you have the value prop and the options prop. The first one is used to set the actual value of the Form (in your case, it would set the position value) and the other is used to customize how your form behaves.

Please take a look how I solved to provide some default options depending on the value passed https://github.com/APSL/react-native-floating-label/blob/master/FloatingLabel.js#L18-L21. You could do the same using props.options instead of props.value and set some defaults in your factory.

compojoom commented 8 years ago

Hey @alvaromb! Thanks for the fast reply. You know that with react you can have this: https://facebook.github.io/react/docs/reusable-components.html You can define what properties your component expect and you can also define defaultPropValues when one of the expected props is not passed to the component. That's very easy to do.

The issue is that if I define what props my component expects -> those props are never recognized by react as you are passing them over to the props.options object and not directly to the props object.

Actually I can define what expect as properties like this:

    static propTypes = {
        options: PropTypes.shape({
            position: PropTypes.shape({
                latitude: PropTypes.number.isRequired,
                longitude: PropTypes.number.isRequired
            }),
            region: PropTypes.shape({
                latitude: PropTypes.number.isRequired,
                longitude: PropTypes.number.isRequired,
                latitudeDelta:  PropTypes.number.isRequired,
                longitudeDelta:  PropTypes.number.isRequired
            }),
            userPosition: PropTypes.bool
        })
    }

Now if I pass a position.latitude, without longitude react complains. The same goes for region. This helps to define the api. However there is a problem with this code:

    static get defaultProps() {
        return  {
            options: {
                position: {
                    latitude: LATITUDE,
                    longitude: LONGITUDE
                },
                region: {
                    latitude: LATITUDE,
                    longitude: LONGITUDE,
                    latitudeDelta: LATITUDE_DELTA,
                    longitudeDelta: LONGITUDE_DELTA
                },
                userPosition:true
            }
        }
    }

If I do that, no default value is set position, region or userPosition, since react does this check in React.CreateElement

 // Resolve default props
  if (type && type.defaultProps) {
    var defaultProps = type.defaultProps;
    for (propName in defaultProps) {
      if (props[propName] === undefined) {
        props[propName] = defaultProps[propName];
      }
    }
  }

Note if (props[propName] === undefined) { - when the propname is options, the value here is not undefined. Options at this point already has a factory. And my position, userposition etc are never set.

If I change the syntax of my defaultProps to:

    static get defaultProps() {
        return  {
               position: {
                    latitude: LATITUDE,
                    longitude: LONGITUDE
                },
                region: {
                    latitude: LATITUDE,
                    longitude: LONGITUDE,
                    latitudeDelta: LATITUDE_DELTA,
                    longitudeDelta: LONGITUDE_DELTA
                },
                userPosition:true
        }
    }

then my options will end up with default values for position, region and userPosition, but I can't enforce to pass the correct proptypes. And if I pass userPosition: false in my form options, then those won't end up in the props object, but in the props.options.userPosition.

Do you understand the issue now? I would love to use the default things react provide for our component. But due to the way we pass props that doesn't seem to be possible.

gcanti commented 8 years ago

You are right, it's not possible to leverage defaultProps. Fortunately handling default options in a custom way is quite easy

alvaromb commented 8 years ago

@compojoom are you using options to set default values of your form?

compojoom commented 8 years ago

No, to pass default values for the fields I use the value={state.value}

Props are just for layout stuff.

gcanti commented 8 years ago

@compojoom thanks for sharing your code, a google map component is a nice example of custom factory

compojoom commented 8 years ago

This is the final version of the code that I'm going to live with for now.

import React, {PropTypes} from 'react';

import {
    View,
    Text,
    StyleSheet,
    Dimensions
} from 'react-native';

import MapView from 'react-native-maps';

var t = require('tcomb-form-native');

var Component = t.form.Component;

const {width, height} = Dimensions.get('window');

const ASPECT_RATIO = width / height;
const LATITUDE = 51.023033;
const LONGITUDE = 10.3621663;
const LATITUDE_DELTA = 0.0922;
const LONGITUDE_DELTA = LATITUDE_DELTA * ASPECT_RATIO;

class PositionOnMap extends Component {
    static propTypes = {
        options: PropTypes.shape({
            region: PropTypes.shape({
                latitude: PropTypes.number.isRequired,
                longitude: PropTypes.number.isRequired,
                latitudeDelta: PropTypes.number.isRequired,
                longitudeDelta: PropTypes.number.isRequired
            }),
            userPosition: PropTypes.bool
        })
    }

    constructor(props) {
        super(props);

        this.state.region = {
            latitude: LATITUDE,
            longitude: LONGITUDE,
            latitudeDelta: LATITUDE_DELTA,
            longitudeDelta: LONGITUDE_DELTA
        };

        if (props.options.region) {
            this.state.region = {
                ...this.state.region,
                ...props.options.region
            }
        }

        this.state.marker = null;

        if (props.value && props.value.latitude !== null && props.value.longitude !== null) {
            this.state.region.latitude = props.value.latitude;
            this.state.region.longitude = props.value.longitude;

            this.state.marker = {
                coordinate: {
                    latitude: props.value.latitude,
                    longitude: props.value.longitude
                }
            }
        }

        this.onMapPress.bind(this)
    }

    /**
     * Add our marker on the map
     *
     * @param e  - the onClick event from the map
     */
    onMapPress(e) {
        this.setState({
            marker: {
                coordinate: e.nativeEvent.coordinate
            },
            value: e.nativeEvent.coordinate
        });
    }

    /**
     * Update our marker and value state when we drag the marker
     *
     * @param e  - the event
     */
    onMarkerDragEnd(e) {
        this.setState({
            marker: {
                coordinate: e.nativeEvent.coordinate,
            },
            value: e.nativeEvent.coordinate
        });
    }

    /**
     * Get the user's position and update the region state if necessary
     */
    componentDidMount() {
        if (this.props.options.userPosition) {
            navigator.geolocation.getCurrentPosition(
                (position) => {
                    // If the user has set a marker already, there is no need to change the region position
                    if (!this.state.marker) {
                        this.setState(
                            {
                                region: {
                                    latitude: position.coords.latitude,
                                    longitude: position.coords.longitude,
                                    latitudeDelta: 0.0922,
                                    longitudeDelta: 0.0421,
                                }
                            }
                        );
                    }
                },
                (error) => console.log(error),
                {enableHighAccuracy: false, timeout: 20000, maximumAge: 1000}
            );
        }
    }

    shouldComponentUpdate(nextProps, nextState) {
        var should = super.shouldComponentUpdate(nextProps, nextState)

        if (should) {
            return true;
        }

        should = (
            nextState.region !== this.state.region
        );

        return should;
    }

    getTemplate() {
        return (locals) => {
            var stylesheet = locals.stylesheet;
            var controlLabelStyle = stylesheet.controlLabel.normal;

            if (locals.hasError) {
                controlLabelStyle = stylesheet.controlLabel.error;
            }

            var label = locals.label ? <Text style={controlLabelStyle}>{locals.label}</Text> : null;
            return (
                <View style={{marginLeft: -20, marginRight:-20}}>
                    {label}
                    <View style={styles.mapContainer}>
                        <MapView style={styles.map}
                                 region={this.state.region}
                                 onRegionChange={(region) => this.setState({region})}

                                 onPress={(e) => this.onMapPress(e)}
                        >
                            {(() => {
                                if (this.state.marker) {
                                    return <MapView.Marker
                                        key={this.state.marker.key}
                                        onDragEnd={(e) => this.onMarkerDragEnd(e)}
                                        coordinate={this.state.marker.coordinate}
                                        pinColor={this.state.marker.color}
                                        draggable
                                    />
                                }
                            })()}
                        </MapView>
                    </View>
                </View>
            );
        }
    }
}

var styles = StyleSheet.create({
    mapContainer: {
        height: 200,
        justifyContent: 'flex-end',
        alignItems: 'stretch',
    },
    map: {
        ...StyleSheet.absoluteFillObject,
        backgroundColor: '#000'
    },
});

export default PositionOnMap;

I don't like the fact that I have styles in here. Maybe I should move them to the stylesheet, but then the person who does the stylesheet should know about them and I have some hardcoded values in there. But for now I can live with it :)

compojoom commented 8 years ago

@alvaromb @gcanti - could you please share some light on something. My Factory renders a map for a position field. The map relies on some additional parameters to properly display - such as region. If I don't have a region I'm setting a default one.

Now I'm trying to pass a region to my factory.

<Form
                        ref="form"
                        type={FieldModel.model}
                        options={FieldModel.options}
                        value={this.state}
                        onChange={this.onChange.bind(this)}
                    />

this.state has a region value. But this value never arrives in the Factory?

What options am I supposed to pass in order for my Factory to see the region?

this is my form definition:

// Our company model
var model = t.struct({
    id: t.maybe(t.String),
    name: t.String,
    position: t.struct({
        latitude: t.Number,
        longitude: t.Number
    })
});

var options = function () {
    return {
        stylesheet: stylesheet,
        auto: 'none',
        fields: {
            id: {
                hidden: true
            },
            name: {
                placeholder: 'Feldname',
                underlineColorAndroid: 'transparent',
            },

            position: {
                label: 'Feld Position',
                factory: PositionOnMap,
                userPosition: true
            },

        }

    }
};

If I pass a region in the options configuration, then my factory is picking it up, but I don't have the region here. I have it first in my react component when I try to render the form??? How are you dealing with such situations?

gcanti commented 8 years ago

but I don't have the region here

Not sure I understand, why not? Aren't options a function of the value / state?

var options = function (value) {
  return {
    ...
    position: {
      label: 'Feld Position',
      factory: PositionOnMap,
      userPosition: true,
      region: someFunctionOf(value)
    },
    ...
  }
}
compojoom commented 8 years ago

@gcanti wow! Thank you! I seem to have been on a trip of some kind. Of course you are right.

I have my model and option definitions in a different file. And for some reason I ignored the fact that options is a function and when I render the form I can pass it some parameters...

Thank you! I can't believe how easy this was and how I hard I made it seem :D