View source | Discuss this page | Page history | Printable version   
Toolbox
Main Page
Upload file
What links here
Recent changes
Help

PDF Books
Add page
Show collection (0 pages)
Collections help

Search

StateController

Contents

Introduction

Openbravo now includes a new component designed to handle the application state, called StateController.

This component is essential as it includes APIs designed to implement all future business logic inside our mobile applications. Therefore, it is very important that every developer learns the main principles behind this new component and our new APIs, so that he can develop new models and actions correctly.

Main purpose of the StateController

The StateController is designed to store the transaction-related application state. In the WebPOS, this includes current draft tickets, cashup information, and document sequence information. It has been implemented for the main purpose of allowing developers to implement business logic related models and actions in a simple way, but also in a way that makes them fool-proof, predictable and reliable.

In Openbravo, modularity is an essential concept because our customers require specific functionality, and therefore it is also designed to be very extensible, so that modules can add new models, actions to existing models, and additional logic to existing actions.

Design principles of the StateController

The main design principles of the StateController are the following:

Together with these principles, developers should also take the following rule into account when implementing business logic processes:

Differences with the previous way to handle state in the WebPOS

There are several key differences compared to the way the state was handled in the WebPOS before.

Persistence model

Previously the state in general was stored in WebSQL, and persistence was handled explicitly.

Developers would create Backbone models that corresponded to a table in WebSQL. Then they would mutate these records, and after each mutation they would need to remember to execute OB.Dal.save calls, or otherwise the records would not be saved in the database.

If developers would forget to execute these calls, and the user refreshed the application, then the latest information would be lost. This was a very common source of bugs.

Moreover, because of this, developers would sometimes become overeager and perform save calls too often. The save action is an expensive operation because the performance of the WebSQL when saving is not very good. This was also a very common source of performance-related bugs.

In the StateController, persistence is an internal implementation detail of the platform. Therefore, developers do not need to care about saving or updating the database.

The state is actually always kept, read, and manipulated in memory. The information in the state is only backed up in IndexedDB with the purpose of recovering it in case the user refreshes the application. This backup is updated when developers dispatch actions on the state, in an automatic way that ensures both the continuous update of the backup, and the correct throttling of this backup if many actions are quickly dispatched in a short time.

This way, we guarantee excellent performance when reading and updating the state (because this is performed in memory), we ensure developers don’t forget they need to back up the state (because this is handled automatically), and we also ensure that general performance of the application is optimised because we ensure we only save backups when needed.

Mutability vs Immutability

The new state is immutable. This means that you cannot directly change it. This means it works in a very different way compared to the old versions of the WebPOS.

Previously the state, which normally was stored in Backbone models, was designed to be mutated. For example, changing the document number of the ticket involved mutating it via a call to a setter in the Backbone model:

 
receipt.set(‘documentNo’, ‘VBS1/0000002);

The new StateController doesn’t allow developers to do this. The main reason for this is that we want to avoid many cases of bugs that happened due to the complexity of combining asynchronous logic with state mutations. This complexity lead to unpredictable behaviour of the application, and great difficulty for developers when trying to understand what was happening under the hood, and eventually, issues which were very hard to reproduce, but also very frustrating for our customers. You can read more about this problem here.

Now, instead of doing that, you will dispatch an action on the state:

 
OB.App.State.Ticket.setDocumentNumber({docNo: ‘VBS1/00000002});

This will dispatch an action on the state, which will produce a new state (in this case, a new version of the ticket with the new document number), and this new state will eventually replace the previous state.

This ensures that asynchronous code that was reading the previous state continues to work properly, and we don’t cause conflicts to other potential logic that may be under execution.

Backbone vs Redux

Previously we used the Backbone library, through its Backbone models and collections, to store state.

The new StateController internally uses Redux to store plan Javascript objects. This doesn’t mean you will use Redux directly through its actions and reducers. We have created an abstracted API through which you define models and actions which are extensible and well integrated in Openbravo. The infrastructure will then internally create the proper action and reducer combinations.

This said, as currently the WebPOS still uses Backbone heavily in its UI components, we have provided a compatibility layer for the Ticket, which ensures that the Backbone “receipt” object is still updated when changes in the state happen, and that automatically updates the state whenever the receipt is mutated.

However, our plan is to replace all business logic APIs we currently have with actions on the new state, and once the UI layer is replaced with new technology, we anticipate that the Backbone compatibility layer will be eventually removed.

