Membuat Tabel Swing Dengan Jumlah Kolom Dinamis Di simple-jpa

Pada simple-jpa, saya dapat mendefinisikan sebuah JTable dengan menggunakan glazedTable() yang didalamnya mengandung satu atau lebih glazedColumn(). Masing-masing glazedColumn() ini mewakili kolom yang akan ditampilkan pada tabel. Bagaimana seandainya bila jumlah kolom di tabel bisa berubah atau dinamis? Sebagai contoh, pada tabel untuk menampilkan jumlah stok tersedia di masing-masing gudang, saya perlu memiliki sebuah kolom jumlah untuk masing-masing gudang. Masalahnya adalah pengguna bisa menambah atau mengurangi jumlah gudang yang ada.

Salah satu kelebihan Swing builder dari Groovy dibandingkan dengan definisi dalam bentuk XML atau sejenisnya adalah Swing builder dapat diprogram! Saya dapat menyisipkan kode program pada builder. Hal ini tidak dapat dilakukan pada deklarasi UI dalam bentuk XML atau sejenisnya. Sebagai contoh, saya dapat mendeklarasikan sebuah JTable dengan menggunakan kode program seperti berikut ini di view:

glazedTable(...) {
  (1..20).each { x ->
    glazedColumn(name: "$x", expression: { x })    
  }
}

Program di atas memakai looping (dapat juga diganti dengan syntax for biasa) untuk membuat 20 kolom. Bila dijalankan, tabel akan terlihat seperti pada gambar berikut ini:

Tabel dengan 20 kolom yang dibuat secara dinamis

Tabel dengan 20 kolom yang dibuat secara dinamis

Dengan memberikan kode program pada builder yang menghasilkan tabel, saya dapat menghasilkan kolom secara dinamis. Kembali ke contoh tabel yang berisi jumlah stok per gudang, agar lebih jelas, anggap saja saya memiliki 3 domain class berupa Gudang, StokProduk, dan Produk. Isinya terlihat seperti berikut ini:

@DomainClass @Entity @Canonical
class Gudang {

   @NotEmpty @Size(min=2, max=50)
   String nama

}

@Embeddable
class StokProduk {

   @NotNull @ManyToOne
   Gudang gudang

   @NotNull
   Integer jumlah

}

@DomainClass @Entity @Canonical
class Produk {

   @NotEmpty @Size(min=2, max=50)
   String nama

   @ElementCollection(fetch=FetchType.EAGER)
   List<StokProduk> stok = []

}

Saya bisa membuat deklarasi tabel menjadi seperti berikut ini:

glazedTable(id: 'table', list: model.produkList) {
  glazedColumn(name: 'Nama', property: 'nama')
  controller.findAllGudang().each { Gudang g ->
    glazedColumn(name: "Qty ${g.nama}", expression: { it.stokUntuk(g)?.jumlah?: 0 }, columnClass: Integer)
  }
}

Contoh tampilan pada program di atas akan terlihat seperti:

Kolom qty gudang yang dibuat secara dinamis

Kolom qty gudang yang dibuat secara dinamis

Pada saat tabel akan ditampilkan, kode program yang memanggil controller.findAllGudang() akan dipanggil untuk membaca daftar gudang dari database. Dengan demikian, jumlah kolom jumlah stok yang dihasilkan akan tergantung pada jumlah gudang di database.

Bagaimana bila seandainya jumlah gudang menjadi banyak sekali suatu hari nanti? Tabel akan tetap muncul, tetapi kolom akan menjadi sangat sempit seperti yang terlihat pada gambar berikut ini:

Tampilan tabel bila tidak muat

Tampilan tabel bila tidak muat

Ini akan membuat tabel sulit dibaca. Karena saya tidak tahu secara persis jumlah kolom yang akan ditampilkan, akan lebih baik bila saya menampilkan scrollbar secara horizontal. Pada Swing, agar scrollbar horizontal ditampilkan di JTable, saya perlu mematikan modus auto resize dengan memberikan autoResizeMode: JTable.AUTO_RESIZE_OFF. Selain itu, karena auto resize tidak lagi bekerja, akan lebih baik bila saya menambahkan pengaturan width. Sebagai contoh, saya mengubah deklarasi tabel saya menjadi seperti berikut ini:

glazedTable(id: 'table', list: model.produkList, autoResizeMode: JTable.AUTO_RESIZE_OFF) {
  glazedColumn(name: 'Nama', property: 'nama', width: [100,200])
  controller.findAllGudang().each { Gudang g ->
    glazedColumn(name: "Qty ${g.nama}", expression: { it.stokUntuk(g)?.jumlah?: 0 },
      columnClass: Integer, width: [80, 100])
  }
}

Sekarang, scrollbar horizontal akan muncul bila ukuran tabel tidak muat, seperti yang terlihat pada gambar berikut ini:

Memunculkan scrollbar horizontal pada tabel

Memunculkan scrollbar horizontal pada tabel

Penggunaan nilai pada width dalam bentuk seperti [100,200] mungkin awalnya agak membingungkan. Nilai 100 akan diberikan sebagai argumen untuk setMinWidth() dan nilai 200 akan diberikan sebagai argumen untuk setPreferredWith(). Jangan lupa bahwa glazedColumn() adalah sebuah TableColumn yang memiliki property seperti minWidth, preferredWidth dan maxWidth. Bila ingin sebuah kolom memiliki ukuran yang statis dan tidak bisa di-resize, maka gunakan angka sebagai nilai pada width seperti width: 200.

Iklan

Belajar Membuat Komponen Baru Di Swing

NetBeans Swing GUI Builder boleh dibilang adalah visual editor terbaik untuk Swing. Berkat NetBeans Swing GUI Builder, pengguna dapat merancang GUI berbasis Swing dengan mudah dan cepat seperti pada seri ‘visual’ -nya Microsoft. Masalah yang kemudian muncul adalah kampus lokal kemudian membuat kurikulum pemograman Swing dan menggantinya dengan visual builder. Mereka berusaha mengajarkan Swing dan Java seperti pada teknik Rapid Application Development (RAD) layaknya Visual Basic.

Memilih untuk memakai GUI Builder atau membuat GUI secara kode program adalah selera ‘hidup’ masing-masing developer yang tidak boleh dipaksakan. Tapi, mempelajari komponen Swing tanpa menyentuh arsitektur Swing dan memperlakukan mereka layaknya komponen terbatas seperti di seri ‘visual’ adalah sebuah kesalahan besar. Mengapa demikian? Hal ini karena justru kelebihan utama Swing adalah sifatnya yang modular dan extensible.

Sebagai contoh, saya menemukan keterbatasan JToolBar pada kasus yang saya hadapi. Bila ada banyak icon di JToolBar dan ukuran layar terbatas, maka mereka akan terpotong. Sebagai contoh, pada JToolBar saya, terdapat 9 icon besar. Tapi bila JFrame diperkecil, maka icon yang tidak terlihat akan ‘hilang’ seperti yang ditunjukkan pada gambar berikut ini:

JToolBar akan menyembunyikan icon bila tidak muat

JToolBar akan menyembunyikan icon bila tidak muat

Saya membutuhkan fasilitas scrolling sehingga saya tetap dapat memilih icon yang tidak ditampilkan. Tapi, tidak ada properties di JToolBar yang bisa saya atur untuk keperluan ini! Apakah ini berarti ‘kiamat’? Pada teknologi GUI lain, bila sudah tidak ada properties yang bisa diatur, jawabannya mungkin ‘iya’. Tapi tidak untuk Swing! Pada Swing, saya bisa membuat komponen baru dengan mudah. Saya bisa men-reuse elemen dari sebuah komponen tanpa harus membuat segala sesuatunya dari awal.

Sebagai contoh, saya membuat versi JToolBar yang bisa di-scroll dengan nama ScrollableToolBar yang kode programnya terlihat seperti berikut ini:

import javax.swing.*;
import javax.swing.event.*;
import javax.swing.plaf.basic.BasicArrowButton;
import java.awt.*;
import java.awt.event.*;

public class ScrollableToolBar extends JPanel {

    private JToolBar toolBar;
    private JScrollPane scrollPane;
    private JButton btnLeft;
    private JButton btnRight;

    public ActionLeft actionLeft = new ActionLeft();
    public ActionRight actionRight = new ActionRight();

    public ScrollableToolBar() {
        toolBar = new JToolBar();
        scrollPane = new JScrollPane(toolBar, ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
        scrollPane.getViewport().addChangeListener(new DisplayButtonChangeListener());
        btnLeft = new BasicArrowButton(SwingConstants.WEST);
        btnLeft.setAction(actionLeft);
        btnLeft.addMouseListener(new PressButtonMouseAdapter(actionLeft));
        btnRight = new BasicArrowButton(SwingConstants.EAST);
        btnRight.addMouseListener(new PressButtonMouseAdapter(actionRight));

        setLayout(new BorderLayout());
        add(scrollPane, BorderLayout.CENTER);
        add(btnLeft, BorderLayout.LINE_START);
        add(btnRight, BorderLayout.LINE_END);
    }

    public JToolBar getToolBar() {
        return toolBar;
    }

    @Override
    public Component add(Component comp) {
        return toolBar.add(comp);
    }

    public class ActionLeft extends AbstractAction {

        @Override
        public void actionPerformed(ActionEvent e) {
            JScrollBar scrollBar = scrollPane.getHorizontalScrollBar();
            scrollBar.setValue(scrollBar.getValue() - scrollBar.getBlockIncrement());
        }

    }

    public class ActionRight extends AbstractAction {

        @Override
        public void actionPerformed(ActionEvent e) {
            JScrollBar scrollBar = scrollPane.getHorizontalScrollBar();
            scrollBar.setValue(scrollBar.getValue() + scrollBar.getBlockIncrement());
        }

    }

    public class PressButtonMouseAdapter extends MouseAdapter implements ActionListener {

        private Action action;
        private Timer timer;

        public PressButtonMouseAdapter(final Action action) {
            this.action = action;
            timer = new Timer(20, this);
        }

        @Override
        public void mousePressed(MouseEvent e) {
            timer.start();
        }

        @Override
        public void mouseReleased(MouseEvent e) {
            timer.stop();
        }

        @Override
        public void actionPerformed(ActionEvent e) {
            action.actionPerformed(null);
        }
    }

    private class DisplayButtonChangeListener implements ChangeListener {

        @Override
        public void stateChanged(ChangeEvent e) {
            JViewport viewport = scrollPane.getViewport();
            boolean buttonVisible = (toolBar.getWidth() > viewport.getWidth());
            btnLeft.setVisible(buttonVisible);
            btnRight.setVisible(buttonVisible);
        }
    }

}

Sekarang, saya dapat melakukan scrolling pada JToolBar seperti yang diperlihatkan pada gambar berikut ini:

Komponen baru dengan JToolBar yang dapat di-scroll

Komponen baru dengan JToolBar yang dapat di-scroll

ScrollableToolBar diturunkan dari class JPanel sehingga merupakan komponen yang terdiri dari beberapa komponen lainnya. Pada kode program di atas, ScrollableToolBar terdiri atas 2 JButton, sebuah JScrollPane dan sebuah JToolBar yang masing-masing diwakili oleh variabel seperti yang terlihat pada visualisasi berikut ini:

Komposisi dari ScrollableToolBar

Komposisi dari ScrollableToolBar

Saya membutuhkan JScrollPane untuk melakukan scrolling. Tapi, saya tidak ingin memperlihatkan scrollbar. Oleh sebab itu, saya membuat JScrollPane dengan constructor seperti pada:

scrollPane = new JScrollPane(toolBar, 
   ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, 
   ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);

btnLeft dan btnRight adalah sebuah JButton biasa yang memiliki gambar panah. Untuk itu, saya memakai BasicArrowButton yang merupakan komponen yang dipakai oleh JScrollBar. Yup, scrollbar sudah punya tombol panah, saya tidak perlu membuat lagi dari awal!. Disini terlihat bahwa saya bisa me-reuse elemen sebuah komponen dengan mudah. Btw, visual editor biasanya tidak akan menampilkan BasicArrowButton untuk di-drag sehingga komponen seperti ini hanya bisa dipakai melalui kode program! Pada constructor BasicArrowButton, saya menentukan arah tanda panah seperti yang terlihat pada kode program berikut ini:

btnLeft = new BasicArrowButton(SwingConstants.WEST);
btnRight = new BasicArrowButton(SwingConstants.EAST);

Untuk melakukan scrolling pada btnLeft atau btnRight di-klik, saya memanipulasi scrollbar tak terlihat milik JScrollPane seperti yang terlihat pada kode program berikut ini:

public class ActionLeft extends AbstractAction {

  @Override
  public void actionPerformed(ActionEvent e) {
    JScrollBar scrollBar = scrollPane.getHorizontalScrollBar();
    scrollBar.setValue(scrollBar.getValue() - scrollBar.getBlockIncrement());
  }

}

public class ActionRight extends AbstractAction {

   @Override
   public void actionPerformed(ActionEvent e) {
     JScrollBar scrollBar = scrollPane.getHorizontalScrollBar();
     scrollBar.setValue(scrollBar.getValue() + scrollBar.getBlockIncrement());
   }

}

Saya tidak perlu khawatir bila nilai yang berikan melalui JScrollBar.setValue() melewati batas yang diperbolehkan karena setValue() akan memakai nilai maksimal atau nilai minimum bila ada nilai yang melewati batas yang diperbolehkan.

Action di atas didaftarkan agar dikerjakan bila JButton di-klik. Pada saat melakukan scrolling, pengguna cenderung tidak hanya men-klik, melainkan menahan tombol sehingga proses scrolling dapat berlangsung terus menerus. Oleh sebab itu, saya memakai MouseAdapter untuk menjalankan sebuah timer yang aktif bila pengguna menahan tombol (pada handler mousePressed) dan tidak aktif setelah pengguna melepaskan tombol (pada handler mouseReleased). Hal ini terlihat pada cuplikan kode program berikut ini:

public class PressButtonMouseAdapter extends MouseAdapter implements ActionListener {

  private Action action;
  private Timer timer;

  public PressButtonMouseAdapter(final Action action) {
    this.action = action;
    timer = new Timer(20, this);
  }

  @Override
  public void mousePressed(MouseEvent e) {
    timer.start();
  }

  @Override
  public void mouseReleased(MouseEvent e) {
    timer.stop();
  }

  @Override
  public void actionPerformed(ActionEvent e) {
    action.actionPerformed(null);
  }
}

Class javax.swing.Timer (jangan tertukar dengan java.util.Timer!) adalah sebuah class yang sangat berguna untuk mengerjakan sebuah aksi pada GUI secara periodik. Class ini tidak terlihat di NetBeans Swing GUI Editor sehingga mahasiswa yang mempelajari Swing sebagai komponen drag-n-drop cenderung mengabaikannya. Ini adalah salah satu alasan mengapa saya selalu menyarankan mahasiswa mulai membuat GUI berbasis Swing melalui kode program terlebih dahulu. Setelah akrab dengan Swing, mereka boleh menyentuh Swing GUI Editor (analogi yang sama pada dunia web: mahasiswa belajar menulis HTML dan CSS terlebih dahulu baru boleh menyentuh GUI designer seperti Adobe Dreamweaver).

Btw, salah satu cara jitu untuk mempelajari Swing adalah dengan mempelajari kode program untuk komponen Swing itu sendiri. Yup, source untuk seluruh komponen Swing (dan juga API Java lainnya) bisa dibaca secara bebas!. Sebagai contoh, saya meniru cara memakai Timer saat tombol mouse ditahan dengan mempelajari kode program untuk JScrollBar itu sendiri yang bisa ditemui pada class javax.swing.plaf.basic.BasicScrollBarUI. Ini adalah cuplikan kode programnya:

...

/**
 * Listener for cursor keys.
 */
protected class ArrowButtonListener extends MouseAdapter
{
   // Because we are handling both mousePressed and Actions
   // we need to make sure we don't fire under both conditions.
   // (keyfocus on scrollbars causes action without mousePress
   boolean handledEvent;

   public void mousePressed(MouseEvent e)          {
     if(!scrollbar.isEnabled()) { return; }
       // not an unmodified left mouse button
       //if(e.getModifiers() != InputEvent.BUTTON1_MASK) {return; }
       if( ! SwingUtilities.isLeftMouseButton(e)) { return; }
       int direction = (e.getSource() == incrButton) ? 1 : -1;

       scrollByUnit(direction);
       scrollTimer.stop();
       scrollListener.setDirection(direction);
       scrollListener.setScrollByBlock(false);
       scrollTimer.start();

       handledEvent = true;
       if (!scrollbar.hasFocus() && scrollbar.isRequestFocusEnabled()) {
          scrollbar.requestFocus();
       }
   }

