Selection and pagination with GWT, GWTP, smartGWT and JPA
In the previous post we learnt about the four basic functions of persistent storage: Create, Retrieve, Update and Delete (CRUD). In this post, we're going to look at how to add selection and pagination support to Serendipity's MVP components.
In this post, we'll:
- Revisit the MVP Design Pattern
- Create new abstract Presenter and View classes
- Refactor Serendipity's Accounts Presenter and View
- Test the application in development mode
Note: You can download the sample application from the CRMdipity (Se-r-en-dipity) project's download page.
1. Revisit the MVP Design Pattern
In a previous post we looked at gwt-platform's support for a variation of the MVP design pattern so that we could restrict the use of smartGWT components to Views and test Presenters with JUnit. In this post, we'll continue with this approach in order to add selection and pagination support to Serendipity's MVP components.
2. Create new abstract Presenter and View classes
First we need to create two new abstract classes. The Sales Presenter class:
...
public abstract class SalesPresenter<V extends View, Proxy_ extends Proxy<?>> extends Presenter<V, Proxy_> {
public static final int DEFAULT_MAX_RESULTS = 50;
private final DispatchAsync dispatcher;
private int maxResults;
private int firstResult;
private int pageNumber;
private int numberOfElements;
@Inject
public SalesPresenter(EventBus eventBus, V view, Proxy_ proxy, DispatchAsync dispatcher) {
super(eventBus, view, proxy);
this.dispatcher = dispatcher;
}
@Override
protected void onBind() {
super.onBind();
maxResults = DEFAULT_MAX_RESULTS;
firstResult = 0;
pageNumber = 1;
numberOfElements = maxResults;
retrieveResultSet();
}
protected void retrieveResultSet() {
Log.debug("Don't forget to @Override retrieveResultSet()");
}
protected void refreshButtonClicked() {
retrieveResultSet();
}
protected void resultSetFirstButtonClicked() {
firstResult = 0;
pageNumber = 1;
retrieveResultSet();
}
protected void resultSetPreviousButtonClicked() {
firstResult -= maxResults;
pageNumber--;
retrieveResultSet();
}
protected void resultSetNextButtonClicked() {
firstResult += numberOfElements;
pageNumber++;
retrieveResultSet();
}
@Override
protected void revealInParent() {
RevealContentEvent.fire(this, MainPagePresenter.TYPE_SetContextAreaContent, this);
}
protected DispatchAsync getDispatcher() {
return dispatcher;
}
protected int getMaxResults() {
return maxResults;
}
protected void setMaxResults(int maxResults) {
this.maxResults = maxResults;
}
protected int getFirstResult() {
return firstResult;
}
protected void setFirstResult(int firstResult) {
this.firstResult = firstResult;
}
protected int getPageNumber() {
return pageNumber;
}
protected void setPageNumber(int pageNumber) {
this.pageNumber = pageNumber;
}
protected int getNumberOfElements() {
return numberOfElements;
}
protected void setNumberOfElements(int numberOfElements) {
this.numberOfElements = numberOfElements;
}
}
And the corresponding Sales ViewWithUiHandlers class:
...
public abstract class SalesViewWithUiHandlers<C extends UiHandlers> extends ViewImpl
implements HasUiHandlers<C> {
private C uiHandlers;
private static final String CONTEXT_AREA_WIDTH = "*";
private final ToolBar toolBar;
private final SalesContextAreaListGrid listGrid;
private final StatusBar statusBar;
private int numberOfElements;
private int numberSelected;
private int pageNumber;
private VLayout panel;
@Inject
public SalesViewWithUiHandlers(ToolBar toolBar, SalesContextAreaListGrid listGrid,
StatusBar statusBar) {
this.toolBar = toolBar;
this.listGrid = listGrid;
this.statusBar = statusBar;
this.numberOfElements = SalesPresenter.DEFAULT_MAX_RESULTS;
this.numberSelected = 0;
pageNumber = 1;
panel = new VLayout();
// initialise the View's layout container
panel.setStyleName("crm-ContextArea");
panel.setWidth(CONTEXT_AREA_WIDTH);
// add the Tool Bar, List Grid, and Status Bar to the View's layout container
panel.addMember(this.toolBar);
panel.addMember(this.listGrid);
panel.addMember(this.statusBar);
bindCustomUiHandlers();
}
protected void bindCustomUiHandlers() {
// register the ListGird handlers
listGrid.addSelectionChangedHandler(new SelectionChangedHandler() {
@Override
public void onSelectionChanged(SelectionEvent event) {
ListGridRecord[] records = event.getSelection();
setNumberSelected(records.length);
String selectedLabel = Serendipity.getMessages().selected(getNumberSelected(),
getNumberOfElements());
statusBar.getSelectedLabel().setContents(selectedLabel);
}
});
}
protected C getUiHandlers() {
return uiHandlers;
}
@Override
public void setUiHandlers(C uiHandlers) {
this.uiHandlers = uiHandlers;
}
@Override
public Widget asWidget() {
return panel;
}
public ToolBar getToolBar() {
return toolBar;
}
public SalesContextAreaListGrid getListGrid() {
return listGrid;
}
public StatusBar getStatusBar() {
return statusBar;
}
public int getNumberOfElements() {
return numberOfElements;
}
public void setNumberOfElements(int numberOfElements) {
this.numberOfElements = numberOfElements;
}
public int getNumberSelected() {
return numberSelected;
}
public void setNumberSelected(int numberSelected) {
this.numberSelected = numberSelected;
}
public int getPageNumber() {
return pageNumber;
}
public void setPageNumber(int pageNumber) {
this.pageNumber = pageNumber;
}
public void refreshStatusBar() {
// update Selected label e.g "0 of 50 selected"
String selectedLabel = Serendipity.getMessages().selected(getNumberSelected(), getNumberOfElements());
getStatusBar().getSelectedLabel().setContents(selectedLabel);
// update Page number label e.g "Page 1"
String pageNumberLabel = Serendipity.getMessages().page(getPageNumber());
getStatusBar().getPageNumberLabel().setContents(pageNumberLabel);
}
public void removeSelectedData() {
listGrid.removeSelectedData();
}
}
3. Refactor Serendipity's Accounts Presenter and View
Now we can refactor Serendipity's Accounts Presenter and View classes so that they use the new abstract classes. The refactored Accounts Presenter:
...
public class AccountsPresenter extends
SalesPresenter<AccountsPresenter.MyView, AccountsPresenter.MyProxy> implements
AccountsUiHandlers {
// don't forget to update SerendipityGinjector & ClientModule
@ProxyCodeSplit
@NameToken(NameTokens.accounts)
public interface MyProxy extends Proxy<AccountsPresenter>, Place {
}
public interface MyView extends View, HasUiHandlers<AccountsUiHandlers> {
StatusBar getStatusBar();
void refreshStatusBar();
void setNumberOfElements(int numberOfElements);
void setPageNumber(int pageNumber);
void setResultSet(List<AccountsDto> accounts);
void removeSelectedData();
}
@Inject
public AccountsPresenter(EventBus eventBus, MyView view, MyProxy proxy, DispatchAsync dispatcher) {
super(eventBus, view, proxy, dispatcher);
getView().setUiHandlers(this);
}
@Override
protected void onReveal() {
super.onReveal();
MainPagePresenter.getNavigationPaneHeader().setContextAreaHeaderLabelContents(
Serendipity.getConstants().accountsMenuItemName());
MainPagePresenter.getNavigationPane().selectRecord(NameTokens.accounts);
}
@Override
protected void retrieveResultSet() {
getDispatcher().execute(new RetrieveAccountsAction(getMaxResults(), getFirstResult()),
new AsyncCallback<RetrieveAccountsResult>() {
@Override
public void onFailure(Throwable caught) {
Log.debug("onFailure() - " + caught.getLocalizedMessage());
}
@Override
public void onSuccess(RetrieveAccountsResult result) {
setNumberOfElements(result.getAccountDtos().size());
// update Selected label e.g "0 of 50 selected"
getView().setNumberOfElements(getNumberOfElements());
getView().setPageNumber(getPageNumber());
getView().refreshStatusBar();
// enable/disable the pagination widgets
if (getPageNumber() == 1) {
getView().getStatusBar().getResultSetFirstButton().disable();
getView().getStatusBar().getResultSetPreviousButton().disable();
}
// enable/disable the pagination widgets
if (getNumberOfElements() < getMaxResults()) {
getView().getStatusBar().getResultSetNextButton().disable();
}
else {
getView().getStatusBar().getResultSetNextButton().enable();
}
// pass the result set to the View
getView().setResultSet(result.getAccountDtos());
}
});
}
public static native String encodeBase64(String string) /*-{ return $wnd.btoa(string); }-*/;
private static final String HOST_FILENAME = "Account.html";
private static final String ACTIVITY = "activity";
private static final String NEW = "new";
private static final String EDIT = "edit";
private static final String PARAMETER_SEPERATOR = "&"; // GWTP "?"
private static final String NAME = "_blank";
private static final String FEATURES = "width=760, height=480";
@Override
public void onNewButtonClicked() {
StringBuilder url = new StringBuilder();
url.append(HOST_FILENAME).append("?");
String arg0Name = URL.encodeQueryString(AccountsRecord.ACCOUNT_ID);
url.append(arg0Name);
url.append("=");
String arg0Value = URL.encodeQueryString("0");
// url.append(arg0Value);
url.append(encodeBase64(arg0Value));
url.append(PARAMETER_SEPERATOR);
String arg1Name = URL.encodeQueryString(ACTIVITY);
url.append(arg1Name);
url.append("=");
String arg1Value = URL.encodeQueryString(NEW);
// url.append(arg1Value);
url.append(encodeBase64(arg1Value));
Window.open(Serendipity.getRelativeURL(url.toString()), NAME, FEATURES);
}
@Override
public void onDeleteButtonClicked(String accountId) {
Long id = -1L;
try {
id = Long.valueOf(accountId);
}
catch (NumberFormatException nfe) {
Log.debug("NumberFormatException: " + nfe.getLocalizedMessage());
return;
}
getDispatcher().execute(new DeleteAccountAction(id),
new AsyncCallback<DeleteAccountResult>() {
@Override
public void onFailure(Throwable caught) {
Log.debug("onFailure() - " + caught.getLocalizedMessage());
}
@Override
public void onSuccess(DeleteAccountResult result) {
Log.debug("onSuccess()");
getView().removeSelectedData();
}
});
}
@Override
public void onRefreshButtonClicked() {
super.refreshButtonClicked();
}
@Override
public void onResultSetFirstButtonClicked() {
super.resultSetFirstButtonClicked();
getView().getStatusBar().getResultSetFirstButton().disable();
}
@Override
public void onResultSetPreviousButtonClicked() {
super.resultSetPreviousButtonClicked();
}
@Override
public void onResultSetNextButtonClicked() {
super.resultSetNextButtonClicked();
getView().getStatusBar().getResultSetFirstButton().enable();
getView().getStatusBar().getResultSetPreviousButton().enable();
}
@Override
public void onRecordDoubleClicked(String accountId) {
StringBuilder url = new StringBuilder();
url.append(HOST_FILENAME).append("?");
String arg0Name = URL.encodeQueryString(AccountsRecord.ACCOUNT_ID);
url.append(arg0Name);
url.append("=");
String arg0Value = URL.encodeQueryString(accountId);
// url.append(arg0Value);
url.append(encodeBase64(arg0Value));
url.append(PARAMETER_SEPERATOR);
String arg1Name = URL.encodeQueryString(ACTIVITY);
url.append(arg1Name);
url.append("=");
String arg1Value = URL.encodeQueryString(EDIT);
// url.append(arg1Value);
url.append(encodeBase64(arg1Value));
Window.open(Serendipity.getRelativeURL(url.toString()), NAME, FEATURES);
}
}
And the refactored Accounts View:
...
public class AccountsView extends SalesViewWithUiHandlers<AccountsUiHandlers> implements
AccountsPresenter.MyView {
private String recordId;
@Inject
public AccountsView(ToolBar toolBar, AccountsContextAreaListGrid listGrid,
StatusBar statusBar) {
super(toolBar, listGrid, statusBar);
recordId = new String("-1");
}
@Override
protected void bindCustomUiHandlers() {
super.bindCustomUiHandlers();
// initialise the ToolBar and register its handlers
initToolBar();
// register the ListGird handlers
getListGrid().addRecordDoubleClickHandler(new RecordDoubleClickHandler() {
@Override
public void onRecordDoubleClick(RecordDoubleClickEvent event) {
Record record = event.getRecord();
recordId = record.getAttributeAsString(AccountsRecord.ACCOUNT_ID);
if (getUiHandlers() != null) {
getUiHandlers().onRecordDoubleClicked(recordId);
}
}
});
// initialise the StatusBar and register its handlers
initStatusBar();
}
public void setResultSet(List<AccountsDto> resultSet) {
// accountDtos == null when there are no items in table
if (resultSet != null) {
((AccountsContextAreaListGrid) getListGrid()).setResultSet(resultSet);
}
}
protected void initToolBar() {
getToolBar().addButton(ToolBar.NEW_BUTTON, Serendipity.getConstants().newButton(),
Serendipity.getConstants().newButtonTooltip(), new ClickHandler() {
public void onClick(ClickEvent event) {
if (getUiHandlers() != null) {
getUiHandlers().onNewButtonClicked();
}
}
});
getToolBar().addSeparator();
getToolBar().addButton(ToolBar.PRINT_PREVIEW_BUTTON,
Serendipity.getConstants().printPreviewButtonTooltip(), null);
getToolBar().addButton(ToolBar.EXPORT_BUTTON,
Serendipity.getConstants().exportButtonTooltip(), null);
getToolBar().addButton(ToolBar.MAIL_MERGE_BUTTON,
Serendipity.getConstants().mailMergeButtonTooltip(), null);
getToolBar().addButton(ToolBar.REPORTS_BUTTON,
Serendipity.getConstants().reportsButtonTooltip(), null);
getToolBar().addSeparator();
getToolBar().addButton(ToolBar.ASSIGN_BUTTON,
Serendipity.getConstants().assignButtonTooltip(), null);
getToolBar().addButton(ToolBar.DELETE_BUTTON,
Serendipity.getConstants().deleteButtonTooltip(), new ClickHandler() {
public void onClick(ClickEvent event) {
if (getUiHandlers() != null) {
ListGridRecord[] records = getListGrid().getSelection();
if (records.length == 1) {
String title = Serendipity.getConstants().accountWindowTitle() +
records[0].getAttributeAsString(AccountsRecord.ACCOUNT_NAME);
recordId = records[0].getAttributeAsString(AccountsRecord.ACCOUNT_ID);
event.cancel();
SC.ask(title, "Are you sure you want to delete this Account?", new BooleanCallback()
{
@Override
public void execute(Boolean value) {
if (value != null && value) { // Yes
getUiHandlers().onDeleteButtonClicked(recordId);
}
}
});
} else {
getListGrid().deselectAllRecords();
}
}
}
}
);
getToolBar().addButton(ToolBar.EMAIL_BUTTON,
Serendipity.getConstants().emailButtonTooltip(), null);
getToolBar().addButton(ToolBar.WORKFLOW_BUTTON, Serendipity.getConstants().workflowButton(),
Serendipity.getConstants().workflowButtonTooltip(), new ClickHandler() {
public void onClick(ClickEvent event) {
if (getUiHandlers() != null) {
// getUiHandlers().onWorkflowButtonClicked();
}
}
});
getToolBar().addSeparator();
getToolBar().addButton(ToolBar.REFRESH_BUTTON,
Serendipity.getConstants().refreshButtonTooltip(), new ClickHandler() {
public void onClick(ClickEvent event) {
if (getUiHandlers() != null) {
getUiHandlers().onRefreshButtonClicked();
}
}
});
}
protected void initStatusBar() {
getStatusBar().getResultSetFirstButton().addClickHandler(new ClickHandler() {
public void onClick(ClickEvent event) {
if (getUiHandlers() != null) {
getUiHandlers().onResultSetFirstButtonClicked();
}
}
});
getStatusBar().getResultSetPreviousButton().addClickHandler(new ClickHandler() {
public void onClick(ClickEvent event) {
if (getUiHandlers() != null) {
getUiHandlers().onResultSetPreviousButtonClicked();
}
}
});
getStatusBar().getResultSetNextButton().addClickHandler(new ClickHandler() {
public void onClick(ClickEvent event) {
if (getUiHandlers() != null) {
getUiHandlers().onResultSetNextButtonClicked();
}
}
});
}
}
And the Accounts UiHandlers interface (that extends UiHandlers and includes any Presenter methods the View needs to call):
import com.gwtplatform.mvp.client.UiHandlers;
public interface AccountsUiHandlers extends UiHandlers {
void onNewButtonClicked();
void onDeleteButtonClicked(String recordId);
void onRefreshButtonClicked();
void onResultSetNextButtonClicked();
void onResultSetFirstButtonClicked();
void onResultSetPreviousButtonClicked();
void onRecordDoubleClicked(String recordId);
}
We also need to update the RetrieveAccounts class so that the generated 'RetrieveAccounts' Action class includes the required maxResults and firstResult parameters:
package au.com.uptick.serendipity.shared.action;
import java.util.List;
import com.gwtplatform.annotation.GenDispatch;
import com.gwtplatform.annotation.In;
import com.gwtplatform.annotation.Out;
import com.gwtplatform.dispatch.shared.UnsecuredActionImpl;
import au.com.uptick.serendipity.shared.dto.sales.AccountsDto;
@GenDispatch(isSecure = false, serviceName = UnsecuredActionImpl.DEFAULT_SERVICE_NAME)
public class RetrieveAccounts {
@In(1) int maxResults;
@In(2) int firstResult;
@Out(1) List<AccountsDto> accountDtos;
}
And, we need to update the RetrieveAccounts Handler implementation so that it uses the maxResults and firstResult parameters:
...
public class RetrieveAccountsHandler implements
ActionHandler<RetrieveAccountsAction, RetrieveAccountsResult> {
@Inject
RetrieveAccountsHandler(final ServletContext servletContext,
final Provider<HttpServletRequest> requestProvider) {
}
@Override
public RetrieveAccountsResult execute(RetrieveAccountsAction action,
ExecutionContext context) throws ActionException {
RetrieveAccountsResult result = null;
AccountDao accountDao = new AccountDao();
DOMConfigurator.configure("log4j.xml");
Log.info("Retrieve Accounts");
try {
List<Account> accounts = accountDao.retrieveAccounts(action.getMaxResults(),
action.getFirstResult());
if (accounts != null) {
List<AccountsDto> accountsDtos = new ArrayList<AccountsDto>(accounts.size());
for (Account account : accounts) {
accountsDtos.add(createAccountsDto(account));
}
result = new RetrieveAccountsResult(accountsDtos);
}
}
catch (Exception e) {
Log.warn("Unable to retrieve Accounts - ", e);
throw new ActionException(e);
}
return result;
}
private AccountsDto createAccountsDto(Account account) {
return new AccountsDto(account.getAccountId(), account.getAccountName(), account.getMainPhone(),
account.getLocation(), account.getPrimaryContact(), account.getEmailPrimaryContact());
}
@Override
public Class<RetrieveAccountsAction> getActionType() {
return RetrieveAccountsAction.class;
}
@Override
public void undo(RetrieveAccountsAction action, RetrieveAccountsResult result,
ExecutionContext context) throws ActionException {
}
}
4. Test the application in development mode
At this point, you should be able to compile Serendipity and launch it from within Eclipse.
You can use the AccountTestCase to populate the 'Account' table (e.g. just comment out the calls to updateAccount and deleteAccount) and don't forget to update the "hibernate.hbm2ddl.auto" property (e.g. run the JUnit test with value="create" then comment it out) in the persistence.xml file.
The nested Accounts presenter and view, page 1:

