001    /*
002     * Copyright 2002-2006 the original author or authors.
003     * 
004     * Licensed under the Apache License, Version 2.0 (the "License"); you may not
005     * use this file except in compliance with the License. You may obtain a copy of
006     * the License at
007     * 
008     * http://www.apache.org/licenses/LICENSE-2.0
009     * 
010     * Unless required by applicable law or agreed to in writing, software
011     * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
012     * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
013     * License for the specific language governing permissions and limitations under
014     * the License.
015     */
016    package org.springframework.richclient.table.support;
017    
018    import java.awt.event.MouseAdapter;
019    import java.awt.event.MouseEvent;
020    import java.util.Arrays;
021    
022    import javax.swing.JComponent;
023    import javax.swing.JPopupMenu;
024    import javax.swing.JTable;
025    import javax.swing.ListSelectionModel;
026    import javax.swing.event.ListSelectionEvent;
027    import javax.swing.event.ListSelectionListener;
028    
029    import org.springframework.beans.support.PropertyComparator;
030    import org.springframework.context.ApplicationEvent;
031    import org.springframework.context.ApplicationListener;
032    import org.springframework.richclient.application.event.LifecycleApplicationEvent;
033    import org.springframework.richclient.application.statusbar.StatusBar;
034    import org.springframework.richclient.command.ActionCommandExecutor;
035    import org.springframework.richclient.command.CommandGroup;
036    import org.springframework.richclient.command.GuardedActionCommandExecutor;
037    import org.springframework.richclient.factory.AbstractControlFactory;
038    import org.springframework.richclient.util.PopupMenuMouseListener;
039    import org.springframework.util.Assert;
040    
041    import ca.odell.glazedlists.EventList;
042    import ca.odell.glazedlists.GlazedLists;
043    import ca.odell.glazedlists.SortedList;
044    import ca.odell.glazedlists.event.ListEvent;
045    import ca.odell.glazedlists.event.ListEventListener;
046    import ca.odell.glazedlists.gui.AbstractTableComparatorChooser;
047    import ca.odell.glazedlists.gui.TableFormat;
048    import ca.odell.glazedlists.swing.EventSelectionModel;
049    import ca.odell.glazedlists.swing.TableComparatorChooser;
050    import ca.odell.glazedlists.util.concurrent.Lock;
051    
052    /**
053     * This class provides a standard table representation for a set of objects with properties of the objects presented in
054     * the columns of the table. The table created offers the following features:
055     * <ol>
056     * <li>It uses Glazed Lists as the underlying data model and this provides for multi-column sorting and text filtering.</li>
057     * <li>It handles row selection.</>
058     * <li>It offers simple, delegated handling of how to handle a double-click on a row, by setting a command executor.
059     * See {@link #setDoubleClickHandler(ActionCommandExecutor)}.</li>
060     * <li>It supports display of a configured pop-up context menu.</li>
061     * <li>It can report on row counts (after filtering) and selection counts to a status bar</li>
062     * </ol>
063     * <p>
064     * Several I18N messages are needed for proper reporting to a configured status bar. The message keys used are:
065     * <p>
066     * <table border="1">
067     * <tr>
068     * <td><b>Message key </b></td>
069     * <td><b>Usage </b></td>
070     * </tr>
071     * <tr>
072     * <td><i>modelId</i>.objectName.singular</td>
073     * <td>The singular name of the objects in the table</td>
074     * </tr>
075     * <tr>
076     * <td><i>modelId</i>.objectName.plural</td>
077     * <td>The plural name of the objects in the table</td>
078     * </tr>
079     * <tr>
080     * <td><i>[modelId]</i>.objectTable.showingAll.message</td>
081     * <td>The message to show when all objects are being shown, that is no objects have been filtered. This is typically
082     * something like "Showing all nn contacts". The message takes the number of objects nd the object name (singular or
083     * plural) as parameters.</td>
084     * </tr>
085     * <tr>
086     * <td><i>[modelId]</i>.objectTable.showingN.message</td>
087     * <td>The message to show when some of the objects have been filtered from the display. This is typically something
088     * like "Showing nn contacts of nn". The message takes the shown count, the total count, and the object name (singular
089     * or plural) as parameters.</td>
090     * </tr>
091     * <tr>
092     * <td><i>[modelId]</i>.objectTable.selectedN.message</td>
093     * <td>The message to append to the filter message when the selection is not empty. Typically something like ", nn
094     * selected". The message takes the number of selected entries as a parameter.</td>
095     * </tr>
096     * </table>
097     * <p>
098     * Note that the message keys that show the model id in brackets, like this <i>[modelId]</i>, indicate that the model
099     * id is optional. If no message is found using the model id, then the key will be tried without the model id and the
100     * resulting string will be used. This makes it easy to construct one single message property that can be used on
101     * numerous tables.
102     * <p>
103     * <em>Note:</em> If you are using application events to inform UI components of changes to domain objects, then
104     * instances of this class have to be wired into the event distribution. To do this, you should construct instances (of
105     * concrete subclasses) in the application context. They will automatically be wired into the epplication event
106     * mechanism because this class implements {@link ApplicationListener}.
107     * @author Larry Streepy
108     */
109    public abstract class AbstractObjectTable extends AbstractControlFactory implements ApplicationListener {
110    
111            private final String modelId;
112    
113            private String objectSingularName;
114    
115            private String objectPluralName;
116    
117            private Object[] initialData;
118    
119            private String[] columnPropertyNames;
120    
121            private GlazedTableModel model;
122    
123            private SortedList baseList;
124    
125            private EventList finalEventList;
126    
127            private ActionCommandExecutor doubleClickHandler;
128    
129            private CommandGroup popupCommandGroup;
130    
131            private StatusBar statusBar;
132    
133            private AbstractTableComparatorChooser tableSorter;
134    
135            public static final String SHOWINGALL_MSG_KEY = "objectTable.showingAll.message";
136    
137            public static final String SHOWINGN_MSG_KEY = "objectTable.showingN.message";
138    
139            public static final String SELECTEDN_MSG_KEY = "objectTable.selectedN.message";
140    
141            /**
142             * Constructor.
143             * @param modelId used for generating message keys
144             * @param objectType The type of object held in the table
145             */
146            public AbstractObjectTable(String modelId, String[] columnPropertyNames) {
147                    this.modelId = modelId;
148                    setColumnPropertyNames(columnPropertyNames);
149                    init();
150            }
151    
152            /**
153             * Set the initial data to display.
154             * @param initialData Array of objects to display
155             */
156            public void setInitialData(Object[] initialData) {
157                    this.initialData = initialData;
158            }
159    
160            /**
161             * Get the initial data to display. If none has been set, then return the default initial data.
162             * @return initial data to display
163             * @see #getDefaultInitialData()
164             */
165            public Object[] getInitialData() {
166                    if (initialData == null) {
167                            initialData = getDefaultInitialData();
168                    }
169                    return initialData;
170            }
171    
172            /**
173             * Get the base event list for the table model. This can be used to build layered event models for filtering.
174             * @return base event list
175             */
176            public EventList getBaseEventList() {
177                    if (baseList == null) {
178                            // Construct on demand
179                            Object[] data = getInitialData();
180                            if (logger.isDebugEnabled()) {
181                                    logger.debug("Table data: got " + data.length + " entries");
182                            }
183                            // Construct the event list of all our data and layer on the sorting
184                            EventList rawList = GlazedLists.eventList(Arrays.asList(data));
185                            int initialSortColumn = getInitialSortColumn();
186                            if (initialSortColumn >= 0) {
187                                    String sortProperty = getColumnPropertyNames()[initialSortColumn];
188                                    baseList = new SortedList(rawList, new PropertyComparator(sortProperty, false, true));
189                            }
190                            else {
191                                    baseList = new SortedList(rawList);
192                            }
193                    }
194                    return baseList;
195            }
196    
197            /**
198             * Set the event list to be used for constructing the table model. The event list provided MUST have been
199             * constructed from the list returned by {@link #getBaseEventList()} or this table will not work properly.
200             * @param event list to use
201             */
202            public void setFinalEventList(EventList finalEventList) {
203                    this.finalEventList = finalEventList;
204            }
205    
206            /**
207             * Get the event list to be use for constructing the table model.
208             * @return final event list
209             */
210            public EventList getFinalEventList() {
211                    if (finalEventList == null) {
212                            finalEventList = getBaseEventList();
213                    }
214                    return finalEventList;
215            }
216    
217            /**
218             * Get the data model for the table.
219             * <p>
220             * <em>Note:</em> This method returns null unless {@link #getTable()} or {@link #createTable()} is called
221             * @return model the table model which is used for the table
222             */
223            public GlazedTableModel getTableModel() {
224                    return model;
225            }
226    
227            /**
228             * Get the names of the properties to display in the table columns.
229             * @return array of columnproperty names
230             */
231            public String[] getColumnPropertyNames() {
232                    return columnPropertyNames;
233            }
234    
235            /**
236             * Set the names of the properties to display in the table columns.
237             * @param columnPropertyNames
238             */
239            public void setColumnPropertyNames(String[] columnPropertyNames) {
240                    this.columnPropertyNames = columnPropertyNames;
241            }
242    
243            /**
244             * @return the doubleClickHandler
245             */
246            public ActionCommandExecutor getDoubleClickHandler() {
247                    return doubleClickHandler;
248            }
249    
250            /**
251             * Set the handler (action executor) that should be invoked when a row in the table is double-clicked.
252             * @param doubleClickHandler the doubleClickHandler to set
253             */
254            public void setDoubleClickHandler(ActionCommandExecutor doubleClickHandler) {
255                    this.doubleClickHandler = doubleClickHandler;
256            }
257    
258            /**
259             * Returns the sorter which is used to sort the content of the table
260             * @return the sorter, null if {@link #getTable()} or {@link #createTable()} is not called before
261             */
262            protected AbstractTableComparatorChooser getTableSorter() {
263                    return tableSorter;
264            }
265            
266            /**
267             * @return the popupCommandGroup
268             */
269            public CommandGroup getPopupCommandGroup() {
270                    return popupCommandGroup;
271            }
272    
273            /**
274             * Set the command group that should be used to construct the popup menu when a user initiates the UI gesture to
275             * show the context menu. If this is null, then no popup menu will be shown.
276             * @param popupCommandGroup the popupCommandGroup to set
277             */
278            public void setPopupCommandGroup(CommandGroup popupCommandGroup) {
279                    this.popupCommandGroup = popupCommandGroup;
280            }
281    
282            /**
283             * Set the status bar associated with this table. If non-null, then any time the final event list on this table
284             * changes, then the status bar will be updated with the current object counts.
285             * @param statusBar to update
286             */
287            public void setStatusBar(StatusBar statusBar) {
288                    this.statusBar = statusBar;
289                    updateStatusBar();
290            }
291            
292            /**
293             * @return the modelId
294             */
295            public String getModelId() {
296                    return modelId;
297            }
298    
299            /**
300             * Initialize our internal values.
301             */
302            protected void init() {
303                    // Get all our messages
304                    objectSingularName = getMessage(modelId + ".objectName.singular");
305                    objectPluralName = getMessage(modelId + ".objectName.plural");
306            }
307    
308            protected JComponent createControl() {
309                    // Contstruct the table model and table to display the data
310                    EventList finalEventList = getFinalEventList();
311                    model = createTableModel(finalEventList);
312    
313                    JTable table = getComponentFactory().createTable(model);
314                    table.setSelectionModel(new EventSelectionModel(finalEventList));
315                    table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
316    
317                    // Install the sorter
318                    Assert.notNull(baseList);
319                    tableSorter = createTableSorter(table, baseList);
320    
321                    // Allow the derived type to configure the table
322                    configureTable(table);
323    
324                    int initialSortColumn = getInitialSortColumn();
325                    if (initialSortColumn >= 0) {
326                            tableSorter.clearComparator();
327                            tableSorter.appendComparator(initialSortColumn, 0, false);
328                    }
329    
330                    // Add the context menu listener
331                    table.addMouseListener(new ContextPopupMenuListener());
332    
333                    // Add our mouse handlers to setup our desired selection mechanics
334                    table.addMouseListener(new DoubleClickListener());
335    
336                    // Keep our status line up to date with the selections and filtering
337                    StatusBarUpdateListener statusBarUpdateListener = new StatusBarUpdateListener();
338                    table.getSelectionModel().addListSelectionListener(statusBarUpdateListener);
339                    getFinalEventList().addListEventListener(statusBarUpdateListener);
340            
341                    return table;
342            }
343    
344    
345            /**
346             * Configure the newly created table as needed. Install any needed column sizes, renderers, and comparators. The
347             * default implementation does nothing.
348             * @param table The table to configure
349             */
350            protected void configureTable(JTable table) {
351            }
352    
353            /**
354             * Get the default set of objects for this table.
355             * @return Array of data for the table
356             */
357            protected abstract Object[] getDefaultInitialData();
358            
359            /**
360             * Returns the created JTable.
361             */
362            protected JTable getTable() {
363                    return (JTable) getControl();
364            }
365    
366            protected AbstractTableComparatorChooser createTableSorter(JTable table, SortedList sortedList) {
367                    return new TableComparatorChooser(table, sortedList, isMultipleColumnSort());
368            }
369    
370            protected boolean isMultipleColumnSort() {
371                    return true;
372            }
373    
374            /**
375             * Handle a double click on a row of the table. The row will already be selected.
376             */
377            protected void onDoubleClick() {
378                    // Dispatch this to the doubleClickHandler, if any
379                    if (doubleClickHandler != null) {
380                            boolean okToExecute = true;
381                            if (doubleClickHandler instanceof GuardedActionCommandExecutor) {
382                                    okToExecute = ((GuardedActionCommandExecutor) doubleClickHandler).isEnabled();
383                            }
384    
385                            if (okToExecute) {
386                                    doubleClickHandler.execute();
387                            }
388                    }
389            }
390    
391            /**
392             * Construct the table model for this table. The default implementation of this creates a GlazedTableModel using an
393             * Advanced format.
394             * @param eventList on which to build the model
395             * @return table model
396             */
397            protected GlazedTableModel createTableModel(EventList eventList) {
398                    return new GlazedTableModel(eventList, getColumnPropertyNames(), modelId) {
399                            protected TableFormat createTableFormat() {
400                                    return new DefaultAdvancedTableFormat();
401                            }
402                    };
403            }
404    
405            /**
406             * Determine if the event should be handled on this table. If <code>true</code> is returned (the default), then
407             * the list holding the table data will be scanned for the object and updated appropriately depending on then event
408             * type.
409             * @param event to inspect
410             * @return boolean true if the object should be handled, false otherwise
411             * @see #handleDeletedObject(Object)
412             * @see #handleNewObject(Object)
413             * @see #handleUpdatedObject(Object)
414             */
415            protected boolean shouldHandleEvent(ApplicationEvent event) {
416                    return true;
417            }
418    
419            /**
420             * Create the context popup menu, if any, for this table. The default operation is to create the popup from the
421             * command group if one has been specified. If not, then null is returned.
422             * @return popup menu to show, or null if none
423             */
424            protected JPopupMenu createPopupContextMenu() {
425                    return (getPopupCommandGroup() != null) ? getPopupCommandGroup().createPopupMenu() : null;
426            }
427    
428            /**
429             * Create the context popup menu, if any, for this table. The default operation is to create the popup from the
430             * command group if one has been specified. If not, then null is returned.
431             * @param e the event which contains information about the current context.
432             * @return popup menu to show, or null if none
433             */
434            protected JPopupMenu createPopupContextMenu(MouseEvent e) {
435                    return createPopupContextMenu();
436            }
437    
438            /**
439             * Get the default sort column. Defaults to 0.
440             * @return column to sort on
441             */
442            protected int getInitialSortColumn() {
443                    return 0;
444            }
445    
446            /**
447             * Get the selection model.
448             * @return selection model
449             */
450            public ListSelectionModel getSelectionModel() {
451                    return getTable().getSelectionModel();
452            }
453    
454            /**
455             * Executes the runnable with a write lock on the event list.
456             * 
457             * @param runnable its run method is executed while holding a write lock for
458             * the event list.
459             * 
460             * @see #getFinalEventList()
461             */
462            protected void runWithWriteLock(Runnable runnable) {
463                    runWithLock(runnable, getFinalEventList().getReadWriteLock().writeLock());
464            }
465    
466            /**
467             * Executes the runnable with a read lock on the event list.
468             * 
469             * @param runnable its run method is executed while holding a read lock for
470             * the event list.
471             * 
472             * @see #getFinalEventList()
473             */
474            protected void runWithReadLock(Runnable runnable) {
475                    runWithLock(runnable, getFinalEventList().getReadWriteLock().readLock());
476            }
477    
478            private void runWithLock(Runnable runnable, Lock lock) {
479                    Assert.notNull(runnable);
480                    Assert.notNull(lock);
481                    lock.lock();
482                    try {
483                            runnable.run();
484                    }
485                    finally {
486                            lock.unlock();
487                    }
488            }
489    
490            /**
491             * Handle the creation of a new object.
492             * @param object New object to handle
493             */
494            protected void handleNewObject(final Object object) {
495                    runWithWriteLock(new Runnable() {
496                            public void run() {
497                                    getFinalEventList().add(object);
498                            }
499                    });
500            }
501            
502            /**
503             * Handle an updated object in this table. Locate the existing entry (by equals) and replace it in the underlying
504             * list.
505             * @param object Updated object to handle
506             */
507            protected void handleUpdatedObject(final Object object) {               
508                    runWithWriteLock(new Runnable() {
509                            public void run() {
510                                    int index = getFinalEventList().indexOf(object);
511                                    if (index >= 0) {
512                                            getFinalEventList().set(index, object);
513                                    }
514                            }
515                    });
516            }
517    
518            /**
519             * Handle the deletion of an object in this table. Locate this entry (by equals) and delete it.
520             * @param object Updated object being deleted
521             */
522            protected void handleDeletedObject(final Object object) {
523                    runWithWriteLock(new Runnable() {
524                            public void run() {
525                                    int index = getFinalEventList().indexOf(object);
526                                    if (index >= 0) {
527                                            getFinalEventList().remove(index);
528                                    }
529                            }
530                    });
531            }
532    
533            /**
534             * Update the status bar with the current display counts.
535             */
536            protected void updateStatusBar() {
537                    if (statusBar != null) {
538                            int all = getBaseEventList().size();
539                            int showing = getFinalEventList().size();
540                            String msg;
541                            if (all == showing) {
542                                    String[] keys = new String[] { modelId + "." + SHOWINGALL_MSG_KEY, SHOWINGALL_MSG_KEY };
543                                    msg = getMessage(keys, new Object[] { "" + all, (all != 1) ? objectPluralName : objectSingularName });
544                            }
545                            else {
546                                    String[] keys = new String[] { modelId + "." + SHOWINGN_MSG_KEY, SHOWINGN_MSG_KEY };
547    
548                                    msg = getMessage(keys, new Object[] { "" + showing,
549                                                    (showing != 1) ? objectPluralName : objectSingularName, "" + all });
550                            }
551                            // Now add the selection info
552                            int nselected = getTable().getSelectedRowCount();
553                            if (nselected > 0) {
554                                    String[] keys = new String[] { modelId + "." + SELECTEDN_MSG_KEY, SELECTEDN_MSG_KEY };
555    
556                                    msg += getMessage(keys, new Object[] { "" + nselected });
557                            }
558                            statusBar.setMessage(msg.toString());
559                    }
560            }
561    
562            /**
563             * Handle an application event. This will notify us of object adds, deletes, and modifications. Update our table
564             * model accordingly.
565             * @param e event to process
566             */
567            public void onApplicationEvent(ApplicationEvent e) {
568                    if (e instanceof LifecycleApplicationEvent) {
569                            LifecycleApplicationEvent le = (LifecycleApplicationEvent) e;
570                            if (shouldHandleEvent(e)) {
571                                    if (le.getEventType() == LifecycleApplicationEvent.CREATED) {
572                                            handleNewObject(le.getObject());
573                                    }
574                                    else if (le.getEventType() == LifecycleApplicationEvent.MODIFIED) {
575                                            handleUpdatedObject(le.getObject());
576                                    }
577                                    else if (le.getEventType() == LifecycleApplicationEvent.DELETED) {
578                                            handleDeletedObject(le.getObject());
579                                    }
580                            }
581                    }
582            }
583    
584            final class ContextPopupMenuListener extends PopupMenuMouseListener {
585                    protected JPopupMenu getPopupMenu(MouseEvent e) {
586                            return createPopupContextMenu(e);
587                    }
588            }
589    
590            final class DoubleClickListener extends MouseAdapter {
591                    public void mousePressed(MouseEvent e) {
592                            // If the user right clicks on a row other than the selection,
593                            // then move the selection to the current row
594                            if (e.getButton() == MouseEvent.BUTTON3) {
595                                    int rowUnderMouse = getTable().rowAtPoint(e.getPoint());
596                                    if (rowUnderMouse != -1 && !getTable().isRowSelected(rowUnderMouse)) {
597                                            // Select the row under the mouse
598                                            getSelectionModel().setSelectionInterval(rowUnderMouse, rowUnderMouse);
599                                    }
600                            }
601                    }
602    
603                    /**
604                     * Handle double click.
605                     */
606                    public void mouseClicked(MouseEvent e) {
607                            // If the user double clicked on a row, then call onDoubleClick
608                            if (e.getClickCount() == 2) {
609                                    onDoubleClick();
610                            }
611                    }
612            }
613    
614            final class StatusBarUpdateListener implements ListSelectionListener, ListEventListener {
615                    public void valueChanged(ListSelectionEvent e) {
616                            updateStatusBar();
617                    }
618    
619                    public void listChanged(ListEvent listChanges) {
620                            updateStatusBar();
621                    }
622            }
623    }