As we are internally using Redux, you are free to use the Redux Developer Tools, which are very useful when debugging the application. With these tools, you can easily see which is the current state, and you can also navigate through all previous states, and see the list of actions that were dispatched, with their corresponding payloads.

StateController API

There are two main objects that are part of the StateController API:

Developers need to use the StateAPI object to add models and actions to the state definition, before the state object is initialized. After the state object has been initialized, developers are free to dispatch actions to transition to new states, but they can no longer register new models or actions.

Registering new models

Registering a new model only involves specifying the name of the model, and an initial state for this model:


 
  OB.App.StateAPI.registerModel('BusinessPartner', {});


In this case, we have created a new model with an empty initial state.

You can also register models with some values in their initial state:

 
  OB.App.StateAPI.registerModel('DocumentSequence', {
    order: 0,
    invoice: 0,
    quotation: 0
  });


You can also extend the initial state of the models:

 
  OB.App.StateAPI.extendModel('DocumentSequence', { simplifiedInvoice: 0 });

The state can be read simply by doing:

 
  const currentState = OB.App.State.getState();

The initial version of the state is computed from the composition of all the initial states of every registered model. This means that the initial state of the application would be the following:

 
{
    BusinessPartner: {},
    DocumentSequence: {
        order: 0,
        invoice: 0,
        quotation: 0,
        simplifiedInvoice: 0
    }
}

Non persisted models

Bulbgraph.png   This feature is available starting from 3.0RR20Q4.

By default the information of the state models is persisted in the browser's local database. It is possible for a model to override this behavior by using the isPersisted property when being registered:

 
  OB.App.StateAPI.registerModel('BusinessPartner', {}, { isPersisted: false });

With the above definition the BusinessPartner model information will not be persisted in the local database.

Registering and executing actions

As explained before, an action is implemented as a pure function, which receives a state and a payload, and returns a new state.

Actions can be registered for existing models in the state. For example, we can create a new action to set the values of the Business Partner model:

 
  OB.App.StateAPI.BusinessPartner.registerAction(
    'initialize',
    (bpartner, bpInfo) => {
      return { ...bpInfo };
    }
  );

Essentially, we are returning a new state, which is based on the content of the “payload” object, in this case named “bpInfo”. This is a very simple action, as we just want to set the basic properties of an object.

We can execute the initialize action by using the following command:

 
OB.App.State.BusinessPartner.initialize({name: 'Arturo', surname: 'Montoro', greeting: 'Mr.'})


Doing it will produce the following new state:

 
{
    BusinessPartner: {
        name: 'Arturo',
        surname: 'Montoro',
        greeting: 'Mr.'
    }
}


A slightly more interesting action would be one that allows us to update a document sequence. We will register now two more actions, this time in the Document Sequence model:


 
  OB.App.StateAPI.DocumentSequence.registerActions({
    incrementSequence: (docSeq, payload) => {
      let newDocSeq = { ...docSeq };
      newDocSeq[payload.sequence] = docSeq[payload.sequence] + 1;
      return newDocSeq;
    },
    decrementSequence: (docSeq, payload) => {
      let newDocSeq = { ...docSeq };
      newDocSeq[payload.sequence] = docSeq[payload.sequence] - 1;
      return newDocSeq;
    }
  });

Notice that we are never mutating any of the received parameters. We always generate a clone first, and then set properties there. Notice also how we always return a resulting state.

If we execute the increment action using the following command:

 
OB.App.State.DocumentSequence.incrementSequence({sequence: 'order'});

We will get the following resulting state:

 
{
    DocumentSequence: {
        order: 1,
        invoice: 0,
        quotation: 0,
        simplifiedInvoice: 0
    }
}

These are examples of actions on a single model. It is also possible to register “global” actions. These global actions receive the whole state object, and must generate a new global state object.

Sometimes it is necessary to define global actions, because the same action must set new values in several models at the same time. An example of a global action in the WebPOS is the “completeTicket” action, because this action affects the ticket and the cashup models at the same time.

Global actions are defined on the “Global” endpoint:

 
 
  OB.App.StateAPI.Global.registerAction('setNameAndSequence',
    (state, payload) => {
      let newState = { ...state };
      const { name, sequence, value } = payload;
 
      newState.DocumentSequence = { ...state.DocumentSequence };
      newState.DocumentSequence[sequence] = value;
      newState.BusinessPartner = { ...state.BusinessPartner };
      newState.BusinessPartner.name = name;
 
      return newState;
    }
  );

If called using the following command:

 
OB.App.State.Global.setNameAndSequence({name: 'Antonio', sequence: 'order', value: 5})

We will generate the following state:

 
{
    BusinessPartner: {
        name: 'Antonio',
        surname: 'Montoro',
        greeting: 'Mr.'
    },
    DocumentSequence: {
        order: 5,
        invoice: 0,
        quotation: 0,
        simplifiedInvoice: 0
    }
}


There are several important things to consider in this example:

Extending actions

In the previous examples we already showed how to extend the initial state of a model by using the “extendModel” command.

In previous versions of the WebPOS, we were manually adding hooks explicitly, using the HookManager component. However, this mechanism had several disadvantages, and as it had to be explicitly called, many actions still were not extensible.

The StateController automatically allows any developer to extend any model and action defined in the system, without the need to implement any specific code for it.

There are two main mechanisms for this, which will be explained now.

Action Hooks

Every action defined can be extended in a synchronous way, using pre and post action hooks.

These hooks need to be pure functions, because they will be composed with the main action function so that when a developer dispatches the action, everything is called together. Therefore, they cannot execute asynchronous calls, they can not produce side effects, and they cannot use any information other than what is received in the state and payloads. As with the main action function, their goal is to produce a new non-mutated state.

As an example, we are going to add a new property to the DocumentSequence model in a new module. This property will be the sum of all the sequences defined. As its value depends on the value of the sequences, we also need to update it whenever the sequences change.

First, we will extend the DocumentSequence model:

 
  OB.App.StateAPI.DocumentSequence.extendModel({ sequenceSum: 0 });

In this case, it is very easy, because the initial value will always be 0. The tricky part comes now: we need to ensure that whenever the sequences are updated, we also update this property. We will do this by adding a Post Hook to the increment and decrement actions.

However, the logic that must be followed in both cases is the same, we just need to sum all the sequences. Therefore, we will first register a utility function that we will then use in both cases:


 
  OB.App.StateAPI.DocumentSequence.registerUtilityFunctions({
    setSequenceTotal: docSeq => {
      let newDocSeq = { ...docSeq };
      const sum = Object.keys(docSeq)
        .filter(item => item !== 'sequenceSum')
        .reduce(
          (previousValue, currentSeq) => previousValue + docSeq[currentSeq],
          0
        );
      newDocSeq.sequenceSum = sum;
      return newDocSeq;
    }
  });

If you look in detail at the code, you will notice that we are just iterating through the properties in the docSeq object, ignoring the "sequenceSum" property, and just summing them using the reduce operator.

Given a DocumentSequence object, this function will return a new one, but with the property “sequenceSum”, correctly calculated as the sum of all sequences.

Now we will add post hooks to the increment, and the decrement actions, and we will use the utility function we just defined:

 
  OB.App.StateAPI.DocumentSequence.incrementSequence.addPostHook(
    (docSeq, payload) =>
     OB.App.State.DocumentSequence.Utils.setSequenceTotal(docSeq)
  );
  OB.App.StateAPI.DocumentSequence.decrementSequence.addPostHook(
    (docSeq, payload) =>
     OB.App.State.DocumentSequence.Utils.setSequenceTotal(docSeq)
  );


With these additions to our model definition, the initial state of the application will contain the following DocumentSequence model:

 
{
    DocumentSequence: {
        order: 0,
        invoice: 0,
        quotation: 0,
        simplifiedInvoice: 0,
        sequenceSum: 0
    }
}

If we then execute the following command:

 
OB.App.State.DocumentSequence.incrementSequence({sequence: 'order'});

We will get the following state:

 
{
    DocumentSequence: {
        order: 1,
        invoice: 0,
        quotation: 0,
        simplifiedInvoice: 0,
        sequenceSum: 1
    }
}

As you can see, both the order sequence, and the sequenceSum properties, were updated. If we execute it again, we get:

 
{
    DocumentSequence: {
        order: 2,
        invoice: 0,
        quotation: 0,
        simplifiedInvoice: 0,
        sequenceSum: 2
    }
}

If we then execute the following command:

 
OB.App.State.DocumentSequence.incrementSequence({sequence: 'quotation'});
 

We get:

 
{
    DocumentSequence: {
        order: 2,
        invoice: 0,
        quotation: 1,
        simplifiedInvoice: 0,
        sequenceSum: 3
    }
}

So, as a summary:

Action Preparations

