Saya lagi bangun dashboard owner untuk sebuah situs klien: platform booking properti di mana pemilik properti bisa menambahkan listing-nya sendiri lengkap dengan foto. Alurnya standar. Owner buka form "Tambah Properti", isi judul, harga, deskripsi, lalu drag beberapa foto ke area upload. Thumbnail muncul, validasi lolos, klik Publish, listing terbit.
Masalahnya baru ketahuan pas QA sebelum launch: setiap listing yang dibuat owner terbit dengan nol foto. Halaman properti-nya menampilkan gambar placeholder bawaan tema, bukan foto yang tadi di-upload. Saya bikin listing tes sendiri dengan foto valid 1600×1000, preview-nya render mulus di form, validasi hijau, saya publish. Buka halaman publik-nya: placeholder lagi. Saya cek data post-nya, gallery isinya array kosong dan featured_image null. Foto yang jelas-jelas ter-preview di form itu, hilang entah ke mana.
Menelusuri jejaknya
Refleks pertama: server pasti menolak gambarnya. Mungkin mime type ditolak, mungkin ukuran kebesaran. Saya buka handler create_item di sisi server. Dia mengharapkan field gallery berupa array attachment ID yang sudah di-upload sebelumnya. Logikanya masuk akal: properti menyimpan galeri sebagai daftar ID media WordPress. Tapi kalau server menolak, harusnya ada jejak error. error_log bersih. Tidak ada attachment baru yang dibuat di Media Library sama sekali.
Jadi saya pindah ke tempat yang seharusnya saya periksa dari awal: tab Network di browser. Saya submit form lagi sambil merekam request-nya. Request POST untuk membuat properti terkirim, body-nya berisi judul, harga, deskripsi, dan gallery yang isinya array kosong. Tidak ada request upload sama sekali sebelum itu. Tidak ada file yang meninggalkan browser. Fotonya tidak pernah dikirim ke mana-mana.
Di titik ini teka-tekinya berubah. Ini bukan server menolak gambar. Ini gambar yang tidak pernah sampai ke server.
Akar masalahnya
Saya buka dashboard.js dan telusuri apa yang terjadi saat owner memilih foto. Fungsi handleFiles() cuma melakukan satu hal: bikin preview.
// dashboard.js: cuma preview, tidak ada upload
function handleFiles(files) {
Array.from(files).forEach((file) => {
const reader = new FileReader();
reader.onload = (e) => {
const thumb = renderThumb(e.target.result);
thumb._file = file; // File-nya cuma nempel di DOM, tidak dikirim
};
reader.readAsDataURL(file);
});
}FileReader membaca file lokal dan menampilkannya sebagai data URL. Object File-nya disimpan di properti _file pada elemen thumbnail, tersimpan di DOM. Itu saja. Tidak ada fetch, tidak ada upload. Preview yang saya lihat di form itu seratus persen teater sisi klien. Cantik, tapi tidak pernah menyentuh server.
Lalu saya lihat handler submit-nya, initPropertyForm(). Di sinilah kalimat yang bikin saya menghela napas:
const body = {
title: form.title.value,
price: form.price.value,
// Photos handled via separate upload flow (deferred)
};Foto sengaja di-skip saat merakit body, dengan komentar bahwa upload ditangani oleh "flow terpisah" yang ditunda. Masalahnya: flow terpisah itu tidak pernah dibangun. Seseorang menandainya sebagai TODO, form-nya tetap jalan dan terlihat berfungsi karena preview-nya meyakinkan, dan bagian yang benar-benar meng-upload foto tidak pernah ditulis. Server minta gallery berisi array attachment ID, yang dikirim array kosong, jadi setiap listing terbit tanpa satu foto pun. Sempurna secara sintaks, kosong secara isi.
Perbaikannya
Yang hilang adalah satu langkah utuh: file harus jadi attachment WordPress dulu, baru ID-nya dikirim bareng properti.
Pilihan gampangnya adalah memberi role property_owner cap upload_files lalu memakai endpoint bawaan wp/v2/media. Saya tolak itu. Cap upload_files terlalu luas: dia membuka seluruh Media Library ke owner, termasuk media milik owner lain dan admin. Untuk situs multi-owner, itu kebocoran privasi yang tidak saya mau.
Gantinya saya bikin endpoint REST khusus yang cakupannya sempit, hanya untuk foto properti:
register_rest_route( 'listings/v1', '/properties/photo', array(
'methods' => 'POST',
'callback' => 'upload_property_photo',
'permission_callback' => function () {
return current_user_can( 'manage_properties' );
},
) );
function upload_property_photo( WP_REST_Request $request ) {
$file = $request->get_file_params()['file'] ?? null;
// validasi: mime jpeg/png/webp, ukuran <= 10MB, cek dimensi
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
$moved = wp_handle_upload( $file, array( 'test_form' => false ) );
$attachment_id = wp_insert_attachment( array(
'post_mime_type' => $moved['type'],
'post_author' => get_current_user_id(),
'post_status' => 'inherit',
), $moved['file'] );
$meta = wp_generate_attachment_metadata( $attachment_id, $moved['file'] );
wp_update_attachment_metadata( $attachment_id, $meta );
return array( 'id' => $attachment_id, 'url' => $moved['url'] );
}Endpoint ini dijaga oleh cap manage_properties, jadi hanya owner yang boleh. Dia memvalidasi mime dan ukuran, memindahkan file lewat wp_handle_upload, membuat attachment dengan post_author = owner-nya sendiri, membangun metadata, lalu balas { id, url }.
Di sisi klien, saya tulis langkah yang selama ini hilang: upload dulu semua foto, kumpulkan ID-nya, baru kirim properti.
// dashboard.js: upload dulu, kumpulkan ID, baru kirim properti
async function collectGalleryIds() {
const thumbs = document.querySelectorAll('.photo-upload__thumb');
const ids = [];
for (const thumb of thumbs) {
if (thumb.dataset.id) { // edit mode: ID lama dipertahankan
ids.push(Number(thumb.dataset.id));
continue;
}
const fd = new FormData();
fd.append('file', thumb._file);
const res = await fetch('/wp-json/listings/v1/properties/photo', {
method: 'POST',
headers: { 'X-WP-Nonce': window.listingNonce },
body: fd,
});
ids.push((await res.json()).id);
}
return ids;
}Lalu di submit: body.gallery = await collectGalleryIds();. Foto yang belum punya ID di-upload dan menghasilkan ID baru; di edit mode, thumbnail yang sudah punya data-id dipertahankan supaya foto lama tidak hilang, dan foto baru ditambahkan di belakangnya. Sekarang server menerima gallery berisi array ID beneran, dan listing terbit lengkap dengan galerinya.
Pelajaran
- Preview bukan upload.
FileReadercuma menampilkan file di browser, tidak membuktikan apa pun soal state di server. Jangan percaya thumbnail sampai kamu lihat request-nya benar-benar terkirim. - Curigai komentar seperti "deferred", "TODO", atau "handled elsewhere". Stub yang tidak pernah diselesaikan itu diam-diam berbahaya karena UI di sekitarnya tetap terlihat sehat.
- Cek body request yang sebenarnya di tab Network, bukan cuma percaya UI hijau. Field
galleryyang array kosong langsung menunjuk akar masalahnya. - Untuk aksi ber-privilege, lebih baik endpoint khusus yang sempit daripada memberi cap luas seperti
upload_files. Cakupan sempit menutup kebocoran antar-owner. - Di edit mode, pertahankan ID attachment yang sudah ada sebelum menambah yang baru, kalau tidak update malah menghapus foto lama.