   public void mouseReleased(MouseEvent e)         {
       scrollTimer.stop();
       handledEvent = false;
       scrollbar.setValueIsAdjusting(false);
   }
}
...

Sebagai langkah terakhir, saya ingin tombol btnLeft dan btnRight hanya muncul bila wilayah yang ada tidak cukup. Bila masih ada banyak tempat kosong, maka kedua tombol tersebut tidak perlu muncul. Untuk itu, saya perlu berinteraksi dengan JViewPort yang dimiliki oleh JScrollPane. Class JViewPort mewakili wilayah yang terlihat di JScrollPane. Saya bisa mengerjakan sebuah aksi setiap kali wilayah ini berubah dengan membuat kode program seperti berikut ini:

scrollPane.getViewport().addChangeListener(
   new DisplayButtonChangeListener());

Pada ChangeListener yang ada, saya akan membandingkan lebar JViewPort dengan lebar JToolBar. Bila JViewPort masih cukup menampung seluruh JToolBar, maka saya menyembunyikan btnLeft dan btnRight seperti yang terlihat pada cuplikan kode program berikut ini:

private class DisplayButtonChangeListener implements ChangeListener {

   @Override
   public void stateChanged(ChangeEvent e) {
     JViewport viewport = scrollPane.getViewport();
     boolean buttonVisible = (toolBar.getWidth() > viewport.getWidth());
     btnLeft.setVisible(buttonVisible);
     btnRight.setVisible(buttonVisible);
   }

}

Sampai disini, saya sudah memperoleh sebuah komponen baru sesuai dengan keinginan saya, cukup dengan membuat sebuah class baru. Sifat reusable dan extensible merupakan kelebihan Swing yang membuatnya jauh lebih tangguh dari sekedar komponen visual biasa yang di-drag n drop pada GUI editor.

Menerapkan MVC di Swing Dengan Griffon

Walaupun terkenal, MVC adalah sebuah paradigma yang dapat diterapkan dengan berbagai cara. Bahkan hingga saat ini, masih tidak ada sebuah arsitektur universal untuk MVC yang disepakati oleh semua developer. Sebagai contoh, paradigma MVC memang mensyaratkan pemisahan tanggung jawab ke dalam model, view dan controller, tapi sering ada pertanyaan seperti “apa sih yang masuk di bagian model?”. Pada artikel ini, saya menuliskan penerapan MVC yang selama ini terbukti efektif bagi kebutuhan saya. Penerapan ini sehari-hari saya pakai pada aplikasi desktop yang dikembangkan dengan menggunakan Java Swing, Griffon dan simple-jpa. ‘Efektif’ yang saya maksud adalah selama ini saya dapat mengubah presentation layer dengan cepat. ‘Efektif’ juga berarti saya tidak pernah pusing dalam menjelajahi kembali kode presentation layer setelah istirahat berbulan-bulan.

Anggap saja saya ingin membuat presentation layer yang memiliki tampilan seperti yang terlihat pada gambar berikut ini:

Screen yang hendak dibuat

Screen yang hendak dibuat

Ini adalah screen yang umum dijumpai untuk mewakili kartu stok pada sebuah produk. Apa yang saya lakukan untuk membuat screen tersebut?

Langkah pertama yang saya lakukan adalah membuat model. Pada MVC untuk aplikasi desktop, model adalah semua data yang berhubungan dengan layar, bukan saja domain model. Sebagai contoh, pada screen yang hendak saya buat, saya perlu nilai di model untuk mewakili JComboBox. Yang pasti, juga butuh nilai di model untuk mewakili isi tabel. Selain itu, saya juga perlu memiliki nilai di model untuk mewakili masing-masing JCheckBox. Dengan demikian, saya membuat kode program model sehingga terlihat seperti berikut ini:

class ItemStokModel {

    @Bindable boolean showReferensiFinance
    @Bindable boolean showReferensiGudang
    @Bindable boolean showPembuat
    @Bindable boolean showKeterangan

    BasicEventList<ItemStok> itemStokList = new BasicEventList<>()

    BasicEventList<PeriodeItemStok> periodeItemStokList = new BasicEventList<>()
    DefaultEventComboBoxModel<PeriodeItemStok> periodeItemStok = 
        GlazedListsSwing.eventComboBoxModelWithThreadProxyList(periodeItemStokList)


}

Setiap atribut di model mewakili nilai untuk setiap komponen di view. Tidak semua komponen di view perlu memiliki nilai di model. Sebagai contoh, di view terdapat 2 JButton, tetapi karena saya tidak tertarik pada nilai-nya, maka saya tidak perlu membuat atribut untuk mewakili nilai JButton tersebut di model.

Langkah berikutnya adalah membuat view. Sebagai contoh, saya membuat view dengan SwingBuilder seperti berikut ini:

panel(id: 'mainPanel') {
    borderLayout()

    panel(constraints: PAGE_START) {
        flowLayout(alignment: FlowLayout.LEADING)
        comboBox(id: 'periodeItemStok', model: model.periodeItemStok,
            templateRenderer: "${it.tanggalMulai.toString('MMMM YYYY')} (Jumlah: ${it.jumlah})")
        button(app.getMessage('simplejpa.search.label'), actionPerformed: controller.search)
        checkBox('Referensi Finance', selected: bind('showReferensiFinance', target: model, mutual: true))
        checkBox('Referensi Gudang', selected: bind('showReferensiGudang', target: model, mutual: true))
        checkBox('Pembuat', selected: bind('showPembuat', target: model, mutual: true))
        checkBox('Keterangan', selected: bind('showKeterangan', target: model, mutual: true))
    }

    scrollPane(constraints: CENTER) {
        glazedTable(id: 'table', list: model.itemStokList, sortingStrategy: SINGLE_COLUMN) {
            glazedColumn(name: 'Tanggal', property: 'tanggal', width: 100) {
                templateRenderer("${it.toString('dd-MM-yyyy')}")
            }
            glazedColumn(name: 'Qty', property: 'jumlah', columnClass: Integer, width: 40)
            glazedColumn(name: 'Pihak Terkait', expression: {it.referensiStok?.pihakTerkait?: ''})
            glazedColumn(name: 'Referensi Finance', expression: {it.referensiStok?.deskripsiFinance()?: ''},
                visible: bind {model.showReferensiFinance})
            glazedColumn(name: 'Referensi Gudang', expression: {it.referensiStok?.deskripsiGudang()?: ''},
                visible: bind {model.showReferensiGudang})
            glazedColumn(name: 'Dibuat', expression: {it.referensiStok?.dibuatOleh?:''},
                visible: bind {model.showPembuat})
            glazedColumn(name: 'Diubah', expression: {it.referensiStok?.diubahOleh?:''},
                visible: bind {model.showPembuat})
            glazedColumn(name: 'Keterangan', property: 'keterangan', visible: bind {model.showKeterangan})
        }
    }

    panel(constraints: PAGE_END) {
        flowLayout(alignment: FlowLayout.LEADING)
        button(app.getMessage("simplejpa.dialog.close.button"), actionPerformed: controller.tutup)
    }
}

Bila model berisi nilai untuk sebuah komponen, maka view berisi deklarasi komponen itu sendiri. Sebagai contoh, saya membuat beberapa JCheckBox disini. Tapi saya sama sekali tidak tertarik pada JCheckBox itu sendiri (saya bahkan tidak menyimpannya di variabel untuk mengaksesnya kembali nanti!). Yang paling saya butuhkan adalah nilai boolean seperti showReferensiFinance atau showKeterangan di model.

Bagaimana caranya supaya perubahan komponen di view bisa memperbaharui nilai di model secara otomatis? Jawabannya adalah dengan menggunakan observer pattern. Griffon/Groovy mempermudah proses ini dengan fasilitas bind(). Sebagai contoh, pada checkBox(), saya membuat deklarasi seperti selected: bind('showPembuat', target: model, mutual: true). Ini akan menciptakan binding pada nilai atribut JCheckBox.selected dan nilai model.showPembuat. Karena atribut JCheckBox.selected akan bernilai true bila checbox tersebut diberi tanda centang, maka nilai showPembuat akan menjadi true bila checkbox diberi tanda centang, seperti yang terlihat pada gambar berikut ini:

Binding dari view ke model

Binding dari view ke model

Dengan observer pattern, saya tidak perlu memanggil setter secara manual lagi karena ini dilakukan secara otomatis setiap kali JCheckBox di-klik oleh pengguna. Karena saya menambahkan deklarasi mutual: true pada bind(), maka proses binding juga berlaku dari arah yang sebaliknya. Bila saya mengubah nilai showPembuat di model menjadi true atau false, maka nilai selected di JCheckBox juga akan ikut berubah, seperti yang terlihat pada gambar berikut ini:

Binding dari model ke view

Binding dari model ke view

Pada saat sebuah JCheckBox yang ada di-klik oleh pengguna, saya ingin kolom di tabel dimunculkan atau disembunyikan. Hal ini saya lakukan agar tidak menampilkan terlalu banyak kolom yang dapat memusingkan pengguna, tetapi saya juga tidak ingin pengguna kekurangan informasi dalam melakukan troubleshooting (misalnya, bila ada selisih stok di gudang). Untuk itu, saya hanya perlu melakukan binding di kolom tabel seperti:

glazedTable() {
  glazedColumn(..., visible: bind {model.showReferensiFinance})
  glazedColumn(..., visible: bind {model.showReferensiGudang})
  glazedColumn(..., visible: bind {model.showDibuatOleh})
  glazedColumn(..., visible: bind {model.showDibuatOleh})
  glazedColumn(..., visible: bind {model.showKeterangan})
}

Berkat observer pattern, setiap checkbox akan bekerja dengan baik tanpa perlu tambahan kode program lain sama sekali. Proses yang terjadi dibalik layar yang menyebabkan checkbox bisa bekerja akan terlihat seperti pada UML Communication Diagram berikut ini:

Perubahan view secara otomatis melalui binding

Perubahan view secara otomatis melalui binding

Pada pemograman web, anjuran yang paling sering didengar adalah memisahkan antara HTML dan JavaScript pada file yang berbeda. HTML hanya perlu berisi informasi yang dibutuhkan sebagai struktur halaman, sementara file JavaScript (.js) berisi semua kode program JavaScript yang dipakai oleh file HTML. Berdasarkan analogi ini, saya melakukan pemisahan antara view dan controller. Kode program seperti aksi yang akan dikerjakan bila sebuah JButton di-klik harus terletak di controller bukan di view. Untuk menghubungkan view dan controller, saya membuat deklarasi seperti berikut ini di view:

button('Cari', actionPerformed: controller.search)
...
button('Tutup', actionPerformed: controller.tutup)

Kode program controller yang saya buat terlihat seperti berikut ini:

class ItemStokController {

    def model
    def view

    ProdukRepository produkRepository

    void mvcGroupInit(Map args) {
        model.parent = args.'parent'
        model.showReferensiFinance = true
        model.showReferensiGudang = false
        model.showPembuat = false
        model.showKeterangan = true
        execInsideUISync {
            model.periodeItemStokList.clear()
        }
        List periodeItemStok = ...
        execInsideUISync {
            model.periodeItemStokList.addAll(periodeItemStok)
        }
    }

    def cari = {
        ...
    }

    def tutup = {
        ...
    }

}

Kode program controller akan mengisi nilai pada model. Ia juga akan memanggil repository untuk membaca data dari database, memanggil method dari domain class untuk mengerjakan business logic, dan sebagainya.

Selain mengisi nilai model, apa saja kode program yang boleh diletakkan di controller? Saya hanya meletakkan kode program yang berkaitan dengan presentation layer, dalam hal ini adalah Swing, seperti kode program untuk menutup dialog, melakukan resizing tampilan, dan sejenisnya. Kode program untuk membaca dan menulis ke database harus berada di class lain. Kode program yang berkaitan dengan business logic juga harus berada di class lain, tepatnya berada di domain class.

Membuat dan Mengatur JTable + GlazedLists Dengan Mudah Di simple-jpa 0.5

Salah satu perubahan yang cukup besar yang baru saja saya selesaikan di simple-jpa 0.5 adalah merombak node yang menampilkan tabel di view Griffon.   Walaupun plugin simple-jpa pada dasarnya berkaitan dengan persistence layer atau database, saya sengaja menambahkan beberapa node Swing builder yang dipakai di view.   Hal ini karena hampir semua aplikasi bisnis memiliki view yang menyajikan data dalam bentuk tabel.   Dengan menyediakan node tersebut, saya berusaha agar proses pembuatan dan perubahan tabel bisa semudah dan senyaman mungkin.   Btw, Griffon telah memiliki plugin khusus untuk GlazedLists yang juga menyediakan node baru.   Plugin tersebut sebenarnya lebih lengkap tetapi ditujukan untuk keperluan umum.   Sementara itu, yang saya kembangkan di simple-jpa lebih merupakan cara singkat untuk menyelesaikan kebutuhan sehari-hari yang sering saya jumpai saat bekerja dengan tabel.

Node baru yang saya tambahkan akan diawali dengan glazedXXX(), misalnya glazedTable() dan glazedColumn().   Ada juga beberapa yang sifatnya tidak terikat pada GlazedLists seperti templateRenderer() dan condition().   Node lama yang dipakai di versi sebelum 0.5 seperti eventTableModel() dan tableColumnConfig() masih tersedia tapi tidak disarankan untuk dipakai lagi.

Sebagai percobaan, saya akan membuat sebuah proyek baru yang memakai Griffon 1.4 dan plugin simple-jpa 0.5.   Saya kemudian menambahkan sebuah domain class baru dengan nama Mahasiswa yang isinya seperti berikut ini:

package domain

import ...

@DomainModel @Entity @Canonical
class Mahasiswa {

    @Size(min=5, max=5)
    String nim

    @NotNull @Type(type="org.jadira.usertype.dateandtime.joda.PersistentDateTime")
    LocalDate tanggalLahir

    @NotNull @Type(type="org.jadira.usertype.dateandtime.joda.PersistentDateTime")
    LocalDate tanggalDaftar

    @NotNull @Type(type="org.jadira.usertype.dateandtime.joda.PersistentDateTime")
    LocalDate tanggalLulus

    @NotBlank @Size(max=50)
    String nama

    @Size(min=3, max=3)
    String kelas

    @ElementCollection
    Set daftarNilai = new HashSet<>()

    public Float nilaiRataRata() {
        daftarNilai.sum() / daftarNilai.size()
    }

}

Setelah itu, saya memakai fasilitas scaffolding dari simple-jpa untuk menghasilkan sebuah MVC baru.   Saya kemudian mengubah isi dari file MahasiswaModel.groovy menjadi seperti berikut ini:

package project

import ...

class MahasiswaModel {

    BasicEventList mahasiswaList = new BasicEventList<>()

}

Pada kode program di atas, saya mendeklarasikan sebuah BasicEventList (dari GlazedLists) yang akan saya pakai untuk menampung seluruh instance dari Mahasiswa yang akan ditampilkan di tabel nantinya.   Kode program yang memakainya terlihat di MahasiswaController.groovy seperti berikut ini:

package project

import ...

@SimpleJpaTransaction
class MahasiswaController {

    def model
    def view

    void mvcGroupInit(Map args) {
        execInsideUISync {
            DateTimeFormatter df = DateTimeFormat.forPattern("dd-MM-yyyy")
            model.mahasiswaList << new Mahasiswa("99001", df.parseLocalDate("01-03-1985"),
                df.parseLocalDate("09-09-2003"), df.parseLocalDate("10-10-2010"),
                "Jocki Hendry", "T01",  [90, 95, 80, 70, 90, 93, 87, 77, 88, 90].toSet())
            model.mahasiswaList << new Mahasiswa("99002", df.parseLocalDate("14-02-1987"),
                df.parseLocalDate("01-09-2005"), df.parseLocalDate("10-11-2012"),
                "Lena", "T01",  [80, 70, 60, 80, 75, 85, 50, 70, 90, 90].toSet())
            model.mahasiswaList << new Mahasiswa("99003", df.parseLocalDate("01-03-1986"),
                df.parseLocalDate("09-09-2006"), df.parseLocalDate("10-10-2012"),
                "Berry", "T02",  [90, 90, 88, 88, 99, 87, 60, 89].toSet())
            model.mahasiswaList << new Mahasiswa("99004", df.parseLocalDate("01-03-1983"),
                df.parseLocalDate("09-09-2003"), null,
                "Sandy", "T02",  [50, 60, 40, 30, 80, 40, 30, 90, 55, 70, 60].toSet())
            model.mahasiswaList << new Mahasiswa("99005", df.parseLocalDate("01-03-1984"),
                df.parseLocalDate("09-09-2004"), null,
                "Gerry", "T03",  [40, 30, 50, 30, 80, 40, 30, 55, 65, 70, 66, 50].toSet())
        }
    }

