/* * Copyright (C) 2015 Red Hat, Inc. and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.jboss.errai.ui.nav.client.local; import java.util.Collection; import java.util.HashMap; import java.util.LinkedList; import java.util.Map; import java.util.Queue; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import javax.inject.Inject; import org.jboss.errai.common.client.api.IsElement; import org.jboss.errai.common.client.api.extension.InitVotes; import org.jboss.errai.common.client.ui.ElementWrapperWidget; import org.jboss.errai.ioc.client.api.EntryPoint; import org.jboss.errai.ioc.client.container.Factory; import org.jboss.errai.ioc.client.container.Proxy; 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.jboss.errai.ui.shared.TemplateWidgetMapper; import org.slf4j.Logger; import com.google.common.collect.ImmutableListMultimap; import com.google.common.collect.ImmutableMultimap; import com.google.common.collect.Multimap; import com.google.gwt.core.client.GWT; import com.google.gwt.event.logical.shared.AttachEvent; import com.google.gwt.event.logical.shared.AttachEvent.Handler; import com.google.gwt.event.logical.shared.ValueChangeEvent; import com.google.gwt.event.logical.shared.ValueChangeHandler; import com.google.gwt.user.client.Window; import com.google.gwt.user.client.ui.Composite; import com.google.gwt.user.client.ui.IsWidget; import com.google.gwt.user.client.ui.RootPanel; import com.google.gwt.user.client.ui.Widget; 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<C> { PageNode<C> 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(final PageNode<C> pageNode, final HistoryToken state) { this.pageNode = pageNode; this.state = state; } } private final NavigatingContainer navigatingContainer = GWT.create(NavigatingContainer.class); protected PageNode<Object> currentPage; protected Object currentComponent; protected IsWidget currentWidget; protected HistoryToken currentPageToken; private PageNavigationErrorHandler navigationErrorHandler; private HandlerRegistration historyHandlerRegistration; private final Map<IsWidget, HandlerRegistration> attachHandlerRegistrations = new HashMap<>(); @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<>(); 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; final String hash = Window.Location.getHash(); navigationErrorHandler = new DefaultNavigationErrorHandler(this); historyHandlerRegistration = HistoryWrapper.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()) { final 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()); if (currentPage == null || !token.equals(currentPageToken)) { final PageNode<IsWidget> toPage = navGraph.getPage(token.getPageName()); navigate(new Request<>(toPage, token), false); } } catch (final Exception e) { logger.warn("An error occurred while navigating.", e); if (token == null) navigationErrorHandler.handleInvalidURLError(e, event.getValue()); else navigationErrorHandler.handleInvalidPageNameError(e, token.getPageName()); } } }); maybeConvertHistoryToken(hash); // finally, we bootstrap the navigation system (this invokes the callback // above) InitVotes.registerOneTimeInitCallback(new Runnable() { @Override public void run() { HistoryWrapper.fireCurrentHistoryState(); } }); } protected String inferAppContext(String url) { if (!(url.startsWith("/"))) url = "/" + url; final 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(final 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 <C> void goTo(final Class<C> toPage, final Multimap<String, String> state) { PageNode<C> toPageInstance = null; try { toPageInstance = navGraph.getPage(toPage); navigate(toPageInstance, state); } catch (final RedirectLoopException e) { throw e; } catch (final 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, Multimap)} but then with the page name. * * @param toPage * the name of the page node to lookup and display. */ public void goTo(final String toPage) { PageNode<?> toPageInstance = null; try { toPageInstance = navGraph.getPage(toPage); navigate(toPageInstance); } catch (final RedirectLoopException e) { throw e; } catch (final 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(final Class<? extends UniquePageRole> role) { PageNode<?> toPageInstance = null; try { toPageInstance = navGraph.getPageByRole(role); navigate(toPageInstance); } catch (final RedirectLoopException e) { throw e; } catch (final 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<?>> getPagesByRole(final Class<? extends PageRole> pageRole) { return navGraph.getPagesByRole(pageRole); } private <C> void navigate(final PageNode<C> toPageInstance) { navigate(toPageInstance, ImmutableListMultimap.<String, String> of()); } private <C> void navigate(final PageNode<C> toPageInstance, final Multimap<String, String> state) { final HistoryToken token = historyTokenFactory.createHistoryToken(toPageInstance.name(), state); logger.debug("Navigating to " + toPageInstance.name() + " at url: " + token.toString()); navigate(new Request<>(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 <C> void navigate(final Request<C> request, final 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 <C> void handleQueuedRequests(final Request<C> request, final 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; HistoryWrapper.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() { final 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 && currentComponent != null) { currentPage.pageHidden(currentComponent); currentPage.destroy(currentComponent); } } /** * Call navigation and page related lifecycle methods. If the {@link Access} is fired successfully, load the new page. */ private <C> void maybeShowPage(final Request<C> request, final boolean fireEvent) { request.pageNode.produceContent(component -> { if (component == null) { throw new NullPointerException("Target page " + request.pageNode + " returned a null content widget"); } final C unwrappedComponent = Factory.maybeUnwrapProxy(component); final Widget widget; if (unwrappedComponent instanceof IsWidget) { widget = ((IsWidget) unwrappedComponent).asWidget(); } else if (unwrappedComponent instanceof IsElement) { widget = ElementWrapperWidget.getWidget(((IsElement) unwrappedComponent).getElement()); } else if (TemplateWidgetMapper.containsKey(unwrappedComponent)) { widget = TemplateWidgetMapper.get(unwrappedComponent); } else { throw new RuntimeException("Page must implement IsWidget, IsElement, or be @Templated."); } maybeAttachContentPanel(); currentPageToken = request.state; if ((unwrappedComponent instanceof Composite) && (getCompositeWidget((Composite) unwrappedComponent) == null)) { final HandlerRegistration reg = widget.addAttachHandler(new Handler() { @Override public void onAttachOrDetach(final AttachEvent event) { if (event.isAttached() && currentWidget != unwrappedComponent) { pageHiding(unwrappedComponent, widget, request, fireEvent); } } }); attachHandlerRegistrations.put(widget, reg); } else { pageHiding(unwrappedComponent, widget, request, fireEvent); } }); } private <C, W extends IsWidget> void pageHiding(final C component, final W componentWidget, final Request<C> request, final boolean fireEvent) { if (component instanceof Proxy) { throw new RuntimeException("Was passed in a proxy, but should always receive an unwrapped widget."); } final HandlerRegistration reg = attachHandlerRegistrations.remove(component); if (reg != null) { reg.removeHandler(); } final NavigationControl control = new NavigationControl(Navigation.this, new Runnable() { @Override public void run() { final Access<C> accessEvent = new AccessImpl<>(); accessEvent.fireAsync(component, new LifecycleCallback() { @Override public void callback(final boolean success) { if (success) { NavigationControl showControl = new NavigationControl(Navigation.this, new Runnable() { @Override public void run() { try { // 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(component); setCurrentPage(request.pageNode); currentWidget = componentWidget; currentComponent = component; navigatingContainer.setWidget(componentWidget); request.pageNode.pageShown(component, request.state); } finally { locked = false; } handleQueuedRequests(request, fireEvent); } }, new Runnable() { @Override public void run() { locked = false; } }); try { locked = true; hideCurrentPage(); request.pageNode.pageShowing(component, request.state, showControl); } catch (Exception ex) { locked = false; throw ex; } } else { request.pageNode.destroy(component); } } }); } }, new Runnable() { @Override public void run() { hideCurrentPage(); setCurrentPage(null); } }); if (currentPage != null && currentWidget != null && currentComponent != null && currentWidget.asWidget() == navigatingContainer.getWidget()) { currentPage.pageHiding(Factory.maybeUnwrapProxy(currentComponent), control); } else { control.proceed(); } } /** * Return the current page that is being displayed. * * @return the current page */ public PageNode<?> getCurrentPage() { return currentPage; } /** * @return The state multimap used to show the currently displayed page. If a navigation request has been submitted * this may return the state of page being navigated to before that page has actually been displayed. */ public Multimap<String, String> getCurrentState() { return (currentPageToken != null) ? currentPageToken.getState() : ImmutableMultimap.of(); } /** * 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(final 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 starts with a slash and never ends with one. */ public static String getAppContext() { if (PushStateUtil.isPushStateActivated()) return getAppContextFromHostPage(); else return ""; } private static String getAppContextFromHostPage() { String context = getRawAppContextFromHostPage(); if (!context.isEmpty() && !context.startsWith("/")) { context = "/" + context; } if (context.endsWith("/")) { context = context.substring(0, context.length()-1); } return context; } private static native String getRawAppContextFromHostPage() /*-{ if ($wnd.erraiApplicationWebContext === undefined || $wnd.erraiApplicationWebContext.length === 0) { return ""; } else { return $wnd.erraiApplicationWebContext; } }-*/; private void maybeConvertHistoryToken(String token) { if (PushStateUtil.isPushStateActivated()) { if (token == null || token.isEmpty()) { return; } if (token.startsWith("#")) { token = token.substring(1); } HistoryWrapper.newItem(Window.Location.getPath() + token, false); } } private native static IsWidget getCompositeWidget(Composite instance) /*-{ return instance.@com.google.gwt.user.client.ui.Composite::widget; }-*/; }