Application with Multiple Configurable UI Themes

Hi all,

I build an application that can be configured during build time to use different sets of Phoenix UI Components.

The whole setup looks like this (app name is :portal - how creative…):

Configuration in config/dev.exs

config :portal, theme: :default
  • this sets the theme named “default” during compile time

Configuration in config/prod.exs

# --------------------------
# THEME CONFIG
# --------------------------
#
# can be one of: [:default, :devpunx]
app_theme =
  case System.get_env("APP_THEME") do
    "" -> :default
    nil -> :default
    val -> String.to_atom(val)
  end

## config :portal, theme: app_theme
  • When compiling a release for production, env variable APP_THEME can be used to build the app with different themes

How does it come into place?

For example live_compoentns do use PortalWeb, :live_component

def live_component do
  quote do
    use Phoenix.LiveComponent

    unquote(html_helpers())
    unquote(utils())
  end
end

… which includes html_helpers like this:

defp html_helpers do
    quote do
      use Gettext, backend: PortalWeb.Gettext

      import Phoenix.HTML
      import PortalWeb.CoreComponents

      alias unquote(resolve_theme(@theme)), as: Custom
      alias Phoenix.LiveView.JS

      unquote(verified_routes())
    end
  end

… which resolves the theme, where theme is a compile time module attribute

@theme Application.compile_env!(:portal, :theme)

… and is resolved like that (currently just two themes)

defp resolve_theme(theme) do
    case theme do
      :devpunx -> PortalWeb.Applications.Wcm.Sites.Frontend.Ui.Devpunx
      :default -> PortalWeb.Applications.Wcm.Sites.Frontend.Ui.Default
      _ -> PortalWeb.Applications.Wcm.Sites.Frontend.Ui.Default
    end
  end

The libraries

… are then just regular component libraries as we know from core_components
Like so:

defmodule PortalWeb.Applications.Wcm.Sites.Frontend.Ui.Default do
  use PortalWeb, :components_foundation
  use PortalWeb, :verified_routes

  alias PortalWeb.CoreComponents, as: Core
  alias PortalWeb.Applications.Wcm.Sites.Frontend.Commons.Utils, as: FrontendUtils
  alias Portal.Applications.Wcm.Blocks
  alias Portal.Applications.Wcm.Pages

  @wcm_config Application.compile_env(:portal, [:apps, :wcm])

  def theme, do: "#{__MODULE__}" |> String.split(".") |> List.last()

  slot(:inner_block, required: true)

  def page_body(assigns) do
    ~H"""
    <body class="antialiased mx-auto bg-slate-900">
      {render_slot(@inner_block)}
    </body>
    """
  end

... # and so on till the end

How to use?

And then I can simply use these themes in the common live_components for example like this:

  • I can use them now with the prefix Custom as I aliased them during compile time…
 def render(assigns) do
    ~H"""
    <div data-sortable-block-id={@id} class="mx-auto w-full p-4 md:p-6 md:max-w-6xl">
      <Custom.sortable_item id={"content-block-#{@id}"} data-id={@id} edit={@block.edit}>
        <Custom.content_block_meta block={@block} target={@myself} active_tab={@active_tab} />
        <Custom.content_block
          id={"block-#{@block.id}"}
          block={@block}
          type={@block.type}
          edit_mode={true}
        />
      </Custom.sortable_item>

      <.modal id={"confirm-deleting-block#{@id}"}>
        <:header>
          Do you realy want to delete this block?
        </:header>
        <:body>
          This is ths last used instance of this block. Its not referenced on any other page.
          The content block will be deleted.
        </:body>
        <:footer>
          <.button phx-click={hide_modal("confirm-deleting-block#{@id}")}>
            Cancel
          </.button>
          <.button
            kind={:caution}
            phx-disable-with="Deleting..."
            phx-click={hide_modal("confirm-deleting-block#{@id}") |> JS.push("delete_block")}
            phx-target={@myself}
          >
            Delete
          </.button>
        </:footer>
      </.modal>

The good!

  • this actually works.
    Of course the themes have to have always the same function components. But I can use these custom components besides my core library.
    And I only have to add a new theme-library without changing any live_view as the currently build theme is always available under Custom.

The bad!

  • This somehow feels not right and maybe someone has a way nicer concept
  • This introduces Compile time cycles that I can not get rid of… I even haven’t understood where this happens… its a bit spaghetti…

Any experience, hint, comment on this is appreciated.
Maybe I should use using macro to do that and not alias it … but then I have conflicts with component names.
One convenience is, that I can Adress <.Custom.button> from the theme and still use the <.button> from the core_components library.

Hope my issue is a bit understandable. I need an approach to do that kind of theming in a nice elegant ways that does not introduce compile time cycles. Also since I introduced that compile time dribbled or so…

kind regards!
Martin

So how I understand it, the problem with this concept is,

  • that the theme modules which host all the function components (UI) are hard-coded in PortalWeb.
  • If you change the theme files (for example just changing a style class) then this needs to recompile PortalWeb which then forces to recompile 62 other files (in my situation) - because PortalWeb is mighty and the whole Web part of the app basically depends on it…

So I am searching for a solution which gives that functionality to compile the app with different UI components but not blocking this central part.

So I somehow have to find a different approach, that does not do the decision making which theme to use in the central PortalWeb module.

Okay, I found an approach that

  • works
  • is not over engineered
  • I can use custom components in combination with the core_components
  • it important keeps every mechanism out of PortalWeb
  • I reduced compile-time cycles to ZERO and
  • have instant recompilation back for custom components

… so I mark is as solved, but do not hesitate to leave any comments if this is good practice or hint me to some points I haven’t considered..

And special thanks for supporting in resolving compile-cycles goes to @LostKobrakai !!!

Approach

  1. I removed this unquote resolve_theme logic completely from PortalWeb
  2. Introduced a “Custom” Theme-Wrapper that looks like this:
defmodule PortalWeb.Applications.Wcm.Sites.Frontend.Theme.Custom do
  @theme Application.compile_env(:portal, :theme)

  alias PortalWeb.Applications.Wcm.Sites.Frontend.Theme.Default
  alias PortalWeb.Applications.Wcm.Sites.Frontend.Theme.Devpunx

  case @theme do
    :default ->
      defdelegate brand_logo(assigns), to: Default
      defdelegate button(assigns), to: Default
      defdelegate content_block(assigns), to: Default
      #... and all other custom component functions

    :devpunx ->
      defdelegate brand_logo(assigns), to: Devpunx
      defdelegate button(assigns), to: Devpunx
      defdelegate content_block(assigns), to: Devpunx
      #... and all other custom component functions
  end

  def theme, do: @theme
end

Usage

  • In the live_components, views and layouts I want to use the currently configured custom theme, I just do:
...
alias PortalWeb.Applications.Wcm.Sites.Frontend.Theme.Custom
...

def render(assigns) do
    ~H"""
    <div>
      <Custom.modal :if={@update_needed} show={true} id={"update-needed-for-asset-#{@id}"}>
        <:header>