    void mvcGroupDestroy() {
        destroyEntityManager()
    }

}

Kode program pada mvcGroupInit() akan dikerjakan pada saat MVCGroup dibuat.   Kode program tersebut akan membuat lima instance dari class Mahasiswa dan meletakkannya pada model.mahasiswaList (sebuah BasicEventList yang saya definisikan sebelumnya).

Setelah BasicEventList terisi, langkah selanjutnya adalah menampilkannya.   Bagaimana cara menampilkan BasicEventList yang berisi kumpulan Mahasiswa tersebut? Saya cukup memberikan kode program berikut ini di MahasiswaView.groov:

package project

import ...

application(title: 'Mahasiswa',
        preferredSize: [520, 340],
        pack: true,
        locationByPlatform: true) {

    panel(id: 'mainPanel') {
        borderLayout()

        scrollPane(constraints: CENTER) {
            glazedTable(list: model.mahasiswaList) {
                glazedColumn(name: 'Nim', property: 'nim')
                glazedColumn(name: 'Nama', property: 'nama')
                glazedColumn(name: 'Kelas', property: 'kelas')
            }
        }
    }
}

Pada kode program di atas, node glazedTable() mewakili sebuah JTable secara keseluruhan.   Node ini akan selalu mengembalikan sebuah instance dari GlazedTable yang merupakan sebuah JTable.   Di dalam node glazedTable(), saya menambahkan masing-masing node glazedColumn() untuk mewakili sebuah kolom di tabel.   Atribut name akan menjadi judul untuk kolom tersebut, sementara itu atribut property adalah nama property dari object Mahasiswa yang harus ditampilkan untuk tabel tersebut.   Hasilnya akan terlihat seperti pada gambar berikut ini:

Tampilan Tabel Biasa

Tampilan Tabel Biasa

Dengan kode program yang sederhana yang mudah dipahami, saya sudah menampilkan isi dari sebuah BasicEventList melalui sebuah JTable.

Tapi seringkali saya tidak hanya perlu menampilkan isi property, tapi juga perlu menampilkan hasil kalkulasi dari method.   Sebagai contoh, pada class Mahasiswa terdapat method hitungRataRata() yang akan mengembalikan nilai rata-rata dari sebuah object mahasiswa.   Untuk mengerjakan sebuah method, saya dapat menggunakan atribut expression dari node GlazedColumn() seperti yang ditunjukkan oleh kode program berikut ini:

package project

import ...

application(title: 'Mahasiswa',
        preferredSize: [520, 340],
        pack: true,
        locationByPlatform: true) {

    panel(id: 'mainPanel') {
        borderLayout()

        scrollPane(constraints: CENTER) {
            glazedTable(list: model.mahasiswaList) {
                glazedColumn(name: 'Nim', property: 'nim')
                glazedColumn(name: 'Nama', property: 'nama')
                glazedColumn(name: 'Kelas', property: 'kelas')
                glazedColumn(name: 'Nilai Rata-Rata', expression: { it.nilaiRataRata() })
            }
        }
    }
}

Pada closure yang diberikan untuk atribut expression, nilai it akan merujuk pada object yang sedang ditampilkan (ingat bahwa setiap baris adalah sebuah object!).   Tampilan kode program di atas akan terlihat seperti:

Tabel dengan kolom berisi hasil perhitungan

Tabel dengan kolom berisi hasil perhitungan

Dengan menggunakan atribut expression, saya dapat melewatkan kode program apa saja untuk menghitung nilai sebuah kolom.   Sebagai contoh, saya menambahkan beberapa kolom yang merupakan hasil perhitungan sehingga kode program view menjadi seperti berikut ini:

package project

import ...

application(title: 'Mahasiswa',
        preferredSize: [520, 340],
        pack: true,
        locationByPlatform: true) {

    panel(id: 'mainPanel') {
        borderLayout()

        scrollPane(constraints: CENTER) {
            glazedTable(list: model.mahasiswaList) {
                glazedColumn(name: 'Nim', property: 'nim')
                glazedColumn(name: 'Nama', property: 'nama')
                glazedColumn(name: 'Kelas', property: 'kelas')
                glazedColumn(name: 'Usia', expression: { Years.yearsBetween(it.tanggalLahir, LocalDate.now()).years })
                glazedColumn(name: 'Angkatan', expression: { it.tanggalDaftar.getYear() })
                glazedColumn(name: 'Tahun Lulus', expression: { it.tanggalLulus?.getYear() })
                glazedColumn(name: 'Nilai Rata-Rata', expression: { it.nilaiRataRata() })
                glazedColumn(name: 'Nilai OK?', expression: { it.nilaiRataRata() > 70 })

            }
        }
    }
}

Kode program di atas memiliki 5 kolom yang nilainya merupakan hasil kalkulasi dari closure, seperti yang terlihat pada gambar berikut ini:

Tabel dengan kolom hasil kode program

Tabel dengan kolom hasil kode program

Node glazedColumn memiliki atribut columnClass yang dapat dipakai sebagai informasi jenis tipe data dari sebuah kolom. Tampilan dari tabel akan disesuaikan berdasarkan tipe data tersebut. Sebagai contoh, bila kolom memiliki columnClass dengan tipe data angka, maka nilai dari kolom tersebut akan rata kanan. Saya dapat menambahkan informasi columnClass seperti pada contoh berikut ini:

package project

import ...

application(title: 'Mahasiswa',
        preferredSize: [520, 340],
        pack: true,
        locationByPlatform: true) {

    panel(id: 'mainPanel') {
        borderLayout()

        scrollPane(constraints: CENTER) {
            glazedTable(list: model.mahasiswaList) {
                glazedColumn(name: 'Nim', property: 'nim')
                glazedColumn(name: 'Nama', property: 'nama')
                glazedColumn(name: 'Kelas', property: 'kelas')
                glazedColumn(name: 'Usia', expression: { Years.yearsBetween(it.tanggalLahir, LocalDate.now()).years },
                    columnClass: Integer)
                glazedColumn(name: 'Angkatan', expression: { it.tanggalDaftar.getYear() },
                    columnClass: Integer)
                glazedColumn(name: 'Tahun Lulus', expression: { it.tanggalLulus?.getYear() },
                    columnClass: Integer)
                glazedColumn(name: 'Nilai Rata-Rata', expression: { it.nilaiRataRata() },
                    columnClass: Float)
                glazedColumn(name: 'Nilai OK?', expression: { it.nilaiRataRata() > 70 },
                    columnClass: Boolean)
            }
        }
    }
}

Tampilan program saat dijalankan kini akan menjadi seperti yang terlihat pada gambar berikut ini:

Tampilan tabel setelah definisi tipe data kolom

Tampilan tabel setelah definisi tipe data kolom

Tidak seperti perancangan view di simple-jpa versi sebelumnya, node glazedColumn() memiliki pemisahan antara nilai dan renderer-nya.   Sebuah nilai dapat ditampilkan melalui renderer yang men-format nilai tersebut.   Operasi seperti pengurutan (sorting) kolom nantinya akan berdasarkan nilai asli, bukan berdasarkan nilai yang sudah di-format.   Salah satu renderer bawaan yang sangat berguna disini adalah templateRenderer().   Sebagai contoh, saya menambahkan renderer pada view saya sehingga menjadi seperti berikut ini:

package project

import ...

application(title: 'Mahasiswa',
        preferredSize: [520, 340],
        pack: true,
        locationByPlatform: true) {

    panel(id: 'mainPanel') {
        borderLayout()

        scrollPane(constraints: CENTER) {
            glazedTable(list: model.mahasiswaList) {
                glazedColumn(name: 'Nim', property: 'nim')
                glazedColumn(name: 'Nama', property: 'nama')
                glazedColumn(name: 'Kelas', property: 'kelas')
                glazedColumn(name: 'Usia', expression: { Years.yearsBetween(it.tanggalLahir, LocalDate.now()).years },
                    columnClass: Integer)
                glazedColumn(name: 'Angkatan', expression: { it.tanggalDaftar.getYear() },
                        columnClass: Integer) {
                    templateRenderer(templateString: '${it}/${it+1}')
                }
                glazedColumn(name: 'Tahun Lulus', expression: { it.tanggalLulus?.getYear() },
                        columnClass: Integer) {
                    templateRenderer(templateString: "\${it?:'Belum Lulus'}")
                }
                glazedColumn(name: 'Nilai Rata-Rata', expression: { it.nilaiRataRata() },
                        columnClass: Float) {
                    templateRenderer(templateString: "\${floatFormat(it,2)}")
                }
                glazedColumn(name: 'Nilai OK?', expression: { it.nilaiRataRata() > 70 },
                    columnClass: Boolean)

            }
        }
    }
}

Tampilan view kini akan terlihat seperti pada gambar berikut ini:

Memakai template renderer

Memakai template renderer

Pada atribut templateString di node templateRenderer(), saya dapat memberikan sebuah ekspresi Groovy.   Nilai dari variabel it dan value akan merujuk pada nilai yang hendak ditampilkan.   Walaupun templateString dapat berisi kode program apa saja, sebaiknya atribut tersebut tidak melakukan proses yang rumit (karena proses yang rumit sebaiknya diolah di controller).   Agar ekspresi di templateString tidak terlalu panjang, saya menyediakan beberapa fungsi untuk men-format data, yang berupa:  numberFormat(), floatFormat(), percentFormat(), currencyFormat(), dan titleCase().

Node templateRenderer() pada dasarnya akan menghasilkan sebuah JLabel.   Hal ini menyebabkan saya dapat mengkonfigurasi property JLabel yang dihasilkan dengan melewatkannya sebagai atribut di templateRenderer().   Salah satu contoh yang sering dipakai adalah mengubah sebuah kolom menjadi rata kiri, rata tengah, atau rata kanan, seperti yang terlihat pada kode program berikut ini:

package project

import ...

application(title: 'Mahasiswa',
        preferredSize: [520, 340],
        pack: true,
        locationByPlatform: true) {

    panel(id: 'mainPanel') {
        borderLayout()

        scrollPane(constraints: CENTER) {
            glazedTable(list: model.mahasiswaList) {
                glazedColumn(name: 'Nim', property: 'nim')
                glazedColumn(name: 'Nama', property: 'nama')
                glazedColumn(name: 'Kelas', property: 'kelas')
                glazedColumn(name: 'Usia', expression: { Years.yearsBetween(it.tanggalLahir, LocalDate.now()).years },
                    columnClass: Integer)
                glazedColumn(name: 'Angkatan', expression: { it.tanggalDaftar.getYear() },
                        columnClass: Integer) {
                    templateRenderer(templateString: '${it}/${it+1}', horizontalAlignment: SwingConstants.CENTER)
                }
                glazedColumn(name: 'Tahun Lulus', expression: { it.tanggalLulus?.getYear() },
                        columnClass: Integer) {
                    templateRenderer(templateString: "\${it?:'Belum Lulus'}", horizontalAlignment: SwingConstants.CENTER)
                }
                glazedColumn(name: 'Nilai Rata-Rata', expression: { it.nilaiRataRata() },
                        columnClass: Float) {
                    templateRenderer(templateString: "\${floatFormat(it,2)}", horizontalAlignment: SwingConstants.RIGHT)
                }
                glazedColumn(name: 'Nilai OK?', expression: { it.nilaiRataRata() > 70 },
                    columnClass: Boolean)

            }
        }
    }
}

Sebuah node templateRenderer() dapat menerima satu atau lebih node condition().   Fungsi node condition() adalah melakukan evaluasi pada saat tabel sudah terisi dengan data dan melakukan perubahan property dari renderer berdasarkan kondisi tersebut.   Sebagai contoh, saya dapat membuat tulisan ‘Belum Lulus’ pada kolom ‘Tahun Lulus’ ditampilkan dalam tulisan berwarna merah dengan menggubah kode program view menjadi:

...
glazedColumn(name: 'Tahun Lulus', expression: { it.tanggalLulus?.getYear() },
     columnClass: Integer) {
  templateRenderer(templateString: "\${it?:'Belum Lulus'}", horizontalAlignment: SwingConstants.CENTER) {
    condition(if_: {value}, then_property_: 'foreground', is_: Color.BLACK, else_is_: Color.RED)
  }
}
...

Tampilan program setelah perubahan di atas akan terlihat seperti pada gambar berikut ini:

Memakai condition() di renderer

Memakai condition() di renderer

Beberapa variasi yang mungkin untuk node condition() adalah:

condition(if_: {...}, then_property_: '...', is_: ...)
condition(if_: {...}, then_property_: '...', is_: ..., else_is_: ...)
condition(if_: {...}, then_property_: '...', is_: ..., else_property_: '...', else_is_: ...)
condition(if_: {...}, then_: {...})
condition(if_: {...}, then_: {...}, else_: {...})

Pada closure yang dilewatkan sebagai atribut untuk if_, then_ dan else_, saya dapat menggunakan salah satu variabel yang nilainya adalah seperti berikut ini:

  • table – berisi JTable yang sedang ditampilkan.
  • value – berisi nilai yang hendak ditampilkan oleh renderer ini.
  • isSelected – menunjukkan apakah renderer ini berada dalam baris yang sedang terpilih.
  • hasFocus – menunjukkan apakah renderer ini sedang mendapat fokus.
  • row – berisi nilai yang menunjukkan nomor baris.
  • column – berisi nilai yang menunjukkan nomor kolom.
  • component – adalah sebuah JComponent yang mewakili renderer ini sendiri.

Sebuah renderer yang mendukung node condition() akan membolehkan terdapat lebih dari satu node tersebut, dimana mereka akan di-eksekusi secara berurutan.   Sebagai contoh, berikut ini adalah contoh view yang menunjukkan variasi penggunaan condition():

package project

import ...

application(title: 'Mahasiswa',
        preferredSize: [520, 340],
        pack: true,
        locationByPlatform: true) {

    panel(id: 'mainPanel') {
        borderLayout()

        scrollPane(constraints: CENTER) {
            glazedTable(list: model.mahasiswaList) {
                glazedColumn(name: 'Nim', property: 'nim')
                glazedColumn(name: 'Nama', property: 'nama')
                glazedColumn(name: 'Kelas', property: 'kelas')
                glazedColumn(name: 'Usia', expression: { Years.yearsBetween(it.tanggalLahir, LocalDate.now()).years },
                    columnClass: Integer)
                glazedColumn(name: 'Angkatan', expression: { it.tanggalDaftar.getYear() },
                        columnClass: Integer) {
                    templateRenderer(templateString: '${it}/${it+1}', horizontalAlignment: SwingConstants.CENTER)
                }
                glazedColumn(name: 'Tahun Lulus', expression: { it.tanggalLulus?.getYear() },
                        columnClass: Integer) {
                    templateRenderer(templateString: "\${it?:'Belum Lulus'}", horizontalAlignment: SwingConstants.CENTER) {
                        condition(if_: {value}, then_property_: 'foreground', is_: Color.BLACK, else_is_: Color.RED)
                        condition(if_: {isSelected}, then_: {component.foreground = Color.WHITE})
                    }
                }
                glazedColumn(name: 'Nilai Rata-Rata', expression: { it.nilaiRataRata() },
                        columnClass: Float) {
                    templateRenderer(templateString: "\${floatFormat(it,2)}", horizontalAlignment: SwingConstants.RIGHT){
                        condition(if_: {value < 60}, then_property_: 'background', is_: Color.RED)
                        condition(if_: {value >= 60 && value < 80}, then_property_: 'background', is_: Color.YELLOW)
                        condition(if_: {value >= 80}, then_property_: 'background', is_: Color.GREEN)
                        condition(if_: {isSelected}, then_property_: 'foreground', is_: Color.BLUE, else_is_: Color.BLACK)
                    }
                }
                glazedColumn(name: 'Nilai OK?', expression: { it.nilaiRataRata() > 70 },
                    columnClass: Boolean)

            }
        }
    }
}

Hasil dari kode program di atas akan terlihat seperti pada gambar berikut ini:

Lebih dari satu condition() pada renderer

Lebih dari satu condition() pada renderer

Selain memakai renderer bawaan simple-jpa, saya juga dapat memakai custom renderer buatan sendiri.   Sebagai contoh, Swing memiliki renderer untuk kolom bertipe boolean yang menampilkannya dalam bentuk checkbox.   Tapi seandainya saya menginginkan hal yang berbeda, saya dapat membuat sebuah renderer baru seperti berikut ini:

package swing

import ...

class MyBooleanRenderer extends DefaultTableCellRenderer {

