1.4.0 • Published 1 year ago

mobx-binder v1.4.0

Weekly downloads
23
License
MIT
Repository
github
Last release
1 year ago

= mobx-binder :toc: :toc-placement!:

This library provides a convenient way of handling form state in a https://reactjs.org/[React] + https://mobx.js.org/[MobX] web app.

The API roughly reflects the great https://vaadin.com/docs/v10/flow/binding-data/tutorial-flow-components-binder.html[Binder API of the Vaadin Framework], while strongly relying on MobX features.

It is written in https://www.typescriptlang.org/[TypeScript] and has first class support for TypeScript code, but should also work in ES 5+ environments.

toc::[]

== Concept

A link:packages/mobx-binder-core/src/model/binder/Binder.tsBinder manages form state for a set of fields represented by "dumb" observable field instances implementing the link:packages/mobx-binder-core/src/model/fields/FieldStore.tsFieldStore interface. It is configured via a fluent api like this:

source,js

import { DefaultBinder, TextField, EmailValidator } from 'mobx-binder' import { MomentConverter } from 'mobx-binder-moment' import { TranslateFunction } from 'react-mobx-i18n'

export default class ProfileStore { public salutation = new TextField('salutation') // <1> public fullName = new TextField('fullName') public dateOfBirth = new TextField('dateOfBirth') public email = new TextField('email') public phoneNumber = new TextField('phoneNumber')

public binder: DefaultBinder

constructor(private personStore: PersonStore,
            t: TranslateFunction) {

    this.binder = new DefaultBinder({ t }) // <2>
    this.binder
        .forStringField(this.salutation).isRequired().bind() <3>
        .forStringField(this.fullName).isRequired().bind()

        .forStringField(this.dateOfBirth).withConverter(new MomentConverter('DD.MM.YYYY')).bind()

        .forStringField(this.email)
            .isRequired()
            .withAsyncValidator(
                (value) => sleep(1000).then(() => EmailValidator.validate()(value)),
                { onBlur: true })
            .onChange(() => {
                console.info('Email changed')
            })
            .bind()

        .forField(this.phoneNumber).bind()

}

}

<1> These fields store all state needed to render the corresponding field view. All the properties are observable. <2> The Binder needs a translation function. We are using https://github.com/jverhoelen/react-mobx-i18n[react-mobx-i18n] which provides a compatible implementation. <3> Each field get's registered with the Binder, configuring the validator and converter chain as neccessary.

== Getting Started

If you decide for using mobx-binder, there are some flavors to choose from:

Binder:: The core Binder tries to be as independent as possible of validation and translation frameworks. If you already have some of those frameworks in use, you might want to use only the core components and integrate it.

SimpleBinder:: This binder does not care about localization. Use this you don't need translations or your validation framework of choice already provides translated error messages. SimpleBinder is part of the mobx-binder-core module

DefaultBinder:: The DefaultBinder already makes assumptions about the translation library, which should support ES2015 templating style keys and named arguments, like https://www.npmjs.com/package/i18n-harmony[i18n-harmony] and https://github.com/jverhoelen/react-mobx-i18n[react-mobx-i18n]. For that scenario, it already provides a set of ready-to-use Validators and Converters. All this is part of the mobx-binder module.

To illustrate the power of the framework, most examples in this documentation are using the DefaultBinder.

== API

=== FieldStore

The properties and functions provided for each field are typically used by the React frontend components to render their state and make updates.

They are described on the link:packages/mobx-binder-core/src/model/fields/FieldStore.tsFieldStore interface.

=== Binder

The Binder API is typically used from inside MobX stores.

The properties and functions provided are described on the link:packages/mobx-binder-core/src/model/binder/Binder.tsBinder class.

== Binder configuration

To instantiate a Binder, we need a BinderContext containing a translation function. This is needed for conversion and validation error messages. The translation function has to conform to the API of react-mobx-i18n.

source,js

import { DefaultBinder } from 'mobx-binder' ...

const binder = new DefaultBinder({ t: i18nStore.translate })

To add fields to the binder, we just use the fluent API to "bind" fields:

source,js

import { DefaultBinder, TextField } from 'mobx-binder' ... public fullName = new TextField('fullName') ...

binder.forField(fullName).bind()

After a bind or bind2 call, more fields can be added:

source,js

public fullName = new TextField('fullName') public email = new TextField('email') ... binder .forField(fullName).bind()

.forField(email).bind()

