// $HeadURL$ // $Id$ // // Copyright © 2006, 2010, 2011, 2012 by the President and Fellows of Harvard College. // // Screensaver is an open-source project developed by the ICCB-L and NSRB labs // at Harvard Medical School. This software is distributed under the terms of // the GNU General Public License. package edu.harvard.med.screensaver.ui.arch.datatable; import java.io.IOException; import java.io.InputStream; import java.util.Collections; import java.util.List; import java.util.Observable; import java.util.Observer; import javax.faces.component.UIData; import javax.faces.component.UIInput; import javax.faces.event.ActionEvent; import javax.faces.event.ValueChangeEvent; import javax.servlet.http.HttpServletRequest; import com.google.common.collect.Lists; import org.apache.commons.io.IOUtils; import org.apache.log4j.Logger; import edu.harvard.med.screensaver.db.Criterion; import edu.harvard.med.screensaver.io.DataExporter; import edu.harvard.med.screensaver.ui.arch.datatable.column.TableColumn; import edu.harvard.med.screensaver.ui.arch.datatable.column.TableColumnManager; import edu.harvard.med.screensaver.ui.arch.datatable.model.DataTableModel; import edu.harvard.med.screensaver.ui.arch.searchresults.CsvDataExporter; import edu.harvard.med.screensaver.ui.arch.searchresults.ExcelWorkbookDataExporter; import edu.harvard.med.screensaver.ui.arch.util.JSFUtils; import edu.harvard.med.screensaver.ui.arch.util.UISelectOneBean; import edu.harvard.med.screensaver.ui.arch.util.servlet.ImageProviderServlet; import edu.harvard.med.screensaver.ui.arch.view.AbstractBackingBean; import edu.harvard.med.screensaver.ui.arch.view.aspects.UICommand; /** * JSF backing bean for data tables. Provides the following functionality: * <ul> * <li>manages DataModel, UIData, and {@link TableColumnManager} objects * <li>handles (re)sorting, (re)filtering, and changes to column composition, in response to notifications from its * {@link TableColumnManager}</li> * <li>handles "rows per page" command (via JSF listener method)</li> * <li>handles "goto row" command (via JSF listener method)</li> * <li>reports whether the "current" column is numeric {@link #isNumericColumn()}</li> * <li>Management of "filter mode"</li> * <li>Provides the ability to export the search results via one or more {@link DataExporter}s.</li> * </ul> * * @param R the type of the data object associated with each row * @author <a mailto="andrew_tolopko@hms.harvard.edu">Andrew Tolopko</a> * @author <a mailto="john_sullivan@hms.harvard.edu">John Sullivan</a> */ public class DataTable<R> extends AbstractBackingBean implements Observer { // static members private static Logger log = Logger.getLogger(DataTable.class); // instance data members private DataTableModelLazyUpdateDecorator<R> _dataTableModel; private UIData _dataTableUIComponent; private Integer _pendingFirstRow; private TableColumnManager<R> _columnManager; private UIInput _rowsPerPageUIComponent; private UISelectOneBean<Integer> _rowsPerPageSelector = new UISelectOneBean<Integer>(Collections.<Integer>emptyList()); private boolean _isTableFilterMode; private List<DataExporter<R>> _dataExporters; private UISelectOneBean<DataExporter<R>> _dataExporterSelector; private ImageProviderServlet _imageProviderServlet; /** * @motivation for unit tests */ private DataTableModel<R> _baseDataTableModel; // public constructors and methods /** * @motivation for CGLIB2 */ public DataTable() {} /** * @param dataTableModel * @param columns * @param rowsPerPageSelector * @param useReorderListWidget if true use the dual list-based column selector * with the ability to re-order columns; if false, tree-based column * selector */ public void initialize(DataTableModel<R> dataTableModel, List<? extends TableColumn<R,?>> columns, UISelectOneBean<Integer> rowsPerPageSelector, boolean useReorderListWidget) { _columnManager = new TableColumnManager<R>(columns, getCurrentScreensaverUser(), useReorderListWidget); _columnManager.addObserver(this); _rowsPerPageSelector = rowsPerPageSelector; _baseDataTableModel = dataTableModel; _dataTableModel = new DataTableModelLazyUpdateDecorator<R>(_baseDataTableModel); _pendingFirstRow = null; reload(); } public UIData getDataTableUIComponent() { return _dataTableUIComponent; } public void setDataTableUIComponent(UIData dataTableUIComponent) { _dataTableUIComponent = dataTableUIComponent; if (_pendingFirstRow != null) { _dataTableUIComponent.setFirst(_pendingFirstRow); _pendingFirstRow = null; } } /** * @motivation MyFaces dataScroller component's 'for' attribute needs the * (absolute) clientId of the dataTable component, if the * dataScroller is in a different (or nested) subView. */ public String getClientId() { if (_dataTableUIComponent != null) { return _dataTableUIComponent.getClientId(getFacesContext()); } return null; } /** * Get the DataTableModel. Lazy instantiate, re-sort, and re-filter, as * necessary. * * @return the table's DataTableModel, sorted and filtered according to the * latest column settings */ public DataTableModel<R> getDataTableModel() { verifyIsInitialized(); return _dataTableModel; } public TableColumnManager<R> getColumnManager() { verifyIsInitialized(); return _columnManager; } public UIInput getRowsPerPageUIComponent() { return _rowsPerPageUIComponent; } public void setRowsPerPageUIComponent(UIInput rowsPerPageUIComponent) { _rowsPerPageUIComponent = rowsPerPageUIComponent; } public boolean isTableFilterMode() { return _isTableFilterMode; } public void setTableFilterMode(boolean isTableFilterMode) { _isTableFilterMode = isTableFilterMode; } /** * Convenience method that invokes the JSF action for the "current" row and * column (as set by JSF during the invoke application phase). Equivalent to * <code>getSortManager().getCurrentColumn().cellAction((E) getDataModel().getRowData())</code>. */ @SuppressWarnings("unchecked") @UICommand public String cellAction() { return (String) getColumnManager().getCurrentColumn().cellAction(getRowData()); } /** * Convenience method that returns the value of the "current" row and column * (as set by JSF during the render phase, when rendering each table cell). * Equivalent to * <code>getSortManager().getCurrentColumn().getCellValue(getDataModel().getRowData())</code>. */ public Object getCellValue() { return getColumnManager().getCurrentColumn().getCellValue(getRowData()); } /** * Get the data object associated with the "current" row (i.e., as set by JSF * at render time). */ @SuppressWarnings("unchecked") final protected R getRowData() { return (R) getDataTableModel().getRowData(); } public int getRowsPerPage() { return getRowsPerPageSelector().getSelection(); } public UISelectOneBean<Integer> getRowsPerPageSelector() { verifyIsInitialized(); return _rowsPerPageSelector; } public void pageNumberListener(ValueChangeEvent event) { if (event.getNewValue() != null && event.getNewValue().toString().trim().length() > 0) { log.debug("page number changed to " + event.getNewValue()); gotoPageIndex(Integer.parseInt(event.getNewValue().toString()) - 1); getFacesContext().renderResponse(); } } public void gotoPageIndex(int pageIndex) { scrollToRow(pageIndex * getRowsPerPage()); } /** * Scrolls data table to a page boundary. * * @motivation ensures that previous & next commands do not have problems * moving to previous & last page */ public void scrollToPageContainingRow(int rowIndex) { int rowsPerPage = getRowsPerPage(); if (rowIndex % rowsPerPage != 0) { int pageBoundaryRowIndex = rowsPerPage * (rowIndex / rowsPerPage); log.debug("scrolling to page boundary row: " + pageBoundaryRowIndex); scrollToRow(pageBoundaryRowIndex); } } /** * Scroll to the specified row by setting the UIData component's 'first' row * (the row displayed at the top of the current data table page). Does <i>not</i> * update the DataTableModel's current row index. * * @param rowIndex */ public void scrollToRow(int rowIndex) { log.debug("scrollToRow(): requested row: " + rowIndex); // ensure value is within valid range rowIndex = Math.max(0, Math.min(rowIndex, getRowCount() - 1)); setRowIndex(rowIndex); log.debug("scrollToRow(): actual row: " + rowIndex); } public boolean isNumericColumn() { return getColumnManager().getCurrentColumn().isNumeric(); } public int getRowCount() { return getDataTableModel().getRowCount(); } public void rowsPerPageListener(ValueChangeEvent event) { String rowsPerPageValue = (String) event.getNewValue(); log.debug("rowsPerPage changed to " + rowsPerPageValue); getRowsPerPageSelector().setValue(rowsPerPageValue); scrollToPageContainingRow(_dataTableUIComponent.getFirst()); getFacesContext().renderResponse(); } public boolean isMultiPaged() { return getRowCount() > getRowsPerPage(); } public void dataScrollerListener(ActionEvent event) { } public List<DataExporter<R>> getDataExporters() { if (_dataExporters == null) { _dataExporters = Lists.newArrayList(); _dataExporters.add(new CsvDataExporter<R>("searchResult")); if(getImageProviderServlet() != null && getExternalContext() != null) { log.debug("Using the ExcelWorkbookDataExporter initialized with an internal image provider reference"); _dataExporters.add(new ExcelWorkbookDataExporter<R>("searchResult", getImageProviderServlet(), ((HttpServletRequest)getExternalContext().getRequest()).getRealPath("/"))); }else { log.debug("not using the ExcelWorkbookDataExporter with the ImageProviderServlet: "+getImageProviderServlet() + ", "+ getExternalContext()); _dataExporters.add(new ExcelWorkbookDataExporter<R>("searchResult")); } } return _dataExporters; } public UISelectOneBean<DataExporter<R>> getDataExporterSelector() { if (_dataExporterSelector == null) { _dataExporterSelector = new UISelectOneBean<DataExporter<R>>(getDataExporters()) { @Override protected String makeLabel(DataExporter<R> dataExporter) { return dataExporter.getFormatName(); } }; } return _dataExporterSelector; } @SuppressWarnings("unchecked") @UICommand /* final (CGLIB2 restriction) */ public String downloadSearchResults() { InputStream inputStream = null; try { DataExporter<?> dataExporter = getDataExporterSelector().getSelection(); log.debug("starting exporting data for download"); // TODO: TableDataExporter should be injected with the associated data table so they can retrieve the columns on demand if (dataExporter instanceof TableDataExporter) { ((TableDataExporter<R>) dataExporter).setTableColumns(getColumnManager().getVisibleColumns()); } inputStream = ((DataExporter<R>) dataExporter).export(getDataTableModel().iterator()); log.debug("finished exporting data for download"); JSFUtils.handleUserDownloadRequest(getFacesContext(), inputStream, dataExporter.getFileName(), dataExporter.getMimeType()); } catch (IOException e) { reportApplicationError(e.toString()); } finally { if (inputStream != null){ log.info("close temp inputstream"); IOUtils.closeQuietly(inputStream); } } return REDISPLAY_PAGE_ACTION_RESULT; } /** * Resets each column's criteria to a single, non-restricting criterion. This * is useful for a user interface that wants to present a single criterion per * column, that can be edited by the user (without having to explicitly add a * criterion first). */ @UICommand public String resetFilter() { for (TableColumn<R,?> column : getColumnManager().getAllColumns()) { column.resetCriteria(); } return REDISPLAY_PAGE_ACTION_RESULT; } /** * Resets the current column's criteria to a single, non-restricting * criterion. This is useful for a user interface that wants to present a * single criterion per column, that can be edited by the user (without having * to explicitly add a criterion first). */ @UICommand public String resetColumnFilter() { TableColumn<R,?> column = (TableColumn<R,?>) getRequestMap().get("column"); if (column != null) { column.resetCriteria(); } return REDISPLAY_PAGE_ACTION_RESULT; } /** * Delete all criteria from each column. */ @UICommand public String clearFilter() { for (TableColumn<R,?> column : getColumnManager().getAllColumns()) { column.clearCriteria(); } return REDISPLAY_PAGE_ACTION_RESULT; } @SuppressWarnings("unchecked") public void update(Observable o, Object arg) { if (o == getColumnManager()) { if (arg instanceof SortChangedEvent) { log.debug("DataTable notified of sort column change: " + arg); resort(); } else if (arg instanceof Criterion) { log.debug("DataTable notified of criterion change: " + arg); refilter(); } else if (arg instanceof ColumnVisibilityChangedEvent) { log.debug("DataTable notified of column visibility change: " + arg); ColumnVisibilityChangedEvent event = (ColumnVisibilityChangedEvent) arg; if (!event.getColumnsRemoved().isEmpty()) { // note: if removedColumn is also a sort column, TableColumnManager // will handle issuing the event that forces a resort(), as necessary // TODO: this is only beneficial for export, since we'll be re-reading all the data, and shouldn't read data for columns that are no longer visible if (getDataTableModel().getModelType() == DataTableModelType.VIRTUAL_PAGING) { refetch(); } } if (event.getColumnsAdded().size() > 0) { // TODO: if InMemoryModel, only refetch if the added columns add new RelationshipPaths! (since we'll already have the data for the new columns) refetch(); for (TableColumn<?,?> addedColumn : event.getColumnsAdded()) { if (addedColumn.hasCriteria()) { refilter(); break; } } } if (event.getColumnsRemoved().size() > 0) { for (TableColumn<?,?> removedColumn : event.getColumnsRemoved()) { if (removedColumn.hasCriteria()) { refilter(); break; } } } } } } // making the refetch(), refilter(), and resort() methods final prevents subclasses from side-stepping the "lazy benefits" DTMLUD final public void refetch() { getDataTableModel().fetch(getColumnManager().getVisibleColumns()); } final public void refilter() { getDataTableModel().filter(getColumnManager().getVisibleColumns()); // note: we cannot call {@link #scrollToRow}, as this will cause DTMLUD to trigger setRowIndex(0); } final public void resort() { getDataTableModel().sort(getColumnManager().getSortColumns(), getColumnManager().getSortDirection()); setRowIndex(0); } final public void reload() { refetch(); refilter(); resort(); } // private methods private void verifyIsInitialized() { if (_columnManager == null || _dataTableModel == null || _rowsPerPageSelector == null) { throw new IllegalStateException("DataTable not initialized"); } } /** * Low-level setter method for changing the current row index. Client code * should call {@link #scrollToRow(int)} * * @motivation handle the case where client code wants to goto a row index, * but the _dataTableUIComponent has not yet been set by JSF * @param rowIndex the row index */ private void setRowIndex(int rowIndex) { if (_dataTableUIComponent != null) { _dataTableUIComponent.setFirst(rowIndex); } else { _pendingFirstRow = rowIndex; } } private ImageProviderServlet getImageProviderServlet() { return _imageProviderServlet; } public void setImageProviderServlet(ImageProviderServlet imageProviderServlet) { this._imageProviderServlet = imageProviderServlet; } }