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 }