Widgets in GWT Cell Tables

I have recently had the need to embed widgets into a GWT cell table.  I have read about the flyweight pattern, and specifically why cell tables are designed to not have widgets in them, but… when you have a requirement to complete you just need to do it.

I went through two iterations of this design and I am pretty happy with the final design.  I took some ideas from GWT Cell Widgets like EditTextCell as well as some experience from the first not-so-great implementation i created.  If you are new to Cells, it is important to realize that the Cell is instantiated one time, but called (with different data) for each row that is rendered.  This is why it is necessary to maintain a map of this cells data.

Here are some design ideas for this “WidgetCell”

  • Widget creation is expensive, so a widget for each row is only created one time and then stored in a cache for reuse.  This helps with the speed of the table rendering.
  • Widgets are never attached to the DOM.  They exist to be manipulated in memory and then have their HTML extracted and pushed into the DOM.
  • Events from the Widgets, therefore, are not handled.  If you need them you will need to change this implementation a little and pass them through the cell (onBrowserEvent method).  Cell events ARE handled (or can be in subclasses), which was enough for my purposes.
  • Finally, the widgets that I am using need to load data from the backend, so this “WidgetCell” populates the cells asynchronously.  This helps speed up initial table rendering as well.

One important thing to note on that last bullet: Browsers have a limit on the number of HTTP requests that can be active at a given time.  Additional requests (from my experience, i didn’t look into it deeply) are getting queued and block the UI from any other HTTP requests until they are cleared from the queue.  This means anything, like navigation, that requires an HTTP request will be queued and block the user interface.  The best solution I have found for this is to pass all related HTTP requests, like the ones for this widget, through a common RPC handler that performs queuing.  This can limit that data providers allowable HTTP requests, leaves additional HTTP connections available, and allows for cancelling uncalled or in-process requests if navigation changes prior to the table completely populating.

This WidgetCell is an abstract class.  It is meant to be extended, with the implementation defining the widget and handling the widget’s data.  The data can be stored in a map with an entry per row.  The abstract methods all pass through either the CellContext, the Cell context’s key, or the Cell index, depending on that is needed for that method.

Important things to remember:

  1.  As I mentioned earlier, this is a method for displaying widgets into Cells.  The widget lifecycle is NOT respected.  There is no attaching and detaching, and events are not handled from the widget.
  2. Widgets that change themselves over their lifetime will NOT have those changes reflected in the cell unless you take special care to manually reflect those changes in the DOM.  An example is a progress bar.  It will be rendered to the DOM, but any updates need to be re-rendered to the DOM manually because the widget is not attached.

Here is the code.  I am sure there will be questions that i can handle in the comments.  Additionally, I need to thank Colin Alworth at Sencha for comments and pointing out pitfalls that helped direct this final design, and David Maddison’s The GWT rendering process post for more insight on the widget lifecycle.

Here is a file with the code: Textfile with WidgetCell Code


import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;

import com.google.gwt.cell.client.AbstractCell;
import com.google.gwt.cell.client.EditTextCell;
import com.google.gwt.cell.client.ValueUpdater;
import com.google.gwt.core.client.GWT;
import com.google.gwt.core.client.Scheduler;
import com.google.gwt.core.client.Scheduler.ScheduledCommand;
import com.google.gwt.dom.client.Element;
import com.google.gwt.dom.client.NativeEvent;
import com.google.gwt.safehtml.client.SafeHtmlTemplates;
import com.google.gwt.safehtml.shared.SafeHtml;
import com.google.gwt.safehtml.shared.SafeHtmlBuilder;
import com.google.gwt.user.client.Command;
import com.google.gwt.user.client.ui.RequiresResize;
import com.google.gwt.user.client.ui.Widget;

