Stimulus Discourse

How to use Stimulus to create a real-time preview?


#1

Hey folks,

I’m looking to build a simple form building interface where a user would edit attributes for form inputs (like label, placeholder, etc) and have them be represented in real-time in a form preview.

The created forms are rendered (currently) by rails when users share the link to the finished form, or embed with an iframe.

If I build this interface with Angular/React/Vue, then I pretty much have to rewrite the form display logic in that framework too, which I’d prefer not to do, as I like my rails templates as-is.

Has anyone done something like this with Stimulus? If so how?
I’d assume I’d need to use Stimulus along with some sort of DOM binding library?

Thanks for any direction.


#2

Hey there,

If I face this task I’ll do it following way:

  • create controller and assign it to form or element few levels upper
  • render blank preview on server and set data-target's on it
  • on inputs add data-action's and handle all logic in controller, like on input->your_controller#update_label

#3

I have a signu form, here is the code for it (if you have any improvements ideas, they are welcome !)

import {Controller} from 'stimulus';

export default class extends Controller {
  static targets = [
    'name', 'nameLabel', 'nameError',
    'email', 'emailLabel', 'emailError',
    'password', 'passwordLabel', 'passwordError',
    'passwordConfirmation', 'passwordConfirmationLabel', 'passwordConfirmationError',
    'form']

  get validName() {
    return this.nameTarget.checkValidity();
  }

  showNameValidation() {
    if (this.validName) {
      this.nameLabelTarget.classList.remove('hidden');
      this.nameErrorTarget.classList.add('hidden');
    } else {
      this.nameLabelTarget.classList.add('hidden');
      this.nameErrorTarget.classList.remove('hidden');
      this.nameErrorTarget.innerHTML = 'name is required';
    }
  }

  get validEmail() {
    return this.emailTarget.checkValidity();
  }

  showEmailValidation() {
    if (this.validEmail) {
      this.emailLabelTarget.classList.remove('hidden');
      this.emailErrorTarget.classList.add('hidden');
    } else {
      this.emailLabelTarget.classList.add('hidden');
      this.emailErrorTarget.classList.remove('hidden');
      this.emailErrorTarget.innerHTML = 'invalid email';
    }
  }

  get validPassword() {
    return this.passwordTarget.checkValidity();
  }

  showPasswordValidation() {
    if (this.validPassword) {
      this.passwordLabelTarget.classList.remove('hidden');
      this.passwordErrorTarget.classList.add('hidden');
    } else {
      this.passwordLabelTarget.classList.add('hidden');
      this.passwordErrorTarget.classList.remove('hidden');
      this.passwordErrorTarget.innerHTML = 'Minimum password length is 6 characters';
    }
  }


  get validPasswordConfirmation() {
    return this.passwordConfirmationTarget.checkValidity() &&
      this.passwordConfirmationTarget.value === this.passwordTarget.value;
  }

  showPasswordConfirmationValidation() {
    if (this.validPasswordConfirmation) {
      this.passwordConfirmationLabelTarget.classList.remove('hidden');
      this.passwordConfirmationErrorTarget.classList.add('hidden');
    } else {
      this.passwordConfirmationLabelTarget.classList.add('hidden');
      this.passwordConfirmationErrorTarget.classList.remove('hidden');
      this.passwordConfirmationErrorTarget.innerHTML = 'Does not match password';
    }
  }

  get valid() {
    return this.validName &&
      this.validEmail &&
      this.validPassword &&
      this.validPasswordConfirmation;
  }

  showValidations() {
    this.showNameValidation();
    this.showEmailValidation();
    this.showPasswordValidation();
    this.showPasswordConfirmationValidation();
  }