    public static final Icon OK_ICON =
	new ImageIcon(MyBooleanRenderer.getResource("/ok.png"))
    public static final Icon NOT_OK_ICON =
	new ImageIcon(MyBooleanRenderer.getResource("/not_ok.png"))

    public MyBooleanRenderer() {
        super()
    }

    @Override
    Component getTableCellRendererComponent(JTable table, Object value,
		boolean isSelected, boolean hasFocus, int row, int column) {
        JLabel c = super.getTableCellRendererComponent(table, value,
		isSelected, hasFocus, row, column)
        c.setText(null)
        c.setIcon(value? OK_ICON: NOT_OK_ICON)
        c
    }
}

Untuk memakai renderer di atas, saya mengubah kode program view menjadi seperti berikut ini:

package project

import ...

application(title: 'Mahasiswa',
        preferredSize: [520, 340],
        pack: true,
        locationByPlatform: true) {

    panel(id: 'mainPanel') {
        borderLayout()

        scrollPane(constraints: CENTER) {
            glazedTable(list: model.mahasiswaList) {
                glazedColumn(name: 'Nim', property: 'nim')
                glazedColumn(name: 'Nama', property: 'nama')
                glazedColumn(name: 'Kelas', property: 'kelas')
                glazedColumn(name: 'Usia', expression: { Years.yearsBetween(it.tanggalLahir, LocalDate.now()).years },
                    columnClass: Integer)
                glazedColumn(name: 'Angkatan', expression: { it.tanggalDaftar.getYear() },
                        columnClass: Integer) {
                    templateRenderer(templateString: '${it}/${it+1}', horizontalAlignment: SwingConstants.CENTER)
                }
                glazedColumn(name: 'Tahun Lulus', expression: { it.tanggalLulus?.getYear() },
                        columnClass: Integer) {
                    templateRenderer(templateString: "\${it?:'Belum Lulus'}", horizontalAlignment: SwingConstants.CENTER) {
                        condition(if_: {value}, then_property_: 'foreground', is_: Color.BLACK, else_is_: Color.RED)
                        condition(if_: {isSelected}, then_: {component.foreground = Color.WHITE})
                    }
                }
                glazedColumn(name: 'Nilai Rata-Rata', expression: { it.nilaiRataRata() },
                        columnClass: Float) {
                    templateRenderer(templateString: "\${floatFormat(it,2)}", horizontalAlignment: SwingConstants.RIGHT){
                        condition(if_: {value < 60}, then_property_: 'background', is_: Color.RED)
                        condition(if_: {value >= 60 && value < 80}, then_property_: 'background', is_: Color.YELLOW)
                        condition(if_: {value >= 80}, then_property_: 'background', is_: Color.GREEN)
                        condition(if_: {isSelected}, then_property_: 'foreground', is_: Color.BLUE, else_is_: Color.BLACK)
                    }
                }
                glazedColumn(name: 'Nilai OK?', expression: { it.nilaiRataRata() > 70 },columnClass: Boolean) {
                    widget(new MyBooleanRenderer(), horizontalAlignment: SwingConstants.CENTER)
                }

            }
        }
    }
}

Tampilan kode program di atas akan terlihat seperti pada gambar berikut ini:

Memakai renderer buatan sendiri

Memakai renderer buatan sendiri

Renderer buatan sendiri juga dapat mendukung node condition() bila mereka diberi annotation @ConditionSupport seperti berikut ini:

@ConditionSupport
class MyBooleanRenderer extends DefaultTableCellRenderer {
  ...
}

Untuk mendukung node condition() di custom renderer di view, saya dapat menggunakan kode program seperti berikut ini:

...
glazedColumn(name: 'Nilai OK?', expression: { it.nilaiRataRata() > 70 },columnClass: Boolean) {
  customConditionalRenderer(new MyBooleanRenderer()) {
      condition(if_: {!value}, then_property_: 'text', is_: 'Warning!', else_is_: null)
  }
}
...

Jangan lupa bahwa ClosureRenderer bawaan dari Groovy juga masih dapat dipakai, misalnya dengan kode program seperti berikut ini:

...
glazedColumn() {
  cellRenderer {
    label()
    onRender {
       cell = 'text'
    }
  }
}
...

Node glazedColumn() selain menerima node yang berkaitan dengan cell renderer, juga menerima node yang mewakili header renderer seperti defaultHeaderRenderer. Sebagai contoh, berikut adalah kode program yang menambahkan icon pada judul di kolom ‘Angkatan’:

...
glazedColumn(name: 'Angkatan', expression: { it.tanggalDaftar.getYear() }, columnClass: Integer, width: 70) {
  templateRenderer(templateString: '${it}/${it+1}',
     horizontalAlignment: SwingConstants.CENTER)
  defaultHeaderRenderer(icon: imageIcon('/griffon-icon-16x16.png'),
     horizontalTextPosition: SwingConstants.RIGHT)
}
...

Hasilnya akan terlihat seperti pada gambar berikut ini:

Mengubah header renderer

Mengubah header renderer

Hal berikutnya yang sering saya lakukan saat bekerja dengan tabel adalah mengatur ukuran kolom.   Nodes glazedColumn mendukung atribut width yang sama seperti pada Groovy, yaitu dalam format [minWidth, preferredWidth, maxWidth].   Selain itu, sebuah nilai tunggal pada atribut width akan mengisi minWidth, preferredWidth, dan maxWidth dengan nilai yang sama.   Sebagai contoh, saya akan mengatur ukuran kolom dengan mengubah kode program view menjadi seperti berikut ini:

package project

import ...

application(title: 'Mahasiswa',
        preferredSize: [520, 340],
        pack: true,
        locationByPlatform: true) {

    panel(id: 'mainPanel') {
        borderLayout()

        scrollPane(constraints: CENTER) {
            glazedTable(list: model.mahasiswaList) {
                glazedColumn(name: 'Nim', property: 'nim', width: 50)
                glazedColumn(name: 'Nama', property: 'nama', width: [80,80])
                glazedColumn(name: 'Kelas', property: 'kelas', width: 50)
                glazedColumn(name: 'Usia', expression: { Years.yearsBetween(it.tanggalLahir, LocalDate.now()).years },
                    columnClass: Integer, width: 30)
                glazedColumn(name: 'Angkatan', expression: { it.tanggalDaftar.getYear() },
                        columnClass: Integer, width: 70) {
                    templateRenderer(templateString: '${it}/${it+1}', horizontalAlignment: SwingConstants.CENTER)
                }
                glazedColumn(name: 'Tahun Lulus', expression: { it.tanggalLulus?.getYear() },
                        columnClass: Integer, width: 100) {
                    templateRenderer(templateString: "\${it?:'Belum Lulus'}", horizontalAlignment: SwingConstants.CENTER) {
                        condition(if_: {value}, then_property_: 'foreground', is_: Color.BLACK, else_is_: Color.RED)
                        condition(if_: {isSelected}, then_: {component.foreground = Color.WHITE})
                    }
                }
                glazedColumn(name: 'Nilai Rata-Rata', expression: { it.nilaiRataRata() },
                        columnClass: Float) {
                    templateRenderer(templateString: "\${floatFormat(it,2)}", horizontalAlignment: SwingConstants.RIGHT){
                        condition(if_: {value < 60}, then_property_: 'background', is_: Color.RED)
                        condition(if_: {value >= 60 && value < 80}, then_property_: 'background', is_: Color.YELLOW)
                        condition(if_: {value >= 80}, then_property_: 'background', is_: Color.GREEN)
                        condition(if_: {isSelected}, then_property_: 'foreground', is_: Color.BLUE, else_is_: Color.BLACK)
                    }
                }
                glazedColumn(name: 'Nilai OK?', expression: { it.nilaiRataRata() > 70 }, columnClass: Boolean) {
                    customConditionalRenderer(new MyBooleanRenderer(), horizontalAlignment: SwingConstants.CENTER)
                }
            }
        }
    }
}

Tampilan view di atas akan terlihat seperti pada gambar berikut ini:

Mengatur ukuran kolom

Mengatur ukuran kolom

Berikutnya, saya akan menambahkan fasilitas pengurutan (sorting) untuk setiap kolom yang ada.   Saya dapat melakukannya hanya dengan mengubah satu baris kode program di view menjadi seperti berikut ini:

package project

import ...

application(...) {

   glazedTable(list: model.mahasiswaList, sortingStrategy: TableComparatorChooser.SINGLE_COLUMN) {
     ...
   }
}

Hasilnya akan terlihat seperti pada gambar berikut ini:

Menambahkan fasilitas sorting pada tabel

Menambahkan fasilitas sorting pada tabel

Walaupun yang saya definisikan di model adalah sebuah BasicEventList, node glazedTable() akan secara otomatis membuat SortedList dari BasicEventList tersebut bila atribut sortingStrategy diberikan.

Atribut lain dari node GlazedTable() adalah atribut onValueChanged.   Atribut ini akan menerima sebuah closure yang akan dikerjakan bila ada baris yang dipilih di tabel.   Closure ini nantinya akan menjadi implementasi dari ListSelectionListener yang diberikan pada JTable.selectionModel.addListSelectionListener().   Pada closure ini, saya dapat mengakses seluruh atribut dan method dari JTable (nilai dari variabel delegate merujuk pada JTable tersebut).   Sebagai contoh, kode program berikut ini akan menampilkan sebuah kotak dialog berisi nama mahasiswa yang dipilih setiap kali baris di tabel dipilih:

package project

import ...

application(...) {
   glazedTable(list: model.mahasiswaList, onValueChanged: {
      JOptionPane.showMessageDialog(delegate, "Anda memilih ${model.getValueAt(selectedRow,1)}")
   }) {
      ...
   }
}

Cara yang lebih MVC adalah memisahkan kode program yang melakukan aksi ke dalam controller, seperti pada contoh berikut ini:

// Isi pada MahasiswaView.groovy
package project

import ...

application(...) {
   glazedTable(list: model.mahasiswaList, onValueChanged: controller.tableValueChanged) {
      ...
   }
}

// Isi pada MahasiswaController.groovy
package project

import ...

@SimpleJpaTransaction
class MahasiswaController {

    def model
    def view

    ...

    def tableValueChanged = {
        JOptionPane.showMessageDialog(view.mainPanel, "Anda memilih ${model.getValueAt(selectedRow,1)}")
    }

}

Memakai Event Griffon Di simple-jpa

Salah satu penambahan yang saya lakukan di simple-jpa 0.5 adalah dukungan event Griffon.   Plugin simple-jpa kini akan menghasilkan event Griffon yang berupa:

  • simpleJpaCreateEntityManager – Event ini akan dihasilkan setiap kali simple-jpa membuat Entitymanager baru.   Event ini akan melewatkan sebuah object TransactionHolder.
  • simpleJpaDestroyEntityManagers – Event ini akan dihasilkan saat simple-jpa menutup seluruh EntityManager yang ada.
  • simpleJpaBeforeCloseEntityManager – Event ini akan dihasilkan pada saat simple-jpa menutup sebuah EntityManager.   Event ini akan melewatkan sebuah object TransactionHolder.
  • simpleJpaNewTransaction – Event ini akan dihasilkan setiap kali simple-jpa membuat transaksi baru.   Event ini akan melewatkan sebuah object TransactionHolder.
  • simpleJpaCommitTransaction – Event ini akan dihasilkan setiap kali sebuah transaction di-commit.   Event ini akan melewatkan sebuah object TransactionHolder.
  • simpleJpaRollbackTransaction – Event ini akan dihasilkan setiap kali sebuah transaction di-rollback.   Event ini akan melewatkan sebuah object TransactionHolder.
  • simpleJpaBeforeAutoCreateTransaction – Event ini akan dihasilkan bila simple-jpa secara otomatis membuat transaction baru bila sebuah operasi simple-jpa dikerjakan diluar transaksi.   Plugin simple-jpa akan selalu mengerjakan operasi JPA dalam sebuah transaksi; bila tidak ada transaksi yang aktif, maka ia akan membuat transaksi baru khusus untuk mengerjakan operasi tersebut.

Bagaimana cara memakai event tersebut? Pada file griffon-app/conf/Events.groovy, tambahkan kode program seperti berikut ini:

onSimpleJpaCreateEntityManager =  { TransactionHolder th ->
  // kode program yang akan dikerjakan saat sebuah EntityManager baru dibuat
  ...
}

onSimpleJpaNewTransaction = { TransactionHolder th ->
  // kode program yang akan dikerjakan saat transaksi baru dibuat
  ...
}

Salah satu contoh kegunaan dari event yang dihasilkan simple-jpa adalah untuk memberikan penanda bahwa aplikasi sedang sibuk.   Saya menemukan bahwa terkadang sebuah transaksi JPA bisa berlangsung lebih dari beberapa detik terutama bila transaksi tersebut melibatkan banyak object (misalnya dalam membuat laporan).   Agar pengguna tidak bingung dengan respon yang lama, akan lebih baik bila aplikasi menampilkan indikator bahwa sebuah transaksi sedang berlangsung.  Untuk menampilkan indikator sibuk, saya akan menggunakan plugin jxlayer yang akan memanfaatkan JLayer di Java 7.   Saya dapat men-install plugin tersebut dengan memberikan perintah:

griffon install-plugin jxlayer

JLayer bekerja dengan memanfaatkan decorator design pattern.   Saya tidak perlu menambahkan indikator sibuk tersebut pada view yang sebelumnya sudah dibuat.   Saya hanya perlu membuat sebuah dekorator yang nantinya akan diterapkan pada satu atau lebih view.   Sebagai contoh, saya akan membuat class Java (jangan lupa bahwa Groovy tetap bisa memakai class Java) bernama BusyLayerUI yang isinya seperti berikut ini:

import griffon.core.UIThreadManager;
import javax.swing.*;
import javax.swing.plaf.LayerUI;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.InputEvent;
import java.awt.geom.Arc2D;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import java.beans.PropertyChangeEvent;

class BusyLayerUI extends LayerUI<JPanel> implements ActionListener {

    public static final BusyLayerUI instance = new BusyLayerUI();
    public static BusyLayerUI getInstance() {
        return instance;
    }

    private final int RADIUS = 150;
    private final Color WARNA_LATAR = new Color(219, 247, 186);
    private final Color WARNA_PROGRESS = new Color(118, 186, 39);
    private final Color WARNA_BAYANGAN = new Color(150, 240, 82, 157);

    private boolean visible = false;
    private BufferedImage pixelTexture;
    private Rectangle2D ukuranTexture;
    private Arc2D.Double fullCircle, progress;
    private Timer timer;

    private BusyLayerUI() {
        pixelTexture = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB);
        ukuranTexture = new Rectangle2D.Double(0, 0, 2, 2);
        Graphics2D g2 = pixelTexture.createGraphics();
        g2.setColor(Color.BLACK);
        g2.fillRect(0, 0, 1, 2);
        g2.fill(ukuranTexture);
        g2.setColor(Color.GRAY);
        g2.fillRect(1, 0, 1, 2);
        g2.dispose();
        pixelTexture.flush();

        progress = new Arc2D.Double(50, 50, 400, 400, 0, 0, Arc2D.OPEN);
        fullCircle = new Arc2D.Double(50, 50, 400, 400, 0, -360, Arc2D.OPEN);

    }

    void show() {
        if (visible) return;
        UIThreadManager.getInstance().executeSync(new Runnable() {
            public void run() {
                progress.setAngleExtent(0);
                timer = new Timer(1000/24, BusyLayerUI.this);
                timer.start();
                visible = true;
                firePropertyChange("visible", false, true);
            }
        });
    }

    void hide() {
        if (!visible) return;
        UIThreadManager.getInstance().executeSync(new Runnable() {
            public void run() {
                visible = false;
                firePropertyChange("visible", true, false);
            }
        });
    }

    @Override
    public void paint(Graphics g, JComponent c) {
        super.paint(g, c);
        if (!visible) return;

        int w = c.getWidth();
        int h = c.getHeight();

        Graphics2D g2 = (Graphics2D) g.create();
        g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
        g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);

        Composite currentComposite = g2.getComposite();

