Yalın JavaScript ile sıralanabilir tablo yapımı 👨‍💻

Bir süredir hap yazısı konsepti ile gidiyordum ancak bir yandan da bu bilgileri gerçek hayatta nerede kullanacağımıza dair de bir konsept oluşturup yazmak geçiyordu içimden. Bugüne kısmetmiş :)

Başlığı Türkçe yapayım diye kasmaktan “Yalın JavaScript” dedim ancak orijinalinin “Pure JavaScript” olduğunu ufak bir dipnot olarak geçmek isterim.

Hürriyet Sporarena sayfalarından birinde başlıklara basıldığında asc ya da desc şekidle sıralanabilecek bir tablodan oluşan bir komponent isteği geldi. Daha yeni bu sprinte aldık muhtemelen son halini görmeniz 2 haftayı alacak ancak ben yine de oluşturduğum POC halini sizlerle paylaşmak, satır satır şimdiye kadar anlattığım pek çok şeyi nasıl kullandığımı size göstermek istedim.

Bu tarz bir komponenti komple kendim ilk defa yazma fırsatım oldu o sebepten farklı yaklaşımlarınız, daha sade ve makul kodlarınız varsa yorum kısmında okumaktan keyif alırım. Tabii bir de alırım bir like’ınızı :)


Bu kadar girizgahtan sonra artık kodumuz başlasın :)

Başlık kısımları sabit olan bir tablom var. Data kısmında, örnek olacağı için 4 adet objeden oluşan bir dizim var. Bu dizideki bazı kısımlara göre sıralanabilecek bir tablo yapmam gerekiyor ve sayfada herhangi bir view kütüphanesi yok!

Fonksiyonlara genel olarak bakarsanız benim pek benimsediğim Single Responsibility Principle yani tek iş yapan fonksiyonlar prensibine göre yazdığımı göreceksiniz. Bu bana bir çok konuda kolaylık sağlıyor.

<table id="squadTable" class="table table-dark table-striped">
<thead>
<tr>
<th data-filter-value="no" class="active">No</th>
<th>Adı</th>
<th>Pozisyonu</th>
<th data-filter-value="age">Yaş</th>
<th data-filter-value="match">Maç</th>
<th data-filter-value="time">Dk</th>
<th data-filter-value="goal">Gol</th>
<th data-filter-value="assist">AST</th>
<th data-filter-value="yellowCard">SK</th>
<th data-filter-value="doubleYellowCard">ÇSK</th>
<th data-filter-value="redCard">KK</th>
</tr>
</thead>
<tbody>
        </tbody>
</table>

En sonda bir codepen linki paylaşacağım, o linkte güzel gözükmesi için bootstrap 4 dahil ettim projeye, bazı classlar ondan sebep var.

Örnek kod içerisinde koyu olarak belirttiğim alanlar, sıralama olacak alanlar. O kısımı sabit doldurduğum için değerlerini bir data key ile ekliyorum. Ad ve Pozisyon bilgileri için sıralama isteği yok o sebeple dahil etmiyorum.

th {
&[data-filter-value] {
cursor: pointer;
}
  &.active {
color: red;
}
}

CSS tarafında çok bir beklentim yok zaten bootstrap var ama ben yine de sıralamada aktif olan sütunu ve sıralama yapılabilecek sütunları göstermek için yukarıdaki kodları ekliyorum. (Scss seçtiğim için syntax bu şekilde)

Data kısmını içeren, objelerden oluşan şu şekilde bir dizim var:

const data = [{
"id": "1233213",
"no": 17,
"fullName": "Murat Doğan",
"position": "CMD",
"age": 29,
"match": 12,
"time": 1080,
"goal": 12,
"assist": 4,
"yellowCard": 3,
"doubleYellowCard": 0,
"redCard": 2
}, {
"id": "41233213",
"no": 58,
"fullName": "Volkan Demirel",
"position": "GK",
"age": 28,
"match": 34,
"time": 3400,
"goal": 1,
"assist": 17,
"yellowCard": 8,
"doubleYellowCard": 3,
"redCard": 5
}, {
"id": "51233213",
"no": 18,
"fullName": "Soldado",
"position": "ST",
"age": 33,
"match": 5,
"time": 120,
"goal": 1,
"assist": 2,
"yellowCard": 5,
"doubleYellowCard": 1,
"redCard": 4
}, {
"id": "61233213",
"no": 27,
"fullName": "Hasan Ali Kaldırım",
"position": "DL",
"age": 18,
"match": 2,
"time": 12,
"goal": 0,
"assist": 1,
"yellowCard": 3,
"doubleYellowCard": 4,
"redCard": 5
}];

