Interaksi Pengguna: v-model & POST Request
"Menangkap Input, Memvalidasi Niat, dan Mengirimkannya ke Server"
1 Keajaiban Two-Way Binding (v-model)
Jika di JavaScript murni (Vanilla JS) kita harus repot-repot menggunakan
document.getElementById().value lalu menambahkan
addEventListener('input', ...) untuk menangkap ketikan user, di Vue kita
hanya perlu satu direktif: v-model.
v-model menciptakan Two-Way Data Binding (ikatan dua arah).
Artinya:
- Jika user mengetik di input box, variabel di JavaScript (state) otomatis terupdate.
- Jika Anda mengubah variabel di JavaScript secara terprogram, teks di input box otomatis berubah.
<script setup>
import { ref } from 'vue';
const username = ref('');
</script>
<template>
<!-- Input diikat dengan variabel username -->
<input v-model="username" type="text" placeholder="Masukkan nama" />
<!-- Paragraf ini akan langsung berubah setiap kali Anda mengetik -->
<p>Halo, {{ username }}!</p>
</template>
Praktik Terbaik: Gunakan `reactive` untuk Form
Daripada membuat puluhan ref() untuk setiap inputan (nama, email,
password), lebih baik kumpulkan mereka ke dalam satu reactive object
bernama form. Ini membuat kode lebih rapi dan sangat mempermudah
pengiriman data API nantinya.
2 Menahan Refresh dengan @submit.prevent
Sifat bawaan (default) HTML ketika sebuah form di-submit adalah melakukan page reload (me-refresh seluruh halaman) dan mengirimkan data lewat URL. Di aplikasi Vue (SPA - Single Page Application), kita tidak ingin halaman ter-refresh karena akan mereset semua state aplikasi kita.
Kita mengatasi ini menggunakan Event Modifier milik Vue:
@submit.prevent="namaFungsi". Ini sama persis fungsinya dengan
event.preventDefault() di Vanilla JS.
3 Axios POST: Mengirim Payload
Jika `axios.get()` digunakan untuk meminta data, `axios.post()` digunakan untuk mengirim data baru ke server (biasanya untuk membuat *record* baru di database). Argumen kedua dari `axios.post()` adalah **Payload** (data yang ingin dikirim).
const submitForm = async () => {
try {
// 1. Parameter pertama: URL Endpoint
// 2. Parameter kedua: Payload data (form yang kita buat pakai reactive)
const response = await axios.post('https://api.example.com/register', form);
console.log("Sukses!", response.data);
} catch (error) {
console.error("Gagal mengirim data:", error);
}
};
4. Masterclass: Form Registrasi Kader Kompleks
Berikut adalah kode utuh (*Single File Component*) yang merangkum semua materi Modul 3. Form ini memiliki validasi, status loading, dan mengombinasikan berbagai tipe input (text, select, checkbox multiple, checkbox boolean).
<!--
========================================================================
KOMPONEN: KaderRegistration.vue
DESKRIPSI: Form registrasi komprehensif menggunakan reactive object,
v-model multi-type, frontend validation, dan simulasi POST.
AUTHOR: Catatan Kader
========================================================================
-->
<script setup>
import { reactive, ref } from 'vue';
import axios from 'axios';
// ---------------------------------------------------------
// 1. STATE FORM & VALIDASI
// ---------------------------------------------------------
// Menggunakan reactive untuk mengelompokkan data payload form
const form = reactive({
fullName: '',
email: '',
division: '',
skills: [], // v-model untuk multi-checkbox menghasilkan Array
agreeTerms: false // v-model untuk single-checkbox menghasilkan Boolean
});
// State khusus untuk menampung pesan error per-field
const errors = reactive({
fullName: '',
email: '',
division: '',
agreeTerms: ''
});
// State untuk UI Experience (Feedback Form)
const isSubmitting = ref(false);
const submitStatus = ref(null); // 'success' | 'error' | null
const responseMessage = ref('');
// ---------------------------------------------------------
// 2. FUNGSI VALIDASI FRONTEND
// ---------------------------------------------------------
const validateForm = () => {
let isValid = true;
// Reset errors
errors.fullName = '';
errors.email = '';
errors.division = '';
errors.agreeTerms = '';
// Validasi Nama
if (!form.fullName.trim()) {
errors.fullName = 'Nama lengkap wajib diisi';
isValid = false;
} else if (form.fullName.length < 3) {
errors.fullName = 'Nama minimal 3 karakter';
isValid = false;
}
// Validasi Email dengan Regex sederhana
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!form.email.trim()) {
errors.email = 'Email wajib diisi';
isValid = false;
} else if (!emailPattern.test(form.email)) {
errors.email = 'Format email tidak valid';
isValid = false;
}
// Validasi Divisi
if (!form.division) {
errors.division = 'Silakan pilih divisi penempatan';
isValid = false;
}
// Validasi Persetujuan (Checkbox Mutlak)
if (!form.agreeTerms) {
errors.agreeTerms = 'Anda harus menyetujui syarat & ketentuan';
isValid = false;
}
return isValid;
};
// ---------------------------------------------------------
// 3. METHOD SUBMIT FORM (AXIOS POST)
// ---------------------------------------------------------
const handleRegistration = async () => {
// 1. Jalankan fungsi validasi sebelum menembak API
if (!validateForm()) {
// Hentikan proses jika form tidak valid
return;
}
// 2. Mulai Loading State
isSubmitting.value = true;
submitStatus.value = null;
try {
// Menyiapkan payload. Karena form adalah objek reaktif,
// kita bisa langsung mengirimnya atau men-destructure-nya.
const payload = {
name: form.fullName,
email: form.email,
department: form.division,
capabilities: form.skills
};
// Endpoint Publik untuk simulasi POST Data (JSONPlaceholder tidak menyimpan beneran,
// tapi membalas dengan status 201 Created)
const response = await axios.post('https://jsonplaceholder.typicode.com/posts', payload);
// 3. Sukses Skenario
submitStatus.value = 'success';
responseMessage.value = `Registrasi Berhasil! ID Pendaftaran Anda: KDR-${response.data.id}`;
// Kosongkan form kembali ke state awal
form.fullName = '';
form.email = '';
form.division = '';
form.skills = [];
form.agreeTerms = false;
} catch (error) {
// 4. Error Skenario
console.error('Submit Failed:', error);
submitStatus.value = 'error';
responseMessage.value = 'Terjadi kesalahan jaringan atau server saat menyimpan data.';
} finally {
// Matikan Loading State
isSubmitting.value = false;
}
};
</script>
<template>
<div class="max-w-2xl mx-auto py-10">
<div class="bg-white dark:bg-zinc-900 rounded-3xl shadow-xl border border-slate-200 dark:border-zinc-800 overflow-hidden">
<!-- Form Header -->
<div class="bg-brand px-8 py-10 text-white text-center relative overflow-hidden">
<!-- Dekorasi Geometris Header -->
<div class="absolute -right-10 -top-10 w-40 h-40 bg-white opacity-10 rounded-full blur-2xl"></div>
<div class="absolute -left-10 -bottom-10 w-32 h-32 bg-white opacity-10 rounded-full blur-xl"></div>
<h2 class="text-3xl font-black mb-2 relative z-10">Registrasi Kader Baru</h2>
<p class="text-brand-light opacity-90 relative z-10">Isi formulir di bawah ini dengan data valid.</p>
</div>
<!--
Penggunaan @submit.prevent sangat penting di sini.
Ini mencegah browser melakukan reload halaman secara otomatis.
-->
<form @submit.prevent="handleRegistration" class="p-8 sm:p-10 space-y-6">
<!-- 1. Input Text: Nama Lengkap -->
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Nama Lengkap <span class="text-red-500">*</span></label>
<input
v-model="form.fullName"
type="text"
class="form-input-transition w-full px-4 py-3 rounded-xl border bg-slate-50 dark:bg-zinc-800 text-slate-900 dark:text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-brand focus:bg-white dark:focus:bg-zinc-900"
:class="errors.fullName ? 'border-red-500 focus:ring-red-500' : 'border-slate-200 dark:border-zinc-700'"
placeholder="Cth: John Doe"
>
<!-- Pesan Error Dinamis -->
<p v-if="errors.fullName" class="mt-2 text-sm text-red-500 flex items-center gap-1">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path></svg>
{{ errors.fullName }}
</p>
</div>
<!-- 2. Input Email -->
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Alamat Email <span class="text-red-500">*</span></label>
<input
v-model="form.email"
type="email"
class="form-input-transition w-full px-4 py-3 rounded-xl border bg-slate-50 dark:bg-zinc-800 text-slate-900 dark:text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-brand focus:bg-white"
:class="errors.email ? 'border-red-500 focus:ring-red-500' : 'border-slate-200 dark:border-zinc-700'"
placeholder="john@example.com"
>
<p v-if="errors.email" class="mt-2 text-sm text-red-500 flex items-center gap-1">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path></svg>
{{ errors.email }}
</p>
</div>
<!-- 3. Select Dropdown: Divisi -->
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Pilih Divisi <span class="text-red-500">*</span></label>
<div class="relative">
<select
v-model="form.division"
class="form-input-transition appearance-none w-full px-4 py-3 rounded-xl border bg-slate-50 dark:bg-zinc-800 text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand focus:bg-white cursor-pointer"
:class="errors.division ? 'border-red-500 focus:ring-red-500' : 'border-slate-200 dark:border-zinc-700'"
>
<option disabled value="">-- Pilih Divisi --</option>
<option value="engineering">Software Engineering</option>
<option value="design">UI/UX Design</option>
<option value="marketing">Digital Marketing</option>
<option value="product">Product Management</option>
</select>
<!-- Custom Caret Icon karena kita hide default browser appearance -->
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-4 text-slate-500">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
</div>
</div>
<p v-if="errors.division" class="mt-2 text-sm text-red-500 flex items-center gap-1">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path></svg>
{{ errors.division }}
</p>
</div>
<!-- 4. Checkbox Multiple (Array v-model) -->
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">Keahlian Tambahan (Opsional)</label>
<div class="grid grid-cols-2 gap-3">
<!-- Looping manual untuk efisiensi visual di sini, bisa pakai v-for -->
<label class="flex items-center space-x-3 cursor-pointer p-3 border border-slate-200 dark:border-zinc-700 rounded-xl hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors">
<input v-model="form.skills" type="checkbox" value="Vue.js" class="w-5 h-5 text-brand rounded focus:ring-brand border-slate-300">
<span class="text-slate-700 dark:text-slate-300 text-sm font-medium">Vue.js</span>
</label>
<label class="flex items-center space-x-3 cursor-pointer p-3 border border-slate-200 dark:border-zinc-700 rounded-xl hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors">
<input v-model="form.skills" type="checkbox" value="Tailwind" class="w-5 h-5 text-brand rounded focus:ring-brand border-slate-300">
<span class="text-slate-700 dark:text-slate-300 text-sm font-medium">Tailwind CSS</span>
</label>
<label class="flex items-center space-x-3 cursor-pointer p-3 border border-slate-200 dark:border-zinc-700 rounded-xl hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors">
<input v-model="form.skills" type="checkbox" value="Node.js" class="w-5 h-5 text-brand rounded focus:ring-brand border-slate-300">
<span class="text-slate-700 dark:text-slate-300 text-sm font-medium">Node.js API</span>
</label>
<label class="flex items-center space-x-3 cursor-pointer p-3 border border-slate-200 dark:border-zinc-700 rounded-xl hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors">
<input v-model="form.skills" type="checkbox" value="Figma" class="w-5 h-5 text-brand rounded focus:ring-brand border-slate-300">
<span class="text-slate-700 dark:text-slate-300 text-sm font-medium">Figma Design</span>
</label>
</div>
</div>
<!-- 5. Checkbox Boolean (Syarat & Ketentuan) -->
<div class="pt-4 border-t border-slate-200 dark:border-zinc-800">
<label class="relative flex items-start cursor-pointer">
<div class="flex items-center h-5">
<!-- v-model pada single checkbox akan mengembalikan true / false -->
<input
v-model="form.agreeTerms"
type="checkbox"
class="w-5 h-5 border border-slate-300 rounded text-brand focus:ring-brand bg-slate-50 transition-all"
>
</div>
<div class="ml-3 text-sm">
<span class="font-medium text-slate-700 dark:text-slate-300">Saya menyetujui seluruh <a href="#" class="text-brand hover:underline">Syarat & Ketentuan</a> serta Kebijakan Privasi perusahaan.</span>
<p v-if="errors.agreeTerms" class="mt-1 text-sm text-red-500 font-semibold">{{ errors.agreeTerms }}</p>
</div>
</label>
</div>
<!-- Status Alert (Akan muncul setelah submit) -->
<div v-if="submitStatus === 'success'" class="p-4 bg-green-50 border-l-4 border-green-500 rounded-r-xl">
<div class="flex items-center">
<svg class="w-6 h-6 text-green-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<p class="text-green-800 font-bold">{{ responseMessage }}</p>
</div>
</div>
<div v-if="submitStatus === 'error'" class="p-4 bg-red-50 border-l-4 border-red-500 rounded-r-xl">
<div class="flex items-center">
<svg class="w-6 h-6 text-red-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<p class="text-red-800 font-bold">{{ responseMessage }}</p>
</div>
</div>
<!-- Action Button -->
<div class="pt-4">
<!-- Tombol dimatikan (disabled) saat isSubmitting bernilai true -->
<button
type="submit"
:disabled="isSubmitting"
class="w-full flex justify-center items-center py-4 px-4 border border-transparent rounded-xl shadow-lg text-lg font-bold text-white bg-brand hover:bg-brand-hover focus:outline-none focus:ring-4 focus:ring-brand/30 transition-all disabled:opacity-70 disabled:cursor-not-allowed"
>
<!-- Spinner Loading (Tampil hanya saat isSubmitting true) -->
<svg v-if="isSubmitting" class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ isSubmitting ? 'Memproses Data...' : 'Kirim Pendaftaran' }}
</button>
</div>
</form>
</div>
</div>
</template>