WIP (pre-alpha) Ash UI Extension & Component Lib

frankdugan3
2023-02-03

frankdugan3:

Hey all. πŸ‘‹

I’m happy to tease the near public release of Plegethon (provisional title), a (very experimental) Ash UI extension and LiveView component library!

It’s inspired by Petal , AshAdmin , and AshAuthenticationPhoenix . I’m trying very hard to design in the ability to configure/extend nearly every aspect of the components, and to have an Ash-core level of escape hatches/overrides in the extension config. The idea is that you can declare UI config in your Ash resource, then use that in β€œsmart components” that know how to introspect the Ash resource to build themselves. Kind of like AshPhoenix.Form w/ auto: true , but for the UI (examples in images).

The override system for components is very similar to AshAuthenticationPhoenix , if you’ve used that. It adds some other goodies like taking in a color_theme map, and using that to generate a json colors file for Tailwind/Tails and even custom themed (light & dark) Makeup css. πŸ™‚

The components go from whole-page level down to smaller things like forms, filter forms for queries, and datatables. Forms support groups & nesting, and I’m currently working on a wizard syntax. Still working on finishing up form relationships & autocomplete components, that should be ready soon. I have an awesome datatable component in another project, I just need to port it over to this lib.

I’ve been working for quite some time on this, and I’m coming close to releasing this publicly under MIT. I’m interested in opening limited access to some brave souls that want to dig in and help get this to the beta phase. Particularly those are somewhat familiar with writing Ash extensions and/or .heex components and willing to submit PRs. This really isn’t ready to be used unless you want to get your hands dirty – lots of stuff is missing!

If you’re interested in jumping in, let me know via a DM and I’ll consider giving you GitHub access within the next few days. πŸš€

frankdugan3:

^ One thing to note in the default overrides image is that the class overrides are functions that are passed all the component’s assigns, so you can get very specific! There is a merge_classes helper that also runs them through Tails.classes , passing in the components class props as well. This means your custom overrides can still be reliably overridden by props without weird Tailwind conflicts. πŸ™‚

zachdaniel:

Very very excited about this!!! Will absolutely test this out with AshHq and could maybe even see rewriting ash admin with these standardized components!

frankdugan3:

Did a little cleanup on the override system, and I think I’m pretty happy with it! As an example:

defmodule Phlegethon.Components.Form.Label do
  use Phlegethon.Components.Base,
    overrides: [
      class: "the class of the label"
    ]

  @doc """
  Renders a label.
  """
  attr :class, :any, default: nil
  attr :for, :string, default: nil
  slot :inner_block, required: true

  def label(assigns) do
    ~H"""
    <label for={@for} class={class_for(:class, assigns)}>
      <%= render_slot(@inner_block) %>
    </label>
    """
  end
end
override Label do
  set :class, "block text-sm font-black text-root-fg dark:text-root-fg-dark"
end

The class_for macro looks up the override for the module, and figures out how to apply the override (whether it’s a function or simple binary/list). So you can also do e.g.:

set :class, &__MODULE__.label_class/1
# ...
def label_class(assigns) do
#...

It then assumes that it should append the attribute with the same key as the override to the end of the class list for runtime overrides, but it also accepts a different key (or list of keys) to get a different override value from the assigns. For example:

# smart_form.ex
defp render_field(%{field: %Phlegethon.Resource.Form.FieldGroup{}} = assigns) do
  ~H"""
  <section class={class_for(:field_group_class, assigns, [:field, :class])}>

This really cleans up the code a lot, ensures classes always run through Tails.classes and makes it easier to grep what’s going on!

As an added bonus, it makes it trivial to make your own components that leverage all that tooling. Just add use Phlegethon.Components.Base and set your overrides. πŸ™‚

frankdugan3:

And one more thing: Many of the basic components have the same API as the Phoenix 1.7core_components , so you can probably swap out core_components for Phlegethon.Components , and have it work out of the box w/ Phoenix generated LiveViews.

zachdaniel:

That sounds awesome!

.terris:

Wow. This is next level. I will use it. A component library that combines resources and tails is the ash way.. but might need something else like Flowbite or headlessUI

dblack1:

Love the look of this, awesome work! I had to google Phlegethon (turns out I’m not up on Greek mythology). β€˜River of fire’, long may Ash inspire all the names

frankdugan3:

Yeah, every once in a while I have to do something to justify spending 5 semesters learning Greek. πŸ™ƒ

frankdugan3:

I have been hard at work grinding out more of the essential stuff. Did a lot of refinement on the override system.

For one thing, the overrides are set at compile time via application config, so given config.exs :

config :phlegethon, :overrides, [MyApp.CustomOverrides, Phlegethon.Overrides.Default]

The component is able to access them at compile time, allowing validation in attributes:

attr :color, :string,
  default: @override_for[:default_color],
  values: @override_for[:colors],
  doc: "the color of the progress bar"

And a public function is also provided, allowing runtime access as well:

<%= for color <- Progress.override_for(:colors) do %>

This took quite a bit of refactoring, so I’m glad I haven’t opened it just yet as that would have been painful for everyone else to endure. πŸ˜„

frankdugan3:

Another thing I’m quite proud of is I think I came up w/ some great improvements to the Phoenix Flash message component. I made this component a little smarter in that it can accept JSON encoded strings, and it extracts some info from them, such as ttl (time-to-live for auto-closing flashes) and also a title. This allows for some really nice flash messages. This is all done w/ a hook so it’s snappy on the client-side, and it also intelligently resets the ttl if the content of the flash has changed, so you don’t get a quick-close if you overwrite the same flash type. All of this is configurable, of course.

To use the JSON flash, there’s a simple encoder function:

socket
|> put_flash(
  :success,
  encode_flash(~s|User "#{user.name}" successfully created!|, title: "User Generated")
)

frankdugan3:

Putting a lot of effort into generating good docs. πŸ™‚

frankdugan3:

Well, after a long day/night and a lot of caffeine, I added an override prop to Phlegethon.Component (by extending/rewriting half of Phoenix.Component.Declarative ). πŸ”₯

defmodule Phlegethon.Components.Alert do
  use Phlegethon.Component

  @doc """
  A generic alert component.
  """
  @doc type: :component
  
  overridable :color, :string,
    required: true,
    values: :colors
    doc: "the color of the alert"
  overridable :class, :class
  attr :rest, :global
  slot :inner_block, required: true, doc: "the content of the alert"
  def alert(assigns) do
    ~H"""
    <div class={@class} {@rest}>
      <%= render_slot(@inner_block) %>
    </div>
    """
  end
end
override Alert, :alert do
  set :class, &__MODULE__.alert_class/1
  set :color, "info"
  set :colors, ~w[info success warning danger]
end

So a few notes about the behavior/options:

  • All overrides get merged in as the default value
  • required: true option will raise an error if no overrides provide a value – at compile time! πŸ˜„
  • values option can be either an atom, which will pull in overrides for it, or a list, which cannot be overridden
  • All types can be either the type itself, or an arrity 1 function that accepts assigns and returns the type itself
  • Adds the :class type, which additionally handles passing in assigns and running if it’s a function, and runs it through Tails.classes

Edit: hit enter too early, lol

zachdaniel:

πŸ”₯ πŸ”₯ πŸ”₯

zachdaniel:

That looks really awesome

zachdaniel:

gimme gimme gimme πŸ˜†

frankdugan3:

HalfLife 3 soon. πŸ˜‰

frankdugan3:

I uhh… learned a lot about macros doing this, lol

frankdugan3:

A demo:

override Core, :alert do
  set :class, &__MODULE__.alert_class/1
  # set :color, "info"
  set :colors, ~w[info success warning danger]
end
== Compilation error in file lib/phlegethon/components/core.ex ==
** (CompileError) lib/phlegethon/components/core.ex:15: cannot find an override setting for :color, please ensure you define one in a configured override module

And when corrected, notice the default and values extraction:

  %{
    doc: "the color of the alert",
    line: 15,
    name: :color,
    opts: [default: "info", values: ["info", "success", "warning", "danger"]],
    required: true,
    type: :string
  }

zachdaniel:

Question: can child components be overriden with this system? Or non-simple values like functions? Wondering if we could use this to get some of the flexibility we want in ash_authentication_phoenix

zachdaniel:

Since you essentially modeled it after what <@346791515923939328> did there

zachdaniel:

(but it seems have spent much more time on that part of the pattern)

zachdaniel:

Just wondering if there is some synergy that can be achieved with this.

frankdugan3:

  • Basically if you define an override prop, the logic for generic types is this: prop || override || nil .
  • For CSS types, it ends up like this: Tails.classes(override || nil, prop) .
  • If you mark an override prop as required , it will throw an error if you don’t have an override defined in some included override file
  • ALL override types can be functions, in which case they will be passed the assigns:
      def merge_function_overrides(assigns, []), do: assigns
    
      def merge_function_overrides(assigns, [{name, override} | overrides]) do
        if Map.has_key?(assigns, name) do
          assigns
        else
          Map.put(
            assigns,
            name,
            case override do
              function when is_function(function, 1) -> apply(function, [assigns])
              other -> other
            end
          )
        end
        |> merge_function_overrides(overrides)
      end
    
      def merge_class_overrides(assigns, []), do: assigns
    
      def merge_class_overrides(assigns, [{name, override} | overrides]) do
        assigns
        |> Map.put(
          name,
          Tails.classes([
            case override do
              function when is_function(function, 1) -> apply(function, [assigns])
              other -> other
            end,
            assigns[name] || nil
          ])
        )
        |> merge_class_overrides(overrides)
      end
    The assigns get merged in this order: normal defaults -> prop assigns -> override functions -> class type override functions.

frankdugan3:

%{unquote_splicing(defaults)}
|> Map.merge(assigns)
|> merge_function_overrides(unquote(function_overrides))
|> merge_class_overrides(unquote(class_overrides))
|> Map.put(:__given__, assigns)

frankdugan3:

One big difference from the AshAuthenticationPhoenix override system is that it does not support passing in runtime overrides, just overriding via props.

frankdugan3:

In practice, I’m not sure if that matters or not.

frankdugan3:

But I needed compile-time override definitions to make components behave the way I wanted.

zachdaniel:

oh, interesting.

frankdugan3:

Also, at this point, I don’t think there is any shared implementation code. It’s more β€œinspired by” at this point, lol

zachdaniel:

Makes sense. I think there would likely still be some potential synergy there

frankdugan3:

I’m a bit biased, but I think this could make quite a splash. In Phoenix-land, even outside Ash. πŸ™ƒ

zachdaniel:

I think you are very much correct πŸ˜„ May also be a big draw for people to use Ash if its got stuff to integrate in fancy ways

zachdaniel:

Is the stuff like smart form in its own separate dependency? Or is it all one package?

frankdugan3:

ATM, Ash is a hard dependency, but that’s just because I haven’t looked up how to do optional dependencies/optional compilation. Should be no reason why it would need Ash, and I have the smart components in separate modules that could optionally compile.

zachdaniel:

Honestly, its pretty much just what it sounds like

frankdugan3:

I plan to put it all in the same package, but to have optional deps.

zachdaniel:

you mark the dep as optional, and then wrap those modules in if Code.ensure_loaded?(Ash) do

frankdugan3:

Cool. πŸ˜„

frankdugan3:

How about testing?

zachdaniel:

Well, in your test/dev environments the dep is always present

frankdugan3:

Marking it as optional only count for :prod env?

zachdaniel:

I don’t know the best way to test without it though

zachdaniel:

yeah, exactly

frankdugan3:

Awesome.

zachdaniel:

You’re using spark for the DSLs right?

zachdaniel:

Ah, I guess you aren’t

zachdaniel:

because you’ve got top level things

zachdaniel:

😒

frankdugan3:

For the Ash extension? Yeah. Not for the overrides, though.

frankdugan3:

Would it make sense to?

zachdaniel:

If we supported top level builders, yeah

zachdaniel:

but alas, we do not

frankdugan3:

Ahh, right.

zachdaniel:

I’d add it though, just for you

frankdugan3:

Well, it’s pretty simple anyway. Would be no reason not to swap later, I’ve already done all the work for it at this point, lol

frankdugan3:

You can take a look soon, open to any feedback on it,.

frankdugan3:

Once I finish this refactor of the core components w/ my new API, I’ll give you access even though it’s still a bit of a mess in a lot of places. πŸ˜„

jharton:

I’d add it though, just for you He really wants to add this

jharton:

he keeps asking me if I want it

zachdaniel:

lol

zachdaniel:

I just don’t want the lack of it to stop people from using it

jharton:

nah.

frankdugan3:

…Will you add it and refactor to use it? πŸ™ƒ

jharton:

I’m going to wind up with a DSL like:

component do
  prop :foo
  slot :bar
  event :wat
end

jharton:

it’s fine

zachdaniel:

lol, probably not

zachdaniel:

I could see Ash using it for the basic resource things like:

use Ash.Resource

description "..."
...

frankdugan3:

Thinking it over, perhaps using Spark would be great even if the only benefit I got from it was not having to fight w/ the formatter on re-adding parenthesis every time I have a compile error. lol

jharton:

Now you’re getting it.

frankdugan3:

For those interested, the repo is now public! https://github.com/frankdugan3/phlegethon

There is a lot to work on and lots of stuff half-finished, but I feel like the component API and docs are pretty stable. Here is a list of the top priorities I will be working on this week:

  • Finish up the Core components (replacement of core_components.ex
    • <.modal> needs polish
    • <.table> needs polish
  • Flesh out the <.smart_form> component
  • Port my <.smart_data_table> component
  • Trim out all the deprecated cruft that doesn’t need to be in info.ex (ignore that file for now) and finish re-working the tests to work in the lib (they were from a different app originally)
  • Add in the ability to do multi-step forms in the extension and <.smart_form> component (aka wizard)
  • Add a nice autocomplete/multiselect component, and make it ash-smart for use in forms w/ relationship management

frankdugan3:

To clarify the current state, it’s not quite usable as a component lib just yet, so this is more of a thing for those interested in development and checking out the overridable component API, and the :class type. I think it makes for a very composable and clean experience. If you follow the simple dev instructions in the readme, you can check out the component previewer and documentation for yourself. <:ashley:1017613763945443398>

frankdugan3:

I did try and put some effort into decent docs out of the gate, in particular the override modules and components are self-documenting (it extends Phoenix.Component), and any functions used in overridables will link to the appropriate override module that defines them. It should be very easy to click right through any part of a component or override module and find the source right away.

frankdugan3:

And FWIW, the project is already almost 8K LoC! No wonder it’s been taking me so long to get it out the door. πŸ˜…

───────────────────────────────────────────────────────────────────────────────
Language                 Files     Lines   Blanks  Comments     Code Complexity
───────────────────────────────────────────────────────────────────────────────
Elixir                      63      7198      999       370     5829        396
Markdown                     6       331       83         0      248          0
JavaScript                   4       234       19        39      176         18
JSON                         2        82        0         0       82          0
Shell                        2        24        6         2       16          3
CSS                          1         5        1         0        4          0
License                      1        21        4         0       17          0
gitignore                    1        33       10        10       13          0
───────────────────────────────────────────────────────────────────────────────
Total                       80      7928     1122       421     6385        417
───────────────────────────────────────────────────────────────────────────────
Estimated Cost to Develop (organic) $189,240
Estimated Schedule Effort (organic) 7.31 months
Estimated People Required (organic) 2.30
───────────────────────────────────────────────────────────────────────────────
Processed 236899 bytes, 0.237 megabytes (SI)
───────────────────────────────────────────────────────────────────────────────

frankdugan3:

Been hard at work over the past couple weeks!

The core components is just about β€œready” to be used after a significant refactor. I no longer hack Phoenix.Component.Declarative (too much maintenance), and instead use a macro to add the overrides at runtime. There are still quite a few compile time checks and optimizations to ensure things work without much head-scratching.

One example is fuzzy-matching on attribute names when it can’t match up attribute with override.

Going for Elm-level errors. πŸ˜„

attr :classy, :any
def flash(assigns) do
  assigns =
    assigns
    |> assign_overridable(:class, class?: true, required?: true)
** (CompileError) lib/phlegethon/components/core.ex:196: Phlegethon.Component - Invalid Overridable Option

  * Component: Phlegethon.Components.Core.flash/1
  * Prop: attr :class
  * Overridable: assign_overridable(:class, ...)
  * Reason: unable to find prop :class on this component
  * Similar existing prop names: :classy

  Currently only "attr" props are supported.

jharton:

Nicely done

frankdugan3:

Found a nice way to cleanly extend Phoenix.Component.attr/3 , so this is the final(?) component override API:

defmodule MyApp.Components.ExternalLink do
  @moduledoc """
  An external link component.
  """
  use Phlegethon.Component

  attr :overrides, :list, default: nil, doc: @overrides_attr_doc
  attr :class, :tails_classes, overridable: true, required: true
  attr :href, :string, required: true
  attr :rest, :global, include: ~w[download hreflang referrerpolicy rel target type]
  slot :inner_block, required: true

  def external_link(assigns) do
    assigns = assign_overridables(assigns)
    ~H"""
    <a class={@class} href={@href}} {@rest}>
      <%= render_slot(@inner_block) %>
    </a>
    """
  end
end

Has compile-time checks to catch any missing calls/incompatible options. The overridable attrs are accumulated and assigned (in order of definition) by assign_overridables/1 .

All of the components and documentation have been updated to the new new API. Been dog-fooding it and it’s starting to feel pretty good, though I may be biased. πŸ˜„

frankdugan3:

Oh, and the default style is a dark/light variant on the one Phoenix gives you out of the box.

frankdugan3:

Just pushed up the basic version of an autocomplete component, along with support for simple relationships in SmartForm .

For simple use:

<.simple_form for={@form} phx-change="validate" phx-submit="save">
  <.live_component
    module={Phlegethon.Components.Autocomplete}
    id="fiend_id_autocomplete"
    field={@form[:friend_id]}
    label="Friend"
    search_fn={search_friends/1}
    lookup_fn={lookup_friend/1} />
  <:actions>
    <.button>Save</.button>
  </:actions>
</.simple_form>

There are lots of props/overrides to configure lots of behavior, including a slot for custom templates for the options.

To use an autocomplete for simple relationships in Ash w/ a SmartForm :

field :best_friend_id do
  label "Best Friend"
  type :autocomplete
  prompt "Search friends for your bestie"
  autocomplete_option_label_key :name_email
end
argument :best_friend_id, :uuid
change manage_relationship(:best_friend_id, :best_friend, type: :append_and_remove)
belongs_to :best_friend, __MODULE__, api: ComponentPreviewer.Ash.Api

It makes use of hooks and Phoenix.JS so that all the things like moving selection, intercepting key events etc. are all done client-slide for a snappy feel.

It should also be relatively accessible, but I’m no expert on that. 🀞

There’s still a lot more to add, such as loading indicators, supporting multiple entries, supporting more complex relationship types, and allowing templates for adding new entries on the fly. And of course all the bugs that will naturally pop up. Will be adding to it bit-by-bit!

.terris:

Would it be possible to create a channel for phlegethon or do you want questions/discussion in github issues?

zachdaniel:

Where do you stand on this <@433654314175692800> ? Should I make a phlegethon channel or a maple ui channel πŸ˜†

frankdugan3:

Hmm… I think stick w/ phlegethon channel for now. I don’t want to change it twice, and I haven’t made up my mind on maple ui as a name yet. πŸ˜„

zachdaniel:

<#1094268769641185370>

frankdugan3:

I don’t mind questions/discussion on either, but if there is an existing issue on the topic, would be better to continue it on the issue to keep it all in one place.

.terris:

Maple is easier than phegle.. <checks notes>

.terris:

I posted my findings in <#1094268769641185370> which I’m quickly learning how to spell.