The nested Accounts presenter and view, page 2:

Note: You can download the sample application from the CRMdipity (Se-r-en-dipity) project's download page.
What's Next
At this point, we've learnt how to add selection and pagination support to Serendipity's MVP components. Next we'll look at working with HSQLDB, JasperReports and iReport.
- Tags:
- tutorial,
- SmartGWT,
- paging,
- pagination,
- opencsv,
- ListGrid,
- JPA,
- gwt-platform,
- gwt-dispatch,
- GWT MVP,
- GWT,
- grid,
- fileUpload,
- CSV,
- apache

Comments
Great work!
I can't wait for your next post. Thanks for taking the time to share the whole process!
Greetings from Mexico!
SVN access
By the way, I've visited the CRMdipity's google code page and couldn't find the SVN url or Browse the code.
Are you going to allow SVN (readonly) access?
SVN Access
Hi,
I am currently using a local SVN repository which is why you won't find a "Source" link on the CRMdipity Project page.
However, I do plan to migrate the code to a Google Code SVN repository when time permits.
Cheers
Rob
Multipage Motivation
Sorry to comment here, but I was unable to do it in the managing multiple host pages post. My question is, what was your main motivation to make CRMdipity a multipage GWT app? According to a response given in GWT groups, typical GWT applications only require one page.
Multiple host pages
Hi,
CRMdipity (Se-r-en-dipity) is a sample application whose aim is to "provide a practical introduction to GWT, gwt-platform and smartGWT application development".
Sometimes you need to open a new browser window (or tab) and GWT Multipage provides a convenient way to manage multiple host pages.
Cheers
Rob