Este es un blog-post para desarrolladores de JavaScript y sobre todo de Ember.js, es avanzado por lo cual supone que tienes conocimiento de ambas.

Una nota antes de empezar

Este será el primer blog que hago en español, considero que falta mucho contenido sobre tecnología en español y específicamente de Ember.js moderno, el cual forma parte de mi día a día como desarrollador. Para no forzar traducciones cuando complican el texto, voy a dejar un glosario al final cuando considere necesario, sin embargo si tienes alguna duda siempre puedes localizarme en twitter @betocantu93

¿Qué es Ember.js?

Bueno primero no me gustaría suponer que conoces Ember.js, Ember.js es un framework de JavaScript para construir aplicaciones web robustas y escalables, sus principales atractivos son:

  1. Baterías incluídas, un proyecto nuevo de Ember.js incluye entre muchas otras cosas:
  • Build pipeline (olvídate de estar configurando Rollup, Webpack o demás)
  • Un router
  • Data Layer (Ember Data, para manejar los datos en tu proyecto)
  • Testing
  • Performance
  1. Las convenciones, permiten desarrollar un proyecto web escalable de manera ordenada y predecible, un desarrollador de Ember.js puede brincar de proyecto en proyecto sin ningún problema ya que se sentirá en casa con código muy familiar.
  2. Addons, son como "plugins" que le puedes añadir a tu aplicación, paquetes de npm que gracias a las convenciones de Ember.js, pueden añadir código que aumente la funcionalidad de tu aplicación de manera asertiva, incremental y poderosa.
  1. Código Libre, Ember.js es desarrollado por la comunidad alrededor del 🌍, todos pueden contribuír y no hay ninguna empresa entorpeciendo con sus intereses.
  2. Fuerte versionamiento, Ember.js tiene un compromiso con no romper las aplicaciones de todos entre versiones ya que tiene un fuerte sistema de versionamiento y de ir removiendo código obsoleto, puedes estar seguro de que tu aplicación podrá migrar a nuevas versiones sin tener que reescribirla a através de los años.

Auto Save 💾

Pero, ¿qué es Auto Save?

Auto save es guardar un modelo o documento cada vez que ciertos eventos sucedan, esto tiene dos beneficios importantes

  1. Mejora la experiencia de usuario al no tener que estar dando clic en 💾 a cada cambio en documentos o formularios grandes
  2. Puede evitar el miedo de perder la información

Basta de intros, empecemos a ver código.

Nuestro modelo Person

import Model, { attr } from '@ember-data/model';

export default class PersonModel extends Model {
  @attr firstName;
  @attr lastName;
  @attr birthday;
}

Primero que nada necesitamos un componente para nuestro formulario básico tradicional y lo iremos poco a poco mejorando, en este caso estaré utilizando ember-paper por facilidad

{{! components/edit-person/index.hbs }}
<PaperForm @onSubmit={{@onSubmit}} as |Form|>
    <Form.input @onChange={{fn (mut @person.firstName)}} @value={{@person.firstName}} />
    <Form.input @onChange={{fn (mut @person.lastName)}} @value={{@person.lastName}} />
    <Form.input @onChange={{fn (mut @person.birthday)}} @value={{@person.birthday}} />

    <Form.on-submit>
      Guardar
     </Form.on-submit>
</PaperForm>

Bueno y ¿cómo lo uso? Muy fácil, hay que suponer que el this.save es una función en el controller que se encarga de hacer this.model.save

{{! templates/person.hbs}}
<EditPerson 
    @person={{this.model}} 
    @onSubmit={{this.save}}
/>

Ahora la primer cosa que puede mejorar un poco para hacer más fácil el trabajo con estas tribialidades como crear un controller para definir una acción this.save con this.model.save, utilizando ember-composable-helpers puedes simplemente utilizar el helper de invoke

{{! templates/person.hbs}}
<EditPerson 
    @person={{this.model}} 
    @onSubmit={{invoke "save" this.model}}
/>

Ahora, ¿cómo se vería nuestro componente de EditPerson si reemplazamos el botón de guardar por una función de autosave?

{{! components/edit-person/index.hbs }}
<PaperForm as |Form|>
    <Form.input 
        @onChange={{fn (mut @person.firstName)}} 
        @value={{@person.firstName}}
        @onBlur={{@autoSave}}
     />
    <Form.input 
        @onChange={{fn (mut @person.lastName)}} 
        @value={{@person.lastName}} 
        @onBlur={{@autoSave}}
  />
    <Form.input 
        @onChange={{fn (mut @person.birthday)}} 
        @value={{@person.birthday}}
        @onBlur={{@autoSave}}
   />
</PaperForm>

De esta manera, cuando el usuario salga del input (onBlur) se estará ejecutando una función de autoSave, esto deja al que invoca el componente la decisión de cómo hacer el autosave.

{{! templates/person.hbs}}
<EditPerson @person={{this.model}} @autoSave={{invoke "save" this.model}}/>

Esto funciona, pero qué tal si el usuario utilizara [ Tab ] para moverse por tu forma, estarías haciendo muchos {{invoke "save" this.model}} probablemente innecesarios, para eso vamos a introducir un nuevo concepto

El Componente AutoSave

Este componente nos ayudará a encapsular lógica de auto save y que vamos a poder "inyectarla" en cualquier template.

// components/auto-save/index.js
import GlimmerComponent from '@glimmer/component';
import { task, timeout } from 'ember-concurrency';