/**
 * A Cell for adding Widgets into CellTables. A single instance of a cell is used for a column, with the render method
 * being called for each new cell in a new row. data changes per row must come in through the render method (via the
 * setValue method in super)
 * 
 * The approached used here:
 * <ol>
 * <li>Keep a cache of widgets so only a finite number are made, and they are reused to minimize expensive widget
 * creation (this is based on the method GWT's {@link EditTextCell} uses to maintain data.)</li>
 * <li>The widgets are never attached to the DOM, they are only used for manipulating them and extracting their HTML</li>
 * <li>Events from the widgets, therefore, are not handled. Only events on the cells themselves. However, if needed, the
 * cell events can be passed along to the widgets in the reuse-map and handled. (the context contains the key for the
 * reuse-map)</li>
 * <li>For ajax use, only a wrapper (placeholder) is laid down in the DOM. This placeholder is replaced when the widget
 * HTML when it is ready.</li>
 * </ol>
 * 
 * @author mpickell
 * @version 1.0
 * @param <W>
 *            the widget that is to be used in this cell
 * @param <D>
 *            the type of data used for this widget. This is passed in through the render method for each cell created.
 * 
 * @see <b>For more information on this implementation:</b>
 *      http://www.sencha.com/forum/showthread.php?196112-Widget-Rendered-Grid/page2
 * 
 *      TODO: rename to AbstractClickableWidgetCell?
 * 
 */
public abstract class ClickableWidgetCell<W extends Widget, D> extends AbstractCell<D> implements RequiresResize {

    /**
     * Widget generator for use by this cell renderer. This used as a factory that is used when a new widget is needed.
     * 
     * @param <C>
     *            widget type returned
     */
    protected interface ClickableWidgetCellWidgetFactory<C extends Widget> {
        C createWidget();
    }

    private static int uniqueKeyForWidgetID = 0;

    /**
     * Widget's displaying the table may enlarge it. If this is non-null, it will be resized when all widgets display.
     */
    private RequiresResize containerToResizeAfterAllWidgetsDisplay;

    /**
     * map of widgets used in this column. Once created, the widget is cached so it doesn't need to be created again.
     * The key here is the context.getKey() object, which uniquely IDs a row. (reuse-map is based on design of GWT's
     * {@link EditTextCell})
     */
    private Map<Object, W> reusableWidgetsMap = new HashMap<Object, W>();
    private Map<Object, String> domPlaceholderIds = new HashMap<Object, String>();

    /**
     * This cell can be used as a non-clickable cell as well. this boolean will block the click and keydown events when
     * set to true via the setter.
     */
    private boolean blockClickableEvents = false;

    /**
     * The HTML templates used to render the cell placeholder.
     */
    interface ClickableWidgetCellPlaceholderTemplate extends SafeHtmlTemplates {
        /**
         * The template for the widget placeholder for this Cell, which includes styles and a value.
         * 
         * @param id
         *            the id for this placeholder template
         * @param value
         *            the safe value. Since the value type is {@link SafeHtml}, it will not be escaped before including
         *            it in the template. Alternatively, you could make the value type String, in which case the value
         *            would be escaped.
         * @return a {@link SafeHtml} instance of a simple container. The container position is set to relative so that
         *         any value placed inside can use absolute positioning.
         */
        @SafeHtmlTemplates.Template("<div style='position:relative;' id='{0}'>{1}</div>")
        SafeHtml cell(String id, SafeHtml value);
    }

    /**
     * Create a singleton instance of the templates used to render the cell.
     */
    private static ClickableWidgetCellPlaceholderTemplate placeholderTemplate = GWT
            .create(ClickableWidgetCellPlaceholderTemplate.class);

    /**
     * Construct a new ClickableWidgetCell
     */
    public ClickableWidgetCell() {
        super("click", "keydown");
    }

    /**
     * @return a factory that creates the default widget type, with any necessary setup, that is used by this Dto.
     */
    protected abstract ClickableWidgetCellWidgetFactory<W> getWidgetFactory();

    /**
     * Trigger any asynchronous calls that are needed to get data, if needed, and then update the widget. When update is
     * complete, execute command to push widget HTML to DOM.
     * 
     * @param widget
     *            the widget that needs to be updated
     * @param onDataReadyCallback
     *            the command to trigger when the widget is updated. This command displays the widget in the page
     */
    protected abstract void updateWidget(final W widget, final Context cellContext, final D data,
            final Command onDataReadyCallback);