id’ler ile bir işim yok ama belki olur diye oluşturdum, dummy data oluştururken :)

Şimdi global değişkenlerimi sıralıyorum. Buradan sonrası benim yoğurt yiyişim. Ama doğru ama yanlış bakalım :)

let currentFilter = "no",
prevFilter = "",
orderAsc = true;

İlk açılıştaki sıralama oyuncu numaraları üzerinden olacak o sebepten currentFilter’ı no diye belirtiyorum.

prevFilter, aynı filtre tekrar tekrar tıklandığında asc/desc değişikliğini yapmak için tuttuğum bir değişken. İlerleyen satırlarda ne işe yaradığını daha iyi göreceksiniz. Şu an için boş tabii ki.

orderAsc ise prevFilter ve currentFilter arasındaki koşullara göre değişecek olan bir değişken.

const toggleOrder = () => {
if (currentFilter === prevFilter) {
orderAsc = !orderAsc; // Aynı değişkeni al tersi olarak atama yap demek
} else {
orderAsc = true; //Güncellenen alan*
}
}

Burada daha önce belirttiğim gibi, eğer aynı başlığa tekrar tıklanırsa sıralama yönünü değiştiriyorum.

*Update: Üstteki kodun else durumu için orderAsc’yi tekrardan true olarak işaretliyorum. Bir sütunu desc olarak sıraladıktan sonra diğer sütunun asc sıralanması gerekiyordu ancak bu satır olmayınca önce yine desc sıralayıp sonraki tıklamada asc sıralıyordu o sebepten bir reset iş gördü :)

const sortTable = (array, sortKey) => {
return array.sort((a, b) => {
let x = a[sortKey],
y = b[sortKey];
                return orderAsc ? x - y : y - x; // ternary operatör 
});
}

Burası da parametre olarak bir dizi ve sıralama değişkenini alan bir fonksiyon. Array.sort methodunu kullandığım bu fonksiyonda her bir adımda bir önceki ve sonraki arasındaki farka göre sonuç dönüyor. Eğer string bir şeyleri karşılaştıracaksanız da büyüktür/küçüktür ile karşılaştırmanız yeterli oluyor. Burada eğer asc sıralanacaksa x-y desc sıralanacaksa y -x şeklinde sıralamasını yapıp çıktı üretiyor.

Şimdi sıra tabloyu oluşturmakta :

const renderTable = tableData => {
return (`${tableData.map(item => {
return (
`<tr>
<td>${item.no}</td>
<td>${item.fullName}</td>
<td>${item.position}</td>
<td>${item.age}</td>
<td>${item.match}</td>
<td>${item.time}</td>
<td>${item.goal}</td>
<td>${item.assist}</td>
<td>${item.yellowCard}</td>
<td>${item.doubleYellowCard}</td>
<td>${item.redCard}</td>
</tr>`
)
}).join('')}`);
}

Daha önce dediğim gibi bu fonksiyon sadece verilen diziye göre tr’leri oluşturuyor. Dizi içerisinde map ile dönüp her bir objeyi string output olarak sunuyor. Sonraki join ne işe yarıyor derseniz, bildiğiniz üzere map aslında bize bir array dönüyor ve dolayısıyla onu koymazsanız her bir tr sonunda bir virgül işareti olduğunu görürsünüz. Join ise bunları kaldırıp bir bütün olarak birleştirerek bize tek string şeklinde sunar. bkz: mdn

const appendTable = (table, destination) => {
document.querySelector(destination).innerHTML = table;
}

