3

On a rails 6 installation, I have the following:

Controller:

# app/controllers/foo_controller.rb
def bar
  @items = [["firstname", "{{ FIRSTNAME }}"], ["lastname", "{{ LASTNAME }}"], ["company", "{{ COMPANY }}"]]
end

View:

# app/views/foo/bar.html.erb
<p>Quia <span data-field="firstname">{{&nbsp;FIRSTNAME&nbsp;}}</span> quibusd <span data-field="firstname">{{&nbsp;FIRSTNAME&nbsp;}}</span> am sint culpa velit necessi <span data-field="lastname">{{&nbsp;LASTNAME&nbsp;}}</span> tatibus  s impedit recusandae modi dolorem  <span data-field="company">{{&nbsp;COMPANY&nbsp;}}</span> aut illo ducimus unde quo u <span data-field="firstname">{{&nbsp;FIRSTNAME&nbsp;}}</span> tempore voluptas.</p>

<% @items.each do |variable, placeholder| %>
<div data-controller="hello">
  <input
  type="text"
  data-hello-target="name"
  data-action="hello#greet"
  data-field="<%= variable %>"
  value="<%= placeholder %>">
</div>
<% end %>

and the relevant stimulus code (vanilla JS):

//app/javascript/controllers/hello_controller.js
import { Controller } from "stimulus"

export default class extends Controller {
  static targets = [ "name" ]

  greet() {
    var elements = document.body.querySelectorAll('[data-field="' + this.nameTarget.dataset.field + '"]');
    for (var i = 0; i < elements.length; i++) {
      elements[i].innerText = this.nameTarget.value;
    };
  }
}

Now, as you might have guessed, the idea is to generate one <input> field per item from the @items hash, pre-filled with the relevant value and "linked" with a <span>, which it updates on value change. So far, everything works.

Here's my issue though. This part is plain old dirty vanilla js, which doesn't feel too 'stimulusy':

var elements = document.body.querySelectorAll('[data-field="' + this.nameTarget.dataset.field + '"]');
for (var i = 0; i < elements.length; i++) {
  elements[i].innerText = this.nameTarget.value;
};

Surely there's some way to improve this. Any suggestion as to how to refactor this code in a more elegant way would be most welcome.

ggorlen
  • 44,755
  • 7
  • 76
  • 106

2 Answers2

4

An approach would be to have two controllers, one for the 'thing that will change the content' (let's call this content) and another for the 'thing that will show any updated content somewhere else' (let's call this output).

Once you set up two controllers, it becomes a bit easier to reason about them as being discrete. One does something when a value updates from user interaction and the other should so something when it knows about an updated value.

Stimulus recommends cross controller coordination with events. JavaScript event passing is a powerful, browser native, way to communicate across elements in the DOM.

First, let's start with the simplest case in HTML only

  • In general, it is good to think about the HTML first, irrespective of how the content is generated on the server side as it will help you solve one problem at a time.
  • As an aside, I do not write Ruby and this question would be easier to parse if it only had the smallest viable HTML to reproduce the question.
  • Below we have two div elements, one sits above and is meant to show the name value inside the h1 tag and the email in the p tag.
  • The second div contains a two input tags and these are where the user will update the value.
  • I have hard-coded the 'initial' data as this would come from the server in the first HTML render.
<body>
  <div
    class="container"
    data-controller="output"
    data-action="content:updated@window->output#updateLabel"
  >
    <h1 class="title">
      Hello
      <span data-output-target="item" data-field="name">Joe</span>
    </h1>
    <p>
      Email:
      <span data-output-target="item" data-field="email">joe@joe.co</span>
    </p>
  </div>
  <div data-controller="content">
    <input
      type="text"
      data-action="content#update"
      data-content-field-param="name"
      value="Joe"
    />
    <input
      type="text"
      data-action="content#update"
      data-content-field-param="email"
      value="joe@joe.co"
    />
  </div>
</body>