export default class AutoSaveComponent extends GlimmerComponent {  
  @(task(function(){
    yield timeout(500);
    try {
      return yield this.args.model.save();
    } finally {
      //Si hay un error, de permisos, por ejemplo.
      this.args.model.rollbackAttributes();
      //manera fácil de no tener que guardar track de el dirtinessde las relaciones
      this.args.model.reload();
    }
  }).keepLatest()) autoSaveTask;
}

Para evitar que nuestra función se dispare de maneras no controladas, podemos utilizar el addon ember-concurrency que de una manera muy declarativa nos deja implementar el patrón de debouncing, que obliga a que una función no sea llamada otra vez hasta que cierto tiempo haya pasado sin ser llamada otra vez, algo así como "Ejecuta esta función, solo si 500 milisegundos han pasado sin que se haya vuelto a llamar".

¿Pero cómo utilizar este componente? Necesitamos primero hacer yield de nuestra task en nuestro template

{{! components/auto-save/index.hbs }}
{{yield (perform this.autoSaveTask)}}

Finalmente, podemos utilizarlo con facilidad.

<AutoSave @model={{this.model}} as |autoSave|>
    <EditPerson @person={{this.model}} @autoSave={{autoSave}} />
</AutoSave>

Me gusta este patrón en el cual abstraemos una funcionalidad a un componente, de esa manera podemos reutilizar código sin tener que utilizar Mixins, son como Mixins pero en "Render", interesante.

Todavía tenemos algunos casos extremos a resolver

  1. Con el diseño actual, podemos estar ejecutando guardar aún y cuando no es necesario, ya que puede que no haya cambiado el valor, por lo tanto innecesario.
  2. ember-concurrency cancela todas las task cuando el objeto en el que viven es destruído. Como estamos manejando la creación de la task en un componente, hay un caso extremo en donde el usuario podría hacer cambios en el modelo y cambiarse de ruta por el algún botón de tu UI, mientras aún se está ejectuando la estrategia de debounce (esperando los 500ms) en la task, por ende puede ser que no se complete y guarde nuestro modelo lo que representa posible pérdida de información y es intolerable.

Para resolver el punto 1, podemos agregar un check antes del debouncing

// components/auto-save/index.js
import GlimmerComponent from '@glimmer/component';
import { task, timeout } from 'ember-concurrency';

export default class AutoSaveComponent extends GlimmerComponent {  
    /*
        @param {Boolean} checkIfDirtyAttributes Verificar si el modelo tiene cambios 
    */
  @(task(function(checkIfDirtyAttributes = true){
    if(
      checkIfDirtyAttributes && 
      this.args.model.get('hasDirtyAttributes')
    ) { 
      yield timeout(500);
      try {
        return yield this.args.model.save();
      } finally {
        //Si hay un error, de permisos, por ejemplo.
        this.args.model.rollbackAttributes();
        //manera fácil de no tener que guardar track de el dirtinessde las relaciones
        this.args.model.reload();
      }
    }
  }).keepLatest()) autoSaveTask;
}

Ember Data lleva un registro en todo momento si el atributo de un modelo cambió, pero no de si sus relaciones han cambiado, entonces si quieres guardar por que se cambió una relación, puedes ignorar los atributos, @onChange={{@autoSave false}}

Para resolver el punto 2, podemos mover el task del componente al modelo en sí.

import Model, { attr } from '@ember-data/model';
import { task, timeout } from 'ember-concurrency';

export default class PersonModel extends Model {
  @attr firstName;
  @attr lastName;
  @attr birthday;

  /*
        @param {Boolean} shouldRun puede o no ejecutarse?
    */
  @(task(function(shouldRun = function(){return true;}, checkIfDirtyAttributes = true){
    shouldRun = typeOf(shouldRun) === 'function' ? shouldRun(this) : shouldRun;

    if(!shouldRun) { return; }
    if(checkIfDirtyAttributes && !this.get('hasDirtyAttributes')) { return; }

    yield timeout(500);
    try {
      return yield this.save();
    } finally {
      //Si hay un error, de permisos, por ejemplo.
      this.rollbackAttributes();
      //manera fácil de no tener que guardar track de el dirtinessde las relaciones
      this.reload();
    }

  }).keepLatest()) autoSaveTask;
}

Como podemos ver, reemplazamos el this.args.model por this, porque ahora esta task vive en un modelo en sí, además para hacerla un poco más flexible y reutilizable, permitimos que nos envién una función que retorne un boolean o un boolean en sí para saber si correr o no el task.

Tenemos que actualizar nuestro componente AutoSave

// components/auto-save/index.js
import GlimmerComponent from '@glimmer/component';
import { task, timeout } from 'ember-concurrency';

export default class AutoSaveComponent extends GlimmerComponent {  
    get shouldRun() {
    /*
        Podríamos tener aquí cualquier validación extra, 
        por ejemplo, permisos de ember-can
    */
    return true;
  }
}
{{! components/auto-save/index.hbs }}
{{yield (perform @model.autoSaveTask this.shouldRun)}}

Al final, nuestro componente <AutoSave /> se puede decir que es un helper / mixin que funciona como middleware para ayudarnos a ejecutar la task en nuestro modelo.

Es un patrón algo complejo, pero permite agregar agregar fácil funcionalidad de autoSave a cualquier formulario de manera sencilla, ideal para aplicaciones modernas.