The action hooks have great advantages, because as they are defined as pure functions, they are combined with the main hook action, and provide automatic “transactionality”, meaning that if any hook or the action itself fails, the whole process fails, but as the state was yet not modified, and we know that there were no side effects, no need to rollback anything is required, and the consistency of the data would not be affected. They can also very easily be tested using unit tests.

However, sometimes it is necessary to either execute asynchronous code to retrieve information required by the action, to validate the information specified in the payload, or the generation of some side-effects, especially if there is some integration with some external system. For this, we have created the concept of “Action Preparation”.

Action Preparations are functions executed before the main action (including its associated pre and post hooks). Action Preparations are different from the pre and post actions in the following ways:

Action Preparations can be used to include additional information later on required by the action, but can also be used to perform validations on the data, or to send information to external systems. They can generate side-effects, but other action preparations, or even the action itself, may fail later on, which means that in this case we would be leaving those side-effects even though the action itself was not completed. To prevent it, they can also declare a rollback mechanism, to remove those side-effects in case the whole action process fails.

To better explain how this works, we are going to define two Action Preparations on the initialize action of the Business Partner model.

The first action will validate that the taxId field of the business partner is correctly defined, using a call to some external webservice:


 
  OB.App.StateAPI.BusinessPartner.initialize.addActionPreparation(
    async (businessPartner, payload) => {
      const validationResult = await OB.App.Request.get(
        'someTaxIdValidatorURL',
        { taxId: payload.taxId }
      );
      if (validationResult === 'OK') {
        return payload;
      } else {
        throw new OB.App.Class.ActionCanceled('TaxId field it not correct');
      }
    },
    async (businessPartner, payload) => payload, //this is the rollback function, we don't need to rollback anything here as we don't generate any side-effect
    200  //this is the priority of the action preparation
  );

The function will basically perform some asynchronous request to check if the TaxId is correct, and if it is then we will return the payload (which these functions always need to do), and otherwise we will fail.

Notice that this function is defined as “async”. This means that it will be asynchronous, and it will be returning a promise. However, the code itself is not polluted with the traditionally verbose Promise syntax, and we definitely prefer this way of defining these functions.


Now, we are going to define a second action preparation. This one will save the business partner in an external system:


 
  OB.App.StateAPI.BusinessPartner.initialize.addActionPreparation(
    async (businessPartner, payload) => {
      const saveBpResponse = await OB.App.Request.post(
        'someBPRemoteDatabaseEndpoint',
        {
          bp: businessPartner
        }
      );
      if (saveBpResponse.result === 'OK') {
        let newPayload = { ...payload };
        newPayload.transactionId = saveBpResponse.transactionId;
        return newPayload;
      } else {
        throw new OB.App.Class.ActionCanceled('BP save failed');
      }
    },
    async (businessPartner, payload) => {
      //This is the rollback function. In this case, we need to rollback the save in the remote system, because something else failed
      let newPayload = { ...payload };
      const removeBpResponse = await OB.App.Request.post('removeBPEndpoint', {
        transactionId: transactionId
      });
      delete newPayload.transactionId;
      return payload;
    },
    100
  );
 

As you can see, this action preparation is a little bit different.

First, we are including new information in the payload (in this case, the transactionId). This is a good way of adding information coming from external systems, that later on will be used in the action implementation, via the action itself (as in this case, because the initialize action will automatically see this property and add it to the business partner), or via an action hook defined over the action.

Second, in this case we are generating a side-effect in an external system (in this case, the external service where we save the business partner). Therefore, we need to also provide a rollback mechanism to undo this side-effect in case the action itself fails.

This is crucial, because if you notice, we defined this action with a lower priority than the first one, and this means that it will be executed before it. Therefore, it might happen that the business partner is created in the external system, but then we validate the TaxId and realise that it is not correct. If this is the case, the action process will be cancelled, but it’s not a problem, because our rollback mechanism will trigger, and the business partner will be removed from the external system.

It is mandatory, whenever possible, to provide a rollback mechanism in all action preparations that may generate side-effects. Failing to do so may leave those side-effects in the external system in case any other action preparation, any action hook, or the action itself, fails.

Model Hooks

The mechanism described above are used to extend a single action, either by contributing in modifying the state (pre/post hooks), or adding data to the action payload (action preparations). However, there are code that we may want to execute equally thoughout all actions available for a single Model to retain consistency in the data contained in the model, or to perform additional operations. For example, for the Ticket model every time the ticket changes, we want to check for discounts and recalculate their taxes in order to maintain all ticket data consistent. If a developer forgets to add those steps when creating new Ticket actions, it may result in a Ticket with an inconsistent state, which is an undesiderable situation. To achieve this, the StateAPI class provides a function for each model to add a new hook at Model level. For example, assume we want to add a hook that must be executed for all Ticket actions:

