improvement: make generators more consistent

This commit is contained in:
Zach Daniel 2023-10-24 10:22:24 -04:00
parent 6b80d0fe50
commit 459dca3dfe
11 changed files with 201 additions and 113 deletions

View file

@ -2385,9 +2385,8 @@ defmodule AshPhoenix.Form do
defp get_non_attribute_non_argument_param(changeset, form, field) do defp get_non_attribute_non_argument_param(changeset, form, field) do
if Ash.Resource.Info.attribute(changeset.resource, field) || if Ash.Resource.Info.attribute(changeset.resource, field) ||
Enum.any?(changeset.action.arguments, &(&1.name == field)) do Enum.any?(changeset.action.arguments, &(&1.name == field)) do
with :error <- Map.fetch(changeset.params, field), with :error <- Map.fetch(changeset.params, field) do
:error <- Map.fetch(changeset.params, to_string(field)) do Map.fetch(changeset.params, to_string(field))
:error
end end
else else
Map.fetch(AshPhoenix.Form.params(form), Atom.to_string(field)) Map.fetch(AshPhoenix.Form.params(form), Atom.to_string(field))

View file

@ -0,0 +1,79 @@
defmodule AshPhoenix.Gen do
@moduledoc false
def docs do
"""
## Positional Arguments
- `api` - The API (e.g. "Shop").
- `resource` - The resource (e.g. "Product").
## Options
- `--resource-plural` - The plural resource name (e.g. "products")
"""
end
def parse_opts(argv) do
{api, resource, rest} =
case argv do
[api, resource | rest] ->
{api, resource, rest}
argv ->
raise "Not enough arguments. Expected 2, got #{Enum.count(argv)}"
end
if String.starts_with?(api, "-") do
raise "Expected first argument to be an api module, not an option"
end
if String.starts_with?(resource, "-") do
raise "Expected second argument to be a resource module, not an option"
end
{parsed, _, _} =
OptionParser.parse(rest,
strict: [resource_plural: :string, actor: :string, no_actor: :boolean]
)
api = Module.concat([api])
resource = Module.concat([resource])
parsed =
Keyword.put_new_lazy(rest, :resource_plural, fn ->
plural_name!(resource, parsed)
end)
{api, resource, parsed, rest}
end
defp plural_name!(resource, opts) do
plural_name =
opts[:resource_plural] ||
Ash.Resource.Info.plural_name(resource) ||
Mix.shell().prompt(
"""
Please provide a plural_name for #{inspect(resource)}. For example the plural of tweet is tweets.
This can also be configured on the resource. To do so, press enter to abort,
and add the following configuration to your resource (using the proper plural name)
resource do
plural_name :tweets
end
>
"""
|> String.trim()
)
|> String.trim()
case plural_name do
empty when empty in ["", nil] ->
raise("Must configure `plural_name` on resource or provide --resource-plural")
plural_name ->
to_string(plural_name)
end
end
end

View file

@ -2,38 +2,12 @@ defmodule AshPhoenix.Gen.Live do
@moduledoc false @moduledoc false
def generate_from_cli(argv) do def generate_from_cli(argv) do
if Mix.Project.umbrella?() do {api, resource, opts, _rest} = AshPhoenix.Gen.parse_opts(argv)
Mix.raise(
"mix phx.gen.live must be invoked from within your *_web application root directory"
)
end
{api, resource, rest} =
case argv do
[api, resource | rest] ->
{api, resource, rest}
argv ->
raise "Not enough arguments. Expected 2, got #{Enum.count(argv)}"
end
if String.starts_with?(api, "-") do
raise "Expected first argument to be an api module, not an option"
end
if String.starts_with?(resource, "-") do
raise "Expected second argument to be a resource module, not an option"
end
{parsed, _, _} =
OptionParser.parse(rest,
strict: [resource_plural: :string, actor: :string, no_actor: :boolean]
)
generate( generate(
Module.concat([api]), api,
Module.concat([resource]), resource,
Keyword.put(parsed, :interactive?, true) Keyword.put(opts, :interactive?, true)
) )
end end
@ -47,11 +21,11 @@ defmodule AshPhoenix.Gen.Live do
"Would you like to name your actor? For example: `current_user`. If you choose no, we will not add any actor logic." "Would you like to name your actor? For example: `current_user`. If you choose no, we will not add any actor logic."
) do ) do
actor = actor =
Mix.shell().prompt("What would you like to name it? For example: `current_user`") Mix.shell().prompt("What would you like to name it? Default: `current_user`")
|> String.trim() |> String.trim()
if actor == "" do if actor == "" do
opts Keyword.put(opts, :actor, "current_user")
else else
Keyword.put(opts, :actor, actor) Keyword.put(opts, :actor, actor)
end end
@ -172,7 +146,7 @@ defmodule AshPhoenix.Gen.Live do
|> Ash.Resource.Info.short_name() |> Ash.Resource.Info.short_name()
|> to_string() |> to_string()
plural_name = plural_name!(resource, opts) plural_name = opts[:resource_plural]
pkey = pkey =
case Ash.Resource.Info.primary_key(resource) do case Ash.Resource.Info.primary_key(resource) do
@ -331,33 +305,6 @@ defmodule AshPhoenix.Gen.Live do
end end
end end
defp plural_name!(resource, opts) do
plural_name =
opts[:resource_plural] ||
Ash.Resource.Info.plural_name(resource) ||
Mix.shell().prompt(
"""
Please provide a plural_name. For example the plural of tweet is tweets.
You can press enter to abort, and then configure one on the resource, for example:
resource do
plural_name :tweets
end
>
"""
|> String.trim()
)
|> String.trim()
case plural_name do
empty when empty in ["", nil] ->
raise("Must configure `plural_name` on resource or provide --resource-plural")
plural_name ->
to_string(plural_name)
end
end
defp web_path do defp web_path do
web_module().module_info[:compile][:source] web_module().module_info[:compile][:source]
|> Path.relative_to(root_path()) |> Path.relative_to(root_path())

View file

@ -6,18 +6,14 @@ defmodule Mix.Tasks.AshPhoenix.Gen.Html do
@moduledoc """ @moduledoc """
This task renders .ex and .heex templates and copies them to specified directories. This task renders .ex and .heex templates and copies them to specified directories.
## Arguments #{AshPhoenix.Gen.docs()}
api The API (e.g. "Shop"). mix ash_phoenix.gen.html MyApp.Shop MyApp.Shop.Product --plural-name products
resource The resource (e.g. "Product").
plural The plural schema name (e.g. "products").
## Example
mix ash_phoenix.gen.html Shop Product products
""" """
def run([]) do def run([]) do
not_umbrella!()
Mix.shell().info(""" Mix.shell().info("""
#{Mix.Task.shortdoc(__MODULE__)} #{Mix.Task.shortdoc(__MODULE__)}
@ -25,55 +21,70 @@ defmodule Mix.Tasks.AshPhoenix.Gen.Html do
""") """)
end end
def run(args) when length(args) == 3 do def run(args) do
not_umbrella!()
Mix.Task.run("compile") Mix.Task.run("compile")
[api, resource, plural] = args {api, resource, opts, _} = AshPhoenix.Gen.parse_opts(args)
singular = String.downcase(resource)
singular = to_string(Ash.Resource.Info.short_name(resource))
opts = %{ opts = %{
api: api, resource: List.last(Module.split(resource)),
resource: resource, full_resource: resource,
full_api: api,
singular: singular, singular: singular,
plural: plural plural: opts[:resource_plural]
} }
if Code.ensure_loaded?(resource_module(opts)) do if Code.ensure_loaded?(resource) do
source_path = Application.app_dir(:ash_phoenix, "priv/templates/ash_phoenix.gen.html") source_path = Application.app_dir(:ash_phoenix, "priv/templates/ash_phoenix.gen.html")
resource_html_dir = Macro.underscore(opts[:resource]) <> "_html" resource_html_dir = to_string(opts[:singular]) <> "_html"
template_files(resource_html_dir, opts) template_files(resource_html_dir, opts)
|> generate_files(assigns([:api, :resource, :singular, :plural], opts), source_path) |> generate_files(
assigns([:api, :full_resource, :full_api, :resource, :singular, :plural], resource, opts),
source_path
)
print_shell_instructions(opts[:resource], opts[:plural]) print_shell_instructions(opts)
else else
Mix.shell().info( Mix.shell().info(
"The resource #{app_name()}.#{opts[:api]}.#{opts[:resource]} does not exist." "The resource #{inspect(opts[:api])}.#{inspect(opts[:resource])} does not exist."
) )
end end
end end
defp assigns(keys, opts) do defp not_umbrella! do
if Mix.Project.umbrella?() do
Mix.raise(
"mix phx.gen.html must be invoked from within your *_web application root directory"
)
end
end
defp assigns(keys, resource, opts) do
binding = Enum.map(keys, fn key -> {key, opts[key]} end) binding = Enum.map(keys, fn key -> {key, opts[key]} end)
binding = [{:route_prefix, Macro.underscore(opts[:plural])} | binding] binding = [{:route_prefix, to_string(opts[:plural])} | binding]
binding = [{:app_name, app_name()} | binding] binding = [{:app_name, app_name()} | binding]
binding = [{:attributes, attributes(opts)} | binding] binding = [{:attributes, attributes(resource)} | binding]
binding = [{:update_attributes, update_attributes(resource)} | binding]
binding = [{:create_attributes, create_attributes(resource)} | binding]
Enum.into(binding, %{}) Enum.into(binding, %{})
end end
defp template_files(resource_html_dir, opts) do defp template_files(resource_html_dir, opts) do
app_web_path = "lib/#{Macro.underscore(app_name())}_web" app_web_path = "lib/#{app_name_underscore()}_web"
%{ %{
"index.html.heex" => "#{app_web_path}/controllers/#{resource_html_dir}/index.html.heex", "index.html.heex" => "#{app_web_path}/controllers/#{resource_html_dir}/index.html.heex",
"show.html.heex" => "#{app_web_path}/controllers/#{resource_html_dir}/show.html.heex", "show.html.heex" => "#{app_web_path}/controllers/#{resource_html_dir}/show.html.heex",
"resource_form.html.heex" => "resource_form.html.heex" =>
"#{app_web_path}/controllers/#{resource_html_dir}/#{Macro.underscore(opts[:resource])}_form.html.heex", "#{app_web_path}/controllers/#{resource_html_dir}/#{opts[:singular]}_form.html.heex",
"new.html.heex" => "#{app_web_path}/controllers/#{resource_html_dir}/new.html.heex", "new.html.heex" => "#{app_web_path}/controllers/#{resource_html_dir}/new.html.heex",
"edit.html.heex" => "#{app_web_path}/controllers/#{resource_html_dir}/edit.html.heex", "edit.html.heex" => "#{app_web_path}/controllers/#{resource_html_dir}/edit.html.heex",
"controller.ex" => "controller.ex" => "#{app_web_path}/controllers/#{opts[:singular]}_controller.ex",
"#{app_web_path}/controllers/#{Macro.underscore(opts[:resource])}_controller.ex", "html.ex" => "#{app_web_path}/controllers/#{opts[:singular]}_html.ex"
"html.ex" => "#{app_web_path}/controllers/#{Macro.underscore(opts[:resource])}_html.ex"
} }
end end
@ -86,36 +97,63 @@ defmodule Mix.Tasks.AshPhoenix.Gen.Html do
end) end)
end end
defp app_name_underscore do
Mix.Project.config()[:app]
end
defp app_name do defp app_name do
app_name_atom = Mix.Project.config()[:app] app_name_atom = Mix.Project.config()[:app]
Macro.camelize(Atom.to_string(app_name_atom)) Macro.camelize(Atom.to_string(app_name_atom))
end end
defp print_shell_instructions(resource, plural) do defp print_shell_instructions(opts) do
Mix.shell().info(""" Mix.shell().info("""
Add the resource to your browser scope in lib/#{Macro.underscore(resource)}_web/router.ex: Add the resource to your browser scope in lib/#{opts[:singular]}_web/router.ex:
resources "/#{plural}", #{resource}Controller resources "/#{opts[:plural]}", #{opts[:resource]}Controller
""") """)
end end
defp resource_module(opts) do defp attributes(resource) do
Module.concat(["#{app_name()}.#{opts[:api]}.#{opts[:resource]}"]) resource
|> Ash.Resource.Info.public_attributes()
|> Enum.reject(&(&1.type == Ash.Type.UUID))
|> Enum.map(&attribute_map/1)
end end
defp attributes(opts) do defp create_attributes(resource) do
resource_module(opts) create_action = Ash.Resource.Info.primary_action!(resource, :create)
|> Ash.Resource.Info.attributes()
attrs =
create_action.accept
|> Enum.map(&Ash.Resource.Info.attribute(resource, &1))
|> Enum.filter(& &1.writable?)
create_action.arguments
|> Enum.concat(attrs)
|> Enum.map(&attribute_map/1)
end
defp update_attributes(resource) do
update_action = Ash.Resource.Info.primary_action!(resource, :update)
attrs =
update_action.accept
|> Enum.map(&Ash.Resource.Info.attribute(resource, &1))
|> Enum.filter(& &1.writable?)
update_action.arguments
|> Enum.concat(attrs)
|> Enum.map(&attribute_map/1) |> Enum.map(&attribute_map/1)
|> Enum.reject(&reject_attribute?/1)
end end
defp attribute_map(attr) do defp attribute_map(attr) do
%{name: attr.name, type: attr.type, writable?: attr.writable?, private?: attr.private?} %{
name: attr.name,
type: attr.type,
writable?: Map.get(attr, :writable?, true),
private?: attr.private?
}
end end
defp reject_attribute?(%{name: :id, type: Ash.Type.UUID}), do: true
defp reject_attribute?(%{private?: true}), do: true
defp reject_attribute?(_), do: false
end end

View file

@ -4,6 +4,8 @@ defmodule Mix.Tasks.AshPhoenix.Gen.Live do
The api and resource must already exist, this task does not define them. The api and resource must already exist, this task does not define them.
#{AshPhoenix.Gen.docs()}
For example: For example:
```bash ```bash
@ -15,6 +17,13 @@ defmodule Mix.Tasks.AshPhoenix.Gen.Live do
@shortdoc "Generates liveviews for a resource" @shortdoc "Generates liveviews for a resource"
def run(argv) do def run(argv) do
Mix.Task.run("compile") Mix.Task.run("compile")
if Mix.Project.umbrella?() do
Mix.raise(
"mix phx.gen.live must be invoked from within your *_web application root directory"
)
end
AshPhoenix.Gen.Live.generate_from_cli(argv) AshPhoenix.Gen.Live.generate_from_cli(argv)
end end
end end

View file

@ -124,6 +124,10 @@ defmodule AshPhoenix.MixProject do
AshPhoenix.LiveView, AshPhoenix.LiveView,
AshPhoenix.SubdomainPlug AshPhoenix.SubdomainPlug
], ],
Generators: [
Mix.Tasks.AshPhoenix.Gen.Html,
Mix.Tasks.AshPhoenix.Gen.Live
],
Forms: [ Forms: [
AshPhoenix.Form, AshPhoenix.Form,
AshPhoenix.Form.Auto, AshPhoenix.Form.Auto,

View file

@ -1,7 +1,7 @@
defmodule <%= @app_name %>Web.<%= @resource %>Controller do defmodule <%= @app_name %>Web.<%= @resource %>Controller do
use <%= @app_name %>Web, :controller use <%= @app_name %>Web, :controller
alias <%= @app_name %>.<%= @api %>.<%= @resource %> alias <%= inspect @full_resource %>
def index(conn, _params) do def index(conn, _params) do
<%= @plural %> = <%= @resource %>.read!() <%= @plural %> = <%= @resource %>.read!()
@ -69,10 +69,10 @@ defmodule <%= @app_name %>Web.<%= @resource %>Controller do
end end
defp create_form(params \\ nil) do defp create_form(params \\ nil) do
AshPhoenix.Form.for_create(<%= @resource %>, :create, as: "<%= @singular %>", api: <%= @app_name %>.<%= @api %>, params: params) AshPhoenix.Form.for_create(<%= @resource %>, :create, as: "<%= @singular %>", api: <%= @full_api %>, params: params)
end end
defp update_form(<%= @singular %>, params \\ nil) do defp update_form(<%= @singular %>, params \\ nil) do
AshPhoenix.Form.for_update(<%= @singular %>, :update, as: "<%= @singular %>", api: <%= @app_name %>.<%= @api %>, params: params) AshPhoenix.Form.for_update(<%= @singular %>, :update, as: "<%= @singular %>", api: <%= @full_api %>, params: params)
end end
end end

View file

@ -9,7 +9,7 @@
<.table id="<%= @plural %>" rows={@<%= @plural %>} row_click={&JS.navigate(~p"/<%= @route_prefix %>/#{&1}")}> <.table id="<%= @plural %>" rows={@<%= @plural %>} row_click={&JS.navigate(~p"/<%= @route_prefix %>/#{&1}")}>
<%= for attribute <- @attributes do %> <%= for attribute <- @attributes do %>
<:col :let={<%= @singular %>} label="<%= attribute.name %>"><%%= <%= @singular %>.<%= attribute.name %> %></:col> <:col :let={<%= @singular %>} label="<%= Phoenix.Naming.humanize(attribute.name) %>"><%%= <%= @singular %>.<%= attribute.name %> %></:col>
<% end %> <% end %>
<:action :let={<%= @singular %>}> <:action :let={<%= @singular %>}>
<div class="sr-only"> <div class="sr-only">

View file

@ -5,4 +5,4 @@
<.<%= @singular %>_form form={@form} action={~p"/<%= @plural %>/"} /> <.<%= @singular %>_form form={@form} action={~p"/<%= @plural %>/"} />
<.back navigate={~p"/products"}>Back to <%= @plural %></.back> <.back navigate={~p"/<%= @plural %>"}>Back to <%= @plural %></.back>

View file

@ -2,13 +2,25 @@
<.error :if={@form.submitted_once?}> <.error :if={@form.submitted_once?}>
Oops, something went wrong! Please check the errors below. Oops, something went wrong! Please check the errors below.
</.error> </.error>
<%= for attribute <- @attributes do %> <%%= if @form.type == :update do %>
<%= for attribute <- @update_attributes do %>
<%= if attribute.type in [Ash.Type.Integer] do %> <%= if attribute.type in [Ash.Type.Integer] do %>
<.input field={f[:<%= attribute.name %>]} type="number" label="<%= attribute.name %>" /> <.input field={f[:<%= attribute.name %>]} type="number" label="<%= Phoenix.Naming.humanize(attribute.name) %>" />
<% else %> <% else %>
<.input field={f[:<%= attribute.name %>]} type="text" label="<%= attribute.name %>" /> <.input field={f[:<%= attribute.name %>]} type="text" label="<%= Phoenix.Naming.hunamize(attribute.name) %>" />
<% end %> <% end %>
<% end %> <% end %>
<%% end %>
<%%= if @form.type == :create do %>
<%= for attribute <- @update_attributes do %>
<%= if attribute.type in [Ash.Type.Integer] do %>
<.input field={f[:<%= attribute.name %>]} type="number" label="<%= Phoenix.Naming.humanize(attribute.name) %>" />
<% else %>
<.input field={f[:<%= attribute.name %>]} type="text" label="<%= Phoenix.Naming.humanize(attribute.name) %>" />
<% end %>
<% end %>
<%% end %>
<:actions> <:actions>
<.button>Save Product</.button> <.button>Save Product</.button>
</:actions> </:actions>

View file

@ -10,7 +10,7 @@
<.list> <.list>
<%= for attribute <- @attributes do %> <%= for attribute <- @attributes do %>
<:item title="<%= attribute.name %>"><%%= @<%= @singular %>.<%= attribute.name %> %></:item> <:item title="<%= Phoenix.Naming.hunamize(attribute.name) %>"><%%= @<%= @singular %>.<%= attribute.name %> %></:item>
<% end %> <% end %>
</.list> </.list>