Wizard done choreographies#

A wizard inside a modal finishes on its last step. The request is created, the modal closes, and the list on the page beneath it has to refresh. There are two ways to drive that refresh. This page describes both and compares them honestly so the choice is informed.

The opening and the steps are identical for both, see Partial rendering by scenario. The two choreographies differ only in what done returns and how the list gets fresh data.

Accept and re-GET#

The default. The wizard closes the layer with a result, and the link that opened the modal re-fetches the zone it named in data-next-accepted. The wizard does not know the list exists.

request/[step]/page.py#
class AccessRequestWizard(FormWizard):
    def done(
        self, request: HttpRequest, cleaned_data: dict[str, Any]
    ) -> HttpResponse:
        """Create the access request and close the layer with a result."""
        access_request = AccessRequest.objects.create(**cleaned_data)
        return (
            Patches(request)
            .layer_close(result={"id": access_request.pk})
            .toast("Request created", variant="success")
            .response(fallback=f"/request/{access_request.pk}/audit/")
        )

The last step answers with a close and a toast.

response body#
{
  "version": "9f3c2e1b",
  "ops": [
    {"op": "layer.close", "result": {"id": 42}},
    {"op": "toast", "text": "Request created", "variant": "success"}
  ]
}

The runtime closes the layer, fires accept, and by data-next-accepted="request-list" sends a second GET for the list.

the re-GET#
GET / HTTP/1.1
X-Next-Request: 1
X-Next-Zone: request-list
X-Next-Origin: /

The list re-renders through its own page view, with its own guards and middleware, on a request that carries the caller’s cookies. The wizard is portable. It sits on any page without a change, and data-next-accepted moves with the markup.

This is the cycle of a redirect with reloaded props. The cost is one extra GET after the modal closes.

Server OOB through page addressing#

The canonical server-driven done choreography. The wizard puts the morph of the list’s zone into its own response, so one envelope closes the layer, refreshes the list, and shows the toast.

This addresses a zone of a foreign page through morph(page=, url_kwargs=), the one path the server takes to refresh a host-page zone from a done step. The builder takes page= and url_kwargs= alongside zone=, and the request carries X-Next-Origin with the path and query string of the page that hosts the layer. resolve_partial_origin is the thin helper that reads that header back into the page_path and url_kwargs the morph needs, nothing more.

request/[step]/page.py#
from django.http import Http404, HttpResponse

from next.partial import resolve_partial_origin


class AccessRequestWizard(FormWizard):
    def done(
        self, request: HttpRequest, cleaned_data: dict[str, Any]
    ) -> HttpResponse:
        """Create the access request and patch the host page list in one response."""
        access_request = AccessRequest.objects.create(**cleaned_data)
        origin = resolve_partial_origin(request)
        if origin is None or origin.page_path is None:
            raise Http404
        return (
            Patches(request)
            .layer_close(result={"id": access_request.pk})
            .morph(
                zone="request-list",
                page=origin.page_path,
                url_kwargs=origin.url_kwargs,
            )
            .toast("Request created", variant="success")
            .response(fallback=f"/request/{access_request.pk}/audit/")
        )

The last step answers with all three operations in one envelope.

response body#
{
  "version": "9f3c2e1b",
  "ops": [
    {"op": "layer.close", "result": {"id": 42}},
    {"op": "morph", "target": {"zone": "request-list"},
     "html": "<div data-next-zone=\"request-list\">…</div>"},
    {"op": "toast", "text": "Request created", "variant": "success"}
  ]
}

A morph(page=…) re-runs the foreign page’s body resolution before rendering its zone. The page’s guards and redirects are honoured, so the list never travels in the response when the page would have denied the caller on its own request. A denial surfaces as ForeignPageNotAuthorizedError rather than an empty morph.

This is the cycle of an out-of-band swap. One round trip closes everything at once.

The comparison#

Dimension

Accept and re-GET

Server OOB through page=

Round trips after the last step

Two: the close, then the list GET

One

Coupling

done does not know the list, the opening link binds them

done names the zone of a foreign page

List authorization

The list’s own view, on a request with its cookies and middleware

The shaping step re-runs the host page’s body resolution

Protocol surface

No new headers or addressing

Adds X-Next-Origin and the page= addressing of the builder

Wizard reuse

Drops onto any page, data-next-accepted moves with it

done hard-codes the zone or branches on the origin

UX atomicity

A one-GET gap between close and list

Close, list, and toast apply in one envelope

Without the runtime

Identical: a 303 to fallback

Identical

The recommendation is accept and re-GET as the default. The deciding argument is not the round trip but the authorization and the decoupling. The re-GET keeps the list’s rights where they live, in the list’s view, and leaves the wizard portable. Reach for server OOB when the one extra GET is genuinely visible and the wizard is dedicated to one host page.

See also#

See also

Partial rendering by scenario for the full modal wizard scenario. Partial rendering reference for the layer.close and morph verbs and the X-Next-Origin header.