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 }