=== Loading and storing field values

==== ...using bind()

The 'bind()` method binds the value of a form field to a property named like the field name:

source,js

public fullName = new TextField('fullName') ... binder.forField(fullName).bind()

// loading from object binder.load({ fullName: 'Max Mustermann' }) // => fullName.value === 'Max Mustermann'

// storing to object const values = binder.store() // values === { fullName: 'Max Mustermann' }

// storing to existing object const values = { foo: 'bar' }

binder.store(values) // => values == { foo: 'bar', fullName: 'Max Mustermann' }

==== ...using bind2()

The bind() command is a shorthand for a call to bind2, which just stores a (converted and validated) field value to a backing object using a property named like the field. But it's also possible to bind using more complex read and write callbacks:

source,js

public fullName = new TextField('fullName') ... binder.forField(fullName).bind2( source => source.businessRelation.person.fullName, (target, newValue) => target.businessRelation.person.fullName = newValue) )

const account = { businessRelation: { person: { fullName: 'Max Mustermann' } } }

// loading account data into fields binder.load(account) // => fullName.value === 'Max Mustermann'

// updating account data

binder.store(account) // => account.businessRelation.person.fullName === 'Max Mustermann'

=== Handling changes

When you load() data, all the field values get a new value, which is internally stored as "unchanged". Only if the field value is changing via an updateValue() operation, the changed property on field level gets true.

You can get a backend object only filled with data that has been changed via the Binder.changedData getter.

In combination with the Binders apply() method it's possible to find changes between two sets of data:

source,js

public fullName = new TextField('fullName') public email = new TextField('email') ... binder .forStringField(fullName).bind() .forStringField(email).bind()

// loading from object binder.load({ fullName: 'Max Mustermann', email: 'max.mustermann@codecentric.de' })

// applying new set of data as field changes binder.apply({ fullName: 'Max Mustermann-Musterfrau', email: 'max.mustermann@codecentric.de' })

// binder.changedData returns { fullName: 'Max Mustermann-Musterfrau' }

=== Validation

For every field, we can specify validations to be done:

source,js

binder.forField(fullName).isRequired().withValidator(EmailValidator.validate()).bind()

Validations are processed in order of method calls - so in this example, it is first checked if the required validation fails, and if it does, no further validation will happen.

To see the list of already supported validations, take a look into the mobx-binder/src/validation/ folder. You can also easily define your own custom validator, as long as it implements the Validator type.

The isRequired() validation has the special side effect that the required property is set on the field, so that the rendering component can highlight it.

Only valid field values are written to an object via binder.store().

=== Asynchronous validation

If validation incurs expensive calculations or a backend request, it's possible to do it asynchronously:

source,js

binder .forStringField(fullName) .withAsyncValidator((value) => sleep(1000).then(() => EmailValidator.validate()(value)))

.bind()

In contrast to synchronous validation, the async validation expects to get back a Promise of the validation result. As this is a more expensive validation, it does not happen on every change of the field value, but only on submission. If you want an additional check on blur, you can configure this like so:

source,js

.withAsyncValidator(myAsyncValidator, { onBlur: true })

Only field values where asynchronous validation has been successfully finished are written to an object via binder.store().

=== Conditional validation

Sometimes, the validation of one field depends on the value of another field. In this case, we can trigger the validation via an onChange event handler of that other field.

source,js

public salutation = new TextField('salutation') // <1> public fullName = new TextField('fullName')