OB.App.StateAPI.Ticket.addModelHook({
  hook: state => {
  // Recalculate discounts
  // Recalculate taxes
    return newState;
  }
});

The code registered above will be executed for all Ticket actions after all postHooks are executed and just before the state is saved in the state store. In more complex scenarios, we may require to process additional data to be used as input for the code executed in the hook. In order to separate both steps and keep a clear separation between initialization and the state processing itself, a generatePayload function is available to perform all this initialization and pass the result to the hook using a payload object. Using both functions, the hook declared above would be declared as the following:

OB.App.StateAPI.Ticket.addModelHook({
  hook: (state, payload) => {
    // Recalculate discounts using payload data
    // Recalculate taxes using payload data
    return newState;
  },
  generatePayload: state => {
    return {
      // return a payload with data
    };
  }
});

Note that both functions are synchronous, so we cannot use asynchronous functions both creating the payload or inside the hook.

Action execution

Hopefully, it should be clear at this point how an action is executed:

 
OB.App.State.DocumentSequence.incrementSequence({sequence: 'order'});

The developer just needs to call the corresponding endpoint defined automatically inside the “State” object, inside the corresponding model or in “Global” in the case of the global actions, and the payload must be provided as parameter for the called function.

As we just explained, this will not just call the pure function defined when the action was registered. In fact, a stack of items will be called:


View larger

As you can imagine, the whole process is in fact asynchronous, because Action Preparations can add asynchronously executed calls. Therefore, the main endpoint returns a promise, and you should take this fact into account in your code, mainly by awaiting for the result before doing anything else:


 
await OB.App.State.DocumentSequence.incrementSequence({sequence: 'order'});
console.log(this log will appear only after the action has been executed and a new state has been obtained’).

Backward compatibility

State models are a replacement for old Backbone models. By default State and Backbone models work independently. It is possible though to bind a Backbone model with a State model so that new states of one type are reflected in the other. The purpose is to provide a smother transition from old to new architecture. In this way it is possible, for example, to implement state transitions through actions as defined in this document, keeping old Backbone model synchronized and listening to its changes to reflect them in the UI as a transitory phase before its complete removal.

Once a backward state model is bound to a backbone model, state transition in any of them are propagated to the other. This propagation occurs synchronously.

This module implements a complete example on how to create a compatible state model and to bind it to a backbone model.

Note: this feature is meant mainly in the context of the WebPOS to provide a transition path so that the UI (which is heavily linked to Backbone models) doesn't need to be rewritten. However, most developers in practice will not want to register or declare models as backwards compatible. The recommended path is to have models using plain Javascript objects, and declare them as normal State models.

Registering backward compatible models

Backward compatible models are registered by OB.App.StateAPI.registerBackwardCompatibleModel function instead of by OB.App.StateAPI.registerModel.

 
  OB.App.StateAPI.registerBackwardCompatibleModel('BackwardCompatDemoTicket');

Once registered, the model can be used as any other state model.

Binding compatible models with Backbone models

Backward compatible models can be bound to a backbone model in this way:

 
OB.App.StateBackwardCompatibility.bind(
      OB.App.State.BackwardCompatDemoTicket,
      backboneModelTicket)

After the two models are bound, state transitions in one of them will be synchronously propagated to the other.

Additional options can be passed as a third parameter to bind function:

 
OB.App.StateBackwardCompatibility.bind(
      OB.App.State.BackwardCompatDemoTicket,
      backboneModelTicket,
      options)

Where options is a JavaScript object that can contain the following keys:

A complete bind example can be found here.

Limitations

Backward compatible models subscribe to backbone events emitted by their bound backbone model. This means backbone silent changes will not be propagated. If in this situation, compatible state can be reset to match current backbone state by triggering 'change' event (or any other of the resetEvents) without any other parameter.

Because of the same reason, if changes in backbone are performed not following standard conventions they will not be propagated. For example if a JavaScript Array is set as a property instead of a Backbone collection, backward compatible model will not notice element changes inside this array.

Retrieved from "http://wiki.openbravo.com/wiki/StateController"

This page has been accessed 1,373 times. This page was last modified on 17 September 2020, at 08:06. Content is available under Creative Commons Attribution-ShareAlike 2.5 Spain License.