Turbo ilə Rails-də Optimistik UI Qurma

Bu məqalə əvvəlcə Rails Designer bloqunda dərc edilmişdir.

Bir müddət əvvəl sizə custom elements istifadə edərək optimist UI necə yaradılır göstərmişdim. Əla işləyirdi! Siz də belə düşündünüz, geniş şəkildə paylaşıldı (minlərlə nəfər tərəfindən oxundu görüldü!).

Amma bir şey məni narahat edirdi. Custom element wrapper əlavə mərasim kimi hiss olunurdu. Bəs əlavə markup olmadan eyni ani əks-əlaqəni əldə edə bilsəydim? Sadəcə bir form, bir neçə data atributu (Rails developerları data atributlarını ❤️ edir) və bir az (xüsusi) JavaScript? 😊

Təxmin edin nə oldu? Edə bilərsiniz! Və daha da sadədir. 🎉

Belə bir şey (xeyr, həqiqətən, bu custom elements məqaləsindəki eyni gif deyil):

Kod GitHub-da mövcuddur (son commit-ə baxın).

Custom element yanaşması belə görünürdü:

<optimistic-form>
  <form action="<%= messages_path %>" method="post">
    <%%= text_area_tag "message[content]", nil, placeholder: "Write a message…", required: true %>

    <%= submit_tag "Send" %>
  </form>

  <template response>
    <%= render Message.new(content: "", created_at: Time.current) %>
  </template>
</optimistic-form>

Həmin <optimistic-form> wrapper əlavə markup-dır. Template onun içində yaşayır. Custom element-i təyin etməli, qeydiyyatdan keçirməli, onun lifecycle-ını idarə etməlisiniz. O qədər də pis deyil, amma tam olaraq yüngül deyil.

Bəs formu özünü optimist kimi işarələyə bilsəniz?

Data atributları əlavə etmək

Yeni versiya belə görünür:

<%= form_with model: @message,
              data: {
                optimistic: true,
                optimistic_target: "messages",
                optimistic_template: "message-template",
                optimistic_position: "prepend"
              } do |form| %>
  <%= form.text_area :content, placeholder: "Write a message…", required: true %>

  <%= form.submit "Send" %>
<%% end %>

<template id="message-template">
  <%= render Message.new(content: "", created_at: Time.current) %>
</template>

<div id="messages">
  <%= render @messages %>
</div>

Sadəcə bəzi data atributları olan adi bir form. Template ayrıca yaşayır (istənilən yerə qoya bilərsiniz). Hər şey data atributları vasitəsilə açıq şəkildə göstərilir.

JavaScript data-optimistic="true" ilə işarələnmiş istənilən formda Turbo-nun submit-start hadisəsini dinləyir. Hadisə baş verdikdə, template-i klonlayır, form məlumatları ilə doldurur və sonra hədəfə daxil edir. Əla, elə deyilmi?!

Bəs bu versiya necə işləyir? Sadəcə adi, köhnə bir JavaScript class-ı, həqiqətən!

// app/javascript/optimistic_form.js
class OptimisticForm {
  static start() {
    document.addEventListener("turbo:submit-start", (event) => this.#startSubmit(event))
    document.addEventListener("turbo:submit-end", (event) => this.#endSubmit(event))
  }

  // private

  static #startSubmit(event) {
    const form = event.target

    if (!this.#isOptimistic(form)) return
    if (!form.checkValidity()) return

    const formData = new FormData(form)
    const element = this.#build({ form, with: formData })

    this.#insert({ element, into: form })
  }

  static #endSubmit(event) {
    const form = event.target

    if (!this.#isOptimistic(form)) return

    form.reset()
  }

  static #isOptimistic(form) {
    return form.dataset.optimistic === "true"
  }

  static #build({ form, with: formData }) {
    const template = this.#findTemplate(form)
    const element = template.content.cloneNode(true).firstElementChild

    this.#populate({ element, with: formData })

    return element
  }

  static #findTemplate(form) {
    const selector = form.dataset.optimisticTemplate

    return document.getElementById(selector)
  }

  static #populate({ element, with: formData }) {
    for (const [name, value] of formData.entries()) {
      const field = element.querySelector(`[data-field="${name}"]`)

      if (field) field.textContent = value
    }
  }

  static #insert({ element, into: form }) {
    const target = this.#findTarget(form)
    const position = form.dataset.optimisticPosition || "append"

    if (position === "prepend") {
      target.prepend(element)
    } else {
      target.append(element)
    }
  }

  static #findTarget(form) {
    const selector = form.dataset.optimisticTarget

    return document.getElementById(selector)
  }
}

