diff --git a/.gitignore b/.gitignore index 44fba26034..943c1c1b7b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,13 @@ +# Intellij +.idea/ +installer/phx_new.iml +installer/templates/phx_single/phx_single.iml +installer/templates/phx_umbrella/apps/app_name/app_name.iml +installer/templates/phx_umbrella/apps/app_name_web/app_name_web.iml +installer/templates/phx_umbrella/phx_umbrella.iml +integration_test/phoenix_integration.iml +phoenix.iml + /_build/ /deps/ /doc/ diff --git a/lib/mix/phoenix/custom_generator_behavior.ex b/lib/mix/phoenix/custom_generator_behavior.ex new file mode 100644 index 0000000000..39024583ef --- /dev/null +++ b/lib/mix/phoenix/custom_generator_behavior.ex @@ -0,0 +1,82 @@ +defmodule Mix.Phoenix.CustomGeneratorBehaviour do + @moduledoc """ + This module defines the behavior for custom generators. + Implement modules that implement this behavior and use with mix phx.gen.live,phx.gen.schema,phx.gen.context to + extend default generators with project specific types and form components. + + For example if using a postgres user type enums you can use type_for_migration/1 to return the user type as an atom and + type_and_opts_for_schema/1 to return an Ecto.Enum, values: [] string + by implementing a custom generator along the lines of MyProject.CustomEnumGenerator passed to mix phx.gen.schema as + "field:MyProject.CustomEnumGenerator:custom_user_type:list:of:allowed:values" + """ + + @doc """ + Unpack custom generator and it's options. + Return {key, {:custom, __MODULE__, unpacked_options | nil}} + """ + @callback validate_attr!(attrs :: tuple) :: {name :: atom, {:custom, provider :: atom, opts :: any}} | {term, term} + + @doc """ + return the string that will be used to populate schema field e.g. a string like "Ecto.Enum, values: [:a,:b,:c]" + """ + @callback type_and_opts_for_schema(attrs :: any) :: String.t() + + @doc """ + return the ecto migration field type term. e.g. {:enum, [:a,:b,:c]} + """ + @callback type_for_migration(opts :: any) :: term + + @doc """ + return the default value for the field type used by live view and ecto tests. + """ + @callback type_to_default(key :: atom, opts :: any, action :: atom) :: any + + @doc """ + return the input/live component used to display this custom field type in a live view form. + """ + @callback live_form_input(key :: atom, opts :: any) :: String.t() | nil + + @doc """ + used for unpacking a complex type that requires serialization from one or more form params. + For example if your live_form_input routes to a live component that uses a hidden input field containing json that needs + to be unpacked before passing to the module's changeset. + return params if no special processing required. + """ + @callback hydrate_form_input(key :: atom, params :: Map.t, opts :: any) :: Map.t + + + @doc """ + Pass to behavior provider. @see validate_attr!/1 + """ + def validate_attr!(provider, attrs) do + apply(provider, :validate_attr!, [attrs]) + end + + @doc """ + Pass to behavior provider. @see type_and_opts_for_schema/1 + """ + def type_and_opts_for_schema(provider, opts) do + apply(provider, :type_and_opts_for_schema, [opts]) + end + + @doc """ + Pass to behavior provider. @see type_for_migration/1 + """ + def type_for_migration(provider, opts) do + apply(provider, :type_for_migration, [opts]) + end + + @doc """ + Pass to behavior provider. @see `type_to_default/3` + """ + def type_to_default(provider, key, opts, action) do + apply(provider, :type_to_default, [key, opts, action]) + end + + @doc """ + Pass to behavior provider. @see live_form_input/2 + """ + def live_form_input(provider, key, opts) do + apply(provider, :live_form_input, [key, opts]) + end +end diff --git a/lib/mix/phoenix/schema.ex b/lib/mix/phoenix/schema.ex index f77af333c2..528863e9a3 100644 --- a/lib/mix/phoenix/schema.ex +++ b/lib/mix/phoenix/schema.ex @@ -247,6 +247,7 @@ defmodule Mix.Phoenix.Schema do end def type_for_migration({:enum, _}), do: :string + def type_for_migration({:custom, provider, opts}), do: Mix.Phoenix.CustomGeneratorBehaviour.type_for_migration(provider, opts) def type_for_migration(other), do: other def format_fields_for_schema(schema) do @@ -265,7 +266,7 @@ defmodule Mix.Phoenix.Schema do def type_and_opts_for_schema({:enum, opts}), do: ~s|Ecto.Enum, values: #{inspect(Keyword.get(opts, :values))}| - + def type_and_opts_for_schema({:custom, provider, opts}), do: Mix.Phoenix.CustomGeneratorBehaviour.type_and_opts_for_schema(provider, opts) def type_and_opts_for_schema(other), do: inspect(other) def maybe_redact_field(true), do: ", redact: true" @@ -352,6 +353,9 @@ defmodule Mix.Phoenix.Schema do :naive_datetime_usec -> NaiveDateTime.add(build_utc_naive_datetime_usec(), -@one_day_in_seconds) + {:custom, provider, opts} -> + Mix.Phoenix.CustomGeneratorBehaviour.type_to_default(provider, key, opts, :create) + _ -> "some #{key}" end @@ -375,6 +379,8 @@ defmodule Mix.Phoenix.Schema do :utc_datetime_usec -> build_utc_datetime_usec() :naive_datetime -> build_utc_naive_datetime() :naive_datetime_usec -> build_utc_naive_datetime_usec() + {:custom, provider, opts} -> + Mix.Phoenix.CustomGeneratorBehaviour.type_to_default(provider, key, opts, :update) _ -> "some updated #{key}" end end @@ -436,12 +442,21 @@ defmodule Mix.Phoenix.Schema do defp validate_attr!({_name, type} = attr) when type in @valid_types, do: attr defp validate_attr!({_name, {:enum, _vals}} = attr), do: attr defp validate_attr!({_name, {type, _}} = attr) when type in @valid_types, do: attr - - defp validate_attr!({_, type}) do - Mix.raise( - "Unknown type `#{inspect(type)}` given to generator. " <> - "The supported types are: #{@valid_types |> Enum.sort() |> Enum.join(", ")}" - ) + defp validate_attr!({name, {type, opts}}) do + if Kernel.function_exported?(:"Elixir.#{type}", :validate_attr!, 1) do + Mix.Phoenix.CustomGeneratorBehaviour.validate_attr!(:"Elixir.#{type}", {name, :"Elixir.#{type}", opts}) + else + Mix.raise("Unknown type `#{inspect(type)}` given to generator. " <> + "The supported types are: #{@valid_types |> Enum.sort() |> Enum.join(", ")}") + end + end + defp validate_attr!({name, type}) do + if Kernel.function_exported?(:"Elixir.#{type}", :validate_attr!, 1) do + Mix.Phoenix.CustomGeneratorBehaviour.validate_attr!(:"Elixir.#{type}", {name, :"Elixir.#{type}", []}) + else + Mix.raise("Unknown type `#{inspect(type)}` given to generator. " <> + "The supported types are: #{@valid_types |> Enum.sort() |> Enum.join(", ")}") + end end defp partition_attrs_and_assocs(schema_module, attrs) do diff --git a/lib/mix/tasks/phx.gen.live.ex b/lib/mix/tasks/phx.gen.live.ex index 825b27190a..57e65059aa 100644 --- a/lib/mix/tasks/phx.gen.live.ex +++ b/lib/mix/tasks/phx.gen.live.ex @@ -326,9 +326,12 @@ defmodule Mix.Tasks.Phx.Gen.Live do /> """ + {key, {:custom, provider, opts}} -> + Mix.Phoenix.CustomGeneratorBehaviour.live_form_input(provider, key, opts) + {key, _} -> ~s(<.input field={@form[#{inspect(key)}]} type="text" label="#{label(key)}" />) - end) + end) |> Enum.reject(&is_nil/1) end defp default_options({:array, :string}), diff --git a/priv/templates/phx.gen.live/form_component.ex b/priv/templates/phx.gen.live/form_component.ex index 52cf6e85b4..65fb8509a1 100644 --- a/priv/templates/phx.gen.live/form_component.ex +++ b/priv/templates/phx.gen.live/form_component.ex @@ -40,6 +40,18 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web @impl true def handle_event("validate", %{"<%= schema.singular %>" => <%= schema.singular %>_params}, socket) do + <%= + Enum.filter(schema.types, + fn + {_, {:custom, _, _}} -> true + _ -> false + end) + |> Enum.map_join("\n ", + fn + {key, {:custom, provider, opts}} -> + "#{ schema.singular }_params = #{inspect provider}.hydrate_form_input(#{inspect key}, #{ schema.singular }_params, #{inspect opts, limit: :infinity})" + end) + %> changeset = socket.assigns.<%= schema.singular %> |> <%= inspect context.alias %>.change_<%= schema.singular %>(<%= schema.singular %>_params) @@ -49,6 +61,18 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web end def handle_event("save", %{"<%= schema.singular %>" => <%= schema.singular %>_params}, socket) do + <%= + Enum.filter(schema.types, + fn + {_, {:custom, _, _}} -> true + _ -> false + end) + |> Enum.map_join("\n ", + fn + {key, {:custom, provider, opts}} -> + "#{ schema.singular }_params = #{inspect provider}.hydrate_form_input(#{inspect key}, #{ schema.singular }_params, #{inspect opts, limit: :infinity})" + end) + %> save_<%= schema.singular %>(socket, socket.assigns.action, <%= schema.singular %>_params) end diff --git a/test/mix/tasks/phx.gen.live_test.exs b/test/mix/tasks/phx.gen.live_test.exs index 20108f396d..dc5e046d73 100644 --- a/test/mix/tasks/phx.gen.live_test.exs +++ b/test/mix/tasks/phx.gen.live_test.exs @@ -1,10 +1,38 @@ Code.require_file "../../../installer/test/mix_helper.exs", __DIR__ + +defmodule Phoenix.Test.LiveCustomGen do + @behaviour Mix.Phoenix.CustomGeneratorBehaviour + def validate_attr!({name, __MODULE__, opts}) do + {name, {:custom, __MODULE__, opts}} + end + def type_and_opts_for_schema(_opts) do + ~s|:custom_schema_type| + end + def type_for_migration(_opts) do + :custom_ecto_type + end + def type_to_default(key, _opts, :create) do + "Special #{key}" + end + def type_to_default(key, _opts, :update) do + "Special Updated #{key}" + end + def live_form_input(key, _opts) do + "[SPECIAL INPUT HANDLER: #{key}]" + end + def hydrate_form_input(_key, params, _opts), do: params +end + + defmodule Mix.Tasks.Phx.Gen.LiveTest do use ExUnit.Case import MixHelper alias Mix.Tasks.Phx.Gen + @moduletag feature: :gen + @moduletag gen: :live + setup do Mix.Task.clear() :ok @@ -29,6 +57,142 @@ defmodule Mix.Tasks.Phx.Gen.LiveTest do end) end + + describe "custom schema generator support" do + test "generate", config do + in_tmp_live_project config.test, fn -> + Gen.Live.run(~w(Blog Post posts title:Phoenix.Test.LiveCustomGen slug:unique votes:integer cost:decimal + tags:array:text popular:boolean drafted_at:datetime + status:enum:unpublished:published:deleted + published_at:utc_datetime + published_at_usec:utc_datetime_usec + deleted_at:naive_datetime + deleted_at_usec:naive_datetime_usec + alarm:time + alarm_usec:time_usec + secret:uuid:redact announcement_date:date alarm:time + metadata:map + weight:float user_id:references:users)) + + assert_file "lib/phoenix/blog/post.ex" + assert_file "lib/phoenix/blog.ex" + assert_file "test/phoenix/blog_test.exs" + + assert_file "lib/phoenix_web/live/post_live/index.ex", fn file -> + assert file =~ "defmodule PhoenixWeb.PostLive.Index" + end + + assert_file "lib/phoenix_web/live/post_live/show.ex", fn file -> + assert file =~ "defmodule PhoenixWeb.PostLive.Show" + end + + assert_file "lib/phoenix_web/live/post_live/form_component.ex", fn file -> + assert file =~ "defmodule PhoenixWeb.PostLive.FormComponent" + end + + assert [path] = Path.wildcard("priv/repo/migrations/*_create_posts.exs") + assert_file path, fn file -> + assert file =~ "create table(:posts)" + assert file =~ "add :title, :custom_ecto_type" + assert file =~ "create unique_index(:posts, [:slug])" + end + + assert_file "lib/phoenix_web/live/post_live/index.html.heex", fn file -> + assert file =~ ~S|~p"/posts"| + end + + assert_file "lib/phoenix_web/live/post_live/show.html.heex", fn file -> + assert file =~ ~S|~p"/posts"| + end + + assert_file "lib/phoenix_web/live/post_live/form_component.ex", fn file -> + assert file =~ ~s(<.simple_form) + assert file =~ ~s([SPECIAL INPUT HANDLER: title]) + assert file =~ ~s(<.input field={@form[:votes]} type="number") + assert file =~ ~s(<.input field={@form[:cost]} type="number" label="Cost" step="any") + assert file =~ """ + <.input + field={@form[:tags]} + type="select" + multiple + """ + assert file =~ ~s(<.input field={@form[:popular]} type="checkbox") + assert file =~ ~s(<.input field={@form[:drafted_at]} type="datetime-local") + assert file =~ ~s(<.input field={@form[:published_at]} type="datetime-local") + assert file =~ ~s(<.input field={@form[:deleted_at]} type="datetime-local") + assert file =~ ~s(<.input field={@form[:announcement_date]} type="date") + assert file =~ ~s(<.input field={@form[:alarm]} type="time") + assert file =~ ~s(<.input field={@form[:secret]} type="text" label="Secret" />) + refute file =~ ~s( + assert file =~ ~r"@invalid_attrs.*popular: false" + assert file =~ ~S|~p"/posts"| + assert file =~ ~S|~p"/posts/new"| + assert file =~ ~S|~p"/posts/#{post}"| + assert file =~ ~S|~p"/posts/#{post}/show/edit"| + end + + send self(), {:mix_shell_input, :yes?, true} + Gen.Live.run(~w(Blog Comment comments title:string)) + assert_received {:mix_shell, :info, ["You are generating into an existing context" <> _]} + + assert_file "lib/phoenix/blog/comment.ex" + assert_file "test/phoenix_web/live/comment_live_test.exs", fn file -> + assert file =~ "defmodule PhoenixWeb.CommentLiveTest" + end + + assert [path] = Path.wildcard("priv/repo/migrations/*_create_comments.exs") + assert_file path, fn file -> + assert file =~ "create table(:comments)" + assert file =~ "add :title, :string" + end + + assert_file "lib/phoenix_web/live/comment_live/index.ex", fn file -> + assert file =~ "defmodule PhoenixWeb.CommentLive.Index" + end + + assert_file "lib/phoenix_web/live/comment_live/show.ex", fn file -> + assert file =~ "defmodule PhoenixWeb.CommentLive.Show" + end + + assert_file "lib/phoenix_web/live/comment_live/form_component.ex", fn file -> + assert file =~ "defmodule PhoenixWeb.CommentLive.FormComponent" + end + + assert_receive {:mix_shell, :info, [""" + + Add the live routes to your browser scope in lib/phoenix_web/router.ex: + + live "/comments", CommentLive.Index, :index + live "/comments/new", CommentLive.Index, :new + live "/comments/:id/edit", CommentLive.Index, :edit + + live "/comments/:id", CommentLive.Show, :show + live "/comments/:id/show/edit", CommentLive.Show, :edit + """]} + + assert_receive({:mix_shell, :info, [""" + + You must update :phoenix_live_view to v0.18 or later and + :phoenix_live_dashboard to v0.7 or later to use the features + in this generator. + """]}) + end + end + end + test "invalid mix arguments", config do in_tmp_live_project config.test, fn -> assert_raise Mix.Error, ~r/Expected the context, "blog", to be a valid module name/, fn -> diff --git a/test/mix/tasks/phx.gen.schema_test.exs b/test/mix/tasks/phx.gen.schema_test.exs index ea267f9274..feed83b7b5 100644 --- a/test/mix/tasks/phx.gen.schema_test.exs +++ b/test/mix/tasks/phx.gen.schema_test.exs @@ -3,17 +3,89 @@ Code.require_file "../../../installer/test/mix_helper.exs", __DIR__ defmodule Phoenix.DupSchema do end +defmodule Phoenix.Test.CustomGen do + @behaviour Mix.Phoenix.CustomGeneratorBehaviour + def validate_attr!({name, __MODULE__, opts}) do + {name, {:custom, __MODULE__, opts}} + end + def type_and_opts_for_schema(_opts) do + ~s|:custom_schema_type| + end + def type_for_migration(_opts) do + :custom_ecto_type + end + def type_to_default(key, _opts, :create) do + "Special #{key}" + end + def type_to_default(key, _opts, :update) do + "Special Updated #{key}" + end + def live_form_input(key, _opts) do + "[SPECIAL INPUT HANDLER: #{key}]" + end + def hydrate_form_input(_key, params, _opts), do: params +end + defmodule Mix.Tasks.Phx.Gen.SchemaTest do use ExUnit.Case import MixHelper alias Mix.Tasks.Phx.Gen alias Mix.Phoenix.Schema + @moduletag feature: :gen + @moduletag gen: :schema + setup do Mix.Task.clear() :ok end + describe "custom schema generator support" do + + + test "build with custom gen extension" do + in_tmp_project "build", fn -> + schema = Gen.Schema.build(~w(Blog.Post posts title:Phoenix.Test.CustomGen:option:list tags:map), []) + + assert %Schema{ + alias: Post, + module: Phoenix.Blog.Post, + repo: Phoenix.Repo, + migration?: true, + migration_defaults: %{title: ""}, + plural: "posts", + singular: "post", + human_plural: "Posts", + human_singular: "Post", + attrs: [title: {:custom, Phoenix.Test.CustomGen, :"option:list"}, tags: :map], + types: %{title: {:custom, Phoenix.Test.CustomGen, :"option:list"}, tags: :map}, + optionals: [:tags], + route_helper: "post", + defaults: %{title: "", tags: ""}, + } = schema + assert String.ends_with?(schema.file, "lib/phoenix/blog/post.ex") + end + end + + test "generates schema with custom gen extension", config do + in_tmp_project config.test, fn -> + Gen.Schema.run(~w(Blog.CustomPost custom_blog_posts title:Phoenix.Test.CustomGen)) + + assert [migration] = Path.wildcard("priv/repo/migrations/*_create_custom_blog_posts.exs") + assert_file migration, fn file -> + assert file =~ "defmodule Phoenix.Repo.Migrations.CreateCustomBlogPosts do" + assert file =~ "create table(:custom_blog_posts) do" + assert file =~ "add :title, :custom_ecto_type" + end + + assert_file "lib/phoenix/blog/custom_post.ex", fn file -> + assert file =~ "defmodule Phoenix.Blog.CustomPost do" + assert file =~ "field :title, :custom_schema_type" + end + end + end + end + test "build" do in_tmp_project "build", fn -> schema = Gen.Schema.build(~w(Blog.Post posts title:string tags:map), [])