Implementing page-specific titles in Phoenix

Implementing page-specific titles in Phoenix

Every website needs informative, SEO-friendly page titles that change during navigation. Here’s how to have them in Phoenix.

(moving my link from Phoenix Blog Posts - #42 by OvermindDL1 to separate topic to move the discussion with @OvermindDL1 here)

1 Like

That sounds interesting and I’d gladly see some more code samples of such architecture (the menu building code and how you actually pass title into the template).

Although putting view-oriented code into the router sounds weird to me, it may make sense to break out of the MVC bounds in more complicated projects. I remember coding (in Rails) a multi-module application with complicated menu structure that changed during navigation and I was struggling so hard to find a fitting place in the MVC to place that damn menu building code :slight_smile:

Eh, the router is not so much just a router (it used to be) but it grew it the entrance point of my modules.

One of the more simple examples:

defmodule MyServer.Routers.Help do

  use MyServer.ModuleBehaviour

  @doc false
  defmacro __using__(opts) do
    base_scope = Keyword.get(opts, :base_scope, [])

    quote bind_quoted: [base_scope: base_scope] do

      scope base_scope, MyServer.Help, as: :help do

        put "/issue/:id/add_tag", IssueController, :add_tag, as: :issue
        delete "/issue/:id/:tag", IssueController, :delete_tag, as: :issue
        resources "/issue", IssueController, as: :issue

        resources "/communication", CommunicationController, as: :communication

        get "/", IndexController, :index

      end

    end

  end

  import MyServer.Gettext

  def title(_conn, _view, _assigns), do: "Admin"

  def menu(conn) do
    happy_path(else: handle_noauth) do
      @perm true = conn |> can?(index(%Perms.Help{}))
      {:link, [:module, :help], gettext("Help"), MyServer.Router.Helpers.help_index_path(conn, :index)}
    end
  end

end

I’ve been meaning to encapsulate the using function for easier use, but it has been too easy to just copy/paste it around as I make new modules (bad bad I know). But I have a drop-down menu on my titlebar at the top of every page (part of the layout) that has a “Home” link, a “Modules” link (which dynamically fills up with the modules that the currently logged in user has access to, and it is also dynamically built so if I make a new module with use MyServer.ModuleBehaviour then it will be automatically added to the module list, it is part of my Plugin system capabilities).

My MySever.ModuleBehaviour is:

defmodule MyServer.ModuleBehaviour do

  @type breadcrumb           :: atom() | list(breadcrumb)
  @type menu_link            :: {:link, breadcrumb, String.t, String.t}
  @type menu_dropdown        :: {:dropdown, String.t, menu}
  @type menu_dropdown_header :: {:dropdown_header, String.t}
  @type menu_separator       :: :separator
  @type menuitem             :: [] | nil | menu_link | menu_dropdown | menu_dropdown_header | menu_separator
  @type menu                 :: menuitem | list(menuitem)

  @callback title(conn::struct(), view::atom(), assigns::map()) :: binary()
  @callback menu(conn::struct()) :: menu

  defmacro __using__(_opts) do
    quote bind_quoted: [] do
      @behaviour MyServer.ModuleBehaviour
      import MyServer.ModuleBehaviour
      import Happy
      import Canada
      alias MyServer.Perms
    end
  end

  def handle_noauth({:perm, false}) do
    []
  end

end

So it is pretty basic right now, but the important parts are that it sets up the typespecs for the menu system (so you can see how that works there, it allows adding menu entries, dropdowns, separators, and I’ll just add more as I need). The permission checks (via happy and my PermissionEx system combined with canada) are cached in mnesia (with in-line ets access, I’ve used erlang a lot so I know the parts of mnesia to work around and when it is safe to do so for speed). And the most important part, it adds the @behaviour MyServer.ModuleBehaviour behaviour, which is what registers it with my plugin system so it can be dynamically picked up (my plugin system is used to dynamically access and register permissions and modules so far, I try to minimize its use as it is a little bit magic, but it saves SOMUCH* work otherwise).

The modules have other optionally supported callbacks too, such as a module can define a sublayout that is applied within the main site layout, which then wraps each page within the module (so a module can add a custom bar at the top/bottom below the main titlebar for when the menu system is insufficient, only one of my modules so far is complicated enough to need it). The module views specify what module to point to to handle callbacks and such. The layout_view handles the title and menu and such, here is the menu function in the main layout module as it is the more complicated one to show:

  def render_module_specific_menu(conn, assigns, loc) do
    view_module = assigns.view_module
    view_template = assigns.view_template
    if not :erlang.function_exported(view_module, :menu, 4) do
      _ = Logger.debug("Unhandled menu/4 for module: #{inspect view_module}")
      nil
    else
      menu = view_module.menu(conn, assigns, view_module, view_template)
      render_module_specific_menu(menu, conn, assigns, loc)
    end
  end

And let me also show the sublayout rendering as it fairly unique:

  def render_sublayout(view_module, view_template, submodule, sublayout, assigns) do
    sub_assigns = Map.merge(assigns, %{layout: {submodule, sublayout}, render_existing: {submodule, sublayout}})
    sub_layout = render(view_module, view_template, sub_assigns)
    if sub_layout === nil do
      if :erlang.function_exported(view_module, :sublayout_module, 0) do
        case view_module.sublayout_module() do
          ^submodule -> # The same as itself, don't infinite loop...
            render(view_module, view_template, assigns)
          parent_view_module ->
            render_sublayout(view_module, view_template, parent_view_module, sublayout, assigns)
        end
      else
        render(view_module, view_template, assigns)
      end
    else
      sub_layout
    end
  end

Each modules should define a few various things, here is the most complicated one (excusing module-specific view stuff, just the callbacks), and one of the sub-views within that module and how it delegates up:

defmodule MyServer.MyModule.IndexView do
  @moduledoc false

  use MyServer.Web, :view

  def breadcrumb(_conn, _assigns, MyServer.MyModule.IndexView, _template), do: [:module, :MyModule, :index]
  def breadcrumb(_conn, _assigns, MyServer.MyModule.RequirementView, _template), do: [:module, :MyModule, :section1]
  def breadcrumb(_conn, _assigns, MyServer.MyModule.SemesterView, _template), do: [:module, :MyModule, :section2]
  def breadcrumb(_conn, _assigns, MyServer.MyModule.StudentView, _template), do: [:module, :MyModule, :section3]
  def breadcrumb(_conn, _assigns, _view_module, _template), do: [:module, :MyModule]


  def menu(conn, assigns, _view_module, _template), do: {:dropdown, gettext("MyModule"), [
    {:link, breadcrumb(conn, assigns, MyServer.MyModule.IndexView, "index."), gettext("MyModule Home"), MyModule_index_path(conn, :index)},
    :separator,
    {:link, breadcrumb(conn, assigns, MyServer.MyModule.Section1View, "index."), gettext("Section 1"), MyModule_section1_path(conn, :index)},
    {:link, breadcrumb(conn, assigns, MyServer.MyModule.Section2View, "index."), gettext("Section 2"), MyModule_section2_path(conn, :index)},
    {:link, breadcrumb(conn, assigns, MyServer.MyModule.Section3View, "index."), gettext("Section 3"), MyModule_section3_path(conn, :index)},
    ]}

end


defmodule MyServer.MyModule.Section3View do
  use MyServer.Web, :view

  alias MyServer.MyModule.IndexView, as: Parent

  defdelegate breadcrumb(conn, assigns, view_module, template), to: Parent
  defdelegate menu(conn, assigns, view_module, template),       to: Parent

  def sublayout_module, do: MyServer.MyModule.IndexView

end

I of course do not have things named like that, just web-sanitization. :slight_smile:

Let me whip up an example, it is a crappy gif with weird coloring and such, but:

The ‘Home’ is the root link, the Modules are dynamically filled out based on the user that is logged in, the menu that appears is the ‘menu’ callback when in a given module (if the module opts for a menu at all, which ‘home’ does not). The breadcrumb is used to colorize the given menu links showing what you are currently in and so forth. It is fairly simple overall, and should use a redesign, it ‘grew’ over time rather than be designed.

5 Likes

Cool, thanks for putting the effort to post all this :thumbsup: In the end, designing multi-module app sure is an interesting challenge. Your example code may be a valuable starting point for someone just going to do that. The use of __using__, behaviours and delegates was the most interesting to me. Also, this may be a cool topic for a blog article as well.

I’ve thought about blogging my code work and had a little before (site is gone now) but no one ever spoke or responded or even tracked any views, so I eventually just stopped thinking about it. I’ve made a lot of fairly cool things over the past 30 years. ^.^

2 Likes

Hi @karolsluszniak, I love this blog post and started using it.
Have you tried implementing the same type of thing when LiveView is involved? Would love to see some examples of that if you have any.

Thanks!