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
if Ash.Resource.Info.attribute(changeset.resource, field) ||
Enum.any?(changeset.action.arguments, &(&1.name == field)) do
with :error <- Map.fetch(changeset.params, field),
:error <- Map.fetch(changeset.params, to_string(field)) do
:error
with :error <- Map.fetch(changeset.params, field) do
Map.fetch(changeset.params, to_string(field))
end
else
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
def generate_from_cli(argv) do
if Mix.Project.umbrella?() do
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]
)
{api, resource, opts, _rest} = AshPhoenix.Gen.parse_opts(argv)
generate(
Module.concat([api]),
Module.concat([resource]),
Keyword.put(parsed, :interactive?, true)
api,
resource,
Keyword.put(opts, :interactive?, true)
)
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."
) do
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()
if actor == "" do
opts
Keyword.put(opts, :actor, "current_user")
else
Keyword.put(opts, :actor, actor)
end
@ -172,7 +146,7 @@ defmodule AshPhoenix.Gen.Live do
|> Ash.Resource.Info.short_name()
|> to_string()
plural_name = plural_name!(resource, opts)
plural_name = opts[:resource_plural]
pkey =
case Ash.Resource.Info.primary_key(resource) do
@ -331,33 +305,6 @@ defmodule AshPhoenix.Gen.Live do
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
web_module().module_info[:compile][:source]
|> Path.relative_to(root_path())

View file

@ -6,18 +6,14 @@ defmodule Mix.Tasks.AshPhoenix.Gen.Html do
@moduledoc """
This task renders .ex and .heex templates and copies them to specified directories.
## Arguments
#{AshPhoenix.Gen.docs()}
api The API (e.g. "Shop").
resource The resource (e.g. "Product").
plural The plural schema name (e.g. "products").
## Example
mix ash_phoenix.gen.html Shop Product products
mix ash_phoenix.gen.html MyApp.Shop MyApp.Shop.Product --plural-name products
"""
def run([]) do
not_umbrella!()
Mix.shell().info("""
#{Mix.Task.shortdoc(__MODULE__)}
@ -25,55 +21,70 @@ defmodule Mix.Tasks.AshPhoenix.Gen.Html do
""")
end
def run(args) when length(args) == 3 do
def run(args) do
not_umbrella!()
Mix.Task.run("compile")
[api, resource, plural] = args
singular = String.downcase(resource)
{api, resource, opts, _} = AshPhoenix.Gen.parse_opts(args)
singular = to_string(Ash.Resource.Info.short_name(resource))
opts = %{
api: api,
resource: resource,
resource: List.last(Module.split(resource)),
full_resource: resource,
full_api: api,
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")
resource_html_dir = Macro.underscore(opts[:resource]) <> "_html"
resource_html_dir = to_string(opts[:singular]) <> "_html"
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
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
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 = [{:route_prefix, Macro.underscore(opts[:plural])} | binding]
binding = [{:route_prefix, to_string(opts[:plural])} | 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, %{})
end
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",
"show.html.heex" => "#{app_web_path}/controllers/#{resource_html_dir}/show.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",
"edit.html.heex" => "#{app_web_path}/controllers/#{resource_html_dir}/edit.html.heex",
"controller.ex" =>
"#{app_web_path}/controllers/#{Macro.underscore(opts[:resource])}_controller.ex",
"html.ex" => "#{app_web_path}/controllers/#{Macro.underscore(opts[:resource])}_html.ex"
"controller.ex" => "#{app_web_path}/controllers/#{opts[:singular]}_controller.ex",
"html.ex" => "#{app_web_path}/controllers/#{opts[:singular]}_html.ex"
}
end
@ -86,36 +97,63 @@ defmodule Mix.Tasks.AshPhoenix.Gen.Html do
end)
end
defp app_name_underscore do
Mix.Project.config()[:app]
end
defp app_name do
app_name_atom = Mix.Project.config()[:app]
Macro.camelize(Atom.to_string(app_name_atom))
end
defp print_shell_instructions(resource, plural) do
defp print_shell_instructions(opts) do
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
defp resource_module(opts) do
Module.concat(["#{app_name()}.#{opts[:api]}.#{opts[:resource]}"])
defp attributes(resource) do
resource
|> Ash.Resource.Info.public_attributes()
|> Enum.reject(&(&1.type == Ash.Type.UUID))
|> Enum.map(&attribute_map/1)
end
defp attributes(opts) do
resource_module(opts)
|> Ash.Resource.Info.attributes()
defp create_attributes(resource) do
create_action = Ash.Resource.Info.primary_action!(resource, :create)
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.reject(&reject_attribute?/1)
end
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
defp reject_attribute?(%{name: :id, type: Ash.Type.UUID}), do: true
defp reject_attribute?(%{private?: true}), do: true
defp reject_attribute?(_), do: false
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.
#{AshPhoenix.Gen.docs()}
For example:
```bash
@ -15,6 +17,13 @@ defmodule Mix.Tasks.AshPhoenix.Gen.Live do
@shortdoc "Generates liveviews for a resource"
def run(argv) do
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)
end
end

View file

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

View file

@ -1,7 +1,7 @@
defmodule <%= @app_name %>Web.<%= @resource %>Controller do
use <%= @app_name %>Web, :controller
alias <%= @app_name %>.<%= @api %>.<%= @resource %>
alias <%= inspect @full_resource %>
def index(conn, _params) do
<%= @plural %> = <%= @resource %>.read!()
@ -69,10 +69,10 @@ defmodule <%= @app_name %>Web.<%= @resource %>Controller do
end
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
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

View file

@ -9,7 +9,7 @@
<.table id="<%= @plural %>" rows={@<%= @plural %>} row_click={&JS.navigate(~p"/<%= @route_prefix %>/#{&1}")}>
<%= 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 %>
<:action :let={<%= @singular %>}>
<div class="sr-only">

View file

@ -5,4 +5,4 @@
<.<%= @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?}>
Oops, something went wrong! Please check the errors below.
</.error>
<%= for attribute <- @attributes do %>
<%%= if @form.type == :update do %>
<%= for attribute <- @update_attributes 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 %>
<.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 %>
<%%= 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>
<.button>Save Product</.button>
</:actions>

View file

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