  submit(event) {
    event.preventDefault();
    this.emailTarget.value = this.emailTarget.value.trim().toLowerCase();
    this.showValidations();
    if (this.valid) this.formTarget.submit();
  }
}
<%=
  form_for @changeset,
  registration_path(@conn, :create),
  [
    {:as, :registration},
    {:class, "form-validate login-form"},
    {:"data-controller", "signup-form"},
    {:"data-target", "signup-form.form"}
  ],
  fn f -> %>
  <div class="panel panel-body border-white no-panel-shadow">
    <div class="content-divider text-muted form-group"><span>Sign up</span></div>
    <div class="form-group has-feedback has-feedback-right">
      <label data-target="signup-form.nameLabel">
        Name: <span class="text-danger">*</span>
      </label>
      <label
        class="text-danger-700 pull-right"
        data-target="signup-form.nameError"
      >
        <%= error_tag f, :name %>
      </label>
      <%=
        text_input f,
        :name,
        [
          {:class, "form-control"},
          {:"data-target", "signup-form.name"},
          {:required, true},
          {:autofocus, true},
          {:"data-action", "input->signup-form#showNameValidation"}
        ]
      %>
      <div class="form-control-feedback" style="display: none;">
        <i class="icon-cross2 text-danger-700"></i>
      </div>
    </div>
    <div class="form-group has-feedback has-feedback-right">
      <label data-target="signup-form.emailLabel">
        Email: <span class="text-danger">*</span>
      </label>
      <label
        class="text-danger-700 pull-right"
        data-target="signup-form.emailError"
      >
        <%= error_tag f, :email %>
      </label>
      <%=
        email_input f,
        :email,
        [
          {:class, "form-control"},
          {:"data-target", "signup-form.email"},
          {:required, true},
          {:"required pattern", ~S'^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)+$' },
          {:"data-action", "input->signup-form#showEmailValidation"}
        ]
      %>
    </div>
    <div class="form-group has-feedback has-feedback-right">
      <label data-target="signup-form.passwordLabel">
        Password: <span class="text-danger">*</span>
      </label>
      <label
        class="text-danger-700 pull-right"
        data-target="signup-form.passwordError"
      >
        <%= error_tag f, :password %>
      </label>
      <%=
        password_input f,
        :password,
        [
          {:class, "form-control"},
          {:"data-target", "signup-form.password"},
          {:required, true},
          {:minlength, "6"},
          {:"data-action", "input->signup-form#showPasswordValidation"}
        ]
      %>
    </div>
    <div class="form-group has-feedback has-feedback-right">
      <label data-target="signup-form.passwordConfirmationLabel">
        Confirm password: <span class="text-danger">*</span>
      </label>
      <label
        class="text-danger-700 pull-right"
        data-target="signup-form.passwordConfirmationError"
      >
        <%= error_tag f, :password_confirmation %>
      </label>
      <%=
        password_input f,
        :password_confirmation,
        [
          {:class, "form-control"},
          {:"data-target", "signup-form.passwordConfirmation"},
          {:required, true},
          {:"data-action", "input->signup-form#showPasswordConfirmationValidation"}
        ]
      %>
    </div>

    <div class="form-group login-options">
      <div class="row">
        <div class="col-sm-6">
          <label class="checkbox-inline">
            <input type="checkbox" id="remember" class="styled" name="remember" checked="checked">
            Remember
          </label>
        </div>

        <div class="col-sm-6 text-right">
          <a href="/passwords/new">Forgot password?</a>
        </div>
      </div>
    </div>

    <div class="form-group">
      <%=
        submit dgettext("coherence", "Sign up"),
        [
          {:class, "btn btn-primary btn-block"},
          {:"data-action", "click->signup-form#submit"}
        ]
      %>
    </div>

    <div class="content-divider text-muted form-group"><span>Already signed up?</span></div>
    <a href="/sessions/new" class="btn btn-default btn-block content-group">Login</a>
    <span class="help-block text-center no-margin">By continuing, you're confirming that you've read our <a href="#">Terms &amp; Conditions</a> and <a href="#">Cookie Policy</a></span>
  </div>
<% end %>

The html part is in a templating language, so you’ll have to convert that to html, but it’s pretty straightforward. Let me know if anything doesn’t make sense


#4

For those interested – I made a prototype, but actually ended up abandoning the stimulus version in favor of a simple Vue.js app.

There was so much rendering logic involved that using Vue + a Rails API ended up being much more simple.