package org.jboss.errai.ui.nav.client.local;
import java.util.Collection;
import java.util.LinkedList;
import java.util.Queue;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import javax.inject.Inject;
import org.jboss.errai.common.client.api.extension.InitVotes;
import org.jboss.errai.common.client.util.CreationalCallback;
import org.jboss.errai.ioc.client.api.EntryPoint;
import org.jboss.errai.ioc.client.lifecycle.api.Access;
import org.jboss.errai.ioc.client.lifecycle.api.LifecycleCallback;
import org.jboss.errai.ioc.client.lifecycle.api.StateChange;
import org.jboss.errai.ioc.client.lifecycle.impl.AccessImpl;
import org.jboss.errai.ui.nav.client.local.api.NavigationControl;
import org.jboss.errai.ui.nav.client.local.api.PageNavigationErrorHandler;
import org.jboss.errai.ui.nav.client.local.api.PageNotFoundException;
import org.jboss.errai.ui.nav.client.local.api.RedirectLoopException;
import org.jboss.errai.ui.nav.client.local.pushstate.PushStateUtil;
import org.jboss.errai.ui.nav.client.local.spi.NavigationGraph;
import org.jboss.errai.ui.nav.client.local.spi.PageNode;
import org.slf4j.Logger;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.Multimap;
import com.google.gwt.core.client.GWT;
import com.google.gwt.event.logical.shared.ValueChangeEvent;
import com.google.gwt.event.logical.shared.ValueChangeHandler;
import com.google.gwt.user.client.History;
import com.google.gwt.user.client.ui.IsWidget;
import com.google.gwt.user.client.ui.RootPanel;
import com.google.web.bindery.event.shared.HandlerRegistration;
/**
* Central control point for navigating between pages of the application.
* <p>
* Configuration is decentralized: it is based on fields and annotations present in other application classes. This
* configuration is gathered at compile time.
*
* @see Page
* @see PageState
* @see PageShowing
* @see PageShown
* @see PageHiding
* @see PageHidden
* @author Jonathan Fuerth <jfuerth@gmail.com>
*/
@EntryPoint
public class Navigation {
/**
* Maximum number of successive redirects until Errai suspects an endless loop.
*/
final static int MAXIMUM_REDIRECTS = 99;
/**
* Encapsulates a navigation request to another page.
*/
private static class Request<W extends IsWidget> {
PageNode<W> pageNode;
HistoryToken state;
/**
* Construct a new {@link Request}.
*
* @param pageNode
* The page node to display. Normally, the implementation of PageNode is generated at compile time based on
* a Widget subclass that has been annotated with {@code @Page}. Anything calling this method must ensure
* that the given PageNode has been entered into the navigation graph, or later navigation back to
* {@code toPage} will fail.
* @param state
* The state information to pass to the page node before showing it.
*/
private Request(PageNode<W> pageNode, HistoryToken state) {
this.pageNode = pageNode;
this.state = state;
}
}
private final NavigatingContainer navigatingContainer = GWT.create(NavigatingContainer.class);
protected PageNode<IsWidget> currentPage;
protected IsWidget currentWidget;
private PageNavigationErrorHandler navigationErrorHandler;
private HandlerRegistration historyHandlerRegistration;
@Inject
private Logger logger;
/**
* Indicates that a navigation request is currently processed.
*/
private boolean locked = false;
/**
* Queued navigation requests which could not handled immediately.
*/
private final Queue<Request> queuedRequests = new LinkedList<Request>();
private int redirectDepth = 0;
@Inject
private NavigationGraph navGraph;
@Inject
private StateChange<Object> stateChangeEvent;
@Inject
private HistoryTokenFactory historyTokenFactory;
@PostConstruct
private void init() {
if (navGraph.isEmpty())
return;
navigationErrorHandler = new DefaultNavigationErrorHandler(this);
historyHandlerRegistration = History.addValueChangeHandler(new ValueChangeHandler<String>() {
@Override
public void onValueChange(final ValueChangeEvent<String> event) {
HistoryToken token = null;
try {
logger.debug("URL value changed to " + event.getValue());
if (needsApplicationContext()) {
String context = inferAppContext(event.getValue());
logger.info("No application context defined. Inferring application context as "
+ context
+ ". Change this value by setting the variable \"erraiApplicationWebContext\" in your GWT host page"
+ ", or calling Navigation.setAppContext.");
setAppContext(context);
}
token = historyTokenFactory.parseURL(event.getValue());
PageNode<IsWidget> toPage = null;
toPage = navGraph.getPage(token.getPageName());
if (currentPage == null || !toPage.name().equals(currentPage.name())) {
navigate(new Request<IsWidget>(toPage, token), false);
}
} catch (Exception e) {
if (token == null)
navigationErrorHandler.handleInvalidURLError(e, event.getValue());
else
navigationErrorHandler.handleInvalidPageNameError(e, token.getPageName());
}
}
});
// finally, we bootstrap the navigation system (this invokes the callback
// above)
InitVotes.registerOneTimeInitCallback(new Runnable() {
@Override
public void run() {
History.fireCurrentHistoryState();
}
});
}
protected String inferAppContext(String url) {
if (!(url.startsWith("/")))
url = "/" + url;
int indexOfNextSlash = url.indexOf("/", 1);
if (indexOfNextSlash < 0)
return "";
else
return url.substring(0, indexOfNextSlash);
}
/**
* Set an error handler that is called in case of a {@link PageNotFoundException} error during page navigation.
*
* @param handler
* An error handler for navigation. Setting this to null assigns the {@link DefaultNavigationErrorHandler}
*/
public void setErrorHandler(PageNavigationErrorHandler handler) {
if (handler == null)
navigationErrorHandler = new DefaultNavigationErrorHandler(this);
else
navigationErrorHandler = handler;
}
/**
* Public for testability.
*/
@PreDestroy
public void cleanUp() {
historyHandlerRegistration.removeHandler();
setErrorHandler(null);
}
/**
* Looks up the PageNode instance that provides content for the given widget type, sets the state on that page, then
* makes the widget visible in the content area.
*
* @param toPage
* The content type of the page node to look up and display. Normally, this is a Widget subclass that has
* been annotated with {@code @Page}.
* @param state
* The state information to set on the page node before showing it. Normally the map keys correspond with the
* names of fields annotated with {@code @PageState} in the widget class, but this is not required.
*/
public <W extends IsWidget> void goTo(Class<W> toPage, Multimap<String, String> state) {
PageNode<W> toPageInstance = null;
try {
toPageInstance = navGraph.getPage(toPage);
navigate(toPageInstance, state);
} catch (RedirectLoopException e) {
throw e;
} catch (RuntimeException e) {
if (toPageInstance == null)
// This is an extremely unlikely case, so throwing an exception is preferable to going through the navigation error handler.
throw new PageNotFoundException("There is no page of type " + toPage.getName() + " in the navigation graph.");
else
navigationErrorHandler.handleInvalidPageNameError(e, toPageInstance.name());
}
}
/**
* Same as {@link #goTo(Class, com.google.common.collect.Multimap)} but then with the page name.
*
* @param toPage
* the name of the page node to lookup and display.
*/
public void goTo(String toPage) {
PageNode<? extends IsWidget> toPageInstance = null;
try {
toPageInstance = navGraph.getPage(toPage);
navigate(toPageInstance);
} catch (RedirectLoopException e) {
throw e;
} catch (RuntimeException e) {
navigationErrorHandler.handleInvalidPageNameError(e, toPage);
}
}
/**
* Looks up the PageNode instance of the page that has the unique role set and makes the widget visible in the content
* area.
*
* @param role
* The unique role of the page that needs to be displayed.
*/
public void goToWithRole(Class<? extends UniquePageRole> role) {
PageNode<?> toPageInstance = null;
try {
toPageInstance = navGraph.getPageByRole(role);
navigate(toPageInstance);
} catch (RedirectLoopException e) {
throw e;
} catch (RuntimeException e) {
navigationErrorHandler.handleError(e, role);
}
}
/**
* Return all PageNode instances that have specified pageRole.
*
* @param pageRole
* the role to find PageNodes by
* @return All the pageNodes of the pages that have the specific pageRole.
*/
public Collection<PageNode<? extends IsWidget>> getPagesByRole(Class<? extends PageRole> pageRole) {
return navGraph.getPagesByRole(pageRole);
}
private <W extends IsWidget> void navigate(PageNode<W> toPageInstance) {
navigate(toPageInstance, ImmutableListMultimap.<String, String> of());
}
private <W extends IsWidget> void navigate(PageNode<W> toPageInstance, Multimap<String, String> state) {
HistoryToken token = historyTokenFactory.createHistoryToken(toPageInstance.name(), state);
logger.debug("Navigating to " + toPageInstance.name() + " at url: " + token.toString());
navigate(new Request<W>(toPageInstance, token), true);
}
/**
* Captures a backup of the current page state in history, sets the state on the given PageNode from the given state
* token, then makes its widget visible in the content area.
*/
private <W extends IsWidget> void navigate(Request<W> request, boolean fireEvent) {
if (locked) {
queuedRequests.add(request);
return;
}
redirectDepth++;
if (redirectDepth >= MAXIMUM_REDIRECTS) {
throw new RedirectLoopException("Maximum redirect limit of " + MAXIMUM_REDIRECTS + " reached. "
+ "Do you have a redirect loop?");
}
maybeShowPage(request, fireEvent);
}
private <W extends IsWidget> void handleQueuedRequests(Request<W> request, boolean fireEvent) {
if (queuedRequests.isEmpty()) {
// No new navigation requests were recorded in the lifecycle methods.
// This is the page which has to be displayed and the browser's history
// can be updated.
redirectDepth = 0;
History.newItem(request.state.toString(), fireEvent);
}
else {
// Process all navigation requests captured in the lifecycle methods.
while (queuedRequests.size() != 0) {
navigate(queuedRequests.poll(), fireEvent);
}
}
}
/**
* Attach the content panel to the RootPanel if does not already have a parent.
*/
private void maybeAttachContentPanel() {
if (getContentPanel().asWidget().getParent() == null) {
RootPanel.get().add(getContentPanel());
}
}
/**
* Hide the page currently displayed and call the associated lifecycle methods.
*/
private void hideCurrentPage() {
IsWidget currentContent = navigatingContainer.getWidget();
// Note: Optimized out in production mode
if (currentPage != null && (currentContent == null || currentWidget.asWidget() != currentContent)) {
// This could happen if someone was manipulating the DOM behind our backs
GWT.log("Current content widget vanished or changed. " + "Not delivering pageHiding event to " + currentPage
+ ".");
}
// Ensure clean contentPanel regardless of currentPage being null
navigatingContainer.clear();
if (currentPage != null && currentWidget != null) {
currentPage.pageHidden(currentWidget);
currentPage.destroy(currentWidget);
}
}
/**
* Call navigation and page related lifecycle methods. If the {@link Access} is fired successfully, load the new page.
*/
private <W extends IsWidget> void maybeShowPage(final Request<W> request, final boolean fireEvent) {
request.pageNode.produceContent(new CreationalCallback<W>() {
@Override
public void callback(final W widget) {
if (widget == null) {
throw new NullPointerException("Target page " + request.pageNode + " returned a null content widget");
}
maybeAttachContentPanel();
pageHiding(widget, request, fireEvent);
}
});
}
private <W extends IsWidget> void pageHiding(final W widget, final Request<W> request, final boolean fireEvent) {
final NavigationControl control = new NavigationControl(new Runnable() {
@Override
public void run() {
final Access<W> accessEvent = new AccessImpl<W>();
accessEvent.fireAsync(widget, new LifecycleCallback() {
@Override
public void callback(final boolean success) {
if (success) {
locked = true;
try {
hideCurrentPage();
request.pageNode.pageShowing(widget, request.state);
// Fire IOC lifecycle event to indicate that the state of the
// bean has changed.
// TODO make this smarter and only fire state change event when
// fields actually changed.
stateChangeEvent.fireAsync(widget);
setCurrentPage(request.pageNode);
currentWidget = widget;
navigatingContainer.setWidget(widget);
request.pageNode.pageShown(widget, request.state);
} finally {
locked = false;
}
handleQueuedRequests(request, fireEvent);
}
else {
request.pageNode.destroy(widget);
}
}
});
}
});
if (currentPage != null && currentWidget != null && currentWidget.asWidget() == navigatingContainer.getWidget()) {
currentPage.pageHiding(currentWidget, control);
}
else {
control.proceed();
}
}
/**
* Return the current page that is being displayed.
*
* @return the current page
*/
public PageNode<IsWidget> getCurrentPage() {
return currentPage;
}
/**
* Returns the panel that this Navigation object manages. The contents of this panel will be updated by the navigation
* system in response to PageTransition requests, as well as changes to the GWT navigation system.
*
* @return The content panel of this Navigation instance. It is not recommended that client code modifies the contents
* of this panel, because this Navigation instance may replace its contents at any time.
*/
public IsWidget getContentPanel() {
return navigatingContainer.asWidget();
}
/**
* Returns the navigation graph that provides PageNode instances to this Navigation instance.
*/
// should this method be public? should we expose a way to set the nav graph?
NavigationGraph getNavGraph() {
return navGraph;
}
/**
* Just sets the currentPage field. This method exists primarily to get around a generics Catch-22.
*
* @param currentPage
* the new value for currentPage.
*/
@SuppressWarnings({ "unchecked", "rawtypes" })
private void setCurrentPage(PageNode currentPage) {
this.currentPage = currentPage;
}
private boolean needsApplicationContext() {
return (currentPage == null) && (PushStateUtil.isPushStateActivated()) && (getAppContextFromHostPage() == null);
}
/**
* Sets the application context used in pushstate URL paths. This application context should match the deployed
* application context in your web.xml
*
* @param path The context path. Never null.
*/
public static native void setAppContext(String path) /*-{
if (path == null) {
$wnd.erraiApplicationWebContext = undefined;
}
else {
$wnd.erraiApplicationWebContext = path;
}
}-*/;
/**
* Gets the application context used in pushstate URL paths. This application context should match the deployed
* application context in your web.xml
*
* @return The application context. This may return the empty String (but never null). If non-empty, the return value
* always ends with a slash.
*/
public static String getAppContext() {
if (PushStateUtil.isPushStateActivated())
return getAppContextFromHostPage();
else
return "";
}
private static native String getAppContextFromHostPage() /*-{
if ($wnd.erraiApplicationWebContext === undefined) {
return null;
}
else if ($wnd.erraiApplicationWebContext.length === 0) {
return "";
}
else {
if ($wnd.erraiApplicationWebContext.substr(-1) !== "/") {
return $wnd.erraiApplicationWebContext + "/";
}
return $wnd.erraiApplicationWebContext;
}
}-*/;
}