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

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.

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

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

1 Like

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.