    /**
     * After the widget is displayed, handle any clean up (like resize to fill cell maybe). This is a default impl.
     * 
     * @param widget
     *            the widget that was displayed in the DOM
     * @param cellContext
     *            this widget's context
     * @param data
     *            the data used when displaying this widget.
     */
    protected void finalizeWidgetAfterDisplayInGrid(final W widget, final Context cellContext, final D data) {
    }

    /**
     * When resize is triggered on this cell (passed down from the grid) the cell implementation can handle it. This is
     * the default impl.
     * 
     * @param widget
     *            a widget from the reuse queue
     * @param cellContextKey
     *            this widget's context
     * @param placeholderId
     *            the placeholder ID for this widget in the DOM, so the HTML can be updated.
     */
    protected void handleWidgetOnWindowResize(W widget, Object cellContextKey, String placeholderId) {
    }

    /**
     * Default implementation.
     * 
     * This method returns HTML to be placed in the page while waiting for the widget to be displayed. If the widget
     * requires service calls, this will be displayed until the service calls return and the onDataReadyCallback command
     * is executed.
     * 
     * @return a SafeHtml object containing whatever HTML should be displayed while the main widget is being updated.
     *         This will be completely removed and placed by the widget HTML when the updateWidget method calls the
     *         onDataReadyCallback.
     */
    protected SafeHtml getWidgetUpdatingHtml() {
        return new SafeHtml() {
            private static final long serialVersionUID = 1L;

            @Override
            public String asString() {
                return "<i>Loading...</i>";
            }
        };
    }

    @Override
    public void onBrowserEvent(Context context, Element parent, D widgetDataDto, NativeEvent event,
            ValueUpdater<D> valueUpdater) {
        super.onBrowserEvent(context, parent, widgetDataDto, event, valueUpdater); // Handles user press actual
                                                                                   // ENTER key
        if ("click".equals(event.getType())) {
            onEnterKeyDown(context, parent, widgetDataDto, event, valueUpdater);
        }
    }

    @Override
    protected void onEnterKeyDown(Context context, Element parent, D widgetDataDto, NativeEvent event,
            ValueUpdater<D> valueUpdater) {
        if (!blockClickableEvents && valueUpdater != null) {
            valueUpdater.update(widgetDataDto);
        }
    }

    @Override
    public void render(final Context context, final D widgetDataDto, final SafeHtmlBuilder sb) {
        /*
         * Widgets are reused so that their (expensive) creation is not performed each time. After creation, they are
         * attached and detached as needed and re-added to the page.
         */
        Object rowKey = context.getKey();

        /*
         * Either get the existing widget from the cache or create it.
         */
        W wfc = reusableWidgetsMap.get(rowKey);
        if (wfc == null) {
            // Create once and store.
            wfc = this.getWidgetFactory().createWidget();
            reusableWidgetsMap.put(rowKey, wfc);
        }
        final W wfcAsFinal = wfc;

        /*
         * Setup a unique key for the placeholder in this row
         */
        if (!domPlaceholderIds.containsKey(context.getKey())) {
            setupPlaceholderId(context);
        }

        /*
         * Allow the Dto to update the widget, possibly after asynchronous calls to service. Once updated, the command
         * is (i.e., has to be) executed to finalize the displaying of the cell.
         * 
         * This update is triggered as a GWT "thread" using the deferred command so that the render method can complete
         * quickly. The update and display of the widget can be deferred safely because everything needed for it is
         * packaged in the updateWidget method
         */
        Scheduler.get().scheduleDeferred(new ScheduledCommand() {
            @Override
            public void execute() {
                updateWidget(wfcAsFinal, context, widgetDataDto, new Command() {
                    @Override
                    public void execute() {
                        ClickableWidgetCell.this.displayWidget(context, widgetDataDto);
                    }
                });
            }
        });

        /*
         * Put the placeholder in the table so we have a location to go to later. Put the "busy" html in there.
         */
        sb.append(placeholderTemplate.cell(domPlaceholderIds.get(context.getKey()), getWidgetUpdatingHtml()));
    }

