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:

js tugas.mjs
// 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:

  1. daftar.map((tugas) => ...): map mengunjungi setiap tugas dan selalu menghasilkan array baru dengan jumlah item yang sama — persis seperti mengganti satu kartu di tumpukan tanpa menyentuh kartu lain.
  2. tugas.id === id ? { ...tugas, selesai: !tugas.selesai } : tugas: tugas yang id-nya cocok digantikan salinannya yang field selesai-nya dibalik (! menjadikan toggle — sekali klik menandai selesai, klik lagi mengembalikannya aktif). Tugas lain dikembalikan apa adanya.
  3. 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:

  1. status = 'semua': default parameter membuat pemanggilan tanpa argumen kedua menampilkan seluruh daftar — kebiasaan yang dibahas pada chapter Default Parameters.
  2. if (!STATUS_FILTER.includes(status)): nilai di luar tiga yang terdaftar melempar Error dengan pesan yang menyebut nilai pelakunya. Typo seperti 'aktifs' tertangkap di gerbang, bukan diam-diam menghasilkan daftar kosong yang membingungkan.
  3. daftar.filter((tugas) => !tugas.selesai): cabang aktif memilih tugas yang field selesai-nya bernilai false; cabang selesai kebalikannya; dan cabang semua mengembalikan [...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:

html index.html.txt
<!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:

  1. const tampilan = filterTugas(daftar, filterAktif);: render tidak lagi menggambar seluruh daftar, melainkan hasil filter. State tidak berubah sama sekali di sini — yang berubah hanya pilihan tampilan.
  2. item.className = tugas.selesai ? 'tugas tugas--selesai' : 'tugas';: baris tugas selesai mendapat kelas modifier CSS yang mencoret teksnya dan mencerahkan warnanya, memanfaatkan aturan .tugas--selesai yang sudah kita siapkan di Bagian 1.
  3. elemenDaftar.addEventListener('change', ...): peristiwa change dari 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.
  4. Klik filter hanya menyalin nilai: filterAktif = event.target.dataset.filter lalu render(). Tidak ada fungsi logika yang dipanggil, dan daftar tidak 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:

js tests/tugas.test.mjs — tambahan Bagian 2
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:

bash
node --test

Output yang Dihasilkan:

text
▶ 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.947463

Tujuh 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
css
/* 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;
}