defmodule LiveNavigator do @moduledoc """ This library improves [Phoenix LiveView](https://hexdocs.pm/phoenix_live_view/) navigation and adds application state to your application. Often when user refreshes browser or presses back button you loose current live view state. Also when user navigates through live views you can't get this navigation path (for example from which page user reached current page). This library solves both of these problems. > WARNGING! The `0.1.x` version is not production ready yet. The nearest > production ready version would be `0.2.x` ## Usage Add library to dependencies {:live_navigator, "~> 0.1"} Add `LiveNavigator` to your application supervisor children = [ ... LiveNavigator, ... ] Add `LiveNavigator.Plug` to your browser pipeline: defmodule YourWebApp.Router do pipeline :browser do ... LiveNavigator.Plug end end To detect different browser tabs LiveNavigator needs to run some code on the client side. So add the following to your `assets/package.json`: ```json "live_navigator": "file:../../../deps/live_navigator", ``` And to your `assets/app.js`: ```js import { initNavigator } from './live_navigator'; const liveSocket = new LiveSocket('/live', Socket, initNavigator({ // your phoenix LiveSocket params here })); ``` Then use `LiveNavigator` in your live views and `LiveNavigator.Component` in your live components (`LiveNavigator.Component` is necessary only in those components that are doing any kind of redirection or navigator/page assignments) defmodule YourWebApp.ExampleLive do use YourWebApp, :live_view use LiveNavigator ... end defmodule YourWebApp.Components.NavBar do use YourWebApp, :live_component use LiveNavigator.Component ... end In case you are going to use LiveNavigator for entire application it's better to place usage into your app definitions: defmodule YourWebApp do def liver_view do quote do use Phoenix.LiveView use LiveNavigator end end def live_component do quote do use Phoenix.LiveComponent use LiveNavigator.Component end end end Now you have few additional assign functions in your live views: `&assign_page/3`, `&assign_nav/3`, `&assign_page_new/3`, `&assign_nav_new/3`, `&clear_page/2` and `&clear_nav/3`. All of them works with assigns. LiveNavigator introduces two application states: navigator state and page state. Navigator state is a global application state that is unique for live views opened from deffierent browser tab and different HTTP session (that is set up via cookies usualy). Page state is a state that unique for different live view module and live view action and with above conditions for navigator state. In other words when user opens in browser one of your pages, lets say page `A` the new navigator and page states created. That he navigates to different page `B` new page state created (but page state `A` is not deleted). If he then backs to page `A` it's state will be loaded into your assigns. While navigator state stay same for all above operations. If user then opens any page in new browser tab then all states including navigator state will be created from sсratch. To control user navigation you now has several additional callbacks in your live views: `&handle_page_refresh/2`, `&handle_page_leave/4` and `&handle_page_enter/4`. `&handle_page_refresh/2` will be called if user refreshes browser and stay on the same page. `&handle_page_enter/4` called when user enters the page from other location. And `&handle_page_leave/4` called when user going to another location. Note that while `&handle_page_refresh/2` and `&handle_page_enter/4` are called before standard `handle_params` callback and executed in live view process, but `&handle_page_leave/4` called in process of live view where user went and the only save functions here is the assign-related functions provided by LiveNavigator. """ alias IEx.History alias LiveNavigator.{Controller, History, Lifecycle, Page} alias Phoenix.{Component, LiveView} alias Phoenix.LiveView.Socket use Supervisor @type action_method :: :navigate | :patch | :redirect | :browse @type history_action :: :put | :stack | :stack_add | {:replace, History.index, boolean} @type back_index :: History.index | History.spec | {History.index | History.spec, boolean} @typedoc """ """ @type action_spec :: %{ required(:method) => action_method, required(:action) => history_action, optional(:as) => History.name, optional(:to) => non_neg_integer | atom, } @type session_id :: binary @type tab :: integer @type url :: binary @type view :: module @type action :: atom @type changes :: [atom] @type assigns :: map @type field :: :session_id | :tab | :url | :view | :action | :awaiting | :history | :assigns | :fallback_url @type assign_new_callback :: (-> any) | (map -> any) @type t :: %__MODULE__{ session_id: session_id, tab: tab, url: url | nil, view: view | nil, action: action | nil, awaiting: tuple | nil, history: History.t, assigns: assigns, fallback_url: url | nil, __changed__: changes, } @enforce_keys ~w[session_id tab]a defstruct [ session_id: nil, tab: nil, url: nil, view: nil, action: nil, awaiting: nil, fallback_url: "/", history: [], assigns: %{}, __changed__: [], ] @doc """ Called when user refreshes the page in browser. The first argument is the page view specification and the second is the socket. This function must return `{:noreply, Socket.t}` tuple. """ @callback handle_page_refresh(History.spec, Socket.t) :: {:noreply, Socket.t} @doc """ Called when user moves to this page from another. The first argument is the action that leaded the event (see `action_spec` for details), the second argument is the view specification of the page the user comes from, the third argument is the view specification of the page the user comes to (which is current view in this case) and the fourth is the socket. This function must return `{:noreply, Socket.t}` tuple. """ @callback handle_page_enter(action_spec, History.spec | nil, History.spec, Socket.t) :: {:noreply, Socket.t} @doc """ Called when user moves from another page to this one. This function unlike `&handle_page_refresh/2` and `&handle_page_enter/4` called from another process and have no access to socket. The only safe functions here is the assets-related functions provided by `LiveNavigator`. Arguments are the same as in `&handle_page_enter/4` except that the last argument is the `LiveNavigator` object instead of `Socket`. This function must return `{:noreply, LiveNavigator.t}` tuple """ @callback handle_page_leave(action_spec, History.spec, History.spec, t) :: {:noreply, t} @app :live_navigator @session_key to_string(Application.compile_env(@app, :session_key, @app)) # @tab_key to_string(Application.compile_env(@app, :tab_key, "_live_navigator_tab")) @navigator @app @spec start_link() :: Supervisor.on_start @spec start_link(keyword) :: Supervisor.on_start def start_link(opts \\ []) do Supervisor.start_link(__MODULE__, opts, name: __MODULE__) end @impl true def init(opts) do children = [ {Controller, opts}, ] Supervisor.init(children, strategy: :one_for_all) end @spec __using__(any) :: Macro.t defmacro __using__(opts) do fallback_url = Keyword.get(opts, :fallback_url, "/") quote do @behaviour unquote(__MODULE__) @before_compile unquote(__MODULE__) @live_navigator_fallback_url unquote(fallback_url) import unquote(LiveView), except: [ push_navigate: 2, push_patch: 2, push_redirect: 2, redirect: 2, ] import unquote(__MODULE__), only: [ assign_nav: 2, assign_nav: 3, assign_nav_new: 3, assign_page: 2, assign_page: 3, assign_page_new: 3, clear_nav: 2, clear_page: 2, current_url: 1, fallback_url: 1, history: 1, nav_back: 1, nav_back: 2, nav_back_url: 1, nav_back_url: 2, nav_pop_stack: 1, nav_pop_stack: 2, nav_pop_stack_url: 1, push_navigate: 2, push_patch: 2, push_redirect: 2, put_history: 2, put_history: 3, put_history: 4, put_history: 5, redirect: 2, set_fallback_url: 2, ] on_mount unquote(__MODULE__) @impl unquote(__MODULE__) def handle_page_refresh(_view, socket), do: {:noreply, socket} @impl unquote(__MODULE__) def handle_page_enter(_action, _from, _to, socket), do: {:noreply, socket} @impl unquote(__MODULE__) def handle_page_leave(_action, _from, _to, nav), do: {:noreply, nav} defoverridable [ handle_page_enter: 4, handle_page_leave: 4, handle_page_refresh: 2, ] end end @spec __before_compile__(Macro.Env.t) :: Macro.t defmacro __before_compile__(env) do lifecycle = Lifecycle.lifecycle(env) fallback_url = Module.get_attribute(env.module, :live_navigator_fallback_url) quote do def __navigator__(:lifecycle), do: unquote(lifecycle) def __navigator__(:fallback_url), do: unquote(fallback_url) end end @spec fallback_url(nil | atom | binary) :: Macro.t defmacro fallback_url(url) when is_nil(url) or is_atom(url) or is_binary(url) do quote do @live_navigator_fallback_url unquote(url) end end @spec on_mount(any, map, map, Socket.t) :: {:cont, Socket.t} def on_mount(_args, _params, %{@session_key => nav_id}, socket) when is_binary(nav_id) and byte_size(nav_id) > 0 do socket = socket |> put_navigator({nav_id, 0}) |> attach_handle_params() |> attach_handle_info() {:cont, socket} end def on_mount(_args, _params, _session, socket) do {:cont, socket} end # def on_mount(_args, _params, session, socket) do # socket = # if LiveView.connected?(socket) do # case {Map.get(session, to_string(@session_key)), LiveView.get_connect_params(socket)} do # {nav_id, %{@tab_key => tab}} when is_binary(nav_id) and is_integer(tab) and tab > 0 -> # socket # |> put_navigator({nav_id, tab}) # |> attach_handle_params() # |> attach_handle_info() # _ -> # socket # end # else # socket # end # {:cont, socket} # end @spec handle_params(map, binary, Socket.t) :: {:cont | :halt, Socket.t} def handle_params( _params, url, %Socket{view: view, assigns: assigns, private: %{@navigator => navigator}} = socket ) do action = Map.get(assigns, :live_action) {navigator, page} = case navigator do {session_id, tab} when is_binary(session_id) and is_integer(tab) -> # and tab > 0 -> { Controller.load_navigator(session_id, tab), Controller.load_page(session_id, tab, view, action) } %__MODULE__{session_id: session_id, tab: tab} = navigator when is_binary(session_id) and is_integer(tab) -> { navigator, Controller.load_page(session_id, tab, view, action) } end socket = socket |> apply_assigns(navigator) |> apply_assigns(page) {navigator, actions} = checkout_navigator(navigator, url, view, action) navigator = put_fallback_url(navigator, page, socket) socket = put_navigator(socket, navigator) case Enum.reduce_while(actions, socket, &run_callback/2) do %Socket{redirected: nil} = socket -> {:cont, socket} socket -> {:halt, socket} end end def handle_params(_params, _url, socket), do: {:cont, socket} def handle_info({__MODULE__, :reload, to_reload}, socket) do socket = Enum.reduce(to_reload, socket, fn :navigator, socket -> navigator = Controller.get_navigator() socket |> put_navigator(socket) |> apply_assigns(navigator) :page, socket -> apply_assigns(socket, Controller.get_page()) end) {:halt, socket} end def handle_info(_, socket) do {:cont, socket} end def handle_event( "lv:put-awaiting", %{"method" => method, "to" => to} = params, %Socket{private: %{@navigator => %__MODULE__{} = navigator}} = socket ) when method in ~w[navigate patch] and is_binary(to) do method = String.to_existing_atom(method) opts = [] opts = case params do %{"stack" => true} -> Keyword.put(opts, :stack, true) %{"stack" => "new"} -> Keyword.put(opts, :stack, :new) _ -> opts end opts = case params do %{"replace" => true} -> Keyword.put(opts, :replace, true) %{"replace" => idx} when is_integer(idx) -> Keyword.put(opts, :replace, idx) %{"replace" => name} when is_binary(name) -> Keyword.put(opts, :replace, String.to_existing_atom(name)) _ -> opts end spec = push_action_spec(navigator, method, opts) put_awaiting(navigator, to, spec) {:halt, socket} end def handle_event("ln:back", params, socket) do unstack = Map.get(params, "unstack") == true opts = case params do %{"to" => to} when is_integer(to) -> [to: {to, unstack}] %{"to" => to} when is_binary(to) -> [to: {String.to_existing_atom(to), unstack}] _ -> [] end {:halt, nav_back(socket, opts)} end def handle_event("ln:pop_stack", _params, socket) do {:halt, nav_pop_stack(socket, [])} end def handle_event(_event, _data, socket) do {:cont, socket} end @simple_fields ~w[session_id tab url view action fallback_url]a @complex_fields ~w[awaiting history assigns]a @spec update(t, map | keyword) :: t @spec update(t, field, any) :: t def update(%__MODULE__{__changed__: changed} = navigator, field, value) when field in @simple_fields do case Map.get(navigator, field) do ^value -> navigator _ -> Map.put(%{navigator | __changed__: Enum.uniq([field | changed])}, field, value) end end def update(%__MODULE__{__changed__: changed} = navigator, field, value) when field in @complex_fields do Map.put(%{navigator | __changed__: Enum.uniq([field | changed])}, field, value) end def update(navigator, fields) do Enum.reduce(fields, navigator, fn {field, value}, navigator -> update(navigator, field, value) end) end @spec put_history(Socket.t, History.spec) :: Socket.t @spec put_history(Socket.t, History.spec, keyword) :: Socket.t @spec put_history(Socket.t, url, view) :: Socket.t @spec put_history(Socket.t, url, view, action | keyword) :: Socket.t @spec put_history(Socket.t, url, view, action, keyword) :: Socket.t @spec put_history(t, History.spec) :: t @spec put_history(t, History.spec, keyword) :: t @spec put_history(t, url, view) :: t @spec put_history(t, url, view, action | keyword) :: t @spec put_history(t, url, view, action, keyword) :: t def put_history(socket_or_nav, %History{} = spec), do: put_history(socket_or_nav, spec, []) def put_history(socket_or_nav, url, view) when is_binary(url) do put_history(socket_or_nav, url, view, nil, []) end def put_history( %Socket{private: %{@navigator => %__MODULE__{} = navigator}} = socket, %History{} = spec, opts ) do put_navigator(socket, put_history(navigator, spec, opts)) end def put_history(%__MODULE__{history: history} = navigator, %History{} = spec, opts) do history = if opts[:stacked] == true do History.put_stacked(history, spec) else History.put(history, spec) end %{navigator | history: history} end def put_history(socket_or_nav, url, view, action) when is_atom(action) do put_history(socket_or_nav, url, view, action, []) end def put_history(socket_or_nav, url, view, action, opts) when is_binary(url) and is_atom(view) and is_atom(action) do url = normalize_url(socket_or_nav, url) put_history(socket_or_nav, History.new(url, view, action), opts) end @spec set_fallback_url(Socket.t, url | nil) :: Socket.t @spec set_fallback_url(t, url | nil) :: t def set_fallback_url(%Socket{private: %{@navigator => %__MODULE__{} = navigator}} = socket, url) do navigator = set_fallback_url(navigator, url) put_navigator(socket, navigator) end def set_fallback_url(%__MODULE__{} = navigator, url) do with %Page{} = page <- Controller.get_page(navigator) do page |> Page.update(:fallback_url, url) |> Controller.save() end %{navigator | fallback_url: url} end @doc """ """ @spec assign_page(Socket.t, atom, any) :: Socket.t @spec assign_page(t, atom, any) :: t def assign_page( %Socket{private: %{@navigator => %__MODULE__{} = navigator}} = socket, key, value ) when is_atom(key) do update_page_assigns(navigator, [{key, value}], []) Component.assign(socket, key, value) end def assign_page(%__MODULE__{} = navigator, key, value) when is_atom(key) do update_page_assigns(navigator, [{key, value}], []) end def assign_page(socket, key, value) do Component.assign(socket, key, value) end @doc """ """ @spec assign_page(Socket.t, keyword | map) :: Socket.t @spec assign_page(t, keyword | map) :: t def assign_page(%Socket{private: %{@navigator => %__MODULE__{} = navigator}} = socket, assigns) do update_page_assigns(navigator, assigns, []) Component.assign(socket, assigns) end def assign_page(%__MODULE__{} = navigator, assigns) do update_page_assigns(navigator, assigns, []) end def assign_page(socket, assigns) do Component.assign(socket, assigns) end @doc """ """ @spec assign_page_new(Socket.t, atom, assign_new_callback) :: Socket.t def assign_page_new(%Socket{private: %{@navigator => %__MODULE__{} = navigator}} = socket, key, setter) do wrapper = case setter do setter when is_function(setter, 0) -> fn -> value = setter.() update_page_assigns(navigator, [{key, value}], []) value end setter when is_function(setter, 1) -> fn assigns -> value = setter.(assigns) update_page_assigns(navigator, [{key, value}], []) value end end Component.assign_new(socket, key, wrapper) end def assign_page_new(%Socket{} = socket, key, setter) do Component.assign_new(socket, key, setter) end @doc """ """ @spec clear_page(Socket.t, atom | [atom]) :: Socket.t @spec clear_page(t, atom | [atom]) :: t def clear_page(%Socket{private: %{@navigator => %__MODULE__{} = navigator}, assigns: assigns} = socket, keys) do update_page_assigns(navigator, [], keys) %{socket | assigns: clear_assigns(assigns, keys)} end def clear_page(%__MODULE__{} = navigator, keys) do update_page_assigns(navigator, [], keys) end def clear_page(%Socket{assigns: assigns} = socket, keys) do %{socket | assigns: clear_assigns(assigns, keys)} end def clear_page(socket, _keys) do socket end @doc """ """ @spec assign_nav(Socket.t, atom, any) :: Socket.t @spec assign_nav(t, atom, any) :: t def assign_nav(%Socket{private: %{@navigator => %__MODULE__{} = navigator}} = socket, key, value) when is_atom(key) do socket |> put_navigator(update_nav_assigns(navigator, [{key, value}], [])) |> Component.assign(key, value) end def assign_nav(%__MODULE__{} = navigator, key, value) when is_atom(key) do update_nav_assigns(navigator, [{key, value}], []) end def assign_nav(socket, key, value) do Component.assign(socket, key, value) end @doc """ """ @spec assign_nav(Socket.t, keyword | map) :: Socket.t @spec assign_nav(t, keyword | map) :: t def assign_nav(%Socket{private: %{@navigator => %__MODULE__{} = navigator}} = socket, assigns) do socket |> put_navigator(update_nav_assigns(navigator, assigns, [])) |> Component.assign(assigns) end def assign_nav(%__MODULE__{} = navigator, assigns) do update_nav_assigns(navigator, assigns, []) end def assign_nav(socket, assigns) do Component.assign(socket, assigns) end @doc """ """ @spec assign_nav_new(Socket.t, atom, assign_new_callback) :: Socket.t def assign_nav_new( %Socket{private: %{@navigator => %__MODULE__{} = navigator}} = socket, key, setter ) do wrapper = case setter do setter when is_function(setter, 0) -> fn -> value = setter.() update_nav_assigns(navigator, [{key, value}], []) value end setter when is_function(setter, 1) -> fn assigns -> value = setter.(assigns) update_nav_assigns(navigator, [{key, value}], []) value end end socket = Component.assign_new(socket, key, wrapper) navigator = Controller.get_navigator() put_navigator(socket, navigator) end def assign_nav_new(%Socket{} = socket, key, setter) do Component.assign_new(socket, key, setter) end @doc """ """ @spec clear_nav(Socket.t, atom | [atom]) :: Socket.t @spec clear_nav(t, atom | [atom]) :: t def clear_nav(%Socket{private: %{@navigator => %__MODULE__{} = navigator}, assigns: assigns} = socket, keys) do navigator = update_nav_assigns(navigator, [], keys) put_navigator(%{socket | assigns: clear_assigns(assigns, keys)}, navigator) end def clear_nav(%__MODULE__{} = navigator, keys) do update_nav_assigns(navigator, [], keys) end def clear_nav(%Socket{assigns: assigns} = socket, keys) do %{socket | assigns: clear_assigns(assigns, keys)} end def clear_nav(socket, _keys) do socket end @doc """ """ @spec history(Socket.t | t) :: History.t def history(%Socket{private: %{@navigator => %__MODULE__{history: history}}}), do: history def history(%__MODULE__{history: history}), do: history def history(_), do: [] @doc """ """ @spec navigator(Socket.t | t) :: t | nil def navigator(%Socket{private: %{@navigator => %__MODULE__{} = navigator}}), do: navigator def navigator(%__MODULE__{} = navigator), do: navigator def navigator(_), do: nil @doc """ """ @spec current_url(Socket.t | t) :: binary | nil def current_url(%Socket{private: %{@navigator => %__MODULE__{url: url}}}), do: url def current_url(%__MODULE__{url: url}), do: url def current_url(_), do: nil @doc """ """ @spec nav_back_url(Socket.t) :: binary | nil @spec nav_back_url(Socket.t, History.index) :: binary | nil def nav_back_url(socket, index \\ -2) def nav_back_url( %Socket{private: %{@navigator => %__MODULE__{history: history, fallback_url: fallback_url}}}, index ) do case History.find(history, index) do %{url: url} -> url_path(url) _ -> fallback_url end end def nav_back_url(_socket, _index), do: nil @doc """ """ @spec nav_pop_stack_url(Socket.t) :: binary | nil def nav_pop_stack_url( %Socket{private: %{@navigator => %__MODULE__{history: history, fallback_url: fallback_url}}} ) when length(history) < 2 do fallback_url end def nav_pop_stack_url(%Socket{private: %{@navigator => %__MODULE__{history: history, fallback_url: fallback_url}}}) do case History.stack_preceding(history) do %{url: url} -> url_path(url) _ -> fallback_url end end def nav_pop_stack_url(_socket), do: nil @doc """ """ @spec nav_back(Socket.t) :: Socket.t @spec nav_back(Socket.t, back_index | keyword) :: Socket.t def nav_back(socket, opts \\ []) def nav_back(socket, to) when not is_list(to), do: nav_back(socket, to: to) def nav_back(%Socket{private: %{@navigator => %__MODULE__{history: history} = navigator}} = socket, opts) do {index, unstack} = case Keyword.fetch(opts, :to) do {:ok, %History{} = spec} -> {spec, false} {:ok, {%History{} = spec, unstack}} when is_boolean(unstack) -> {spec, unstack} {:ok, {index, unstack}} when (is_integer(index) or is_atom(index)) and is_boolean(unstack) -> {index, unstack} {:ok, index} when is_integer(index) or is_atom(index) -> {index, false} _ -> {-2, false} end navigator |> navigate_back(History.find(history, index), unstack) |> apply_awaiting(socket, opts[:navigate]) end def nav_back(socket, _opts), do: socket @spec nav_pop_stack(Socket.t) :: Socket.t @spec nav_pop_stack(Socket.t, keyword) :: Socket.t def nav_pop_stack(socket, opts \\ []) def nav_pop_stack( %Socket{private: %{@navigator => %__MODULE__{history: history} = navigator}} = socket, opts ) when length(history) >= 2 do spec = with nil <- History.stack_preceding(history), do: History.find(history, -1) navigator |> navigate_back(spec, true) |> apply_awaiting(socket, opts[:navigate]) end def nav_pop_stack(%Socket{private: %{@navigator => %__MODULE__{fallback_url: url} = navigator}} = socket, _opts) do navigator = update(navigator, :history, []) Controller.save(navigator) socket |> put_navigator(navigator) |> LiveView.push_navigate(to: url_path(url)) end def nav_pop_stack(socket, _opts), do: socket @spec push_navigate(Socket.t, keyword) :: Socket.t def push_navigate(%Socket{private: %{@navigator => %__MODULE__{} = navigator}} = socket, opts) do if Keyword.has_key?(opts, :to) do navigator |> navigate_forward(:navigate, opts) |> apply_awaiting(socket, opts[:navigate]) else LiveView.push_navigate(socket, opts) end end def push_navigate(socket, opts), do: LiveView.push_navigate(socket, opts) @spec push_patch(Socket.t, keyword) :: Socket.t def push_patch(%Socket{private: %{@navigator => %__MODULE__{} = navigator}} = socket, opts) do if Keyword.has_key?(opts, :to) do navigator |> navigate_forward(:patch, opts) |> apply_awaiting(socket, opts[:navigate]) else LiveView.push_patch(socket, opts) end end def push_patch(socket, opts), do: LiveView.push_patch(socket, opts) @doc deprecated: "Use push_navigate/2 instead" # Deprecate in 0.19 @spec push_redirect(Socket.t, keyword) :: Socket.t def push_redirect(%Socket{private: %{@navigator => %__MODULE__{} = navigator}} = socket, opts) do if Keyword.has_key?(opts, :to) do navigator |> navigate_forward(:navigate, opts) |> apply_awaiting(socket, opts[:navigate]) else LiveView.push_redirect(socket, opts) end end def push_redirect(socket, opts), do: LiveView.push_redirect(socket, opts) @spec redirect(Socket.t, keyword) :: Socket.t def redirect(%Socket{private: %{@navigator => %__MODULE__{} = navigator}} = socket, opts) do if Keyword.has_key?(opts, :to) do navigator |> navigate_forward(:redirect, opts) |> apply_awaiting(socket, opts[:navigate]) else LiveView.redirect(socket, opts) end end def redirect(socket, opts), do: LiveView.redirect(socket, opts) @doc false @spec url_path(binary) :: binary def url_path(url) do to_string(%{URI.parse(url) | scheme: nil, authority: nil, userinfo: nil, host: nil, port: nil}) end @doc false @spec normalize_url(t | Socket.t, binary) :: binary def normalize_url(%Socket{private: %{@navigator => %__MODULE__{} = navigator}}, path) do normalize_url(navigator, path) end def normalize_url(%__MODULE__{url: url}, path) do case URI.parse(path) do %{scheme: nil} = path -> URI.to_string(%{URI.parse(url) | path: path.path, query: path.query, fragment: path.fragment}) _ -> path end end @doc false @spec now() :: NaiveDateTime.t def now, do: NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) defp push_action_spec(navigator, action_method, opts) do action = cond do :stack in opts -> :stack_add opts[:stack] == true -> :stack_add opts[:stack] == :new -> :stack true -> :push end %{method: action_method} |> put_action_spec_as(opts[:as]) |> put_action_spec_action(action, opts[:replace], navigator) end defp put_awaiting(%{url: url, view: view, action: action} = navigator, to, action_spec) do from = History.new(url, view, action) to = normalize_url(navigator, to) navigator |> update(:awaiting, {action_spec, to, from}) |> Controller.save() end @doc false @spec navigate_back(t, History.spec, boolean) :: t def navigate_back(%__MODULE__{view: view} = navigator, %{id: id, view: view, url: url}, unstack) do put_awaiting(navigator, url, %{method: :patch, action: {:replace, id, unstack}}) end def navigate_back(%__MODULE__{} = navigator, %{id: id, url: url}, unstack) do put_awaiting(navigator, url, %{method: :navigate, action: {:replace, id, unstack}}) end def navigate_back(%__MODULE__{fallback_url: to} = navigator, _spec, unstack) when is_binary(to) do put_awaiting(navigator, to, %{method: :navigate, action: {:replace, 0, unstack}}) end def navigate_back(navigator, _spec, _unstack), do: navigator @doc false @spec navigate_forward(t, action_method, keyword) :: t def navigate_forward(%__MODULE__{} = navigator, action, opts) do spec = push_action_spec(navigator, action, opts) put_awaiting(navigator, opts[:to], spec) end def navigate_forward(navigator, _action_method, _opts), do: navigator @doc false @spec clear_assigns(map, atom | [atom]) :: map def clear_assigns(assigns, keys) do keys = List.wrap(keys) changes = Enum.into(keys, %{}, & {&1, true}) changed = Map.merge(Map.get(assigns, :__changed__, %{}), changes) assigns |> Map.drop(keys) |> Map.put(:__changed__, changed) end @doc false @spec update_page_assigns(t, map | keyword, atom | [atom]) :: t def update_page_assigns(navigator, assigns, clear) do [%Page{assigns: page_assigns} = page] = load_page(navigator) page_assigns = page_assigns |> Map.merge(Enum.into(assigns, %{})) |> Map.drop(List.wrap(clear)) page |> Page.update(:assigns, page_assigns) |> Controller.save() navigator end @doc false @spec update_nav_assigns(t, map | keyword, atom | [atom]) :: t def update_nav_assigns(%{assigns: nav_assigns} = navigator, assigns, clear) do nav_assigns = nav_assigns |> Map.merge(Enum.into(assigns, %{})) |> Map.drop(List.wrap(clear)) navigator |> update(:assigns, nav_assigns) |> Controller.save() end defp apply_awaiting(navigator, socket, false), do: put_navigator(socket, navigator) defp apply_awaiting(%{awaiting: {%{method: :patch}, to, _}} = navigator, socket, _) do socket |> put_navigator(navigator) |> LiveView.push_patch(to: url_path(to)) end defp apply_awaiting(%{awaiting: {%{method: :navigate}, to, _}} = navigator, socket, _) do socket |> put_navigator(navigator) |> LiveView.push_navigate(to: url_path(to)) end defp apply_awaiting(%{awaiting: {%{method: :redirect}, to, _}} = navigator, socket, _) do socket |> put_navigator(navigator) |> LiveView.redirect(to: url_path(to)) end defp load_page(%__MODULE__{session_id: session_id, tab: tab, view: view, action: action}) do Controller.select(Page, session_id: session_id, tab: tab, view: view, action: action) end defp put_navigator(%Socket{private: private} = socket, navigator) do %{socket | private: Map.put(private, @navigator, navigator)} end defp attach_handle_params(socket) do LiveView.attach_hook(socket, :live_navigator_handle_params, :handle_params, &handle_params/3) end defp attach_handle_info(socket) do LiveView.attach_hook(socket, :live_navigator_handle_info, :handle_info, &handle_info/2) end defp apply_assigns(%Socket{} = socket, %{assigns: assigns}), do: Component.assign(socket, assigns) defp apply_assigns(socket, _), do: socket # NO NAVIGATOR INITIALIZED YET defp checkout_navigator(%{url: nil} = navigator, url, view, action) do checkout_new(navigator, url, view, action) end # REFRESH defp checkout_navigator(%{action: action, view: view, url: url, awaiting: nil} = navigator, url, view, action) do checkout_refresh(navigator) end # BACK defp checkout_navigator( %{ history: [[_ | [%{url: url, view: view, action: action} = from | _]] | _], awaiting: nil, } = navigator, url, view, action ) do checkout_back(navigator, from, url, view, action) end defp checkout_navigator( %{ history: [prev | [%{url: url, view: view, action: action} = from | _]], awaiting: nil, } = navigator, url, view, action ) when is_map(prev) or length(prev) == 1 do checkout_back(navigator, from, url, view, action) end defp checkout_navigator( %{ history: [prev | [[%{url: url, view: view, action: action} = from | _] | _]], awaiting: nil, } = navigator, url, view, action ) when is_map(prev) or length(prev) == 1 do checkout_back(navigator, from, url, view, action) end # AWAITED ACTION defp checkout_navigator(%{history: history, awaiting: {nav_action, url, from}} = navigator, url, view, action) do spec = History.new(url, view, action) history = case nav_action do %{action: {:replace, id, unstack}} -> History.replace(history, id, spec, unstack) %{action: :stack_add} -> History.put_stacked(history, spec) %{action: :stack} -> History.put(history, [spec]) %{action: :push} -> History.put(history, spec) end navigator = navigator |> update(view: view, action: action, url: url, history: history, awaiting: nil) |> Controller.save() {navigator, [{:leave, nav_action, from}, {:enter, nav_action, from}]} end # UNEXPECTED ACTION defp checkout_navigator(navigator, url, view, action) do checkout_new(navigator, url, view, action) end defp checkout_refresh(navigator) do {navigator, [:refresh]} end defp checkout_back(%{history: history} = navigator, %{id: id} = from, url, view, action) do nav_action = %{method: :browse, action: {:replace, id}} history = History.replace(history, id, from, false) navigator = navigator |> update(view: view, action: action, url: url, history: history) |> Controller.save() {navigator, [{:leave, nav_action, from}, {:enter, nav_action, from}]} end defp checkout_new(%{history: history} = navigator, url, view, action) do history = History.put(history, History.new(url, view, action)) navigator = navigator |> update(view: view, action: action, url: url, history: history, awaiting: nil) |> Controller.save() {navigator, [{:enter, %{method: :browse, action: :push}, nil}]} end defp run_callback( :refresh, %Socket{private: %{@navigator => %__MODULE__{view: view} = navigator}} = socket ) do to = History.new(navigator) with {:cont, socket} <- Lifecycle.run_lifecycle(view, :on_page_refresh, [to], socket) do to |> view.handle_page_refresh(socket) |> handle_callback_result() end end defp run_callback( {:enter, action, from}, %Socket{private: %{@navigator => %__MODULE__{view: view} = navigator}} = socket ) do to = History.new(navigator) with {:cont, socket} <- Lifecycle.run_lifecycle(view, :on_page_enter, [action, from, to], socket) do action |> view.handle_page_enter(from, to, socket) |> handle_callback_result() end end defp run_callback( {:leave, action, %{view: view, action: view_action} = from}, %Socket{private: %{@navigator => %__MODULE__{} = navigator}} = socket ) do to = History.new(navigator) navigator = %{navigator | view: view, action: view_action} {cont, navigator} = Lifecycle.run_lifecycle(view, :on_page_leave, [action, from, to], navigator) if cont == :cont and function_exported?(view, :handle_page_leave, 4) do {:noreply, _navigator} = view.handle_page_leave(action, from, to, navigator) end {:cont, socket} end defp run_callback(_, socket), do: {:cont, socket} defp handle_callback_result({:noreply, %Socket{redirected: nil} = socket}), do: {:cont, socket} defp handle_callback_result({:noreply, socket}), do: {:halt, socket} defp put_action_spec_as(spec, nil), do: spec defp put_action_spec_as(spec, true), do: spec defp put_action_spec_as(spec, false), do: spec defp put_action_spec_as(spec, name) when is_atom(name), do: Map.put(spec, :as, name) defp put_action_spec_as(spec, _name), do: spec defp put_action_spec_action(spec, action, nil, _navigator), do: Map.put(spec, :action, action) defp put_action_spec_action(spec, action, true, navigator) do put_action_spec_action(spec, action, {-1, false}, navigator) end defp put_action_spec_action(spec, action, false, _navigator), do: Map.put(spec, :action, action) defp put_action_spec_action(spec, action, index, navigator) when is_integer(index) or is_atom(index) do put_action_spec_action(spec, action, {index, false}, navigator) end defp put_action_spec_action( spec, _action, {index, unstack}, %{history: history} ) when (is_integer(index) or is_atom(index)) and is_boolean(unstack) do id = case History.find(history, index) do %{id: id} -> id _ -> 0 end Map.put(spec, :action, {:replace, id, unstack}) end defp put_fallback_url(navigator, %Page{fallback_url: url}, socket) when not is_nil(url) do put_fallback_url(navigator, url, socket) end defp put_fallback_url(%{view: view} = navigator, %Page{}, socket) when not is_nil(view) do put_fallback_url(navigator, view.__navigator__(:fallback_url), socket) end defp put_fallback_url(navigator, nil, _socket), do: navigator defp put_fallback_url(navigator, url, _socket) when is_binary(url) do %{navigator | fallback_url: url} end defp put_fallback_url(%{view: view} = navigator, fun, socket) when is_atom(fun) do cond do function_exported?(view, fun, 0) -> %{navigator | fallback_url: apply(view, fun, [])} function_exported?(view, fun, 1) -> %{navigator | fallback_url: apply(view, fun, [socket])} end end defp put_fallback_url(navigator, fun, _socket) when is_function(fun, 0) do %{navigator | fallback_url: fun.()} end defp put_fallback_url(navigator, fun, socket) when is_function(fun, 1) do %{navigator | fallback_url: fun.(socket)} end defp put_fallback_url(navigator, _url, _socket), do: navigator end