OptimisticForm.start()

export default OptimisticForm

(onu entrypoint-inizdə import etməyi unutmayın)

Bəs bu class necə işləyir? Tamamilə statikdir. Instance-lara ehtiyac yoxdur (əgər bu sizə yad səslənirsə, JavaScript for Rails Developers kitabına baxmağı təklif edirəm). O, Turbo ilə işləyən hadisələr üçün iki dinləyici qurur: turbo:submit-startturbo:submit-end.

Form göndərildikdə, data-optimistic="true" olub-olmadığını yoxlayın. Əgər yoxdursa, ignore edin. Əgər varsa, form məlumatlarını götürün, template-i klonlayın, sahələri doldurun və hədəfə daxil edin.

#build metodu bu ağır işi görür:

static #build({ form, with: formData }) {
  const template = this.#findTemplate(form)
  const element = template.content.cloneNode(true).firstElementChild

  this.#populate({ element, with: formData })

  return element
}

#populate metodu form məlumatları üzərində dövr edir və uyğun data-field atributu olan istənilən elementi yeniləyir:

static #populate({ element, with: formData }) {
  for (const [name, value] of formData.entries()) {
    const field = element.querySelector(`[data-field="${name}"]`)

    if (field) field.textContent = value
  }
}

Bu custom element versiyasından eyni texnikadır. Partial-ınızda doldurmaq istədiyiniz elementlərdə data-field atributları olmalıdır:

<article class="message" id="<%= dom_id(message) %>">
  <p data-field="message[content]"><%= message.content %></p>

  <small><%= message.created_at.strftime("%Y/%m/%d") %></small>
</article>

#insert metodu mövqeləşdirməni idarə edir. Prepend və ya append (default) edə bilərsiniz:

static #insert({ element, into: form }) {
  const target = this.#findTarget(form)
  const position = form.dataset.optimisticPosition || "append"

  if (position === "prepend") {
    target.prepend(element)
  } else {
    target.append(element)
  }
}

Sonra göndərmə tamamlandıqdan sonra formu sıfırlamaq üçün #endSubmit metodu var. Bu ani əks-əlaqə verir. İstifadəçi mesaj yazır, göndər düyməsinə basır, mesaj siyahıda görünür və form təmizlənir. Hamısı server cavab verməzdən əvvəl. ⚡

Bunu Turbo Stream ilə idarə edə bilərdiniz, amma JavaScript-də saxlamaq daha təmiz hiss olunur. Bu optimist UX-in bir hissəsidir, ona görə də optimist koda aiddir.

JavaScript-i yaxşı yazmaq haqqında

Bu implementasiyada xoşuma gələn bir şey adlandırılmış parametrlərdir:

const element = this.#build({ form, with: formData })

this.#insert({ element, into: form })
this.#populate({ element, with: formData })

Bunlar real cümlələr kimi oxunur. 🤓 "Form məlumatları ilə element yarat." "Elementi forma daxil et." "Elementi form məlumatları ilə doldur." JavaScript destructuring bunu mümkün edir:

static #build({ form, with: formData }) {
  // …
}

with: formData sintaksisi parametrin adını with-dən (rezerv edilmiş söz) funksiya daxilində formData-ya dəyişir. Kiçik bir detaldır, amma kodu daha oxunaqlı edir.

Bu barədə JavaScript for Rails Developers kitabımda yazıram.

Niyə statik metodlar?

Niyə hər şeyin statik olduğunu düşünə bilərsiniz. Niyə instance yaratmırsınız? Bunu edə bilərdiniz:

static start() {
  document.addEventListener("turbo:submit-start", (event) => {
    if (form.dataset.optimistic === "true") new OptimisticForm(event.target)
  })
}

constructor(form) {
  this.form = form
  // … göndərməni idarə et
}

Amma bu istifadə halı üçün instance-lar çox fayda olmadan mürəkkəblik əlavə edir. Biz state idarə etmirik. Birdən çox göndərməni izləmirik. Sadəcə bir az DOM manipulyasiyası edirik və irəliləyirik.

Statik metodlar sadə saxlayır. Class həqiqətən əlaqəli funksiyalar üçün sadəcə bir namespace-dir. Və bu normaldır! Hər şeyin instance olması lazım deyil.


İstifadə halları custom element ilə eynidir, amma bu həll daha Rails-ə oxşar hiss olunur: formunuza data-optimistic="true" əlavə edin, onu template və hədəfə yönləndirin və yarışa başlayın.

Olduqca əla, elə deyilmi? Əgər sınasanız və ya suallarınız varsa aşağıda bildirin! ❤️