— Ruby on Rails — 3 min read
Nested (iç içe geçmiş) formlar, önceden gördüğüm ama pratikte hiç uygulamadığım bir konuydu. Yakın zamanda, bulunduğum bir projede kullanma şansım oldu. Uyguladıktan sonra gördüm ki, hem ortaya çıkan kod çok temiz oldu; hem de beni, farklı bir yöntem kullandığımda karşıma çıkacak bir çok problemden kurtardı. Sonuç olarak hem kendim, hem de bir şeyler katabileceğim sizler için bu yazıyı yazmaya karar verdim. Umarım keyifli bir yolculuk olur.
Eğer hazırsak, 'Course Management' isimli kurs yönetimi projemizi oluşturarak başlayalım:
1rails new course_management2cd course_management
Örnek projemizi olabildiğince basit tutarak, iki tane model oluşturalım: Course ve Subject.
Kullanıcı, kursu oluştuturken kursun konularını da hızlıca eklemek isteyebilir.
Bu da bizi tam istedğimiz çıkmaza getiriyor.
Daha oluşmamış Course modelimimz varken ona nasıl Subject ekleyebiliriz?
Bu sorunun cevabını birazdan birlikte göreceğiz.
Şimdilik kendmizi bu konudan soyutlayıp modellerimizi oluşturalım.
1rails g model Course title description:text2rails g model Subject course:references title description is_published:boolean
Modellerin aralarındaki işilkileri tanımlayıp, basit birkaç kontrol yazabiliriz:
1class Course < ApplicationRecord2 # Associations3 has_many :subjects, dependent: :destroy45 # Validations6 validates_presence_of :title, :description7end
1class Subject < ApplicationRecord2 # Associations3 belongs_to :course45 # Validations6 validates_presence_of :title, :description7 validates :is_published, inclusion: { in: [true, false] }8end
Değişikliklerimizi veritabanımıza işlemeyi unutmayalım:
1rails db:migrate
Yazımızla birinci dereceden alakalı olmasa da, test yazmak yeterince önemsenmediği için bu başlığı eklemeyi uygun gördüm. Bir iki tane de olsa, test yazmak bize bir şey kaybettirmez. Yinede, eğer isterseniz bu kısmı atlayabilirsiz.
1require 'test_helper'23class CourseTest < ActiveSupport::TestCase4 test 'course should be valid' do5 course = courses(:one)6 assert course7 course.subjects.delete_all8 assert course.valid?9 end1011 test 'course should be invalid' do12 course = courses(:one)13 course.title = nil14 assert_not course.valid?15 course = courses(:two)16 course.description = nil17 assert_not course.valid?18 end19end
1require 'test_helper'23class SubjectTest < ActiveSupport::TestCase4 test 'subject should be valid' do5 assert subjects(:one)6 end78 test 'subject should be invalid' do9 subject = subjects(:one)10 subject.title = nil11 assert_not subject.valid?12 subject = subjects(:two)13 subject.description = nil14 assert_not subject.valid?15 end1617 test 'subject\'s is_published should be boolean' do18 subject = subjects(:one)19 subject.is_published = nil20 assert_not subject.valid?21 subject.is_published = false22 assert subject.valid?23 end2425 test 'subject should have a course' do26 subject = Subject.new(title: 't', description: 'd', is_published: true)27 assert_not subject.valid?28 subject.course = courses(:one)29 assert subject.valid?30 end31end
Bundan sonra test komutumuzu çalıştırıp, modellerimizin istediğimiz gibi çalıştığından emin olabiliriz.
1rails test
Artık asıl sorumuza geçebiliriz.
Daha oluşmamış Course modelimimz varken ona nasıl Subject ekleyebiliriz?
Bu sorunn cevabını Rails bize Building Complex Forms kısmında veriyor.
Birbirleri arasında one-to-one veya one-to-many ilişki bulunduran modeller arasında, iç içe geçmiş formlar kullanarak, aynı anda birden fazla işikili modeli oluşturabilir ve güncelleyebilir, hatta silebiliriz.
Şimdi biz de, buradaki işlemleri kendi senaryomuzda uygulayalım.
Öncelikle course modelimize, artık subject modeli için değerler alabilceğini söyleyelim:
1class Course < ApplicationRecord2 # Associations3 has_many :subjects, dependent: :destroy, inverse_of: :course4 accepts_nested_attributes_for :subjects56 # Validations7 validates_presence_of :title, :description8end
Burada eklediğimiz accepts_nested_attributes_for :subjects
satırı bize, subject modeli için nested parametreler alabileceğimizi gösteriyor.
inverse_of: :course
kısmı içinse basitçe, yaşayabilceğimiz karışıklıkları önlemek için bir önlem diyebiliriz.
inverse_of kullanımının gerekliliği hakkında güzel bir örneği Bi-directional Associations kısmında bulabilirsiniz.
Model kısmında işimiz bittiğine göre, controllerımızı oluşturarak işe başlayalım:
1class CoursesController < ApplicationController2 before_action :set_course, only: %i[show edit update destroy]34 def index5 @courses = Course.includes(:subjects).all6 end78 def show; end910 def new11 @course = Course.new12 @course.subjects.build13 end1415 def edit; end1617 def create18 @course = Course.new(courses_params)19 if @course.save20 redirect_to courses_path(@course),21 notice: 'Course was successfully created.'22 else23 render :new24 end25 end2627 def update28 if @course.update(courses_params)29 redirect_to course_path(@course),30 notice: 'Course was successfully updated.'31 else32 render :edit33 end34 end3536 def destroy37 @course.destroy38 redirect_to courses_path,39 notice: 'Course was successfully destroyed.'40 end4142 private4344 def set_course45 @course = Course.includes(:subjects).find(params[:id])46 end4748 def courses_params49 params.require(:course).permit(:title, :description, subjects_attributes: %i[id title description is_published])50 end51end
Görüldüğü gibi gayet standart bir controller. Dikkatimizi vermemiz gereken iki tane kısım var:
Parametrelerde subjects_attributes diye bir array alıyoruz (courses_params metodunda). Arrayin içindeyse, tahmin edebileceğiniz gibi subjectin parametreleri var.
New metodunun içinde @course.subjects.build
satırı var.
Bu satır bize, course ile ilişkili bir şekilde oluştuacağımız subject için boş bir alan oluşturacaktır.
View'i oluştuturken bu alan üzerinden gideceğiz.
Son olarak routelerimizi girelim:
1Rails.application.routes.draw do2 root 'courses#index'3 resources :courses4end
Artık asıl sihirin oluştuğu kısımlara geçebiliriz.
Index sayfasında, standart bir şekilde kurslarımızı listeliyoruz:
1<h1>Courses</h1>2<% @courses.each do |course| %>3 <h2><%=course.title %></h2>4 <p><%=course.description %></p>5 <div>6 <% course.subjects.each do |subject| %>7 <h3><%= subject.title %></h3>8 <p><%= subject.description %></p>9 <p>published: <%= subject.is_published.to_s%></p>10 <% end %>11 </div>12<% end %>13<%= link_to 'new', new_course_path %>
New sayfasında, edit sayfasıyla ortak olarak kullanacağımız form partial'ını render ediyoruz:
1<h1>New Course</h1>2<%= render 'form', course: @course %>
Formumuzda, her zaman yaptıklarımızdan hariç olarak; form.fields_for
denilen metodu kullanacağız.
fields_for, parametre olarak verdiğimiz kaynak için, önceden aldığımız build kadar form üretmemizi sağlıyor.
Daha önceden controllerda kullandığımız @course.subjects.build
satırı sayesinde, bir adet subject formu üretebileceğiz:
1<%= form_with(model: course, local: true) do |form| %>2 <% if course.errors.any? %>3 <% course.errors.full_messages.each do |message| %>4 <li><%= message %></li>5 <% end %>6 <br/>7 <% end %>8 <div class="form-group">9 <%= form.label :title %>10 <%= form.text_field :title %>11 </div><br/>12 <div class="form-group">13 <%= form.label :description %>14 <%= form.text_field :description %>15 </div>16 <h3>Subjects:</h3>17 <div class="field">18 <%= form.fields_for :subjects do |subjects_form| %>19 <%= render 'subjects_form', form: subjects_form %>20 <% end %>21 </div>22 <%= form.submit %>23<% end %>
Eğer 3.times { @course.subjects.build }
deseydik, üç adet subject formu oluşacağına dikkat ediniz.
Son olarak, kurs formumuzda kullandığımız partial olan konu formumuzun patial'ını yazıyoruz:
1<div>2 <div class="form-group">3 <%= form.label :title %>4 <%= form.text_field :title %>5 </div><br/>6 <div class="form-group">7 <%= form.label :description %>8 <%= form.text_field :description %>9 </div><br/>10 <div class="form-check">11 <%= form.check_box :is_published %>12 <%= form.label :is_published %>13 </div><br/>14</div>
Artık aşağıdaki gibi mütevazı bir form görebiliriz.
Son olarak eksik kalan show ve edit sayfalarını da hızlıca bitirelim:
1<h1><%=@course.title %></h1>2<p><%=@course.description %></p>3<div>4 <% @course.subjects.each do |subject| %>5 <h2><%= subject.title %></h2>6 <p><%= subject.description %></p>7 <p>published: <%= subject.is_published.to_s%></p>8 <% end %>9</div>10<%= link_to 'back', courses_path %>11<%= link_to 'edit', edit_course_path(@course) %>
1<h1>Edit Course</h1>2<%= render 'form', course: @course %>3<%= link_to 'back', courses_path %>
Evet artık nested formları tamamladık. Konular için herhangi bir ek route ve controller kullanmadan işimizi oldukça basit bir şekilde hallettik. Aynı zamanda kullanıcı, ek bir sayfaya gitmeden hızlıca işlemlerini tamamladı. Yani hem koddan, hem de kullanıcı deneyiminden kazandık.
Şimdi işin daha ayrıntılı kısımlarına geçebiliriz:
Silme işlemi de built-in olarak gelen çözümlerden bir tanesi.
accepts_nested_attributes_for
metoduna allow_destroy parametresi true olarak geçildiğinde, güncellenen kayıtların _destroy
alanı kontrol edilecek, seçili gelmesi durumunda ilgili kayıt silinecektir.
İlk olarak kurs modelimize allow_destroy: true
parametresini ekleyelim:
1class Course < ApplicationRecord2 # ...3 accepts_nested_attributes_for :subjects, allow_destroy: true4 #...5end
Daha sonra kulanıcının, konuyu silebilmesi için _destroy
adında bir check_box ekleyelim:
1<div>2 <!-- ... -->3 <div class="form-check">4 <%= form.check_box :_destroy %>5 <%= form.label :_destroy %>6 </div><br/>7</div>
Son olarak controllerımızda _destroy
alanına izin verelim:
1class CoursesController < ApplicationController2 # ...3 def courses_params4 params.require(:course).permit(:title, :description, subjects_attributes: %i[id title description is_published _destroy])5 end6end
Artık konularımızı da silebiliyoruz. Konular derken şimdiye kadar hep bir adet konu üzerinden ilerledik. Artık bu soruna dinamik bir çözüm getirmenin zamanı geldi.
Rails, malesef bu kadar falza şeye destek verse de, Adding Fields on the Fly
diye tabir ettiği şeye (yani dinamik olarak, client'da nested form oluşturmamıza) built-in bir çözüm sunmuyor.
Bizim senaryomuz için bu, forma koyacağımız bir Konu Ekle
butonu demek.
Rails, yinede bize bir çıkış noktasını Rails Guides 'da sunuyor.
Bu çıkış noktasına göre biz de, Stimulus kullanarak bir client çözümü uygulayalım.
İlk olarak Stimulusu kuralım:
1bundle exec rails webpacker:install:stimulus
Kurs formunu aşağıdaki gibi değiştirelim:
1<!-- ... -->2<div data-controller="nested-forms">3 <h3>Subjects:</h3>4 <div class="field">5 <%= form.fields_for :subjects do |subjects_form| %>6 <%= render 'subjects_form', form: subjects_form %>7 <% end %>8 <% subject_form = form.fields_for(:subjects,9 Subject.new,10 child_index: 'new_field') do |subject_form|11 render('subjects_form', form: subject_form)12 end %>13 <%= button_tag('Add Subject',14 data: { action: 'nested-forms#add',15 nested_forms_form: subject_form }) %>16 </div>17</div>18<!-- ... -->
Değiştirmek istediğimiz minimum alanı sarmalayan bir div oluşturduk ve nested-forms
adını verdiğmiz bir Stimulus controllerına işaret ettik.
Ek olarak, konu eklemek için bir buton oluşturduk. Şimdi Stimulus controllerımızı yazalım:
1import { Controller } from 'stimulus'23export default class extends Controller {45 add(event) {6 event.preventDefault();7 let form = event.target.dataset.form;8 form = form.replace(/new_field/g, new Date().getTime().toString());9 event.target.insertAdjacentHTML('beforebegin', form);10 }11}
Burada da yaptığımız şey kendini belli ediyor.
Tek dikkat etmemiz gerekn şey, form elemanlarının keylerinin unique olması.
Yeni render ettiğimizde new_field
olarak gelen ksımları, o anki zamanla eşleştirerek unique olduklarından emin oluyoruz.
Burdaki unique yapma metodu tabiki iyileştirilebilir.
Ama bu haliyle oldukca iş göreceğini düşünüyorum.
Zaten server tarafında, id uygun bir şekiilde oluşturulacaktır.
Nested form yapısının, hayatı kolaylaştıran şeylerden biri olduğunu düşünüyorum. Ama, her durumda kullanmak için uygun değil.
Mesela konular, kurslarda ortak olarak kullanılsaydı (arada join table ile), aşağıdaki gibi bir yapı daha mantıklı olurdu:
Ekle
butonları ile gerçekleştirilebilir.)Bu senaryonun nested formlar ile yapılmış hali, yukarıda anlattığım alternatifi varken pek de mantıklı olmuyor :/
Ama yinede, kullanıcıdan kayıt sırasında adres almak, cevaplarıyla birlikte soru oluşturmak, varyasyonları ile birlikte ürün oluşturmak gibi akla gelebilecek bir çok alanda, hayatı kolaylaştıran bu özelliği aklımızın bir köşesinde tutmamızda fayda var.
Umarım keyifli bir yolculuk olmuştur. Eşlik ettiğiniz için teşekkür ederim.