        // Buat layar terlihat seperti tidak aktif (lebih gelap dan kabur)
        g2.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.5f));
        g2.setPaint(new TexturePaint(pixelTexture, ukuranTexture));
        g2.fillRect(0, 0, w, h);

        double centerX = (double) (w/2);
        double centerY = (double) (h/2);

        // Buat lingkaran terang
        g2.setStroke(new BasicStroke(20, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
        g2.setColor(WARNA_LATAR);
        fullCircle.setFrameFromCenter(centerX, centerY, centerX+RADIUS, centerY+RADIUS);
        g2.draw(fullCircle);

        // Buat progress yang menandakan program sedang sibuk
        g2.setColor(WARNA_PROGRESS);
        progress.setFrameFromCenter(centerX, centerY, centerX+RADIUS, centerY+RADIUS);
        g2.draw(progress);
        g2.setColor(WARNA_BAYANGAN);
        g2.setStroke(new BasicStroke(30, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
        g2.draw(progress);

        // Selesai
        g2.setComposite(currentComposite);
        g2.dispose();
    }

    @Override
    public void applyPropertyChange(PropertyChangeEvent evt, JLayer<? extends JPanel> l) {
        if ("tick".equals(evt.getPropertyName()) || "visible".equals(evt.getPropertyName())) {
            l.repaint();
        }
    }

    @Override
    public void installUI(JComponent c) {
        super.installUI(c);
        ((JLayer)c).setLayerEventMask(AWTEvent.MOUSE_EVENT_MASK | AWTEvent.MOUSE_MOTION_EVENT_MASK | AWTEvent.KEY_EVENT_MASK);
    }

    @Override
    public void uninstallUI(JComponent c) {
        ((JLayer)c).setLayerEventMask(0);
        super.uninstallUI(c);
    }

    @Override
    public void eventDispatched(AWTEvent e, JLayer<? extends JPanel> l) {
        if (visible && e instanceof InputEvent) {
            ((InputEvent)e).consume();
        }
    }

    public void actionPerformed(ActionEvent e) {
        if (visible) {
            double extend = progress.getAngleExtent();
            if (extend <= -360) {
                extend = 0;
            } else {
                extend -= 3;
            }
            progress.setAngleExtent(extend);
            firePropertyChange("tick", 0, 1);
        } else {
            timer.stop();
        }
    }
}

Bila method show() dipanggil, maka sebuah animasi lingkaran akan ditampilkan dan seluruh komponen di layar tersebut menjadi tidak dapat di-klik atau di-isi.   Pengguna hanya boleh pasrah menunggu 🙂

Sekarang saya perlu menentukan view apa saja yang perlu memiliki ‘informasi sibuk’ ini.   Sebagai contoh, saya dapat mendekorasi view utama dengan mengubah file MainGroupView.groovy menjadi seperti berikut ini:

application(...){

  ...
  borderLayout()
  jxlayer(UI: BusyLayerUI.getInstance(), constraints: BorderLayout.CENTER) {

    panel() {
      borderLayout()

       toolBar(id: 'toolBar', constraints: BorderLayout.PAGE_START, floatable: false) {
         ...
       }

       panel(id: "mainPanel") {
         cardLayout(id: "cardLayout")
       }

       statusBar(constraints: BorderLayout.PAGE_END, border: BorderFactory.createBevelBorder(BevelBorder.LOWERED)) {
         ...
       }
    }
  }
}

Sekarang pertanyaannya adalah bagaimana mengetahui sebuah transaksi sedang berlangsung sehingga method show() akan dipanggil?    Apakah saya harus mengubah kode program di controller dimana saya selalu memanggil show() sebelum memulai transaksi dan memanggil hide() setelah transaksi selesai dikerjakan?   Bila demikian, banyak sekali perubahannya karena hampir seluruh method harus diubah!   Tapi karena simple-jpa 0.5 sudah mempublikasi event yang berkaitan dengan transaksi, maka saya bisa dengan mudah mengetahui kapan sebuah transaksi dimulai dan diakhiri (berlaku secara global).   Dengan demikian, saya hanya perlu menambahkan kode program berikut ini pada griffon-app/conf/Events.groovy:

import simplejpa.transaction.TransactionHolder
import util.BusyLayerUI

onUncaughtExceptionThrown = { Exception e ->
    if (e instanceof org.codehaus.groovy.runtime.InvokerInvocationException) e = e.cause.cause
    javax.swing.JOptionPane.showMessageDialog(null, e.message, "Error", javax.swing.JOptionPane.ERROR_MESSAGE)
    BusyLayerUI.getInstance().hide()
}

onSimpleJpaNewTransaction = { TransactionHolder th ->
    BusyLayerUI.getInstance().show()
}

onSimpleJpaCommitTransaction = { TransactionHolder th ->
    BusyLayerUI.getInstance().hide()
}

onSimpleJpaRollbackTransaction = { TransactionHolder th ->
    BusyLayerUI.getInstance().hide()
}

Sekarang, setiap kali sebuah transaksi sedang berlangsung, layar utama akan menampilkan indikator sibuk seperti yang terlihat pada gambar berikut ini:

Indikator sibuk setiap kali transaksi berlangsung

Indikator sibuk setiap kali transaksi berlangsung

Sebagai informasi tambahan, JPA sendiri sudah memiliki mekanisme event yang disebut sebagai entity listener.   Hasil scaffolding dari simple-jpa sudah mendaftarkan sebuah entity listener bernama simplejpa.AuditingEntityListener seperti yang terlihat pada gambar berikut ini:

Isi file orm.xml

Isi file orm.xml

Pengguna dapat menambahkan entity listener baru pada file orm.xml tersebut.   Method pada entity listener dapat memiliki annotation yang mewakili lifecycle event JPA, yaitu @PrePersist, @PostPersist, @PreUpdate, @PostUpdate, @PreRemove, @PostRemove dan @PostLoad.   Berbeda dengan event dari simple-jpa yang berkaitan dengan transaksi, event disini lebih berkaitan dengan siklus hidup masing-masing object yang dikelola oleh JPA.

Panduan Validasi Di Plugin simple-jpa

Plugin simple-jpa 0.4 dapat di-install dengan memberikan perintah install-plugin simple-jpa 0.4

simple-jpa dapat melakukan validasi domain class dengan memanfaatkan Beans Validation (JSR 349) dengan implementasi berupa Hibernate Validator.  Pengguna perlu memberikan annotation pada domain class.   Daftar annotation bawaan seperti @Size, @Future, @NotNull, dsb dapat ditemukan di lokasi http://docs.jboss.org/hibernate/validator/4.3/reference/en-US/html_single/#validator-defineconstraints-builtin.   Bila ini dianggap masih kurang, pengguna dapat membuat annotation sendiri untuk jenis validasi yang belum ada.   Baca dokumentasi di http://docs.jboss.org/hibernate/validator/4.3/reference/en-US/html_single untuk informasi lebih lanjut.

simple-jpa akan menyuntikkan method validate() pada controller yang membutuhkan sebuah parameter berupa domain object yang akan divalidasi.   Method ini akan mengembalikan nilai true bila validasi sukses atau nilai false bila validasi gagal. Contoh penggunaan:

Buku buku = new Buku('isbn': model.isbn, 'judul': model.judul, 
   'harga': model.harga, 'tahun': model.tahun, 
   'listPengarang': model.listPengarang.selectedValues)
if (!validate(buku)) return_failed()

Lalu, bagaimana dengan pesan kesalahan?  Setiap model (view model) secara otomatis akan memiliki sebuah atribut bernama errors yang bertipe ObservableMap.   Method validate() bila mengembalikan nilai false (validasi gagal) akan menyimpan pesan kesalahan di variabel errors ini.

ObservableMap adalah sebuah struktur data yang terdiri atas key dan value. Nilai key adalah atribut yang mengalami kesalahan. Nilai value adalah pesan kesalahannya. Berikut ini adalah contoh isi errors pada saat validasi gagal:

isbn: "size must be between 9 and 13"
judul: "may not be empty"
harga: "may not be null"
listPengarang: "may not be empty"

Selain memiliki variabel errors, setiap view model juga memiliki sebuah method bernama hasError() yang akan mengembalikan true bila terdapat pesan kesalahan di errors.

Dari sisi service layer, fungsi validasi sudah berjalan dengan sempurna sampai disini.  Dengan kata lain, sebuah domain object sudah dapat diperiksa apakah isinya valid atau tidak.   Selain itu, pesan kesalahan untuk setiap atribut di domain class juga sudah bisa diketahui.

Akan tetapi, dari sisi presentation layer, masih ada beberapa kendala yang harus dijawab:

  1. Bagaimana cara menampilkan pesan kesalahan tersebut?
  2. Kapan menghapus pesan kesalahan yang telah ditampilkan?

Menampilkan Pesan Kesalahan

Untuk menjawab pertanyaan pertama, simple-jpa menyediakan node errorLabel().   Node ini membutuhkan nilai untuk atribut path yang mewakili sebuah key di map errors, umumnya berupa nama atribut.   Nantinya, node ini akan dirender dalam bentuk JLabel.  Bila terdapat kesalahan di errors untuk key ini, maka JLabel akan menampilkan pesan kesalahan untuk key tersebut. Berikut ini contoh deklarasi sebuah errorLabel:

errorLabel(path: 'judul', constraints: 'wrap')

Pada deklarasi di atas, bila errors di view model mengandung pesan kesalahan dengan key berupa 'judul' maka errorLabel akan menampilkan pesan kesalahan tersebut.

errorLabel() hanya menampilkan kesalahan saja. Selama errors masih mengandung pesan kesalahan, maka selama itu pula pesan kesalahan akan ditampilkan! Oleh sebab itu, pertanyaan kedua perlu dijawab.

PENTING: Sebuah path hanya boleh memiliki sebuah errorLabel() saja.   Bila terdapat lebih dari satu errorLabel() yang memakai path yang sama, maka hanya errorLabel() terakhir yang berfungsi.

TIPS: Nilai path tidak harus selalu sesuai dengan field di domain class.  Pengguna dapat menampilkan notifikasi untuk pesan kesalahan yang bersifat global dengan dengan menggunakan path yang belum dipakai untuk field, misalnya “global_error”. Untuk memberikan pesan kesalahan, pengguna dapat menggunakan kode program seperti model.errors.put("global_error", "ini pesan kesalahan global").

Notifikasi Pada Komponen Dan Menghapus Pesan Kesalahan

Kapan harus menghapus pesan kesalahan sehingga errorLabel() tidak menampilkan apa-apa?  Biasanya, ini dilakukan ketika komponen untuk entry data (misalnya JTextField) sudah diubah nilainya oleh pengguna.   Untuk mempermudah, simple-jpa memungkinkan menambah atribut errorPath pada deklarasi komponen, seperti berikut ini:

textField(id: 'judul', columns: 20, text: bind('judul', target: model, mutual: true), 
   errorPath: 'judul')

Penggunaan errorPath: 'judul' di atas akan menyebabkan:

  • Bila terdapat pesan kesalahan di errors dengan key 'judul', saat user mengetik di textField tersebut, maka pesan kesalahan dengan 'judul' tersebut akan dihapus.
  • Bila terdapat pesan kesalahan di errors dengan key berupa 'judul', maka textField tersebut akan di-highlight dengan warna merah muda.

Notifikasi Pada Komponen

Notifikasi pesan kesalahan yang dipakai secara default adalah BasicHighlightErrorNotification yang akan mengubah background komponen menjadi berwarna Color.PINK bila terjadi kesalahan.

Bila pengguna menginginkan notifikasi yang berbeda, ia dapat membuat turunan dari class ErrorNotification dan membuat  kode program yang akan men-format komponen di method performNotification().   Kemudian, ia perlu menambahkan atribut errorNotification di deklarasi node komponen tersebut dimana isinya adalah class turunan ErrorNotification tersebut.

Sebagai contoh, saya akan membuat sebuah turunan ErrorNotification baru yang akan mengubah border dan warna huruf menjadi merah bila terdapat kesalahan. Isinya akan terlihat seperti berikut ini:

package validation

import simplejpa.validation.ErrorNotification
import javax.swing.*
import javax.swing.border.Border
import java.awt.*

class BorderErrorNotification extends ErrorNotification {

    Border defaultBorder
    Color defaultForeground

    protected BorderErrorNotification(JComponent node, ObservableMap errors, String errorPath) {
        super(node, errors, errorPath)
        defaultBorder = node.border
        defaultForeground = node.foreground
    }

    @Override
    void performNotification() {
        if(errors.containsKey(errorPath)) {
            node.border = BorderFactory.createLineBorder(Color.RED, 4, true)
            node.foreground = Color.RED
        } else {
            node.border = defaultBorder
            node.foreground = defaultForeground
        }
    }
}

Lalu, saya mendaftarkan penggunaan BorderErrorNotification tersebut pada sebuah node komponen, misalnya:

textField(id: 'isbn', columns: 20, text: bind('isbn', target: model, mutual: true), 
   errorPath: 'isbn', errorNotification: BorderErrorNotification.class)

Saya menambahkan nilai untuk atribut errorNotification berupa class BorderErrorNotification yang saya buat (jangan lupa menambahkan import bila diperlukan).

Bila program dijalankan dan terdapat perubahan pada errors, maka method performNotification() milik BorderErrorNotification akan dikerjakan, seperti yang terlihat pada gambar berikut ini:

Membuat ErrorNotification Yang Berbeda

Membuat ErrorNotification Yang Berbeda

Bila saya ingin notifikasi kesalahan untuk seluruh komponen yang ada memakai BorderErrorNotification, maka saya dapat menambahkan baris berikut ini pada file Config.groovy:

griffon.simplejpa.validation.defaultErrorNotificationClass = "validation.BorderErrorNotification"

Bila program dijalankan, sekarang seluruh komponen akan memakai notifikasi validasi dari class BorderErrorNotification yang terlihat seperti pada gambar berikut ini:

Perubahan ErrorNotification Secara Global

Perubahan ErrorNotification Secara Global

PENTING: Bila sebuah komponen memiliki atribut errorNotification, maka notifikasi kesalahan yang akan dipakai akan berdasarkan nilai atribut errorNotification di komponen tersebut. Bila komponen tidak memiliki atribut errorNotification, maka notifikasi kesalahan yang dipakai akan berdasarkan nilai griffon.simplejpa.validation.defaultErrorNotificationClass di Config.groovy. Bila pengaturan di Config.groovy tidak dilakukan, maka notifikasi kesalahan yang akan dipakai adalah berdasarkan class BasicHighlightErrorNotification.

TIPS: Untuk memberikan dialog yang berisi pesan kesalahan, ubah method save() di controller di bagian:

if (!validate(buku)) return

menjadi:

if (!validate(buku)) {
    String pesan = model.errors.collect { k, v -> "$k $v" }.join("\n")
    execInsideUISync {
    	JOptionPane.showMessageDialog(view.mainPanel, pesan, "Kesalahan", JOptionPane.ERROR_MESSAGE)
    }
    return
}

Dialog kesalahan akan terlihat seperti pada gambar berikut ini:

Tampilan Notifikasi Kesalahan Dalam Bentuk Dialog

Tampilan Notifikasi Kesalahan Dalam Bentuk Dialog

TIPS: simple-jpa juga memungkinkan pengguna untuk mematikan notifikasi kesalahan (misalnya pada kasus dimana pengguna ingin memberikan notifikasi kesalahan dalam bentuk dialog secara manual). Untuk itu, tambahkan baris berikut ini
di Config.groovy:

griffon.simplejpa.validation.defaultErrorNotificationClass = "simplejpa.validation.NopErrorNotification"

Menghapus Pesan Kesalahan

Setiap komponen memiliki cara masing-masing untuk menandakan bahwa pesan kesalahan perlu dihapus.  Misalnya pada JTextField, event-nya adalah saat user mengetik di textField tersebut.  Pada JComboBox, event-nya adalah pada saat user memiliki item baru.  Dan masih banyak lagi, belum tersebut JComponent buatan developer sendiri.  Oleh sebab itu, simple-jpa hanya mendukung komponen yang sering dipergunakan saja.

Bila developer memakai komponen yang belum didukung, maka ia perlu membuat sebuah class yang meng-implementasi-kan interface ErrorCleaner, kemudian mendaftarkan class tersebut pada Config.groovy, seperti pada contoh berikut ini:

griffon.simplejpa.validation.errorCleaners = [
    "com.snake.swing.customComponent1": "co.id.snake.validation.CustomComponent1ErrorCleanes",
    "com.snake.swing.customComponent2": "co.id.snake.validation.CustomComponent2ErrorCleaner",
]

Selain memberikan key berupa nama lengkap dari class, pengguna juga dapat memberikan key berupa:

  • default menunjukkan bahwa ErrorCleaner ini akan dikerjakan pada seluruh komponen yang tidak memiliki ErrorCleaner terdaftar. Secara default, nilainya adalah JTextFieldErrorCleaner.
  • * menunjukkan bahwa ErrorCleaner ini bersifat global dan akan dikerjakan untuk seluruh komponen yang ada. Bila terdapat ErrorCleaner untuk sebuah komponen, maka ErrorCleaner tersebut tidak akan dikerjakan.

Sebagai contoh, konfigurasi berikut ini akan menyebabkan seluruh ErrorCleaner tidak akan berfungsi:

griffon.simplejpa.validation.errorCleaners = [
    "*": "simplejpa.validation.NopErrorCleaner",
]

PENTING: Nilai ErrorCleaner yang ada pada Config.groovy akan menimpa nilai ErrorCleaner bawaan.

Contoh yang lebih berguna, misalnya, saya membuat sebuah ErrorCleaner baru yang akan menghapus pesan kesalahan setelah beberapa detik, yang isinya seperti berikut ini:

package validation

import simplejpa.validation.ErrorCleaner

import javax.swing.JComponent
import java.awt.event.ActionEvent
import java.awt.event.ActionListener
import java.beans.PropertyChangeEvent
import java.beans.PropertyChangeListener
import javax.swing.Timer

class DelayedErrorCleaner implements ErrorCleaner {

    @Override
    void addErrorCleaning(JComponent component, ObservableMap errors, String errorPath) {
        if (errors.getPropertyChangeListeners().findAll { PropertyChangeListener pcl ->
                pcl instanceof MyPropertyChangeListener &&
                ((MyPropertyChangeListener) pcl).errors == errors
            }.isEmpty()) {

            errors.addPropertyChangeListener(new MyPropertyChangeListener(errors))

        }

    }

    class MyPropertyChangeListener implements PropertyChangeListener {

        ObservableMap errors
        Timer timer

        public MyPropertyChangeListener(ObservableMap errors) {
            this.errors = errors
            timer = new Timer(5000, new ActionListener() {
                @Override
                void actionPerformed(ActionEvent e) {
                    errors.clear()
                }
            })
        }

        @Override
        void propertyChange(PropertyChangeEvent evt) {
            if (!errors.isEmpty() && !timer.isRunning()) {
                timer.start()
            }
        }
    }
}

Setelah itu, saya menambahkan baris berikut ini pada file Config.groovy:

griffon.simplejpa.validation.errorCleaners = [
    "*": "validation.DelayedErrorCleaner"
]

Bila saya menjalankan program, maka sekarang pesan kesalahan dan notifikasi kesalahan akan hilang sendiri setelah beberapa detik, seperti yang terlihat pada gambar berikut ini:

Contoh Perubahan ErrorCleaner Secara Global

Contoh Perubahan ErrorCleaner Secara Global

Panduan Template Renderer Di simple-jpa

Plugin simple-jpa 0.4 dapat di-install dengan memberikan perintah install-plugin simple-jpa 0.4

Salah satu hal yang cukup merepotkan bagi saya selama membuat aplikasi Swing adalah membuat renderer. Untuk JTable, harus dibuat sebuah implementasi TableCellRenderer yang menampilkan dan men-format domain object. Masing-masing JTable memiliki TableCellRenderer-nya sendiri. Begitu juga dengan JComboBox dan JList, harus dibuat sebuah implementasi ListCellRenderer yang menampilkan dan men-format domain object. JComboBox dan JList yang menampung domain object yang berbeda akan memiliki implementasi ListCellRenderer yang berbeda.

Beberapa mahasiswa yang baru belajar sering kali berusaha menghindari repotnya membuat renderer dengan langsung melewatkan nilai domain object yang telah di-format dan dikonversi menjadi String ke JTable, JComboBox dan JList. Sekilas terlihat gampang, tapi hal ini menimbulkan sebuah permasalahan tersendiri, yaitu bila nilai JTable, JComboBox atau JList dipilih atau di-select, maka nilai yang dikembalikan adalah sebuah String. Mereka tetap harus bekerja keras untuk menerjemahkan String tersebut kembali menjadi domain object.

Lalu bagaimana cara gampangnya? Untuk mempermudah membuat renderer di Swing, saya memanfaatkan SimpleTemplateEngine di Groovy untuk menghasilkan sebuah renderer dengan cara yang sangat mudah dan sederhana. Renderer ini akan saya sebut sebagai template renderer.

Sebagai contoh, saya akan membuat sebuah aplikasi dengan domain model seperti yang terlihat pada gambar berikut ini:

Domain Model Sebuah Aplikasi Sederhana

Domain Model Sebuah Aplikasi Sederhana

Saya kemudian membuat sebuah proyek Groovy, memberikan perintah install-plugin diikuti dengan perintah create-simple-jpa. Setelah itu saya membuat domain class dengan perintah berikut ini:

create-domain-class Transaksi ItemTransaksi Buku Pengarang

Setelah itu, saya menambahkan atribut dan methods pada domain class yang dihasilkan sehingga isinya akan terlihat seperti berikut ini:

@DomainModel @Entity @Canonical
class Transaksi {

    @NotEmpty @Size(min=5, max=5)
    String kodeTransaksi

    @NotNull
    LocalDate tanggal

    @OneToMany(cascade=CascadeType.ALL, orphanRemoval=true)
    List<ItemTransaksi> listItemTransaksi = []

    BigDecimal getTotal() {
        listItemTransaksi.sum { it.total }
    }
}

@DomainModel @Entity @Canonical(excludes="transaksi")
class ItemTransaksi {

    @NotNull @ManyToOne
    Buku buku

    @NotNull @Min(1l)
    Integer jumlah

    @ManyToOne
    Transaksi transaksi

    BigDecimal getTotal() {
        buku.harga * jumlah
    }

}

@DomainModel @Entity @Canonical
class Pengarang {

    @NotEmpty @Size(min=2, max=100)
    String namaDepan

    @Size(min=2, max=100)
    String namaBelakang

    @Email
    String email

    String getNamaLengkap() {
        namaDepan + " " + namaBelakang
    }

    @ManyToMany(mappedBy="listPengarang")
    List<Buku> listBuku = []

}

@DomainModel @Entity @Canonical(excludes="listPengarang")
class Buku {

    @NotNull @Size(min=9, max=13)
    String isbn

    @NotNull @Min(1l)
    BigDecimal harga

    @Min(1l)
    Short tahun

    @NotEmpty
    @ManyToMany
    List<Pengarang> listPengarang = []

}

Setelah ini, saya memberikan perintah untuk melakukan scaffolding:

generate-all * --startup-group=MainGroup

Bila saya menjalankan aplikasi dan mengisi data, saya akan memperoleh tampilan yang mirip seperti pada gambar berikut ini:

Tampilan Tabel Secara Default

Tampilan Tabel Secara Default

Pada halaman untuk mengisi Transaksi, tabel terdiri atas tiga kolom, masing-masing menampilkan kode transaksi, tanggal, dan list item transaksi. Tunggu! Kolom ketiga rasanya agak aneh, bukan? Kenapa harus menampilkan list item transaksi? Bukankah lebih baik bila menampilkan total transaksi saja? Untuk itu, saya perlu mengubah baris berikut ini pada TransaksiView.groovy:

eventTableModel(list: model.transaksiList,
  columnNames: ['Kode Transaksi', 
                'Tanggal', 
                'List Item Transaksi'],
  columnValues: ['${value.kodeTransaksi}', 
                 '${value.tanggal}', 
                 '${value.listItemTransaksi}'])

Penggunaan ekspresi pada atribut columnValues adalah apa yang sebut sebagai template renderer. Pada template tersebut, saya dapat menampilkan variabel dengan menggunakan tanda dollar ($). Satu-satunya variabel yang tersedia adalah variabel value yang berisi domain object yang hendak ditampilkan. Selain menampilkan variabel, saya juga dapat memasukkan kode program ke dalam template dengan menyertakannya dalam <% ... %>. Pada template renderer, saya dapat menggunakan fungsi berikut ini:

  • numberFormat() akan men-format sebuah nilai dengan format angka.
  • percentFormat() akan men-format sebuah nilai dengan format persen. Misalnya nilai 0.12 akan menjadi 12%.
  • currencyFormat() akan men-format sebuah nilai dengan format mata uang (memiliki tanda mata uang).
  • titleCase() akan men-format sebuah String sehingga setiap katanya selalu diawali huruf besar. Misalnya, titleCase("solid snake") akan menghasilkan "Solid Snake".

Untuk mengubah kolom ketiga untuk menampilkan total transaksi, saya mengubah kode program di atas menjadi seperti berikut ini:

eventTableModel(list: model.transaksiList,
  columnNames: ['Kode Transaksi', 
                'Tanggal', 
                'Total'],
  columnValues: ['${value.kodeTransaksi}', 
                 '${value.tanggal}', 
                 '${currencyFormat(value.total)}'])

Tampilan setelah perubahan akan terlihat seperti pada gambar berikut ini:

Tampilan Tabel Setelah Perubahan Template Renderer

Tampilan Tabel Setelah Perubahan Template Renderer

Terlihat bahwa dengan template renderer, saya tidak perlu repot-repot membuat sebuah class TableCellRenderer lagi.

Saya juga bisa memberikan ekspresi yang lebih rumit lagi di template renderer, misalnya seperti berikut ini:

eventTableModel(list: model.transaksiList,
  columnNames: ['Kode Transaksi', 
                'Tanggal', 
                'Total'],
  columnValues: ['${value.kodeTransaksi}', 
                 '${value.tanggal}', 
   '${value.listItemTransaksi.size()} Item, Total ${currencyFormat(value.total)}'])

Hasilnya akan terlihat seperti pada gambar berikut ini:

Tampilan Tabel Dengan Ekspresi Template Renderer Yang Lebih Rumit

Tampilan Tabel Dengan Ekspresi Template Renderer Yang Lebih Rumit

Saya akan lanjut melakukan perubahan berikutnya di tabel yang terletak di ItemTransaksiViewAsChild.groovy menjadi seperti berikut ini:

eventTableModel(list: model.itemTransaksiList,
  columnNames: ['Buku', 
                'Jumlah'],
  columnValues: ['${value.buku.judul}', 
                 '${value.jumlah}'])

Perubahan yang terjadi dapat dilihat pada gambar berikut ini:

Template Renderer Pada Tabel

Template Renderer Pada Tabel

Template renderer tidak hanya bisa diterapkan pada tabel, tapi juga pada comboBox(). Sebagai contoh, di ItemTransaksiViewAsChild, tampilan comboBox() untuk memilih Buku akan terlihat seperti pada gambar berikut ini:

Tampilan ComboBox Secara Default

Tampilan ComboBox Secara Default

Tampilan pada combobox tersebut pada dasarnya hanya versi toString() dari domain object. Akan lebih baik bila saya hanya menampilkan judul buku di combobox. Untuk itu, saya mengubah kode program berikut di ItemTransaksiViewAsChild.groovy menjadi seperti berikut ini:

comboBox(model: model.buku, renderer: 
    templateRenderer(template: '${value.judul}'), errorPath: 'buku')

PENTING:  Pada simple-jpa versi 0.4 ke atas, kode program di atas harus berupa:

comboBox(model: model.buku, 
    templateRenderer: '${value.judul}', errorPath: 'buku')

Sekarang, tampilan combobox akan terlihat seperti pada gambar berikut ini:

Tampilan ComboBox Setelah Perubahan Template Renderer

Tampilan ComboBox Setelah Perubahan Template Renderer

Saya juga bisa memberikan ekspresi yang lebih rumit, misalnya menampilkan judul buku dan harga secara bersamaan, dengan melakukan perubahan pada template renderer seperti berikut ini:

comboBox(model: model.buku, renderer: templateRenderer(
   template: '${value.judul} - ${currencyFormat(value.harga)}'), errorPath: 'buku')

PENTING:  Pada simple-jpa versi 0.4 ke atas, kode program di atas harus berupa:

comboBox(model: model.buku, 
   templateRenderer: '${value.judul} - ${currencyFormat(value.harga)}', errorPath: 'buku')

Tampilan combobox sekarang akan terlihat seperti pada gambar berikut ini:

Tampilan ComboBox Dengan Ekspresi Template Renderer Yang Lebih Rumit

Tampilan ComboBox Dengan Ekspresi Template Renderer Yang Lebih Rumit

Saya juga dapat menerapkan template renderer pada tagChooser(). Berikut ini adalah contoh sebuah tagChooser() yang masih segar dan tanpa perubahan:

Tampilan TagChooser Secara Default

Tampilan TagChooser Secara Default

Terlihat sangat aneh karena pada dasarnya yang ditampilkan adalah hasil toString() dari domain object. Oleh sebab itu, saya melakukan perubahan pada BukuView.groovy seperti berikut ini:

tagChooser(model: model.listPengarang, 
    templateString: '${value.namaLengkap}', 
    constraints: 'grow,push,span,wrap', 
    errorPath: 'listPengarang')

Sekarang tampilan tagChooser() akan terlihat seperti berikut ini:

Tampilan TagChooser Setelah Memakai TemplateRenderer

Tampilan TagChooser Setelah Memakai TemplateRenderer

Panduan Memakai Tipe Data Angka Di Plugin simple-jpa

Plugin simple-jpa 0.4 dapat di-install dengan memberikan perintah install-plugin simple-jpa 0.4

Untuk mempermudah konversi tipe data, simple-jpa 0.3 akan menggunakan node numberTextField() yang akan menghasilkan sebuah komponen turunan dari JFormattedTextField.  Fasilitas scaffolding bila menemukan tipe data angka, termasuk BigDecimal dan BigInteger, akan secara otomatis menggunakan numberTextField().

Sebagai contoh, untuk domain class berikut ini:

@DomainClass @Entity @Canonical
class Latihan {
  Byte angka1
  Short angka2
  Integer angka3
  Long angka4
  Float angka5
  Double angka6
  BigInteger angka7
  BigDecimal angka8
}

akan menghasilkan view yang memakai komponen numberTextField() seperti berikut ini:

label('Angka1:')
numberTextField(id: 'angka1', columns: 20, bindTo: 'angka1', errorPath: 'angka1')
errorLabel(path: 'angka1', constraints: 'wrap')

label('Angka2:')
numberTextField(id: 'angka2', columns: 20, bindTo: 'angka2', errorPath: 'angka2')
errorLabel(path: 'angka2', constraints: 'wrap')

...

label('Angka8:')
numberTextField(id: 'angka8', columns: 20, bindTo: 'angka8', nfParseBigDecimal: true, errorPath: 'angka8')
errorLabel(path: 'angka8', constraints: 'wrap')

Untuk melakukan binding ke model saat ini, pengguna perlu memberikan bindTo diikuti dengan nama atribut di model.  Binding secara otomatis akan bersifat mutual.

Pada tipe data BigDecimal, pengguna wajib memberikan nfParseDigDecimal: true agar parsing BigDecimal dilakukan secara benar (bila tidak, akan dianggap sebagai double).

Btw, apa perbedaan antara BigDecimal dan double?  Nilai bilangan desimal pada double merupakan sebuah upaya pendekatan yang nilainya tidak mutlak sementara nilai bilangan desimal pada BigDecimal bersifat pasti. Sebagai contoh, berikut ini adalah kode program:

double d1 = 10000.11 
double d2 = 0.01 
double d = d1-d2*d1 
println "Diskon 1% dari $d1 adalah $d" 

BigDecimal b1 = new BigDecimal("10000.11") 
BigDecimal b2 = new BigDecimal("0.01") 
BigDecimal b = b1.minus(b2.multiply(b1)) 
println "Diskon 1% dari $b1 adalah $b"

Bila kode program Groovy tersebut dijalankan (misalnya di Groovy Console), hasilnya adalah:

Diskon 1% dari 10000.11 adalah 9900.108900000001 
Diskon 1% dari 10000.11 adalah 9900.1089

Terlihat bahwa pada pada versi yang memakai double, hasil perhitungan tidak akurat!  Selisih ini bisa diabaikan untuk perhitungan trivial (misalnya pada komputasi koordinat grafis untuk game), tetapi untuk perhitungan yang sifatnya penting seperti untuk perhitungan yang menyangkut nilai uang, sebaiknya menggunakan BigDecimal.

Karena numberTextField() adalah sebuah JFormattedTextField, maka angka yang diketik oleh pengguna akan secara otomatis diformat seperti yang terlihat pada gambar berikut ini:

Tampilan Angka Akan Diformat Secara Otomatis

Tampilan Angka Akan Diformat Secara Otomatis

Pengguna dapat mengatur format yang dipakai dengan memberikan nilai atribut type pada numberTextField().  Nilai atribut type yang diperbolehkan adalah ‘currency’, ‘percent’ dan ‘integer’.  Masing-masing setara dengan memanggil method getCurrencyInstance(), getPercentInstance(), getIntegerInstance() dari DecimalFormat.  Format yang dipakai secara default (bila tidak ditentukan) adalah DecimalFormat.getNumberInstance().

Sebagai contoh saya mengubah kode program view menjadi:

label('Angka5:')
numberTextField(id: 'angka5', columns: 20, bindTo: 'angka5', type: 'integer', errorPath: 'angka5')
errorLabel(path: 'angka5', constraints: 'wrap')
label('Angka6:')
numberTextField(id: 'angka6', columns: 20, bindTo: 'angka6', type: 'percent', errorPath: 'angka6')
errorLabel(path: 'angka6', constraints: 'wrap')
label('Angka7:')
numberTextField(id: 'angka7', columns: 20, bindTo: 'angka7', type: 'currency', errorPath: 'angka7')
errorLabel(path: 'angka7', constraints: 'wrap')

Hasilnya format pada ketiga numberTextField() di atas akan terlihat seperti pada gambar berikut ini:

Pengaturan type Untuk numberTextField()

Pengaturan type Untuk numberTextField()

Sebagai informasi tambahan, format yang ada sesuai dengan Locale yang sedang aktif.  Sebagai contoh, saya akan melakukan perubahan Locale dengan membuka file griffon-app/lifecycle/Initialize.groovy dan menambahkan baris berikut ini:

Locale.setDefault(new Locale("id", "ID"))

Selain melalui kode program, pengaturan locale juga dapat dilakukan dengan menambahkan baris locale pada Application.groovy seperti pada berikut ini:

application {
  title = 'Latihan'
  startupGroups = ['mainGroup']
  autoShutdown = true
  locale = 'ID_id'
}

Bila aplikasi dijalankan, format yang dipakai oleh numberTextField() sekarang akan menggunakan tata cara di Indonesia, seperti yang terlihat pada gambar berikut ini:

Format yang mengikuti Locale

Format yang mengikuti Locale

Pengaturan lain yang dapat diberikan pada numberTextField() adalah dengan memberikan atribut yang diawali dengn nf.  Atribut ini akan memanggil setter yang ada di NumberFormat (lihat http://docs.oracle.com/javase/7/docs/api/java/text/NumberFormat.html).  Sebagai contoh, atribut nfMaximumFractionDigits akan memanggil method setMaximumFractionDigits() dari NumberFormat.

Kode program berikut ini:

numberTextField(id: 'angka8', columns: 20, bindTo: 'angka8', 
  nfParseBigDecimal: true,
  nfMaximumIntegerDigits: 5, 
  nfMaximumFractionDigits: 2, 
  nfRoundingMode: RoundingMode.DOWN,
  errorPath: 'angka8')

akan menyebabkan:

  • Makimal hanya lima digit bagian bilangan bulat (sebelum tanda pemisah desimal) yang bisa diberikan.  Bila pengguna mengetik lebih dari lima digit, maka yang diambil hanya lima digit terakhir.
  • Angka dibelakang koma maksimal hanya dua digit.
  • Pembulatan yang dipakai bila angka dibelakang koma melebih dua digit adalah pembulatan ke bawah.  Sebagai contoh, bila mengguna mengetik 12345,127 maka yang ditampilkan adalah 12.345,12.  Secara default, 12345,127 akan dibulatkan menjadi 12.345,13.

PENTING:  Pengaturan atribut di atas hanya berlaku untuk men-format tampilan angka.  Yang disimpan ke database nantinya tetap adalah apa yang diketik oleh pengguna sebelum di-format.  Hal ini dapat membingungkan pengguna dan tidak disarankan.  Salah satu contoh kasus dimana atribut di atas dipakai adalah pada numberTextField() yang hanya menampilkan angka dan tidak dapat di-isi. Untuk membuat numberTextField() hanya menampilkan data dan tidak dapat di-isi, tambahkan atribut editable: false.

simple-jpa 0.3 juga memiliki sebuah komponen lain, yaitu maskTextField().  Komponen ini tidak hanya dapat dipakai untuk angka, tetapi juga dapat dipakai pada teks yang memiliki format tertentu.  Komponen ini pada dasarnya adalah sebuah JFormattedTextField yang memakai MaskFormatter.  Informasi mengenai MaskFormatter dapat dibaca di http://docs.oracle.com/javase/7/docs/api/java/text/NumberFormat.html.

Sebagai contoh, saya mengubah definisi domain class menjadi seperti berikut ini:

@DomainModel @Entity @Canonical
class Latihan {

  String nama

  String nomorTelepon

}

Lalu saya memberikan perintah:

generate-all * --force-overwrite

Pada LatihanView.groovy di atribut nomorTelepon, saya mengubah penggunaan textField() menjadi maskTextField() seperti yang terlihat berikut ini:

maskTextField(id: 'nomorTelepon', columns: 20, 
   bindTo: 'nomorTelepon', 
   mask: '(####) ######', 
   errorPath: 'nomorTelepon')

Bila dijalankan, tampilan program akan terlihat seperti berikut ini:

Tampilan maskTextField()

Tampilan maskTextField()

Pengguna wajib mengisi nomor telepon dengan 4 angka di dalam tanda kurung, dan 6 angka setelah tanda kurung.  Pengguna tidak akan bisa memasukkan karakter lain selain angka (hal ini karena mask memakai # yang berarti harus di-isi dengan angka).

Sama seperti pada numberTextField(), setter untuk MaskFormatter dapat dipanggil dengan memberikan atribut yang diawali dengan mf.  Sebagai contoh, atribut mfPlaceholderCharacter akan memanggil setPlaceholderCharacter() milik MaskFormatter, seperti yang terlihat berikut ini:

maskTextField(id: 'nomorTelepon', columns: 20, 
   bindTo: 'nomorTelepon', 
   mask: '(####) ######', 
   mfPlaceholderCharacter: '_', 
   errorPath: 'nomorTelepon')

Tampilan dari kode program di atas akan terlihat seperti pada gambar berikut ini:

Tampilan maskTextField() Setelah Penambahan Atribut mfPlaceholderCharacter

Tampilan maskTextField() Setelah Penambahan Atribut mfPlaceholderCharacter

Panduan Mengubah Template Scaffolding Di simple-jpa

Plugin simple-jpa 0.4 dapat di-install dengan memberikan perintah install-plugin simple-jpa 0.4

Hasil scaffolding dari plugin simple-jpa terkadang terasa perlu disesuaikan. Misalnya, pengguna terkadang ingin menyesuaikan layout view yang dihasilkan. Beruntungnya, simple-jpa memiliki fasilitas untuk mengubah template yang dipakai oleh fitur scaffolding.

Untuk mengubah template scaffolding, berikan perintah berikut ini:

install-templates

Perintah ini akan menghasilkan file template di lokasi src/templates/artifacts. File template tersebut tidak akan terlihat di window Griffon View, sehingga pengguna harus berpindah ke window Project seperti yang terlihat pada gambar berikut ini:

File Templates Untuk Scaffolding

File Templates Untuk Scaffolding

Bila pengguna memberikan perintah generate-all, maka template yang akan dipakai bukan lagi template bawaan simple-jpa melainkan template yang berada di lokasi src/templates/artifacts.  Dengan demikian, setiap proyek yang berbeda boleh memiliki template-nya masing-masing yang dapat berbeda dari template bawaan.

Berikut ini adalah keterangan mengenai fungsi setiap file template yang ada:

  • SimpleJpaDomainClass.groovy akan dipakai untuk menghasilkan domain class oleh perintah create-domain-class.
  • SimpleJpaView.groovy, SimpleJpaController.groovy, dan SimplaJpaModel.groovy akan dipakai untuk menghasilkan view, controller, dan model untuk setiap MVCGroup dari sebuah domain class.
  • Bila domain class memiliki asosiasi one-to-one dengan domain class lain, maka file SimpleJpaPairView.groovy, SimpleJpaPairController.groovy, dan SimpleJpaPairModel.groovy akan dipakai untuk menghasilkan MVCGroup untuk mengisi domain class yang diasosiasikan.
  • Bila domain class memiliki asosiasi one-to-many dengan domain class lain, maka file SimpleJpaChildView.groovy, SimpleJpaChildController.groovy, dan SimpleJpaChildModel.groovy akan dipakai untuk menghasilkan MVCGroup untuk mengisi domain class yang diasosiasikan.
  • SimpleJpaIntegrationTest.groovy akan dipakai untuk menghasil file untuk keperluan integration testing.
  • StartupView.groovy, StartupController.groovy, dan StartupModel.groovy akan dipakai untuk menghasilkan startup group (yang dihasilkan dengan --startup-group saat memanggil generate-all).

Setiap template yang ada akan diproses oleh SimpleTemplateEngine milik Groovy. Beberapa peraturan yang dapat diikuti dalam mengubah file template adalah:

  • Untuk mensubstitusikan nilai variabel, gunakan tanda dollar ($), misalnya $packageName akan digantikan dengan nilai variabel packageName
  • Agar lebih aman, saat memakai tanda dollar ($), awali dengan kurung kurawal buka dan akhiri dengan kurung kurawal tutup, seperti pada import ${domainPackage}.*
  • Pengguna juga dapat memberikan perintah Groovy di dalam template dengan mengawali perintah tersebut dengan <% dan mengakhirinya dengan %>. Misalnya pada <% if (isManyToOne(field)) out << "//many-to-one" %>
  • Bila berada dalam perintah Groovy (dalam <% ... %>), untuk menghasilkan output, gunakan out seperti pada out << "output"

simple-jpa menyediakan beberapa variabel yang dapat diakses oleh template, yaitu:

  • packageName akan berisi lokasi package untuk file yang dihasilkan.
  • domainPackage akan berisi lokasi package yang berisi domain class.
  • className akan berisi nama class yang sedang dihasilkan.
  • domainClass akan berisi nama domain class yang sedang diproses.
  • domainClassAsProp akan berisi nama domain class yang sedang diproses, tetapi huruf pertamanya berupa huruf kecil, misalnya itemTransaksi bukan ItemTransaksi.
  • domainClassLists adalah sebuah List yang berisi nama seluruh domain class yang ada.
  • firstField adalah nama atribut pertama dari domain class yang sedang diproses.
  • parentDomainClass adalah nama domain class yang sedang diasosiasikan dengan domain class ini.
  • parentAttribute adalah atribut yang dimiliki oleh domain class yang sedang diasosiasikan dengan domain class ini.
  • fields adalah sebuah List yang berisi seluruh daftar field milik domain class yang sedang diproses.

PENTING: Tergantung pada domain class dan perintah yang sedang diberikan, beberapa dari variabel di atas mungkin tidak akan memiliki nilai dan mengembalikan null.

Selain variabel, pada template simple-jpa, pengguna juga dapat memanggil fungsi yang tersedia, yaitu:

  • prop() akan mengubah sebuah String dimana huruf pertamanya akan selalu menjadi huruf kecil. Misalnya, prop("ItemTransaksi") akan menghasilkan "itemTransaksi".
  • cls() akan mengubah sebuah String menjadi mengikuti format nama class. Misalnya, cls("itemTransaksi") akan menghasilkan "ItemTransaksi".
  • natural() akan mengembalikan nama natural dari sebuah property. Misalnya, natural("itemTransaksi") akan mengembalikan "Item Transaksi".
  • getField() akan mengembalikan sebuah List yang berisi atribut dari domain class lain.

Fungsi berikut ini dapat dipanggil bila domain class memiliki asosiasi dengan domain class lain. Seluruh fungsi berikut ini menerima parameter berupa sebuah Map yang merupakan isi dari variabel fields. Daftar fungsi tersebut adalah:

  • isMappedBy() akan mengembalikan true bila field ini memiliki annotation dengan atribut mappedBy.
  • isManyToOne() akan mengembalikan true bila field ini memiliki annotation @ManyToOne.
  • isManyToMany() akan mengembalikan true bila field ini memiliki annotation @ManyToMany.
  • isOneToMany() akan mengembalikan true bila field ini memiliki annotation @OneToMany.
  • isRelation() akan mengembalikan true bila field ini mengandung salah satu annotation relasi ke domain class lain.
  • isCascaded() akan mengembalikan true bila field ini mengandung annotation yang memiliki atribut cascade dan orphanRemoval.
  • isBidirectional() akan mengembalikan true bila field ini memiliki asosiasi bidirectional (dua arah).
  • linkedAttribute() akan mengembalikan nama atribut di domain class lain yang diasosiasikan dengan field ini. Nilai ini hanya ada dan berlaku pada asosiasi bidirectional.

Sebagai latihan, saya akan mengubah template pada view. Berikut ini adalah tampilan hasil scaffolding yang berisi template bawaan dan tujuan perubahan:

Mengubah Template View Untuk Relasi One-To-Many

Mengubah Template View Untuk Relasi One-To-Many

Template default akan menghasilkan relasi one-to-many dalam bentuk sebuah tombol yang bila di-klik akan menampilkan dialog. Terkadang, pengguna mungkin merasa lebih baik bila tombol ini berada di deretan paling bawah (bersamaan dengan tombol Save, Update, Delete, dan sebagainya).

Untuk itu, saya perlu melakukan perubahan pada file /src/templates/artifacts/SimpleJpaView.groovy. Saya mengubah baris 52 menjadi seperti berikut ini:

fields.findAll{ !(isOneToOne(it) && isMappedBy(it)) &&
	!(isManyToMany(it) && isMappedBy(it)) &&
	!(isOneToMany(it))}.each { field ->

Perubahan di atas akan menyebabkan relasi one-to-many tidak akan menampilkan label atau tombol di bagian entry data.

Berikutnya, saya perlu menambahkan beberapa bagian setelah baris 137 (beberapa baris terakhir setelah tombol ‘Delete’ di-generate)menjadi seperti:

<%
	fields.each { field -> 
  		if (isOneToMany(field)) {
		  out << "\t\t\t\tbutton(id: '${field.name}', text: '${natural(field.name as String)}', errorPath: '${field.name}', actionPerformed: {\n"
		  out << """\
				app.withMVCGroup("${prop(field.info)}AsChild", [parentList: model.${field.name}]) { m, v, c ->
 	 			Window thisWindow = SwingUtilities.getWindowAncestor(mainPanel)
			  	new JDialog(thisWindow, "${natural(field.name as String)}", Dialog.ModalityType.DOCUMENT_MODAL).with {
  					contentPane = v.mainPanel
			  		pack()
			  		setLocationRelativeTo(thisWindow)
					setVisible(true)

					model.${field.name}.clear()
				    model.${field.name}.addAll(m.${prop(field.info)}List)
  	 			}
		  }
		})
"""             
       }
  }
%>

Kode program di atas pada dasarnya adalah kode program yang saya copy paste dari baris 85 hingga baris 102 pada template asal-nya.

Setelah ini, saya memberikan perintah:

generate-all * --startup-group=MainGroup --force-overwrite

Setelah proses scaffolding selesai, bila saya menjalankan program, saya akan memperoleh tampilan seperti berikut ini:

Hasil Scaffolding Setelah Perubahan Template

Hasil Scaffolding Setelah Perubahan Template

Panduan Scaffolding Di plugin simple-jpa 0.3

Plugin simple-jpa 0.4 dapat di-install dengan memberikan perintah install-plugin simple-jpa 0.4

Salah satu perubahan yang cukup penting di simple-jpa versi 0.3 adalah fasilitas scaffolding-nya. Kini simple-jpa akan menghasilkan dialog untuk asosiasi one-to-many dan one-to-one. Selain itu, pengguna juga tidak perlu lagi mengubah startup group di Application.groovy secara manual. Pada artikel ini, saya akan menuliskan panduan memakai fasilitas scaffolding yang ada.

Membuat Domain Class Dengan create-domain-class

Domain class selaku JPA entity adalah sebuah class biasa yang wajib mengandung annotation @Entity. Selain itu, simple-jpa juga mensyaratkan penggunaan annotation @DomainModel. Annotation tersebut akan menambahkan beberapa atribut pada class misalnya atribut id, deleted, createdDate, dan modifiedDate.

Saat ini, domain class bukanlah artifact Griffon, tidak seperti pada plugin resminya yang belum dirilis. Oleh sebab itu, mereka dikenali berdasarkan sebuah lokasi package tertentu (defaultnya adalah package domain). Bila ingin mengganti lokasi package ini menjadi lokasi lain, tambahkan baris berikut ini pada file Config.groovy:

griffon.simplejpa.model.package = "com.snake.domain"

Agar tidak ada yang terlupa, sebaiknya pengguna menggunakan perintah generate-all untuk menghasilkan template domain class yang nantinya tinggal di-isi.

Sebagai contoh, perintah berikut ini:

create-domain-class Transaksi ItemTransaksi Suplier

akan menghasilkan tiga buah class, yaitu Transaksi, ItemTransaksi, dan Suplier. Ketiga class tersebut akan dihasilkan di lokasi package untuk domain class (bila nilai griffon.simplejpa.model.package diubah, maka class akan dihasilkan di lokasi tersebut).

Selain membuat class, perintah generate-all juga mendaftarkan class yang dihasilkan ke dalam file persistence.xml sehingga Hibernate JPA dapat menemukan domain class tersebut.

Berikut ini adalah contoh sebuah domain class yang dihasilkan oleh create-domain-class:

package com.snake.domain

import ...

@DomainModel @Entity @Canonical
class Transaksi {

}

Annotation @Canonical akan secara otomatis membuat constructor, toString, equals dan hashCode.

PENTING: Bila sebuah domain class memiliki hubungan bidirectional atau hubungan dua arah, maka annotation @Canonical harus mengabaikan salah satu sisi hubungan yang ada. Bila hal ini tidak lakukan, @Canonical akan melakukan proses rekursif hingga terjadi stack overflow. Berikut ini adalah contoh cara mengabaikan atribut di @Canonical:

@DomainModel @Entity @Canonical(excludes="listItemTransaksi")
class Transaksi {
   ...
}

PENTING: Pada konfigurasi default di simple-jpa, tabel atau skema database akan dibuat berdasarkan domain class. Bila domain class memiliki asosiasi dengan domain class lainnya, maka foreign key akan dihasilkan untuk tabel bersangkutan. Hal ini dapat menyebabkan proses penghapusan tabel atau data menjadi gagal karena tabel atau data sekarang harus dihapus secara berurutan sementara Hibernate tidak menghapus tabel berdasarkan urutan tersebut. Tabel atau skema database yang tidak sinkron dengan domain class dapat menyebabkan keanehan pada saat aplikasi dijalankan. Pengguna dapat menghapus tabel secara manual dengan menggunakan perintah MySQL berikut ini:

set foreign_key_checks=0;
drop table transaksi;
drop table itemtransaksi;
drop table suplier;

Melakukan Scaffolding Dengan generate-all

Perintah generate-all akan menghasilkan MVCGroup (berisi model, view dan controller) untuk memanipulasi sebuah domain class. Sebagai contoh, perintah berikut ini akan menghasilkan MVCGroup bernama transaksi:

generate-all Transaksi

akan menghasilkan file TransaksiModel.groovy, TransaksiView.groovy, dan TransaksiController.groovy. Selain itu, untuk membantu pengujian, perintah tersebut juga membuat file TransaksiTest.groovy di lokasi integration\project. Ia juga akan membuat file data.xls bila belum ada. File spreadsheet ini dapat di-isi dengan data yang akan ditambahkan ke tabel selama proses pengujian nanti.

Perintah generate-all juga membuat beberapa file *.properties di lokasi /griffon-app/i18n. Perubahan pada isi file tersebut akan mengubah label dan pesan yang ditampilkan oleh view.

Selain itu, perintah generate-all juga akan mendaftarkan MVC group baru pada file Application.groovy seperti berikut ini:

mvcGroups {
   'transaksi' {
   		model = 'project.TransaksiModel'
        view = 'project.TransaksiView'
        controller = 'project.TransaksiController'
   }
}

Secara default, model, view, dan controller akan dihasilkan di package bernama project. Bila ingin menggunakan package lain, berikan perintah seperti berikut ini:

generate-all Transaksi --generated-package=com.snake

Setiap view yang dihasilkan oleh generate-all dapat ditampilkan secara langsung. Hal ini karena view selalu berada dalam node application(). Untuk secara otomatis mengubah startup group pada saat proses scaffolding, berikan perintah berikut ini:

generate-all Transaksi --set-startup

Bila program dijalankan, view untuk MVCGroup Transaksi akan langsung ditampilkan, seperti yang terlihat pada gambar berikut ini:

Tampilan Hasil Scaffolding

Tampilan Hasil Scaffolding

Bila terjadi perubahan pada kode program domain class dikemudian hari, hasil scaffolding tidak akan berubah. Hal ini sesuai dengan fungsi scaffolding yaitu sebagai kerangka atau alat bantu di masa-masa awal pengembangan aplikasi. Perintah generate-all yang diberikan terhadap domain class yang sudah dibuat sebelumnya tidak akan menyebabkan perubahan. Hal ini untuk mencegah terjadinya hilangnya konten file secara tidak disengaja. Bila pengguna yakin ingin menulis ulang file yang telah di-generate, ia dapat memberikan perintah seperti berikut ini:

generate-all Transaksi --force-overwrite

TIPS: Untuk melihat perubahan yang terjadi atau mengembalikan ke semula akibat penggunaan --force-overwrite, pengguna dapat menggunakan fitur history yang ada pada kebanyakan IDE modern. Sebagai contoh, pada IntelliJ IDEA, dengan men-klik kanan pada editor dan memilih Local History, Show History akan menampilkan riwayat perubahan seperti yang terlihat pada gambar berikut ini:

Local History Di IntelliJ IDEA

Local History Di IntelliJ IDEA

Untuk melakukan scaffolding beberapa domain class dalam satu kali pemanggilan, berikan perintah seperti berikut ini:

generate-all Transaksi ItemTransaksi Suplier

Bila pengguna ingin melakukan scaffolding untuk seluruh domain class yang ada, maka ia bisa memberikan perintah seperti berikut ini:

generate-all *

Pada contoh di atas, walaupun membuat scaffolding untuk seluruh domain class yang ada, tetap hanya akan ada satu MVCGroup yang ditampilkan setiap kali program dijalankan. Bila pengguna ingin merangkai seluruh MVCGroup untuk domain class tersebut ke dalam sebuah halaman utama, maka ia bisa memberikan perintah seperti berikut ini:

generate-all --startup-group=MainGroup

Perintah di atas akan membuat sebuah MVCGroup baru dengan nama MainGroup. Selain itu, perintah di atas juga akan mengubah startup group di Application.groovy menjadi mainGroup sehingga MVCGroup tersebut akan dipanggil setiap kali program dijalankan. Tampilan program bila dijalankan sekarang akan terlihat seperti pada gambar berikut ini:

Tampilan Startup Group

Tampilan Startup Group

Untuk melakukan scaffolding domain class dan membuat startup group dalam satu kali pemanggilan, pengguna bisa memberikan perintah seperti pada gambar berikut ini:

generate-all Transaksi ItemTransaksi Suplier --startup-group=MainGroup
// atau
generate-all * --startup-group=MainGroup

Scaffolding Relasi One To One

Sebagai contoh, berikut ini adalah domain class yang memiliki relasi one-to-one:

@DomainModel
@Entity
@Canonical
class Pegawai {

    @Size(min=2, max=50)
    String nama

    @OneToOne(cascade=CascadeType.ALL, orphanRemoval=true)
    Alamat alamat

}

@DomainModel
@Entity
@Canonical
class Alamat {

    String alamat1

    String alamat2

    String alamat3

}

Bila pengguna memberikan perintah:

generate-all Pegawai

maka simple-jpa akan menghasilkan 2 MVCGroup yaitu pegawai dan alamatAsPair. MVCGroup pegawai adalah MVCGroup untuk melakukan operasi CRUD, sementara itu MVCGroup alamatAsPair dipakai sebagai dialog untuk mengisi nilai alamat (atribut yang memiliki relasi one-to-one).

Program akan terlihat seperti pada gambar berikut ini:

Hasil Scaffolding Untuk Relasi One-To-One

Hasil Scaffolding Untuk Relasi One-To-One

Pada saat tombol Update di-klik di MVCGroup alamatAsPair, TIDAK akan ada operasi penyimpanan objek Alamat ke database. Yang terjadi adalah nilai alamat milik Pegawai akan diatur sesuai dengan yang di-isi oleh pengguna. Proses penyimpan ke database hanya akan terjadi bila tombol Save di-klik di view Pegawai.

PENTING: Karena proses penyimpanan hanya dilakukan pada satu sisi, pastikan bahwa di definisi class untuk sisi tersebut (pada contoh di atas adalah di Pegawai) memiliki atribut cascade seperti berikut ini:

@OneToOne(cascade=CascadeType.ALL, orphanRemoval=true)

PENTING: Pada relasi one-to-one bidirectional, Hibernate JPA mungkin akan menghasilkan tabel yang dilengkapi contrainst foreign-key yang saling merujuk ke tabel lainnya. Hal ini dapat menyebabkan tabel dan data tidak dapat dihapus. Solusinya adalah dengan menghapus foreign-key di kedua tabel secara manual, atau memakai fasilitas soft-delete milik simple-jpa sehingga proses hapus tetap berfungsi.

PENTING: Jangan lupa menambahkan excludes pada annotation @Canonical bila ini adalah hubungan bidirectional.

TIPS: Seluruh MVCGroup yang mengandung “AsPair” tidak akan melakukan operasi CRUD ke database. Dengan demikian, mereka hanya mewakili representasi data di memori. Sifat ini membuat mereka bisa dengan aman mengandung relasi ke domain class lainnya. Sebagai contoh, definsi domain class terlihat seperti berikut ini:

@DomainModel
@Entity
@Canonical
class Pegawai {

    @Size(min=2, max=50)
    String nama

    @OneToOne(cascade=CascadeType.ALL, orphanRemoval=true)
    Alamat alamat

}

@DomainModel
@Entity
@Canonical
class Alamat {

    String alamat1

    String alamat2

    String alamat3

    @ManyToOne
    Kota kota

    @OneToMany(cascade=CascadeType.ALL, orphanRemoval=true)
    List<NomorTelepon> listNomorTelepon

}

@DomainModel
@Entity
@Canonical
class Kota {

    @Size(min=2, max=2)
    String kode

    @Size(min=3, max=50)
    String nama

}

@DomainModel
@Entity
@Canonical
class NomorTelepon {

    String nomorTelepon

}

Pada contoh di atas, Pegawai memiliki relasi one-to-one dengan Alamat. Sebuah Alamat memiliki relasi many-to-one dengan Kota. Alamat juga memiliki relasi one-to-many dengan NomorTelepon (ini bukan contoh rancangan yang baik karena sebenarnya NomorTelepon tidak perlu dibuat menjadi domain class tersendiri!).

Untuk melakukan scaffolding, pengguna memberikan perintah seperti berikut ini:

generate-all * --force-overwrite

Bila pengguna sudah pernah menjalankan program dengan domain class sebelumnya, ia perlu menghapus seluruh tabel yang ada dari database secara manual. Selain itu, bila pengguna ingin tampilan menu di toolbar diperbaharui (karena jumlah domain class bertambah), ia perlu memberikan perintah generate-all --startup-group=MainGroup --force-overwrite.

Sekarang, bila program dijalankan, akan terlihat seperti pada gambar berikut ini:

Hasil Scaffolding Untuk Relasi Di Dalam Relasi

Hasil Scaffolding Untuk Relasi Di Dalam Relasi

Scaffolding Relasi One To Many

Berikut ini adalah contoh domain class yang memiliki relasi one-to-many:

@DomainModel
@Entity
@Canonical
class Master {

    @Size(min=2, max=5)
    String kode

    @Type(type="org.jadira.usertype.dateandtime.joda.PersistentDateTime")
    DateTime tanggal

    @OneToMany(cascade=CascadeType.ALL, orphanRemoval=true)
    List<Detail> listDetail = []

}

@DomainModel
@Entity
@Canonical
class Detail {

    @Size(min=2, max=50)
    String name

}

Bila pengguna memberikan perintah:

generate-all Master

maka simple-jpa akan menghasilkan 2 MVCGroup yaitu master dan detailAsChild. MVCGroup master adalah MVCGroup untuk melakukan operasi CRUD, sementara itu MVCGroup detailAsChild dipakai sebagai dialog untuk mengisi nilai listDetail (atribut yang memiliki relasi one-to-many).

Program akan terlihat seperti pada gambar berikut ini:

Hasil Scaffolding Untuk Relasi One To Many

Hasil Scaffolding Untuk Relasi One To Many

Pada saat tombol Update di-klik di MVCGroup detailAsChild, TIDAK akan ada operasi penyimpanan objek List<Detail> ke database. Yang terjadi adalah nilai listDetail milik Master akan diatur sesuai dengan data yang di-isi di detailAsChild. Proses penyimpan ke database hanya akan terjadi bila tombol Save di-klik di view Master.

PENTING: One-to-many bidirectional lebih disarankan ketimbang memakai one-to-many unidirectional karena tidak membutuhkan tabel baru untuk menampung asosiasi.

PENTING: Pengguna perlu memastikan bahwa List, Set, Map, dan Collection lainnya yang dipakai sudah dibuat instance barunya, seperti pada contoh berikut ini:

List<Detail> listDetail = []  
// atau
List<Detail> listDetail = new ArrayList()

PENTING: Karena proses penyimpanan hanya dilakukan pada satu sisi, pastikan bahwa di definisi class untuk sisi tersebut (pada contoh di atas adalah di Master) memiliki atribut cascade seperti berikut ini:

@OneToMany(cascade=CascadeType.ALL, orphanRemoval=true)

TIPS: Seluruh MVCGroup yang mengandung “AsChild” tidak akan melakukan operasi CRUD ke database. Dengan demikian, mereka hanya mewakili representasi data di memori. Sifat ini membuat mereka bisa dengan aman mengandung relasi ke domain class lainnya. Lihat contoh di relasi one-to-one di atas!

Scaffolding Relasi Many To One

Sebagai contoh, berikut ini adalah domain class yang memiliki relasi many-to-one:

@DomainModel
@Entity
@Canonical
class Pelanggan {

    @Size(min=2, max=50)
    String nama

}

@DomainModel
@Entity
@Canonical
class Transaksi {

    @Size(min=2, max=50)
    String kode

    @Type(type="org.jadira.usertype.dateandtime.joda.PersistentLocalDate")
    LocalDate tanggal

    @ManyToOne
    Pelanggan pelanggan

}

Bila pengguna memberikan perintah:

generate-all Transaksi

Relasi many-to-one akan ditampilkan dalam bentuk sebuah combo box dimana pengguna dapat memilih salah satu Pelanggan yang ada, seperti yang terlihat pada gambar berikut ini:

Hasil Scaffolding Untuk Relasi Many To One

Hasil Scaffolding Untuk Relasi Many To One

Untuk mengisi data Pelanggan, pengguna tetap perlu membuat MVCGroup tersediri dengan menggunakan perintah berikut ini:

generate-all Pelanggan

PENTING: Bila sebuah objek Pelanggan sedang direferensikan oleh minimal sebuah Transaksi, maka objek Pelanggan tersebut tidak dapat dihapus di MVCGroup pelanggan. Salah satu solusinya adalah dengan menggunakan fitur soft-delete yang disediakan oleh simple-jpa.

Scaffolding Relasi Many To Many

Sebagai contoh, berikut ini adalah domain class yang memiliki relasi many-to-many:

@DomainModel
@Entity
@Canonical
class Item {

    @Size(min=5, max=5)
    String kode

    @Size(min=2, max=50)
    String name

    @ManyToMany
    List<Fasilitas> listFasilitas

}

@DomainModel
@Entity
@Canonical(excludes="listItem")
class Fasilitas {

    @Size(min=2, max=2)
    String kode

    @Size(min=2, max=10)
    String nama

    @ManyToMany(mappedBy="listFasilitas")
    List<Item> listItem

}

Bila pengguna memberikan perintah:

generate-all Item Fasilitas

maka relasi many-to-many akan diwakili dengan komponen tagChooser() bawaan simple-jpa yang terlihat seperti pada gambar berikut ini:

Hasil Scaffolding Untuk Relasi Many To Many

Hasil Scaffolding Untuk Relasi Many To Many

PENTING: Jangan lupa menambahkan excludes di annotation @Canonical pada salah satu sisi di relasi bidirectional untuk menghindari proses rekursif.

PENTING: Pada relasi many-to-many bidirectional, yang dapat menyimpan domain class lainnya secara langsung adalah sisi yang bukan inverse (bukan domain class yang memiliki atribut mappedBy).