Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package mybookstore.handoff;

import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.ModelAndView;
import tools.dynamia.navigation.NavigationManager;

import java.io.IOException;

/**
* Real-world navigation hand-off cases for manual/automated verification of
* {@code NavigationManagerSession} and its request-scope filter:
* <ul>
* <li>{@code /demo/handoff/redirect} — records a page + callback, then redirects (like a ZK command
* that calls {@code setPageLater} and {@code sendRedirect}). The target desktop must open Invoices (not the default page).</li>
* <li>{@code /demo/handoff/desktop} — builds a ZK desktop <i>without</i> choosing a page itself, so it
* can only show what was handed off by a previous request (or the default page if nothing was).</li>
* <li>{@code /demo/handoff/login} — see {@link HandOffDemoLoginFilter}.</li>
* <li>{@code /demo/handoff/status} — how many queued callbacks have run.</li>
* </ul>
*/
@Controller
public class HandOffDemoController {

@GetMapping("/demo/handoff/redirect")
public void redirect(HttpServletResponse response) throws IOException {
NavigationManager.setPageLater("library/invoices");
NavigationManager.runLater(HandOffDemoState.CALLBACKS_RUN::incrementAndGet);
response.sendRedirect("/demo/handoff/desktop");
}

@GetMapping("/demo/handoff/desktop")
public ModelAndView desktop() {
return new ModelAndView("embed");
}

@GetMapping(value = "/demo/handoff/status", produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
public String status() {
return "{\"callbacksRun\":" + HandOffDemoState.CALLBACKS_RUN.get() + "}";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package mybookstore.handoff;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import tools.dynamia.navigation.NavigationManager;

import java.io.IOException;

/**
* Simulates what a real login does in an app that uses Spring Security: a listener reacting to the
* authentication success ({@code LoginListener.onLoginSuccess}) calls {@code setPageLater}/{@code runLater}
* <b>inside the security filter chain</b> (order -100, before any controller) and the request ends with an
* HTTP redirect. Both things must work: the navigation scope must already be bound at that point, and the
* pending intent must survive the redirect.
* <p>
* Try: {@code GET /demo/handoff/login} (redirects to {@code /demo/handoff/desktop}, which must open Customers).
*/
@Component
@Order(-100)
public class HandOffDemoLoginFilter extends OncePerRequestFilter {

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !"/demo/handoff/login".equals(request.getRequestURI());
}

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
NavigationManager.setPageLater("library/customers");
NavigationManager.runLater(HandOffDemoState.CALLBACKS_RUN::incrementAndGet);
response.sendRedirect("/demo/handoff/desktop");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package mybookstore.handoff;

import java.util.concurrent.atomic.AtomicInteger;

/**
* Counts how many {@code NavigationManager.runLater} callbacks queued by the hand-off demo endpoints
* actually ran, so the flow can be verified from the outside (see {@link HandOffDemoController}).
*/
final class HandOffDemoState {

static final AtomicInteger CALLBACKS_RUN = new AtomicInteger();

private HandOffDemoState() {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

/**
* <p>
Expand Down Expand Up @@ -81,6 +82,7 @@ public abstract class BaseNavigationManager implements Serializable, NavigationM

private NavigationBuilder currentNavigationBuilder;
private Map<String, Serializable> currentPageParams;
private final String id;

/**
* Creates a new BaseNavigationManager with the given {@link ModuleContainer}.
Expand All @@ -91,6 +93,22 @@ public BaseNavigationManager(ModuleContainer container) {
this.logger = new SLF4JLoggingService(BaseNavigationManager.class);
this.attributes = new HashMap<>();
this.container = container;
this.id = UUID.randomUUID().toString();
var registry = NavigationManagerRegistry.getInstance();
if (registry != null) {
registry.register(this);
}
}

/**
* Returns a stable, framework-agnostic identifier for this instance, generated once at
* construction time.
*
* @return an opaque, stable id for this instance
*/
@Override
public String getId() {
return id;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,15 @@ static NavigationManager getCurrent() {
}

/**
* Delegate set current {@link Page} using a {@link NavigationManagerSession} when NavigationManager is builded
* Delegate set current {@link Page} using a {@link NavigationManagerSession} when NavigationManager is builded.
* <p>
* {@link NavigationManagerSession} is a {@link ScopedValue}: this must be called within the same
* scope that will forward into the target ZK desktop (e.g. from a controller rendering an
* {@code index}/{@code embed} ZUL view via a server-side forward, with the scope established by
* a request-lifecycle filter around the whole request). If the request ends (e.g. with an HTTP
* redirect) before any desktop consumed the value, the filter hands it off through the HTTP
* session to the next request. Throws {@link java.util.NoSuchElementException} if no scope is
* currently bound.
*
* @param page
*/
Expand All @@ -114,7 +122,8 @@ static void setPageLater(Page page) {
}

/**
* Delegate set current {@link Page} using a {@link NavigationManagerSession} when NavigationManager is builded
* Delegate set current {@link Page} using a {@link NavigationManagerSession} when NavigationManager is builded.
* See {@link #setPageLater(Page)} for the scope requirement.
*
* @param page
* @param params
Expand All @@ -132,7 +141,9 @@ static void setPageLater(String path, Map<String, Serializable> params) {
}

/**
* Delegate callback to run when {@link NavigationManager} are builded. Its store a Queue using {@link NavigationManagerSession}
* Delegate callback to run when {@link NavigationManager} are builded. Its store a Queue using {@link NavigationManagerSession}.
* See {@link #setPageLater(Page)} for the scope requirement — this must be called within the
* same scope that will build the target {@link NavigationManager}.
*
* @param callback
*/
Expand Down Expand Up @@ -331,4 +342,19 @@ static void runLater(Callback callback) {
* @param navigationBuilder the navigation builder to set
*/
void setCurrentNavigationBuilder(NavigationBuilder navigationBuilder);

/**
* Returns a stable identifier for this {@link NavigationManager} instance, unique within the
* current user session, framework-agnostic (it never references a UI-specific concept such as a
* ZK {@code Desktop} id).
* <p>
* Used to correlate a manager instance with "which tab/iframe/desktop" it belongs to when more
* than one instance is active in the same session at once (e.g. several {@code <iframe>}s, each
* loading its own page, or several real browser tabs opened concurrently) — for logging,
* debugging, and for a session-level registry of active instances (see
* {@code NavigationManagerRegistry}).
*
* @return an opaque, stable id for this instance
*/
String getId();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1
* Colombia / South America
*
* 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 tools.dynamia.navigation;

import org.springframework.context.annotation.Scope;
import tools.dynamia.integration.Containers;
import tools.dynamia.integration.sterotypes.Component;

import java.io.Serializable;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
* Session-level registry of every {@link NavigationManager} instance currently active for the
* user's session, keyed by {@link NavigationManager#getId()}.
* <p>
* With one {@code NavigationManager} instance per ZK desktop (real browser tab, or one of several
* {@code <iframe>}s each loading its own ZK page), this registry lets a host shell — or any
* session-level code — enumerate or look up every tab/iframe currently open, instead of only being
* able to reach "the current one" implicitly resolved from whatever request/desktop is executing.
* </p>
* <p>
* Registration happens as soon as a manager instance is built (see {@code BaseNavigationManager}).
* Explicit unregistration (e.g. a ZK desktop being destroyed because its tab/iframe was closed) is
* best-effort — see the UI-framework-specific wiring for details. It is not required for
* correctness: since this registry is itself session-scoped, every entry is discarded automatically
* when the whole session ends, regardless of whether individual instances were unregistered first.
* </p>
*
* @author Mario A. Serrano Leones
*/
@Component
@Scope("session")
public class NavigationManagerRegistry implements Serializable {

private final Map<String, NavigationManager> instances = new ConcurrentHashMap<>();

public static NavigationManagerRegistry getInstance() {
return Containers.get().findObject(NavigationManagerRegistry.class);
}

/**
* Registers a manager instance, keyed by {@link NavigationManager#getId()}.
*
* @param manager the instance to register; ignored if null
*/
public void register(NavigationManager manager) {
if (manager != null) {
instances.put(manager.getId(), manager);
}
}

/**
* Removes a manager instance from the registry.
*
* @param id the id of the instance to remove, as returned by {@link NavigationManager#getId()}
*/
public void unregister(String id) {
if (id != null) {
instances.remove(id);
}
}

/**
* Returns every manager instance currently registered for this session.
*
* @return an unmodifiable snapshot-view of the active instances
*/
public Collection<NavigationManager> getActiveInstances() {
return Collections.unmodifiableCollection(instances.values());
}

/**
* Finds a registered manager instance by id.
*
* @param id the id, as returned by {@link NavigationManager#getId()}
* @return the matching instance, or null if none is registered with that id
*/
public NavigationManager find(String id) {
return id != null ? instances.get(id) : null;
}
}
Loading
Loading