binder .forStringField(salutation) .onChange(() => binder.getBinding(fullName).validate() .forField(fullName) .withValidator(someValidatorDependingOnValueOf(salutation))

.bind()

onChange events will only be fired if all validators specified before have been succeeding.

=== Conversion

As with validators, converters can also be added to the binding chain:

source,js

import { MomentConverter, MomentValidators } from 'mobx-binder-moment' ... binder.forStringField(fullName) .isRequired() .withConverter(new MomentConverter('DD.MM.YYYY')) .withValidator(Validators.dayInPast())

.bind()

Converters have to fullfill the following interface:

source,js

interface Converter<_ValidationResult, ViewType, ModelType> { convertToModel(value: ViewType): ModelType convertToPresentation(data: ModelType): ViewType isEqual?(first: ModelType, second: ModelType): boolean

}

A conversion is only tried if previous validations succeeded. A converter may fail if the value is not convertible, which means that Converters also act as validators.

Validators that are added after a converter will act on the already converted value. The API of Binder makes use of TypeScript generics to make sure that a Validator can only be applied to a matching data type.

Converters are bidirectional - that means that on loading values into the form, they are converted back into a string representation. Also, when a to-model conversion has been successful, the resulting value is passed back to convertToPresentation and the rendered field value is updated.

When a simple equality check via === for the converted ModelType is not sufficient, the converter also has to implement isEqual. This is required for the changed property and other internal optimizations.

Please not that by default empty string field values are not any more converted automatically to undefined. Instead, one can use binder.forField().withStringOrUndefined() or simply binder.forStringField() to configure the same behaviour explicitly.

=== Asynchronous conversion

As for the async validation, there might be cases where a conversion is done remotely and needs to be asynchronous. One example could be to contact some external service that validates phone numbers and also brings these numbers into some common format.

source,js

binder .forStringField(fullName) .withAsyncConverter(verifyAndPrettifyPhoneNumberConverter)

.bind()

Asynchronous converters have to fullfill the following interface:

source,js

subs="verbatim,quotes"

interface AsyncConverter<_ValidationResult, ViewType, ModelType> { convertToModel(value: ViewType): Promise convertToPresentation(data: ModelType): ViewType isEqual?(first: ModelType, second: ModelType): boolean

}

The convertToModel method then is expected to return the validation result or reject with a ValidationError. As with the async validation, this does not happen on every change of the field value, but only on submission. If you want an additional check on blur, you can configure this like so:

source,js

.withAsyncValidator(myAsyncValidator, { onBlur: true })

When the conversion has been successful, the resulting value is passed back to convertToPresentation and the rendered field value is updated.

Only field values where asynchronous conversion has been successfully finished are written to an object via binder.store().

== Conditional visibility

If a field should be hidden as part of a value change of a different field, it may become necessary to remove that field from the Binder completely, especially if it's value is currently invalid and would prevent a form submission:

source,js

binder.removeBinding(fullName)

This updates the global validation status based on the fields that are left.

== Submission

If the submit button of a form is clicked, this may trigger a binder.submit() call. Just like binder.store(), it stores the form field values into an object, but it also waits for asynchronous validations to be finished and maintains submission state.

source,js

public handleSubmit() { return this.binder.submit() .then(() => / success /) .catch(() => / validation error /)

}

The submit() methods maintains a binder.submitting property, indicating that submission of the form is still in progress. To make use of it, asynchronous follow actions have to be specified as parameter, so that the binder can still indicate submission as long as the server request is still ongoing.

source,js

public handleSubmit() { return this.binder.submit({}, results => this.sendResultsToServer(results)) .catch(() => / validation or other submission error /)

}

If a field related validation error occurs, the err.message is empty, es it may contain some "global" error message.

== Rendering a form

For rendering a form, best practice is to create form field wrapper components.

Please also see the link:packages/sample/src/app/forms/FormField.tsxexample implementation which integrates with https://reactstrap.github.io/[reactstrap].

== Development

The project is using https://github.com/lerna/lerna[lerna] for multipackage repository support.

=== Initial setup


npm install

npm run bootstrap

=== Full Build


npm run build

=== Start sample application


cd packages/sample

npm start

=== Clean Release (without pull request)

.First merge master into the branch and check that the branch is running fine

git merge master npm run reinitialize npm run build npm run lint

npm run test

.Merge the branch back into develop

git checkout master

git merge

.and perform the release

npm run version

npm run publish

== TODOs

  • Add coverage to the pipeline
  • Create more re-usable validators
  • Create integration components vor various open source React component libraries (contributions are welcome ;-)
1.4.0-alpha.0

1 year ago

1.4.0-alpha.1

1 year ago

1.4.0-alpha.2

1 year ago

1.4.0

1 year ago

1.3.1

1 year ago

1.3.0

2 years ago

1.2.0

3 years ago

1.1.1

3 years ago

1.1.0

3 years ago

1.0.0

3 years ago

0.6.1

3 years ago

0.6.0

3 years ago

0.5.0

3 years ago

0.4.0

5 years ago

0.3.1

5 years ago

0.3.0

5 years ago

0.2.8

5 years ago

0.2.7

5 years ago

0.2.6

5 years ago

0.2.5

5 years ago

0.2.4

5 years ago

0.2.3

5 years ago

0.2.2

5 years ago

0.2.1

5 years ago

0.2.0

5 years ago

0.1.1

5 years ago

0.1.0

5 years ago