Skip to content

Yunus Emre Dilber

Rails Nested Forms

Ruby on Rails3 min read

Giriş

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_management
2cd course_management

Modellerin oluşturulması

Ö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:text
2rails g model Subject course:references title description is_published:boolean

Modellerin aralarındaki işilkileri tanımlayıp, basit birkaç kontrol yazabiliriz:

app/models/course.rb
1class Course < ApplicationRecord
2 # Associations
3 has_many :subjects, dependent: :destroy
4
5 # Validations
6 validates_presence_of :title, :description
7end

app/models/subject.rb
1class Subject < ApplicationRecord
2 # Associations
3 belongs_to :course
4
5 # Validations
6 validates_presence_of :title, :description
7 validates :is_published, inclusion: { in: [true, false] }
8end

Değişikliklerimizi veritabanımıza işlemeyi unutmayalım:

1rails db:migrate

Modellerin test edilmesi

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.

test/models/course_test.rb
1require 'test_helper'
2
3class CourseTest < ActiveSupport::TestCase
4 test 'course should be valid' do
5 course = courses(:one)
6 assert course
7 course.subjects.delete_all
8 assert course.valid?
9 end
10
11 test 'course should be invalid' do
12 course = courses(:one)
13 course.title = nil
14 assert_not course.valid?
15 course = courses(:two)
16 course.description = nil
17 assert_not course.valid?
18 end
19end

test/models/subject_test.rb
1require 'test_helper'
2
3class SubjectTest < ActiveSupport::TestCase
4 test 'subject should be valid' do
5 assert subjects(:one)
6 end
7
8 test 'subject should be invalid' do
9 subject = subjects(:one)
10 subject.title = nil
11 assert_not subject.valid?
12 subject = subjects(:two)
13 subject.description = nil
14 assert_not subject.valid?
15 end
16
17 test 'subject\'s is_published should be boolean' do
18 subject = subjects(:one)
19 subject.is_published = nil
20 assert_not subject.valid?
21 subject.is_published = false
22 assert subject.valid?
23 end
24
25 test 'subject should have a course' do
26 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 end
31end

Bundan sonra test komutumuzu çalıştırıp, modellerimizin istediğimiz gibi çalıştığından emin olabiliriz.

1rails test

accepts nested attributes for kullanımı ve controllerın olışturuması

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:

app/models/course.rb
1class Course < ApplicationRecord
2 # Associations
3 has_many :subjects, dependent: :destroy, inverse_of: :course
4 accepts_nested_attributes_for :subjects
5
6 # Validations
7 validates_presence_of :title, :description
8end

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:

app/controllers/courses_contoller.rb
1class CoursesController < ApplicationController
2 before_action :set_course, only: %i[show edit update destroy]
3
4 def index
5 @courses = Course.includes(:subjects).all
6 end
7
8 def show; end
9
10 def new
11 @course = Course.new
12 @course.subjects.build
13 end
14
15 def edit; end
16
17 def create
18 @course = Course.new(courses_params)
19 if @course.save
20 redirect_to courses_path(@course),
21 notice: 'Course was successfully created.'
22 else
23 render :new
24 end
25 end
26
27 def update
28 if @course.update(courses_params)
29 redirect_to course_path(@course),
30 notice: 'Course was successfully updated.'
31 else
32 render :edit
33 end
34 end
35
36 def destroy
37 @course.destroy
38 redirect_to courses_path,
39 notice: 'Course was successfully destroyed.'
40 end
41
42 private
43
44 def set_course
45 @course = Course.includes(:subjects).find(params[:id])
46 end
47
48 def courses_params
49 params.require(:course).permit(:title, :description, subjects_attributes: %i[id title description is_published])
50 end
51end

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:

config/routes.rb
1Rails.application.routes.draw do
2 root 'courses#index'
3 resources :courses
4end

Görünümlerin oluştutulması

Artık asıl sihirin oluştuğu kısımlara geçebiliriz.

Index sayfasında, standart bir şekilde kurslarımızı listeliyoruz:

app/views/courses/index.html.erb
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:

app/views/courses/new.html.erb
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:

app/views/courses/_form.html.erb
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:

app/views/courses/_subjects_form.html.erb
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.

Uygulamanın çalışır hali

Son olarak eksik kalan show ve edit sayfalarını da hızlıca bitirelim:

app/views/courses/show.html.erb
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) %>

app/views/courses/edit.html.erb
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:

Nested formlarda silme işlemi

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:

app/models/course.rb
1class Course < ApplicationRecord
2 # ...
3 accepts_nested_attributes_for :subjects, allow_destroy: true
4 #...
5end

Daha sonra kulanıcının, konuyu silebilmesi için _destroy adında bir check_box ekleyelim:

app/views/courses/_subjects_form.html.erb
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:

app/controllers/courses_contoller.rb
1class CoursesController < ApplicationController
2 # ...
3 def courses_params
4 params.require(:course).permit(:title, :description, subjects_attributes: %i[id title description is_published _destroy])
5 end
6end

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.

Dinamik nested forms

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:

app/views/courses/_form.html.erb
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:

app/javascript/controllers/nested-forms_controller.js
1import { Controller } from 'stimulus'
2
3export default class extends Controller {
4
5 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.

Sonuç

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:

  1. Kullanıcı kursu oluşturur.
  2. Kursun edit sayfasında, (veya show) konuları insert eder (Ajax isteği atan 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.

Kaynaklar