Skip to content

Instantly share code, notes, and snippets.

@jamesarosen
Created September 13, 2016 22:42
Show Gist options
  • Save jamesarosen/e2bb61b22e9575f8843a98f45ec2dc34 to your computer and use it in GitHub Desktop.
Save jamesarosen/e2bb61b22e9575f8843a98f45ec2dc34 to your computer and use it in GitHub Desktop.
Playing with an idea for Contexts for Ember Objects

Impetus

I have several components that have complex flows. For example, a "deploy this code" button. It might have the following flow:

  1. if the user is typing, the button is hidden
  2. after the user stops typing, the button appears, is disabled, and reads "Saving..."
  3. after the code is saved, the button is enabled and reads "Test & Deploy"
  4. after the user clicks the button, it is disabled and reads "Testing..."
  5. if the tests fail, the button remains disabled and reads "Tests Failed"
  6. if the tests pass, the button remains disabled and reads "Deploying..."
  7. when the deploy completes, the button changes to a "☑️ Deployed" indicator

Maintaining all of that information as a series of computed properties is error-prone and messy. Instead, I've relied on impromptu fake state machines like

STATES = {
  typing: { hidden: true },
  saving: { disabled: true, text: 'Saving...' },
  saved:  { text: 'Test & Deploy', onClick: 'runTests' },
  ...
}

export default Ember.Component.extend({
  buttonState: STATES.typing,
  
  actions: {
    click() {
      const onClick = this.get('buttonState.onClick');
      if (onClick) { return this.send(onClick); }
    }
  }
});

<button disabled={{buttonState.disabled}} hidden={{buttonState.hidden}}>{{buttonState.text}}</button>

There are a few things that bug me about this.

One: {{buttonState.disabled}} in the template. Does the template really need to know that that property is state-dependent? It's possible to solve that will an extra computed property, of course:

buttonDisabled: Ember.computed.reads('buttonState.disabled')

Two: the component is in charge of the state transitions. We've moved the definitions of which state is next into the states themselves, but left how to move there to the component. We could solve this by adding methods to the states and calling them in the context of the component:

saved:  {
  text: 'Test & Deploy',
  onClick() { this.set('buttonState', STATES.runTests); }
}

Of course, now the states need to know the name of the property that the component uses to refer to the current state. We can solve that by creating a real state machine.

According to Wikipedia's State Pattern article, a Context has a State, which has multiple ConcreteStates. In this sense, the component is the Context. That pattern only describes a handle() method, but that could be generalized to at least get() and trigger(), which is what ember-states offers.

We could create some computed property and action macros to help with this:

buttonState: stateMachine(BUTTON_STATES),
buttonDisabled: Ember.computed.reads('buttonState.currentState.disabled'),
buttonHidden: Ember.computed.reads('buttonState.currentState.hidden'),

actions: {
  click: stateMachineAction('buttonState')
}

I'm wary of including a library that hasn't been touched in a year.

There's a pure JavaScript State Machine as well. It doesn't have Ember bindings, though we could use those same macros to add necessary bindings. For example,

function stateMachine(definition) {
  return Ember.computed({
    get(propertyName) {
      definition = $.clone(definition, true);
      
      stateChanged = this.notifyPropertyChange.bind(this, propertyName);

      if (const originalOnafterevent = defintion.callbacks.onafterevent) {
        definition.callbacks.onafterevent = function() {
          originalOnafterevent();
          stateChanged();
        };
      } else {
        definition.callbacks.onafterevent = stateChanged;
      }
      
      return StateMachine.create(definition);
    }
  });
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment