site.lyte.dev/content/blog/ecto-reflection-for-simple-admin-crud-forms.md
2024-05-07 09:08:39 -05:00

420 lines
14 KiB
Markdown

---
image: "/img/teal-bubbles.jpg"
date: "2019-03-28T10:30:00-05:00"
imageOverlayColor: "#000"
imageOverlayOpacity: 0.5
heroBackgroundColor: "#333"
description: "Clean and functional Admin CRUD forms using the metadata already
in your schemas!"
title: Using Ecto Reflection for Simple Admin CRUD Forms in Elixir's Phoenix
draft: false
---
If you're working on a Phoenix project, you probably realized the client might
want to view (or even -- *gasp* -- edit!) their data in a pretty raw form.
Frameworks like [Django][django] provide this out of the box.
[Phoenix][phoenix], however, leaves this up to you!
<!--more-->
Sure, there are [`ex_admin`][ex_admin] and [Torch][torch], but `ex_admin` is
pretty much abandoned and Torch is more of a form generator. Personally, I'm not
really a fan of code generators. In my opinion, if something can generate code,
why doesn't it just offer whatever functionality is being provided by the
generated code?
I'll share what I did and hopefully this helps you!
# TL;DR
Leverage your Ecto schemas' [`__schema__/1`][__schema__] and
[`__schema__/2`][__schema__] functions to retrieve the fields and associations
metadata created when using the [`schema/2`][schema/2] macro. From there, you
can decide how to render an appropriate HTML field in a form. From there, you
can either use [`Ecto.Changeset.change/2`][Ecto.Changeset.change/2] to handle
the results of an admin user submitting those forms or implement some sort of
protocol that lets you specify an admin-specific changeset.
# Getting That Sweet, Sweet Metadata
My first issue was figuring out how to get the metadata that I knew Ecto already
had about my schemas. Y'know, which fields are which types, so that I could use
that metadata to render the appropriate form elements: a text box for
a `:string`, a checkbox for a `:boolean`, a [`multiple`
select][select_attributes] for a [`many_to_many/3`][many_to_many/3] association,
etc.
After asking in the ever-helpful [Elixir Slack][elixir-slack], somebody
mentioned that Ecto Schemas [supported reflection][__schema__] using
a `__schema__` method that could access what I was looking for.
```elixir
iex(1)> MyApp.Accounts.User.__schema__ :fields
[:id, :email, :full_name, :password_hash, :verified, :inserted_at, :updated_at]
iex(2)> MyApp.Accounts.User.__schema__ :associations
[:roles]
iex(3)> MyApp.Accounts.User.__schema__ :type, :id
:id
iex(4)> MyApp.Accounts.User.__schema__ :type, :email
:string
iex(5)> MyApp.Accounts.User.__schema__ :association, :roles
%Ecto.Association.ManyToMany{
cardinality: :many,
defaults: [],
field: :roles,
join_keys: [user_id: :id, role_id: :id],
join_through: MyApp.Accounts.UserRole,
on_cast: nil,
on_delete: :nothing,
on_replace: :raise,
owner: MyApp.Accounts.User,
owner_key: :id,
queryable: MyApp.Accounts.Role,
related: MyApp.Accounts.Role,
relationship: :child,
unique: false,
where: []
}
iex(6)> "siiiiiiiiiick"
...
```
Awesome! Using this, I can *definitely* construct a basic form!
So, we'll want an admin controller that knows how to add new schema entries
generically as well as edit and update existing ones:
```elixir
# admin_controller.ex
@models %{
"user" => MyApp.Accounts.User,
"role" => MyApp.Accounts.Role
# ... and any other possible schema you have!
}
def edit(conn, %{"schema" => schema, "pk" => pk}) do
schema_module = @models[schema]
model = schema_module.__schema__(:associations)
|> Enum.reduce(MyApp.Repo.get(m, pk), fn a, m ->
MyApp.Repo.preload(m, a)
end)
# TODO: load changeset from session?
opts = [
changeset: Ecto.Changeset.change(model, %{}),
schema_module: schema_module,
schema: schema,
pk: pk
]
render(conn, "edit.html", opts)
end
# create/2 works similarly and may be considered an exercise for the reader =)
def update(conn, %{"schema" => schema, "pk" => pk, "data" => data}) do
schema_module = @models[schema]
# TODO: be wary! this lets an admin change EVERYTHING!
# if you want to avoid this like I did, setup an AdminEditable protocol that
# requires your schema to implement an admin-specific changeset and use that
# instead
changeset = Ecto.Changeset.change(MyApp.Repo.get!(module, pk), data)
case MyApp.Repo.update(changeset) do
{:ok, updated_model} ->
conn
|> put_flash(:info, "#{String.capitalize(schema)} ID #{pk} updated!")
|> redirect(to: Routes.admin_path(conn, :edit, schema, pk))
{:error, changeset} ->
conn
|> put_flash(:failed, "#{String.capitalize(schema)} ID #{pk} failed to update!")
|> put_session(:changeset, changeset)
|> redirect(to: Routes.admin_path(conn, :edit, schema, pk))
end
end
```
Yep. That's a lot of code. The first chunk to define the `@models` module
attribute is very simple: map a string to a schema module. Easy. If we want, we
could have this map to a tuple like `"user" => {Module, "user", "users"}` so we
could control how they're displayed as well, passing this information to the
view.
The `edit/2` method takes a schema type (to map to a schema module via
`@models`) and a primary key so we can retrieve the actual entry for that
schema. Generally, this will be a UUID or an integer, but it could be something
more complex, in which case your `router.ex` will probably need unusual help to
accommodate this route.
Anyway, `edit/2` just grabs the schema module and loads the given entry, using
the reflection we saw earlier to go ahead and preload all the associations.
Neat! We also create a blank changeset and pass these important things on to our
view. `new/2` is left as an exercise all for you! But mostly because I haven't
written it yet myself! I'm also not retrieving the changeset from the session
here. I also leave that up to you.
`update/2` works similarly, grabbing the schema module, creating a changeset
from the given data (note the big warning!), and then attempting to update the
repo. Here, we have some reasonable error handling and proper Phoenix CRUD'ing
-- or at least... U'ing, but that doesn't sound as fun.
This gets us really close to being able to update arbitrary schema entries! We
just need to setup the view to know how to handle the view logic (duh).
First, though, let's go to our template and figure out how we want it to work,
then we know what helper functions we'll need in our view.
Oh, by the way, I'm using [Slime][slime] instead of [EEx][eex] for my
templates. If you haven't heard of it, you should check it out. It makes for
very clean template code.
```elixir
# edit.html.slime
h1
= "Edit #{String.capitalize(to_string(@schema))}"
= " ID #{@id}"
- action_path = Routes.admin_path(@conn, :update, @schema, @id)
= form_for @changeset, action_path, [as: :data], fn f ->
h2 Fields
= for field <- @schema_module.__schema__(:fields) do
.field
label
.label-text #{String.capitalize(to_string(field))}
= field(f, @schema_module, field)
h2 Associations
= for association <- @schema_module.__schema__(:associations) do
.field
label
.label-text #{String.capitalize(to_string(association))}
= association(f, @schema_module, association)
```
Wow, this is cool! Obviously, most of the magic is going to be in the view
functions we now have to implement, but this *one* view will theoretically
handle any basic schema! This is great!
If you're locking down which fields an admin can modify, you can do something
like this:
```elixir
# edit.html.slime
# for field in fields...
.field
label
.label-text #{String.capitalize(to_string(field))}
= if Enum.member?(@editable_fields, field) do
= field(f, @schema_module, field)
- else
input readonly="true" value=Map.get(@changeset.data, field)
```
Let's get to the nitty gritty and implement `field/3` and `association/3`:
```elixir
def field(form, schema_module, field, opts \\ []) do
case schema_module.__schema__(:schema, field) do
:boolean -> Phoenix.HTML.Form.checkbox(form, field, opts)
:integer -> Phoenix.HTML.Form.number_input(form, field, opts)
# ... I haven't implemented any other types, yet, but datetimes and other
# such fields shouldn't be too difficult!
# see: https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#functions
_ -> Phoenix.HTML.Form.text_input(form, field, opts)
end
end
def association(form, schema_module, association, opts \\ []) do
association = schema_module.__schema__(:association, association)
# make sure your schemas implement the `String.Chars` protocol so they can
# look nice in a select!
# obviously, if you have massive tables, `Repo.all/1` is a bad idea!
options = MyApp.Repo.all(association.queryable)
|> Enum.map(&{&1.id, to_string(&1)})
case association.cardinality do
:many ->
opts = Keyword.put_new(opts, :multiple, true)
Phoenix.HTML.Form.select(form, association, options, opts)
_ ->
Phoenix.HTML.Form.select(form, association, options, opts)
end
end
```
Holy cow! Not bad at all! Actually, it would be really easy to extend this and
even handle the wacky edge cases that may come down the line! As previously
mentioned implementing some kind of `AdminEditable` protocol would be ideal,
then you could attach whatever metadata you wanted in order for your admin CRUD
system to work exactly how you want.
I'm doing that since I don't actually want an admin to be able to change certain
things that the system is solely responsible for. This includes primary keys,
slugs, user-provided information, etc. Therefore this looks a bit different in
practice on my end.
```elixir
# accounts/user.ex
defimpl MyApp.AdminEditable, for: MyApp.Accounts.User do
@readable [:id, :email, :full_name, :inserted_at, :updated_at]
@editable [:verified]
def admin_readable_fields(_s), do: @readable ++ @editable
def admin_editable_fields(_s), do: @editable
# this controls what shows up on the index for a given schema
def admin_index_fields(s), do: admin_readable_fields(s)
end
```
You could then use these methods instead of schema reflection. You could also
handle associations separately as well.
```elixir
# accounts/user.ex
# defimpl MyApp.AdminEditable ...
@readable_associations []
@editable_associations [:roles]
def admin_readable_associations(_s), do:
@readable_associations ++ @editable_associations
def admin_editable_fields(_s), do: @editable_associations
```
You could use this *and* define the implementation for `Any` *and* use the
aforementioned schema reflection and get the best of both worlds!
Anyways, I have a billion ideas on how to extend this basic concept. Hopefully
you can implement your own admin interface without writing a form for every
single schema you have, too! Ahh, simplicity.
Here's all the code jumbled together (and perhaps slightly different):
# All Together Now!
```elixir
# router.ex
get("/edit/:schema/:pk", AdminController, :edit)
post("/new/:schema/:pk", AdminController, :create)
put("/update/:schema/:pk", AdminController, :update)
# admin_controller.ex
@models %{
"user" => MyApp.Accounts.User,
"role" => MyApp.Accounts.Role
# ... and any other possible schema you have!
}
def edit(conn, %{"schema" => schema, "pk" => pk}) do
schema_module = @models[schema]
model = schema_module.__schema__(:associations)
|> Enum.reduce(MyApp.Repo.get(m, pk), fn a, m ->
MyApp.Repo.preload(m, a)
end)
# TODO: load changeset from session?
opts = [
changeset: Ecto.Changeset.change(model, %{}),
schema_module: schema_module
]
render(conn, "edit.html", opts)
end
# create/2 works similarly and may be considered an exercise for the reader =)
def update(conn, %{"schema" => schema, "pk" => pk, "data" => data}) do
schema_module = @models[schema]
# TODO: be wary! this lets an admin change EVERYTHING!
# if you want to avoid this like I did, setup an AdminEditable protocol that
# requires your schema to implement an admin-specific changeset and use that
# instead
changeset = Ecto.Changeset.change(MyApp.Repo.get!(module, pk), data)
case MyApp.Repo.update(changeset) do
{:ok, updated_model} ->
conn
|> put_flash(:info, "#{String.capitalize(schema)} ID #{pk} updated!")
|> redirect(to: Routes.admin_path(conn, :edit, schema, pk))
{:error, changeset} ->
conn
|> put_flash(:failed, "#{String.capitalize(schema)} ID #{pk} failed to update!")
|> put_session(:changeset, changeset)
|> redirect(to: Routes.admin_path(conn, :edit, schema, pk))
end
end
# admin_view.ex
def field(form, changeset, schema_module, field, opts \\ []) do
# this is pretty much the result of the magic - render a partial, look at
# additional metadata, etc!
case schema_module.__schema__(:schema, field) do
:boolean -> Phoenix.HTML.Form.checkbox(form, field, opts)
_ -> Phoenix.HTML.Form.text_input(form, field, opts)
end
end
def association(form, changeset, schema_module, association, opts \\ []) do
# similar magic for associations! huzzah!
association = schema_module.__schema__(:association, association)
# make sure your schemas implement the `String.Chars` protocol!
options = MyApp.Repo.all(association.queryable)
|> Enum.map(&{&1.id, to_string(&1)})
case association.cardinality do
:many ->
Phoenix.HTML.Form.select(form, association, options, Keyword.put_new(opts, :multiple, true))
_ ->
Phoenix.HTML.Form.select(form, association, options, opts)
end
end
# new.html.slime is similar and also an exercise for the reader =)
# edit.html.slime
= form_for @changeset, Routes.admin_path(@conn, :update, @schema, @id), [as: :data], fn f ->
h2 Fields
= for field <- @schema_module.__schema__(:fields) do
.field
label
.label-text #{String.capitalize(to_string(field))}
= field(f, @changeset, @schema_module, field)
h2 Associations
= for association <- @schema_module.__schema__(:associations) do
.field
label
.label-text #{String.capitalize(to_string(association))}
= association(f, @changeset, @schema_module, association)
```
[django]: https://www.djangoproject.com/
[phoenix]: https://phoenixframework.org/
[ex_admin]: https://github.com/smpallen99/ex_admin
[torch]:https://github.com/danielberkompas/torch
[__schema__]: https://hexdocs.pm/ecto/3.0.7/Ecto.Schema.html#module-reflection
[schema/2]: https://hexdocs.pm/ecto/3.0.7/Ecto.Schema.html#schema/2
[Ecto.Changeset.change/2]: https://hexdocs.pm/ecto/3.0.7/Ecto.Changeset.html#change/2
[elixir-slack]: https://elixir-slackin.herokuapp.com/
[select_attributes]:https://developer.mozilla.org/en-US/docs/Web/HTML/Element/select#Attributes
[many_to_many/3]: https://hexdocs.pm/ecto/3.0.7/Ecto.Schema.html#many_to_many/3
[slime]: https://github.com/slime-lang/slime
[eex]: https://hexdocs.pm/eex/EEx.html