Second - walk through the event flow

  • Once an input is updated, it will fire the conten#update event on change.
  • The data-content-field-param is an Action Parameter that will be available inside the event.params given to the class method update on the content controller.
  • This way, the one class method has knowledge of the element that has changed (via the event) and the field 'name' to give this when passing the information on.
  • The output controller has a separate action to 'listen' for an event called content:updated and it will listen for this event globally (at the window) and then call its own method updateLabel with the received event.
  • The output controller has targets with the name item and each one has the mapping of what 'field' it should referent in a simple data-field attribute.

Third - create the controllers

  • Below, the ContentController has a single update method that will receive any fired input element's change event.
  • The value can be gathered from the event's currentTarget and the field can be gathered via the event.params.field.
  • Then a new event is fired with the this.dispatch method, we give it a name of updated and Stimulus will automatically append the class name content giving the event name content:updated. As per docs - https://stimulus.hotwired.dev/reference/controllers#cross-controller-coordination-with-events
  • The OutputController has a target of name item and then a method updateLabel
  • updateLabel will receive the event and 'pull out' the detail given to it from the ContentController's dispatch.
  • Finally, updateLabel will go through each of the itemTargets and see if any have the matching field name on that element's dataset and then update the innerText when a match is found. This also means you could have multiple 'name' placeholders throughout this controller's scoped HTML.
class ContentController extends Controller {
  update(event) {
    const field = event.params.field;
    const value = event.currentTarget.value;
    this.dispatch('updated', { detail: { field, value } });
  }
}

class OutputController extends Controller {
  static targets = ['item'];

  updateLabel(event) {
    const { field, value } = event.detail;

    this.itemTargets.forEach((element) => {
      if (element.dataset.field === field) {
        element.innerText = value;
      }
    });
  }
}

LB Ben Johnston
  • 4,751
  • 13
  • 29
2

An alternate approach is to follow the Publish-Subscribe pattern and simply have one controller that can both publish events and subscribe to them.

  • This leverages the recommended approach of Cross-controller coordination with events.
  • This approach adds a single controller that will be 'close' to the elements that need to publish/subscribe and is overall simpler to the first answer.

PubSubController - JS code example

  • In the controller below we have two methods, a publish which will dispatch an event, and a subscribe which will receive an event and update the contoller's element.
  • The value used by this controller is a key which will serve as the reference for what values matter to what subscription.
class PubSubController extends Controller {
  static values = { key: String };

  publish(event) {
    const key = this.keyValue;
    const value = event.target.value;
    this.dispatch('send', { detail: { key, value } });
  }

  subscribe(event) {
    const { key, value } = event.detail;

    if (this.keyValue !== key) return;

    this.element.innerText = value;
  }
}

PubSubController - HTML usage example

  • The controller will be added to each input (to publish) and each DOM element you want to be updated (to subscribe).
  • Looking at the inputs you can see that they have the controller pub-sub and also an action (defaults to triggering when the input changes) to fire the publish method.
  • Each input also contains a reference to its key (e.g email or name).
  • Finally, the two spans that 'subscribe' to the content are triggered on the event pub-sub:send and pass the event to the subscribe method. These also have a key.
<body>
  <div class="container">
    <h1 class="title">
      Hello
      <span
        data-controller="pub-sub"
        data-action="pub-sub:send@window->pub-sub#subscribe"
        data-pub-sub-key-value="name"
        >Joe</span
      >
    </h1>
    <p>
      Email:
      <span
        data-controller="pub-sub"
        data-action="pub-sub:send@window->pub-sub#subscribe"
        data-pub-sub-key-value="email"
        >joe@joe.co</span
      >
    </p>
  </div>
  <div>
    <input
      type="text"
      data-controller="pub-sub"
      data-action="pub-sub#publish"
      data-pub-sub-key-value="name"
      value="Joe"
    />
    <input
      type="text"
      data-controller="pub-sub"
      data-action="pub-sub#publish"
      data-pub-sub-key-value="email"
      value="joe@joe.co"
    />
  </div>
</body>
LB Ben Johnston
  • 4,751
  • 13
  • 29