Pada akhir Bagian 1, aplikasi todo bisa menambah dan menghapus tugas, tetapi semua baris terlihat sama: tidak ada cara membedakan tugas yang sudah dikerjakan dari yang masih menunggu. Bagian ini melengkapi itu dengan dua hal yang saling terkait — field selesai pada setiap tugas beserta fungsi pembaliknya, dan tiga pilihan filter yang menentukan baris mana yang tampil. Keduanya dibangun dengan pola yang sama seperti Bagian 1: keputusan data hidup di module logika murni, dan antarmuka hanya meneruskan peristiwa lalu merender ulang.
Wujud Baru tugas.mjs #
Berikut tugas.mjs lengkap pada akhir Bagian 2. Bandingkan dengan versi Bagian 1: ada lima tambahan — field selesai pada tugas baru, konstanta STATUS_FILTER, dan tiga fungsi baru ubahStatus, filterTugas, serta hitungAktif:
// tugas.mjs — logika inti aplikasi todo, terpisah dari tampilan (DOM).
// Semua fungsi murni: menerima state array dan mengembalikan array baru,
// sehingga dapat diuji dengan node --test tanpa browser.
// Nilai sah untuk argumen status filterTugas.
const STATUS_FILTER = ['semua', 'aktif', 'selesai'];
export function tambahTugas(daftar, teks) {
const teksBersih = teks.trim();
if (teksBersih === '') {
throw new Error('Teks tugas tidak boleh kosong.');
}
const idBaru = Math.max(0, ...daftar.map((tugas) => tugas.id)) + 1;
return [...daftar, { id: idBaru, teks: teksBersih, selesai: false }];
}
export function hapusTugas(daftar, id) {
return daftar.filter((tugas) => tugas.id !== id);
}
export function daftarTugas(daftar) {
return daftar.map((tugas) => ({ ...tugas }));
}
export function ubahStatus(daftar, id) {
return daftar.map((tugas) =>
tugas.id === id ? { ...tugas, selesai: !tugas.selesai } : tugas,
);
}
export function filterTugas(daftar, status = 'semua') {
if (!STATUS_FILTER.includes(status)) {
throw new Error(`Status filter tidak dikenal: ${status}`);
}
if (status === 'aktif') {
return daftar.filter((tugas) => !tugas.selesai);
}
if (status === 'selesai') {
return daftar.filter((tugas) => tugas.selesai);
}
return [...daftar];
}
export function hitungAktif(daftar) {
return daftar.filter((tugas) => !tugas.selesai).length;
}Perhatikan bahwa tambahTugas ikut berubah: object tugas baru kini dibawa keluar lengkap dengan selesai: false. Dengan itu, setiap tugas di seluruh proyek selalu punya kedua field sejak lahirnya, dan fungsi di bawah tidak perlu menebak apakah field itu ada. Konstanta STATUS_FILTER sengaja tidak diekspor — hanya filterTugas yang berhak menentukan nilai sah, sama seperti peta operasi yang disembunyikan di dalam module kalkulator.
Menandai Tugas Selesai dengan ubahStatus #
Fungsi ubahStatus menjawab satu pertanyaan: bagaimana mengubah satu item di dalam array tanpa mengubah array itu sendiri. Jawabannya bukan filter seperti hapusTugas, melainkan map:
daftar.map((tugas) => ...):mapmengunjungi setiap tugas dan selalu menghasilkan array baru dengan jumlah item yang sama — persis seperti mengganti satu kartu di tumpukan tanpa menyentuh kartu lain.tugas.id === id ? { ...tugas, selesai: !tugas.selesai } : tugas: tugas yang id-nya cocok digantikan salinannya yang fieldselesai-nya dibalik (!menjadikan toggle — sekali klik menandai selesai, klik lagi mengembalikannya aktif). Tugas lain dikembalikan apa adanya.- Referensi tetap: item yang tidak berubah mempertahankan object aslinya — hanya array pembungkusnya yang baru. Uji “tugas lain mempertahankan referensinya” di bawah mengunci perilaku halus ini.
Pola map plus spread ini adalah versi satu-item dari pola state satu arah yang dipakai pilihOperasi pada kalkulator: state lama masuk, state baru keluar, dan tidak ada fungsi lain yang bisa mengubah object di belakang punggung fungsi saat ini.
Tiga Nilai Filter #
Pilihan tampilan kita ada tiga: semua (tampilkan apa adanya), aktif (yang belum selesai), dan selesai (yang sudah). Fungsi filterTugas menerjemahkan pilihan itu menjadi array tampilan:
status = 'semua': default parameter membuat pemanggilan tanpa argumen kedua menampilkan seluruh daftar — kebiasaan yang dibahas pada chapter Default Parameters.if (!STATUS_FILTER.includes(status)): nilai di luar tiga yang terdaftar melemparErrordengan pesan yang menyebut nilai pelakunya. Typo seperti'aktifs'tertangkap di gerbang, bukan diam-diam menghasilkan daftar kosong yang membingungkan.daftar.filter((tugas) => !tugas.selesai): cabangaktifmemilih tugas yang fieldselesai-nya bernilaifalse; cabangselesaikebalikannya; dan cabangsemuamengembalikan[...daftar]— array baru berisi item yang sama.
Semua cabang mengembalikan array baru. Dijamin demikian karena filter dan spread memang selalu membangun array baru; hasil filter boleh diputar, dihitung, atau disimpan tanpa risiko mengubah state.
Menghitung Sisa Tugas #
Fungsi terakhir kecil tetapi sering dipakai: hitungAktif(daftar) mengembalikan jumlah tugas yang belum selesai — angka “sisa pekerjaan” yang akan tampil di antarmuka sebagai ringkasan. Implementasinya satu filter plus length. Bila seluruh tugas sudah selesai, hasilnya 0, dan bila daftar kosong pun hasilnya tetap 0 — tidak ada cabang khusus yang perlu ditambahkan.
Antarmuka: Kotak Centang, Filter, dan Ringkasan #
Markup antarmuka bertambah dua blok: deretan tiga tombol filter dan paragraf ringkasan. Skripnya bertambah satu state (filterAktif), satu listener, dan beberapa baris di render. Berikut index.html.txt lengkap pada akhir Bagian 2:
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Aplikasi Todo</title>
<link rel="stylesheet" href="../styles/styles.css">
</head>
<body>
<main class="aplikasi">
<h1>Daftar Tugas</h1>
<form id="form-tugas" class="form-tugas">
<input type="text" id="input-tugas" name="tugas" placeholder="Tulis tugas baru..." autocomplete="off">
<button type="submit" class="tombol-tambah">Tambah</button>
</form>
<p id="pesan" class="pesan" hidden></p>
<div class="filter">
<button class="filter-tombol" data-filter="semua">Semua</button>
<button class="filter-tombol" data-filter="aktif">Aktif</button>
<button class="filter-tombol" data-filter="selesai">Selesai</button>
</div>
<p id="ringkasan" class="ringkasan"></p>
<ul id="daftar-tugas" class="daftar-tugas"></ul>
</main>
<script type="module">
import {
tambahTugas,
hapusTugas,
daftarTugas,
ubahStatus,
filterTugas,
hitungAktif,
} from './tugas.mjs';
let daftar = [];
let filterAktif = 'semua';
const form = document.querySelector('#form-tugas');
const input = document.querySelector('#input-tugas');
const elemenDaftar = document.querySelector('#daftar-tugas');
const elemenPesan = document.querySelector('#pesan');
const elemenRingkasan = document.querySelector('#ringkasan');
function render() {
elemenDaftar.textContent = '';
const tampilan = filterTugas(daftar, filterAktif);
for (const tugas of tampilan) {
const item = document.createElement('li');
item.className = tugas.selesai ? 'tugas tugas--selesai' : 'tugas';
const kotakCentang = document.createElement('input');
kotakCentang.type = 'checkbox';
kotakCentang.checked = tugas.selesai;
kotakCentang.dataset.id = String(tugas.id);
const label = document.createElement('span');
label.textContent = tugas.teks;
const tombolHapus = document.createElement('button');
tombolHapus.className = 'tombol-hapus';
tombolHapus.textContent = 'Hapus';
tombolHapus.dataset.id = String(tugas.id);
item.append(kotakCentang, label, tombolHapus);
elemenDaftar.append(item);
}
elemenRingkasan.textContent = `${hitungAktif(daftar)} tugas belum selesai`;
}
form.addEventListener('submit', (event) => {
event.preventDefault();
try {
daftar = tambahTugas(daftar, input.value);
input.value = '';
elemenPesan.hidden = true;
render();
} catch (galat) {
elemenPesan.textContent = galat.message;
elemenPesan.hidden = false;
}
});
elemenDaftar.addEventListener('change', (event) => {
if (event.target.matches('input[type="checkbox"]')) {
daftar = ubahStatus(daftar, Number(event.target.dataset.id));
render();
}
});
elemenDaftar.addEventListener('click', (event) => {
if (event.target.matches('.tombol-hapus')) {
daftar = hapusTugas(daftar, Number(event.target.dataset.id));
render();
}
});
document.querySelector('.filter').addEventListener('click', (event) => {
if (event.target.matches('.filter-tombol')) {
filterAktif = event.target.dataset.filter;
render();
}
});
render();
</script>
</body>
</html>Empat perubahan yang layak dibedah:
const tampilan = filterTugas(daftar, filterAktif);:rendertidak lagi menggambar seluruh daftar, melainkan hasil filter. State tidak berubah sama sekali di sini — yang berubah hanya pilihan tampilan.item.className = tugas.selesai ? 'tugas tugas--selesai' : 'tugas';: baris tugas selesai mendapat kelas modifier CSS yang mencoret teksnya dan mencerahkan warnanya, memanfaatkan aturan.tugas--selesaiyang sudah kita siapkan di Bagian 1.elemenDaftar.addEventListener('change', ...): peristiwachangedari checkbox bergelembung sampai ke<ul>(perilaku bubbling pada chapter Event Bubbling), sehingga satu listener melayani semua kotak centang — pola delegasi yang sama dengan tombol Hapus.- Klik filter hanya menyalin nilai:
filterAktif = event.target.dataset.filterlalurender(). Tidak ada fungsi logika yang dipanggil, dandaftartidak tersentuh — filter adalah lensa, bukan perubahan data.
Ringkasan dihitung dari daftar utuh, bukan dari hasil filter: jumlah sisa tugas tetap benar bahkan ketika layar sedang menampilkan filter selesai.
Menguji Status dan Filter #
Berkas uji bertambah empat kelompok, dan dua asersi lama ikut diperbarui karena bentuk tugas bertambah field selesai. Impornya kini mengambil ubahStatus, filterTugas, dan hitungAktif:
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import {
tambahTugas,
hapusTugas,
daftarTugas,
ubahStatus,
filterTugas,
hitungAktif,
} from '../src/tugas.mjs';
describe('tambahTugas', () => {
test('menambahkan tugas pertama ke daftar kosong', () => {
const hasil = tambahTugas([], 'Belajar module ES');
assert.deepStrictEqual(hasil, [
{ id: 1, teks: 'Belajar module ES', selesai: false },
]);
});
// ... tiga uji Bagian 1 tetap sama ...
test('daftar lama tidak ikut berubah', () => {
const lama = [{ id: 1, teks: 'Satu', selesai: false }];
const hasil = tambahTugas(lama, 'Dua');
assert.notStrictEqual(hasil, lama);
assert.strictEqual(lama.length, 1);
assert.strictEqual(hasil.length, 2);
});
});
// ... kelompok hapusTugas dan daftarTugas tetap sama ...
describe('ubahStatus', () => {
test('menandai tugas sebagai selesai', () => {
let daftar = tambahTugas([], 'Satu');
daftar = tambahTugas(daftar, 'Dua');
daftar = ubahStatus(daftar, 1);
assert.strictEqual(daftar[0].selesai, true);
assert.strictEqual(daftar[1].selesai, false);
});
test('menandai kembali menjadi aktif', () => {
let daftar = tambahTugas([], 'Satu');
daftar = ubahStatus(daftar, 1);
daftar = ubahStatus(daftar, 1);
assert.strictEqual(daftar[0].selesai, false);
});
test('tugas lain mempertahankan referensinya', () => {
let daftar = tambahTugas([], 'Satu');
daftar = tambahTugas(daftar, 'Dua');
const hasil = ubahStatus(daftar, 2);
assert.strictEqual(hasil[0], daftar[0]);
assert.notStrictEqual(hasil[1], daftar[1]);
});
});
describe('filterTugas', () => {
test('filter "semua" mengembalikan seluruh tugas dalam array baru', () => {
let daftar = tambahTugas([], 'Satu');
daftar = tambahTugas(daftar, 'Dua');
const hasil = filterTugas(daftar, 'semua');
assert.strictEqual(hasil.length, 2);
assert.notStrictEqual(hasil, daftar);
});
test('filter "aktif" hanya menampilkan tugas yang belum selesai', () => {
let daftar = tambahTugas([], 'Satu');
daftar = tambahTugas(daftar, 'Dua');
daftar = ubahStatus(daftar, 1);
assert.deepStrictEqual(filterTugas(daftar, 'aktif').map((tugas) => tugas.id), [2]);
});
test('filter "selesai" hanya menampilkan tugas yang sudah selesai', () => {
let daftar = tambahTugas([], 'Satu');
daftar = tambahTugas(daftar, 'Dua');
daftar = ubahStatus(daftar, 1);
assert.deepStrictEqual(filterTugas(daftar, 'selesai').map((tugas) => tugas.id), [1]);
});
test('status yang tidak dikenal ditolak', () => {
const daftar = tambahTugas([], 'Satu');
assert.throws(() => filterTugas(daftar, 'tertunda'), /tidak dikenal/);
});
});
describe('hitungAktif', () => {
test('menghitung tugas yang belum selesai', () => {
let daftar = tambahTugas([], 'Satu');
daftar = tambahTugas(daftar, 'Dua');
daftar = tambahTugas(daftar, 'Tiga');
daftar = ubahStatus(daftar, 2);
assert.strictEqual(hitungAktif(daftar), 2);
});
});
describe('skenario pengguna', () => {
test('tambah dua tugas, tandai satu selesai, sisa satu aktif', () => {
let daftar = tambahTugas([], 'Menyiram tanaman');
daftar = tambahTugas(daftar, 'Membaca materi map');
daftar = ubahStatus(daftar, 1);
assert.strictEqual(hitungAktif(daftar), 1);
assert.deepStrictEqual(
filterTugas(daftar, 'aktif').map((tugas) => tugas.teks),
['Membaca materi map'],
);
});
});Jalankan kembali dari folder proyek:
node --testOutput yang Dihasilkan:
▶ tambahTugas
✔ menambahkan tugas pertama ke daftar kosong (1.911953ms)
✔ id baru menyusul id tertinggi yang sudah ada (0.269913ms)
✔ teks dipangkas spasi di kedua ujungnya (0.206477ms)
✔ teks yang kosong setelah dipangkas ditolak (0.700397ms)
✔ daftar lama tidak ikut berubah (0.366529ms)
✔ tambahTugas (5.263933ms)
▶ hapusTugas
✔ menghapus tugas dengan id yang dicari (0.269889ms)
✔ id yang tidak ada tidak mengubah isi daftar (0.269852ms)
✔ hapusTugas (0.818578ms)
▶ daftarTugas
✔ mengembalikan salinan yang setara tetapi terpisah (0.37438ms)
✔ daftarTugas (0.663201ms)
▶ ubahStatus
✔ menandai tugas sebagai selesai (0.471481ms)
✔ menandai kembali menjadi aktif (0.338217ms)
✔ tugas lain mempertahankan referensinya (0.22284ms)
✔ ubahStatus (1.344102ms)
▶ filterTugas
✔ filter "semua" mengembalikan seluruh tugas dalam array baru (0.398418ms)
✔ filter "aktif" hanya menampilkan tugas yang belum selesai (1.256718ms)
✔ filter "selesai" hanya menampilkan tugas yang sudah selesai (0.240658ms)
✔ status yang tidak dikenal ditolak (0.167794ms)
✔ filterTugas (2.325692ms)
▶ hitungAktif
✔ menghitung tugas yang belum selesai (0.176184ms)
✔ hitungAktif (0.258791ms)
▶ skenario pengguna
✔ tambah dua tugas, tandai satu selesai, sisa satu aktif (0.170122ms)
✔ skenario pengguna (0.246068ms)
ℹ tests 17
ℹ suites 7
ℹ pass 17
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 89.947463Tujuh belas uji lulus — delapan dari Bagian 1 tetap hijau (dua asersinya diperbarui mengikuti field baru, seperti dijelaskan di atas), dan sembilan baru. Uji “tugas lain mempertahankan referensinya” paling padat pelajarannya: strictEqual pada hasil[0] membuktikan item tak tersentuh tetap object yang sama, sementara notStrictEqual pada hasil[1] membuktikan item yang ditandai benar-benar diganti salinan barunya. Uji “skenario pengguna” merangkai beberapa fungsi menjadi satu alur — gaya yang akan kita pakai lagi di Bagian 3.
Ringkasan Bagian 2 #
Aplikasi kini mengenali status: setiap tugas lahir dengan selesai: false, ubahStatus membaliknya lewat kotak centang, filterTugas menata tampilan dalam tiga pilihan, dan hitungAktif menutup paragraf ringkasannya. Tujuh belas uji menjaga semuanya. Masih ada satu kelemahan besar: tutup tab browser dan seluruh tugas itu hilang. Bagian 3 memasang penyimpanan lokal — dengan cara yang tetap menjaga logika kita bisa diuji dari terminal.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
styles/styles.css
Unduh berkas/* styles.css — tampilan aplikasi todo sederhana */
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #f3f4f6;
font-family: system-ui, sans-serif;
}
.aplikasi {
width: 340px;
padding: 20px;
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.aplikasi h1 {
margin: 0 0 16px;
font-size: 1.4rem;
}
.form-tugas {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.form-tugas input {
flex: 1;
padding: 10px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 1rem;
}
.tombol-tambah {
padding: 10px 16px;
border: none;
border-radius: 8px;
background-color: #3b82f6;
color: #ffffff;
font-size: 1rem;
cursor: pointer;
}
.tombol-tambah:hover {
background-color: #2563eb;
}
.pesan {
margin: 0 0 12px;
color: #b91c1c;
font-size: 0.9rem;
}
.filter {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.filter-tombol {
flex: 1;
padding: 8px 0;
border: 1px solid #d1d5db;
border-radius: 8px;
background-color: #f9fafb;
font-size: 0.9rem;
cursor: pointer;
}
.filter-tombol:hover {
background-color: #e5e7eb;
}
.ringkasan {
margin: 0 0 12px;
color: #6b7280;
font-size: 0.9rem;
}
.daftar-tugas {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 8px;
}
.tugas {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
background-color: #f9fafb;
border-radius: 8px;
}
.tugas span {
flex: 1;
}
.tugas--selesai span {
color: #9ca3af;
text-decoration: line-through;
}
.tombol-hapus {
padding: 6px 10px;
border: none;
border-radius: 6px;
background-color: #fecaca;
font-size: 0.85rem;
cursor: pointer;
}
.tombol-hapus:hover {
background-color: #fca5a5;
}