    private void displayWidget(final Context cellContext, final D widgetDataDto) {
        Element e = getElementsBySelectorJSNI("#" + domPlaceholderIds.get(cellContext.getKey()));
        if (e != null && reusableWidgetsMap.containsKey(cellContext.getKey())
                && reusableWidgetsMap.get(cellContext.getKey()).getElement() != null) {
            e.setInnerHTML(reusableWidgetsMap.get(cellContext.getKey()).getElement().getString());

            /*
             * The widget is now displayed. Allow the implementation to perform any adjustments to it. The
             * implementation can use the domPlaceholderIds map (via getter) to replace the HTML in the page since the
             * element in the widget is never attached to the page.
             * 
             * Changes to the widget will be reflected in the widget map, placeholder ID can be used (via getter, as a
             * string so it cannot be changed) to push any updated HTML to the page.
             */
            Scheduler.get().scheduleDeferred(new ScheduledCommand() {
                @Override
                public void execute() {
                    ClickableWidgetCell.this.finalizeWidgetAfterDisplayInGrid(reusableWidgetsMap.get(cellContext.getKey()),
                            cellContext, widgetDataDto);
                }
            });

            /*
             * This is a widget that might be expanding the table. Call on the resize container to resize.
             */
            if (containerToResizeAfterAllWidgetsDisplay != null) {
                containerToResizeAfterAllWidgetsDisplay.onResize();
            }
        }

    }

    /**
     * Query for elements using selectors
     * 
     * @param selectors
     *            a string containing one or more CSS selectors separated by commas
     * @return a JS array of elements.
     * 
     * @see https://developer.mozilla.org/en-US/docs/DOM/Document.querySelector
     * @see http://www.w3.org/TR/selectors-api/
     */
    protected native Element getElementsBySelectorJSNI(String selector) /*-{
		return $doc.querySelector(selector);
    }-*/;

    /**
     * Create a unique ID for a placeholder that will be placed in the cell on render.
     * 
     * @param context
     */
    private void setupPlaceholderId(Context context) {
        if (uniqueKeyForWidgetID >= Integer.MAX_VALUE) {
            // housekeeping.
            uniqueKeyForWidgetID = 0;
        } else {
            uniqueKeyForWidgetID++;
        }
        domPlaceholderIds.put(context.getKey(), "WidgetCellID_" + uniqueKeyForWidgetID);
    }

    /**
     * If true, this will block the clickable events and this cell will simply render the widget without accepting
     * events.
     * 
     * @param blockClickableEvents
     */
    public void setBlockClickableEvents(boolean blockClickableEvents) {
        this.blockClickableEvents = blockClickableEvents;
    }

    @Override
    public void onResize() {
        /*
         * on resize of the page, update the widget to the new cell size if necessary.
         */
        if (reusableWidgetsMap != null && !reusableWidgetsMap.isEmpty()) {
            for (Entry<Object, W> entry : reusableWidgetsMap.entrySet()) {
                ClickableWidgetCell.this.handleWidgetOnWindowResize(entry.getValue(), entry.getKey(),
                        domPlaceholderIds.get(entry.getKey()));
            }
        }
    }

    /**
     * Get the ID for the widget placeholder in the page. the innerHtml of this element is the widget HTML.
     * 
     * @param cellContextKey
     * @return
     */
    protected String getDomPlaceholderId(Object cellContextKey) {
        return domPlaceholderIds.get(cellContextKey);
    }

    /**
     * Set the container of this grid so it can be resized when all widgets display.
     * 
     * @param containerToResizeAfterAllWidgetsDisplay
     *            the container that implements RequiresResize & IsWidget
     */
    public void setContainerToResizeAfterAllWidgetsDisplay(RequiresResize containerToResizeAfterAllWidgetsDisplay) {
        this.containerToResizeAfterAllWidgetsDisplay = containerToResizeAfterAllWidgetsDisplay;
    }

}

Share