Bu fonksiyon ise aslında daha generic bir proje yazsam ismi direkt append olacak bir fonksiyon. Datayı ve eklemek istediğiniz yeri belirttiğiniz ve html olarak içine basmaya yarayan bir fonksiyon. Sadece bunun için fonksiyon yapmaya gerek var mıydı? Şahsen şu an bu satırları yazarken ben de düşündüm ama olsun yine de güzel oldu. Daha generic haliyle bi çok yerde daha kısa şekilde kullanılabilir diyip vicdanımı rahatlatabilirim sanırım :)

const handleSortClick = () => {
const filters = document.querySelectorAll('#squadTable th');
Array.prototype.forEach.call(filters, filter => {
filter.addEventListener("click", () => {
if (!filter.dataset.filterValue) return false;
Array.prototype.forEach.call(filters, filter => {
filter.classList.remove('active');
});
filter.classList.add('active');
currentFilter = filter.dataset.filterValue;
toggleOrder();
init();
});
})
}

Bu kısımı biraz yine detaylı tutmakta fayda var. Bu kod bir poc kodu olduğu için selector kısmını bodozlama yazdım ancak bunu parametrik yapmak zaten oldukça basit.

Tüm th’ları seçiyorum ve bir forEach ile dönüyorum. Burada neden direkt Array.forEach kullanmadın derseniz de biliyorsunuz bu filters’taki değer bana bir liste dönüyor ancak bu bir nodelist. Bu sebeple Array’in her methoduna doğrudan erişemiyorum. Array.prototype.forEach.call ise bana hem tarayıcı uyumluluğu hem de bu forEach’e erişim sağlıyor.

Her bir filtre başlığına tıkladığımda öncelikle bir kontrol yapıyorum. Eğer bu başlığın bir sıralama özelliği yoksa direkt en baştan false döndürüyorum ve geri kalan fonksiyon işleme girmiyor.

Burada göreceğiniz güzel bir özellik ise dataset. Bir dom öğesinin data özelliklerine erişmek istediğinizde, getAttribute(…) diye gitmek yerine .dataset ile giderseniz hem tüm data özelliklerini ve değerlerini görürsünüz hem de istediğinize zincir methodunu kullanarak erişebilirsiniz. Burada kebap-case olarak kullandığım özelliğin kullanırken camelCase olduğunu göz önünde bulundurmanızda fayda var.

Daha sonrasında ise çeşitli class işlemlerinden sonra toggle fonksiyonunu tetikliyorum, bir de aşağıda göreceğiniz init fonksiyonunu tetikliyorum.

const init = () => {
let newTableData = sortTable(data, currentFilter),
tableOutput = renderTable(newTableData);
appendTable(tableOutput, '#squadTable tbody');
prevFilter = currentFilter;
}

Bu fonksiyon init olduğu için de tüm özet işlemleri burada yapıyorum. Neler bunlar sırasıyla bir bakalım :

  • Oyuncu datamızı, mevcut filtre ile sıralamasını yaptırıp yeni bir formata sokuyorum. Bu bana diğer fonksiyonlar içerisinden currentFilter’ı değiştirip init’i çağırdığımda yeni sıralamayı kolaylıkla yapma şansı sunuyor.
  • html için string output’u oluşturuyorum ve tableOutput içerisine eşitliyorum.
  • oluşturduğum bu outputu belirttiğim yere append ediyorum.
  • tüm işlemler bittiğine göre artık mevcut filtreyi eski filtre olarak da işaretleyebilirim bu sebeple gerekli eşitlemeyi yapıyorum :)
init();
handleSortClick();

init fonksiyonunu diğer işlemler içerisinde çağırdığım için handleSortClick fonksiyonunu onun içerisine almadım ayrı tuttum ki her seferinde eventler o seçicilere bind olmasın. Belki uygulamaya aktarırken başka bir yolunu düşünebilirim.

Data kısmını saymazsak 70 satır kod ile (template’ine göre değişir tabii ki ) bir sıralanabilir tablo oluşturdum :

Yukarıdan çalışan demoya göz atabilirsiniz. Umarım size de başka işlerinizde bir fikir vermeye yarar bu yazı.

Vakit ayırıp okuduğunuz için çok teşekkür ediyorum.

Murat