diff --git a/.formatter.exs b/.formatter.exs index 3392b8e..223c6af 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,5 +1,6 @@ # Used by "mix format" [ - import_deps: [:ash], - inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] + import_deps: [:ash, :phoenix], + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: ["{mix}.exs", "{config,lib,test}/**/*.{ex,exs,heex}"] ] diff --git a/lib/ash_phoenix/gen/live.ex b/lib/ash_phoenix/gen/live.ex index 8569cf9..4e3ac1c 100644 --- a/lib/ash_phoenix/gen/live.ex +++ b/lib/ash_phoenix/gen/live.ex @@ -1,17 +1,21 @@ defmodule AshPhoenix.Gen.Live do @moduledoc false - def generate_from_cli(argv) do - {domain, resource, opts, _rest} = AshPhoenix.Gen.parse_opts(argv) + def generate_from_cli(%Igniter{} = igniter, options) do + domain = Keyword.fetch!(options, :domain) |> String.to_existing_atom() + resource = Keyword.fetch!(options, :resource) |> String.to_existing_atom() + resource_plural = Keyword.fetch!(options, :resourceplural) + opts = [] generate( + igniter, domain, resource, - Keyword.put(opts, :interactive?, true) + Keyword.put(opts, :interactive?, true) |> Keyword.put(:resource_plural, resource_plural) ) end - def generate(domain, resource, opts \\ []) do + def generate(igniter, domain, resource, opts \\ []) do Code.ensure_compiled!(domain) Code.ensure_compiled!(resource) @@ -55,13 +59,13 @@ defmodule AshPhoenix.Gen.Live do [ domain: inspect(domain), resource: inspect(resource), - web_module: inspect(web_module()), + web_module: inspect(web_module(igniter)), actor: opts[:actor], actor_opt: actor_opt(opts) ] |> add_resource_assigns(resource, opts) - web_live = Path.join([web_path(), "live", "#{assigns[:resource_singular]}_live"]) + web_live = Path.join([web_path(igniter), "live", "#{assigns[:resource_singular]}_live"]) generate_opts = if opts[:interactive?] do @@ -70,40 +74,50 @@ defmodule AshPhoenix.Gen.Live do [force: true, quiet: true] end - write_formatted_template( - "ash_phoenix.gen.live/index.ex.eex", - "index.ex", - web_live, - assigns, - generate_opts - ) - - if assigns[:update_action] || assigns[:create_action] do + igniter = write_formatted_template( - "ash_phoenix.gen.live/form_component.ex.eex", - "form_component.ex", + igniter, + "ash_phoenix.gen.live/index.ex.eex", + "index.ex", web_live, assigns, generate_opts ) - end - write_formatted_template( - "ash_phoenix.gen.live/show.ex.eex", - "show.ex", - web_live, - assigns, - generate_opts - ) + igniter = + if assigns[:update_action] || assigns[:create_action] do + write_formatted_template( + igniter, + "ash_phoenix.gen.live/form_component.ex.eex", + "form_component.ex", + web_live, + assigns, + generate_opts + ) + else + igniter + end + + igniter = + write_formatted_template( + igniter, + "ash_phoenix.gen.live/show.ex.eex", + "show.ex", + web_live, + assigns, + generate_opts + ) if opts[:interactive?] do Mix.shell().info(""" - Add the live routes to your browser scope in #{web_path()}/router.ex: + Add the live routes to your browser scope in #{web_path(igniter)}/router.ex: #{for line <- live_route_instructions(assigns), do: " #{line}"} """) end + + igniter end defp live_route_instructions(assigns) do @@ -123,7 +137,7 @@ defmodule AshPhoenix.Gen.Live do |> Enum.reject(&is_nil/1) end - defp write_formatted_template(path, destination, web_live, assigns, generate_opts) do + defp write_formatted_template(igniter, path, destination, web_live, assigns, generate_opts) do destination_path = web_live |> Path.join(destination) @@ -137,7 +151,7 @@ defmodule AshPhoenix.Gen.Live do |> EEx.eval_file(assigns: assigns) |> formatter_function.() - Mix.Generator.create_file(destination_path, contents, generate_opts) + Igniter.create_new_file(igniter, destination_path, contents, generate_opts) end defp add_resource_assigns(assigns, resource, opts) do @@ -305,30 +319,15 @@ defmodule AshPhoenix.Gen.Live do end end - defp web_path do - web_module().module_info[:compile][:source] - |> Path.relative_to(root_path()) - |> Path.rootname() - end + defp web_path(igniter) do + web_module_path = Igniter.Project.Module.proper_location(igniter, web_module(igniter)) + lib_dir = Path.dirname(web_module_path) - defp root_path do - Mix.Project.get().module_info[:compile][:source] - |> Path.dirname() + Path.join([lib_dir, Path.basename(web_module_path, ".ex")]) end - defp web_module do - base = Mix.Phoenix.base() - - cond do - Mix.Phoenix.context_app() != Mix.Phoenix.otp_app() -> - Module.concat([base]) - - String.ends_with?(base, "Web") -> - Module.concat([base]) - - true -> - Module.concat(["#{base}Web"]) - end + defp web_module(igniter) do + Igniter.Libs.Phoenix.web_module(igniter) end defp template(path) do diff --git a/lib/mix/tasks/ash_phoenix.gen.live.ex b/lib/mix/tasks/ash_phoenix.gen.live.ex index 89b3f2c..a5f22b8 100644 --- a/lib/mix/tasks/ash_phoenix.gen.live.ex +++ b/lib/mix/tasks/ash_phoenix.gen.live.ex @@ -1,29 +1,55 @@ defmodule Mix.Tasks.AshPhoenix.Gen.Live do + use Igniter.Mix.Task + + @example "mix ash_phoenix.gen.live --domain ExistingDomainName --resource ExistingResourceName --resource-plural ExistingResourceNames" + + @shortdoc "Generates liveviews for a given domain and resource." + + # --domain + # --resource + # --resource-plural @moduledoc """ + #{@shortdoc} + Generates liveviews for a given domain and resource. The domain and resource must already exist, this task does not define them. - #{AshPhoenix.Gen.docs()} - - For example: + ## Example ```bash - mix ash_phoenix.gen.live ExistingDomainName ExistingResourceName + #{@example} ``` + + ## Options + + * `--domain` - Existing domain + * `--resource` - Existing resource + * `--resourceplural` - Plural resource name """ - use Mix.Task - @shortdoc "Generates liveviews for a resource" - def run(argv) do - Mix.Task.run("compile") + def info(_argv, _composing_task) do + %Igniter.Mix.Task.Info{ + # Groups allow for overlapping arguments for tasks by the same author + # See the generators guide for more. + group: :ash_phoenix, + example: @example, + schema: [domain: :string, resource: :string, resourceplural: :string], + # Default values for the options in the `schema`. + defaults: [], + # CLI aliases + aliases: [], + # A list of options in the schema that are required + required: [:domain, :resource, :resourceplural] + } + end - if Mix.Project.umbrella?() do - Mix.raise( - "mix phx.gen.live must be invoked from within your *_web application root directory" - ) - end + def igniter(igniter, argv) do + # extract options according to `schema` and `aliases` above + options = options!(argv) - AshPhoenix.Gen.Live.generate_from_cli(argv) + # Do your work here and return an updated igniter + igniter + |> AshPhoenix.Gen.Live.generate_from_cli(options) end end diff --git a/mix.exs b/mix.exs index b05b99b..d6253e8 100644 --- a/mix.exs +++ b/mix.exs @@ -140,7 +140,9 @@ defmodule AshPhoenix.MixProject do {:dialyxir, ">= 0.0.0", only: [:dev, :test], runtime: false}, {:sobelow, ">= 0.0.0", only: [:dev, :test], runtime: false}, {:mix_audit, ">= 0.0.0", only: [:dev, :test], runtime: false}, - {:mix_test_watch, "~> 1.0", only: [:dev, :test]} + {:mix_test_watch, "~> 1.0", only: [:dev, :test]}, + # Code Generators + {:igniter, "~> 0.4 and >= 0.4.3"} ] end diff --git a/test/mix/tasks/ash_phoenix.gen.live_test.exs b/test/mix/tasks/ash_phoenix.gen.live_test.exs new file mode 100644 index 0000000..1bd81b8 --- /dev/null +++ b/test/mix/tasks/ash_phoenix.gen.live_test.exs @@ -0,0 +1,309 @@ +defmodule Mix.Tasks.AshPhoenix.Gen.LiveTest do + use ExUnit.Case + import Igniter.Test + + setup do + current_shell = Mix.shell() + + :ok = Mix.shell(Mix.Shell.Process) + + on_exit(fn -> + Mix.shell(current_shell) + end) + end + + test "generate phoenix live views from resource" do + send(self(), {:mix_shell_input, :yes?, "n"}) + send(self(), {:mix_shell_input, :prompt, ""}) + + form_path = "lib/ash_phoenix_web/live/artist_live/form_component.ex" + + form_contents = + """ + defmodule AshPhoenixWeb.ArtistLive.FormComponent do + use AshPhoenixWeb, :live_component + + @impl true + def render(assigns) do + ~H\"\"\" +
+ <.header> + <%= @title %> + <:subtitle>Use this form to manage artist records in your database. + + + <.simple_form + for={@form} + id="artist-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save" + > + + + <.input field={@form[:name]} type="text" label="Name" /> + + + <:actions> + <.button phx-disable-with="Saving...">Save Artist + + +
+ \"\"\" + end + + @impl true + def update(assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> assign_form()} + end + + @impl true + def handle_event("validate", %{"artist" => artist_params}, socket) do + {:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, artist_params))} + end + + def handle_event("save", %{"artist" => artist_params}, socket) do + case AshPhoenix.Form.submit(socket.assigns.form, params: artist_params) do + {:ok, artist} -> + notify_parent({:saved, artist}) + + socket = + socket + |> put_flash(:info, "Artist \#{socket.assigns.form.source.type}d successfully") + |> push_patch(to: socket.assigns.patch) + + {:noreply, socket} + + {:error, form} -> + {:noreply, assign(socket, form: form)} + end + end + + defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) + + defp assign_form(%{assigns: %{artist: artist}} = socket) do + form = + if artist do + AshPhoenix.Form.for_update(artist, :update, + as: "artist", + actor: socket.assigns.current_user + ) + else + AshPhoenix.Form.for_create(AshPhoenix.Test.Artist, :create, + as: "artist", + actor: socket.assigns.current_user + ) + end + + assign(socket, form: to_form(form)) + end + end + """ + |> format_contents(form_path) + + index_path = "lib/ash_phoenix_web/live/artist_live/index.ex" + + index_contents = + """ + defmodule AshPhoenixWeb.ArtistLive.Index do + use AshPhoenixWeb, :live_view + + @impl true + def render(assigns) do + ~H\"\"\" + <.header> + Listing Artists + <:actions> + <.link patch={~p"/Artists/new"}> + <.button>New Artist + + + + + <.table + id="Artists" + rows={@streams.Artists} + row_click={fn {_id, artist} -> JS.navigate(~p"/Artists/\#{artist}") end} + > + <:col :let={{_id, artist}} label="Id"><%= artist.id %> + + <:col :let={{_id, artist}} label="Name"><%= artist.name %> + + <:action :let={{_id, artist}}> +
+ <.link navigate={~p"/Artists/\#{artist}"}>Show +
+ + <.link patch={~p"/Artists/\#{artist}/edit"}>Edit + + + <:action :let={{id, artist}}> + <.link + phx-click={JS.push("delete", value: %{id: artist.id}) |> hide("#\#{id}")} + data-confirm="Are you sure?" + > + Delete + + + + + <.modal + :if={@live_action in [:new, :edit]} + id="artist-modal" + show + on_cancel={JS.patch(~p"/Artists")} + > + <.live_component + module={AshPhoenixWeb.ArtistLive.FormComponent} + id={(@artist && @artist.id) || :new} + title={@page_title} + current_user={@current_user} + action={@live_action} + artist={@artist} + patch={~p"/Artists"} + /> + + \"\"\" + end + + @impl true + def mount(_params, _session, socket) do + {:ok, + socket + |> stream(:Artists, Ash.read!(AshPhoenix.Test.Artist, actor: socket.assigns[:current_user])) + |> assign_new(:current_user, fn -> nil end)} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :edit, %{"id" => id}) do + socket + |> assign(:page_title, "Edit Artist") + |> assign(:artist, Ash.get!(AshPhoenix.Test.Artist, id, actor: socket.assigns.current_user)) + end + + defp apply_action(socket, :new, _params) do + socket + |> assign(:page_title, "New Artist") + |> assign(:artist, nil) + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, "Listing Artists") + |> assign(:artist, nil) + end + + @impl true + def handle_info({AshPhoenixWeb.ArtistLive.FormComponent, {:saved, artist}}, socket) do + {:noreply, stream_insert(socket, :Artists, artist)} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + artist = Ash.get!(AshPhoenix.Test.Artist, id, actor: socket.assigns.current_user) + Ash.destroy!(artist, actor: socket.assigns.current_user) + + {:noreply, stream_delete(socket, :Artists, artist)} + end + end + """ + |> format_contents(index_path) + + show_path = "lib/ash_phoenix_web/live/artist_live/show.ex" + + show_contents = + """ + defmodule AshPhoenixWeb.ArtistLive.Show do + use AshPhoenixWeb, :live_view + + @impl true + def render(assigns) do + ~H\"\"\" + <.header> + Artist <%= @artist.id %> + <:subtitle>This is a artist record from your database. + + <:actions> + <.link patch={~p"/Artists/\#{@artist}/show/edit"} phx-click={JS.push_focus()}> + <.button>Edit artist + + + + + <.list> + <:item title="Id"><%= @artist.id %> + + <:item title="Name"><%= @artist.name %> + + + <.back navigate={~p"/Artists"}>Back to Artists + + <.modal + :if={@live_action == :edit} + id="artist-modal" + show + on_cancel={JS.patch(~p"/Artists/\#{@artist}")} + > + <.live_component + module={AshPhoenixWeb.ArtistLive.FormComponent} + id={@artist.id} + title={@page_title} + action={@live_action} + current_user={@current_user} + artist={@artist} + patch={~p"/Artists/\#{@artist}"} + /> + + \"\"\" + end + + @impl true + def mount(_params, _session, socket) do + {:ok, socket} + end + + @impl true + def handle_params(%{"id" => id}, _, socket) do + {:noreply, + socket + |> assign(:page_title, page_title(socket.assigns.live_action)) + |> assign(:artist, Ash.get!(AshPhoenix.Test.Artist, id, actor: socket.assigns.current_user))} + end + + defp page_title(:show), do: "Show Artist" + defp page_title(:edit), do: "Edit Artist" + end + """ + |> format_contents(show_path) + + assert Igniter.new() + |> Igniter.compose_task("ash_phoenix.gen.live", [ + "--domain", + "Elixir.AshPhoenix.Test.Domain", + "--resource", + "Elixir.AshPhoenix.Test.Artist", + "--resourceplural", + "Artists" + ]) + |> assert_creates( + form_path, + form_contents + ) + |> assert_creates(index_path, index_contents) + |> assert_creates(show_path, show_contents) + end + + defp format_contents(contents, path) do + {formatter_function, _options} = + Mix.Tasks.Format.formatter_for_file(path) + + formatter_function.(contents) + end +end