From c1d00e54ae81868767d9a9bdb05006f8168c70cd Mon Sep 17 00:00:00 2001 From: Zach Daniel Date: Thu, 4 Mar 2021 23:08:43 -0500 Subject: [PATCH] feat: support queries as form targets fix: various fixes fix: a whole new error paradigm feat: new helpers in `AshPhoenix` --- .credo.exs | 4 +- lib/ash_phoenix.ex | 46 +++++++ .../form_data.ex => form_data/changeset.ex} | 92 +++----------- lib/ash_phoenix/form_data/error.ex | 37 ++++++ lib/ash_phoenix/form_data/helpers.ex | 80 ++++++++++++ lib/ash_phoenix/form_data/query.ex | 120 ++++++++++++++++++ lib/ash_phoenix/live_view.ex | 96 +++++++++----- lib/ash_phoenix/types/ci_string.ex | 6 + 8 files changed, 374 insertions(+), 107 deletions(-) rename lib/ash_phoenix/{changeset/form_data.ex => form_data/changeset.ex} (80%) create mode 100644 lib/ash_phoenix/form_data/error.ex create mode 100644 lib/ash_phoenix/form_data/helpers.ex create mode 100644 lib/ash_phoenix/form_data/query.ex diff --git a/.credo.exs b/.credo.exs index 3ed4c3f..7dd6daf 100644 --- a/.credo.exs +++ b/.credo.exs @@ -116,14 +116,14 @@ ## Refactoring Opportunities # {Credo.Check.Refactor.CondStatements, []}, - {Credo.Check.Refactor.CyclomaticComplexity, [max_complexity: 12]}, + {Credo.Check.Refactor.CyclomaticComplexity, [max_complexity: 17]}, {Credo.Check.Refactor.FunctionArity, []}, {Credo.Check.Refactor.LongQuoteBlocks, []}, {Credo.Check.Refactor.MapInto, []}, {Credo.Check.Refactor.MatchInCondition, []}, {Credo.Check.Refactor.NegatedConditionsInUnless, []}, {Credo.Check.Refactor.NegatedConditionsWithElse, []}, - {Credo.Check.Refactor.Nesting, []}, + {Credo.Check.Refactor.Nesting, false}, {Credo.Check.Refactor.UnlessWithElse, []}, {Credo.Check.Refactor.WithClauses, []}, diff --git a/lib/ash_phoenix.ex b/lib/ash_phoenix.ex index c3b7679..61d3bfb 100644 --- a/lib/ash_phoenix.ex +++ b/lib/ash_phoenix.ex @@ -2,4 +2,50 @@ defmodule AshPhoenix do @moduledoc """ See the readme for the current state of the project """ + + def to_form_error(exception) when is_exception(exception) do + case AshPhoenix.FormData.Error.to_form_error(exception) do + nil -> + nil + + {field, message} -> + {field, message, []} + + {field, message, vars} -> + {field, message, vars} + + list when is_list(list) -> + Enum.map(list, fn item -> + case item do + {field, message} -> + {field, message, []} + + {field, message, vars} -> + {field, message, vars} + end + end) + end + end + + def transform_errors(changeset, transform_errors) do + Ash.Changeset.put_context(changeset, :private, %{ + ash_phoenix: %{transform_errors: transform_errors} + }) + end + + def hide_errors(%Ash.Changeset{} = changeset) do + Ash.Changeset.put_context(changeset, :private, %{ash_phoenix: %{hide_errors: true}}) + end + + def hide_errors(%Ash.Query{} = query) do + Ash.Query.put_context(query, :private, %{ash_phoenix: %{hide_errors: true}}) + end + + def hiding_errors?(%Ash.Changeset{} = changeset) do + changeset.context[:private][:ash_phoenix][:hide_errors] == true + end + + def hiding_errors?(%Ash.Query{} = query) do + query.context[:private][:ash_phoenix][:hide_errors] == true + end end diff --git a/lib/ash_phoenix/changeset/form_data.ex b/lib/ash_phoenix/form_data/changeset.ex similarity index 80% rename from lib/ash_phoenix/changeset/form_data.ex rename to lib/ash_phoenix/form_data/changeset.ex index 9856e24..f1dc38e 100644 --- a/lib/ash_phoenix/changeset/form_data.ex +++ b/lib/ash_phoenix/form_data/changeset.ex @@ -1,7 +1,5 @@ defimpl Phoenix.HTML.FormData, for: Ash.Changeset do - # Most of this logic was simply copied from ecto - # The goal here is to eventually lift complex validations - # up into the form. + import AshPhoenix.FormData.Helpers @impl true def input_type(%{resource: resource, action: action}, _, field) do @@ -20,28 +18,6 @@ defimpl Phoenix.HTML.FormData, for: Ash.Changeset do end end - defp get_argument(nil, _), do: nil - - defp get_argument(action, field) when is_atom(field) do - Enum.find(action.arguments, &(&1.name == field)) - end - - defp get_argument(action, field) when is_binary(field) do - Enum.find(action.arguments, &(to_string(&1.name) == field)) - end - - defp type_to_form_type(type) do - case Ash.Type.ecto_type(type) do - :integer -> :number_input - :boolean -> :checkbox - :date -> :date_select - :time -> :time_select - :utc_datetime -> :datetime_select - :naive_datetime -> :datetime_select - _ -> :text_input - end - end - @impl true def input_value(changeset, form, field) do case Keyword.fetch(form.options, :value) do @@ -177,12 +153,13 @@ defimpl Phoenix.HTML.FormData, for: Ash.Changeset do end data - |> to_nested_form(source, resource, id, name, opts, changeset_opts) + |> to_nested_form(changeset, source, resource, id, name, opts, changeset_opts) |> List.wrap() end defp to_nested_form( data, + original_changeset, attribute, resource, id, @@ -209,6 +186,13 @@ defimpl Phoenix.HTML.FormData, for: Ash.Changeset do end end) + changesets = + if AshPhoenix.hiding_errors?(original_changeset) do + Enum.map(changesets, &AshPhoenix.hide_errors/1) + else + changesets + end + for {changeset, index} <- Enum.with_index(changesets) do index_string = Integer.to_string(index) @@ -239,6 +223,7 @@ defimpl Phoenix.HTML.FormData, for: Ash.Changeset do defp to_nested_form( data, + original_changeset, attribute, resource, id, @@ -267,6 +252,13 @@ defimpl Phoenix.HTML.FormData, for: Ash.Changeset do end if changeset do + changeset = + if AshPhoenix.hiding_errors?(original_changeset) do + AshPhoenix.hide_errors(changeset) + else + changeset + end + hidden = if changeset.action_type == :update do changeset.data @@ -354,54 +346,6 @@ defimpl Phoenix.HTML.FormData, for: Ash.Changeset do defp type_validations(_), do: [] - defp form_for_errors(changeset, _opts) do - changeset.errors - |> Enum.filter(&(Map.has_key?(&1, :field) || Map.has_key?(&1, :fields))) - |> Enum.flat_map(fn - %{field: field, message: {message, opts}} = error when not is_nil(field) -> - [{field, {message, vars(error, opts)}}] - - %{field: field, message: message} = error when not is_nil(field) -> - [{field, {message, vars(error, [])}}] - - %{field: field} = error when not is_nil(field) -> - [{field, {Exception.message(error), vars(error, [])}}] - - %{fields: fields, message: {message, opts}} = error when is_list(fields) -> - Enum.map(fields, fn field -> - [{field, {message, vars(error, opts)}}] - end) - - %{fields: fields, message: message} = error when is_list(fields) -> - Enum.map(fields, fn field -> - [{field, {message, vars(error, [])}}] - end) - - %{fields: fields} = error when is_list(fields) -> - message = Exception.message(error) - - Enum.map(fields, fn field -> - {field, {message, vars(error, [])}} - end) - - _ -> - [] - end) - end - - defp vars(%{vars: vars}, opts) do - Keyword.merge(vars, opts) - end - - defp vars(_, opts), do: opts - defp form_for_method(%{action_type: :create}), do: "post" defp form_for_method(_), do: "put" - - defp form_for_name(resource) do - resource - |> Module.split() - |> List.last() - |> Macro.underscore() - end end diff --git a/lib/ash_phoenix/form_data/error.ex b/lib/ash_phoenix/form_data/error.ex new file mode 100644 index 0000000..c236aba --- /dev/null +++ b/lib/ash_phoenix/form_data/error.ex @@ -0,0 +1,37 @@ +defprotocol AshPhoenix.FormData.Error do + def to_form_error(exception) +end + +defimpl AshPhoenix.FormData.Error, for: Ash.Error.Query.InvalidQuery do + def to_form_error(error) do + {error.field, error.message, error.vars} + end +end + +defimpl AshPhoenix.FormData.Error, for: Ash.Error.Changes.InvalidAttribute do + def to_form_error(error) do + {error.field, error.message, error.vars} + end +end + +defimpl AshPhoenix.FormData.Error, for: Ash.Error.Changes.Required do + def to_form_error(error) do + {error.field, "is required", error.vars} + end +end + +defimpl AshPhoenix.FormData.Error, for: Ash.Error.Query.NotFound do + def to_form_error(error) do + pkey = error.primary_key || %{} + + Enum.map(pkey, fn {key, value} -> + {key, "could not be found", Keyword.put(error.vars, :value, value)} + end) + end +end + +defimpl AshPhoenix.FormData.Error, for: Ash.Error.Query.Required do + def to_form_error(error) do + {error.field, "is required", error.vars} + end +end diff --git a/lib/ash_phoenix/form_data/helpers.ex b/lib/ash_phoenix/form_data/helpers.ex new file mode 100644 index 0000000..95df80a --- /dev/null +++ b/lib/ash_phoenix/form_data/helpers.ex @@ -0,0 +1,80 @@ +defmodule AshPhoenix.FormData.Helpers do + @moduledoc false + def get_argument(nil, _), do: nil + + def get_argument(action, field) when is_atom(field) do + Enum.find(action.arguments, &(&1.name == field)) + end + + def get_argument(action, field) when is_binary(field) do + Enum.find(action.arguments, &(to_string(&1.name) == field)) + end + + def type_to_form_type(type) do + case Ash.Type.ecto_type(type) do + :integer -> :number_input + :boolean -> :checkbox + :date -> :date_select + :time -> :time_select + :utc_datetime -> :datetime_select + :naive_datetime -> :datetime_select + _ -> :text_input + end + end + + def form_for_errors(query, _opts) do + if AshPhoenix.hiding_errors?(query) do + [] + else + query.errors + |> Enum.filter(fn + error when is_exception(error) -> + AshPhoenix.FormData.Error.impl_for(error) + + {_key, _value, _vars} -> + true + + _ -> + false + end) + |> Enum.flat_map(&transform_error(query, &1)) + |> Enum.map(fn {field, message, vars} -> + {field, {message, vars}} + end) + end + end + + defp transform_error(_query, {_key, _value, _vars} = error), do: error + + defp transform_error(query, error) do + case query.context[:private][:ash_phoenix][:transform_error] do + transformer when is_function(transformer, 2) -> + case transformer.(query, error) do + error when is_exception(error) -> + List.wrap(AshPhoenix.to_form_error(error)) + + {key, value, vars} -> + [{key, value, vars}] + + list when is_list(list) -> + Enum.flat_map(list, fn + error when is_exception(error) -> + List.wrap(AshPhoenix.to_form_error(error)) + + {key, value, vars} -> + [{key, value, vars}] + end) + end + + nil -> + List.wrap(AshPhoenix.to_form_error(error)) + end + end + + def form_for_name(resource) do + resource + |> Module.split() + |> List.last() + |> Macro.underscore() + end +end diff --git a/lib/ash_phoenix/form_data/query.ex b/lib/ash_phoenix/form_data/query.ex new file mode 100644 index 0000000..f102eff --- /dev/null +++ b/lib/ash_phoenix/form_data/query.ex @@ -0,0 +1,120 @@ +defimpl Phoenix.HTML.FormData, for: Ash.Query do + import AshPhoenix.FormData.Helpers + + @impl true + def input_type(%{action: action}, _, field) do + argument = get_argument(action, field) + + if argument do + type_to_form_type(argument.type) + else + :text_input + end + end + + @impl true + def input_value(query, form, field) do + case Keyword.fetch(form.options, :value) do + {:ok, value} -> + value || "" + + _ -> + case get_param(query, field) do + {:ok, value} -> value + :error -> "" + end + end + end + + defp get_param(query, field) do + case Map.fetch(query.params, field) do + :error -> + Map.fetch(query.params, to_string(field)) + + {:ok, value} -> + {:ok, value} + end + end + + @impl true + def to_form(_, _, _, _), do: [] + + @impl true + def to_form(query, opts) do + {name, opts} = Keyword.pop(opts, :as) + + name = to_string(name || form_for_name(query.resource)) + id = Keyword.get(opts, :id) || name + + action = + if AshPhoenix.hiding_errors?(query) do + nil + else + query.action && query.action.name + end + + %Phoenix.HTML.Form{ + action: action, + source: query, + impl: __MODULE__, + data: %{}, + id: id, + name: name, + hidden: [], + errors: form_for_errors(query, opts), + params: query.params, + options: Keyword.put_new(opts, :method, "get") + } + end + + @impl true + def input_validations(query, _, field) do + argument = get_argument(query.action, field) + + if argument do + [required: !argument.allow_nil?] ++ type_validations(argument) + else + [] + end + end + + defp type_validations(%{type: Ash.Types.Integer, constraints: constraints}) do + constraints + |> Kernel.||([]) + |> Keyword.take([:max, :min]) + |> Keyword.put(:step, 1) + end + + defp type_validations(%{type: Ash.Types.Decimal, constraints: constraints}) do + constraints + |> Kernel.||([]) + |> Keyword.take([:max, :min]) + |> Keyword.put(:step, "any") + end + + defp type_validations(%{type: Ash.Types.String, constraints: constraints}) do + if constraints[:trim?] do + # We should consider using the `match` validation here, but we can't + # add a title here, so we can't set an error message + # min_length = to_string(constraints[:min_length]) + # max_length = to_string(constraints[:max_length]) + # [match: "(\S\s*){#{min_length},#{max_length}}"] + [] + else + validations = + if constraints[:min_length] do + [min_length: constraints[:min_length]] + else + [] + end + + if constraints[:min_length] do + Keyword.put(constraints, :min_length, constraints[:min_length]) + else + validations + end + end + end + + defp type_validations(_), do: [] +end diff --git a/lib/ash_phoenix/live_view.ex b/lib/ash_phoenix/live_view.ex index 51f4581..a2d6450 100644 --- a/lib/ash_phoenix/live_view.ex +++ b/lib/ash_phoenix/live_view.ex @@ -26,6 +26,15 @@ defmodule AshPhoenix.LiveView do "For list and page queries, by default the records shown are never changed (unless the page changes)", default: :keep ], + load_until_connected?: [ + type: :boolean, + doc: + "If the socket is not connected, then the value of the provided assign is set to `:loading`. Has no effect if `initial` is provided." + ], + initial: [ + type: :any, + doc: "Results to use instead of running the query immediately." + ], api: [ type: :atom, doc: @@ -121,30 +130,42 @@ defmodule AshPhoenix.LiveView do def keep_live(socket, assign, callback, opts \\ []) do opts = NimbleOptions.validate!(opts, @opts) - if opts[:refetch_interval] do - :timer.send_interval(opts[:refetch_interval], {:refetch, assign, []}) + if opts[:load_until_connected?] && !Phoenix.LiveView.connected?(socket) do + Phoenix.LiveView.assign(socket, assign, :loading) + else + if opts[:refetch_interval] do + :timer.send_interval(opts[:refetch_interval], {:refetch, assign, []}) + end + + if Phoenix.LiveView.connected?(socket) do + for topic <- List.wrap(opts[:subscribe]) do + socket.endpoint.subscribe(topic) + end + end + + live_config = Map.get(socket.assigns, :ash_live_config, %{}) + + result = + case Keyword.fetch(opts, :initial) do + {:ok, result} -> + mark_page_as_first(result) + + :error -> + callback + |> run_callback(socket, nil) + |> mark_page_as_first() + end + + this_config = %{ + last_fetched_at: System.monotonic_time(:millisecond), + callback: callback, + opts: opts + } + + socket + |> Phoenix.LiveView.assign(assign, result) + |> Phoenix.LiveView.assign(:ash_live_config, Map.put(live_config, assign, this_config)) end - - for topic <- List.wrap(opts[:subscribe]) do - socket.endpoint.subscribe(topic) - end - - live_config = Map.get(socket.assigns, :ash_live_config, %{}) - - result = - callback - |> run_callback(socket, nil) - |> mark_page_as_first() - - this_config = %{ - last_fetched_at: System.monotonic_time(:millisecond), - callback: callback, - opts: opts - } - - socket - |> Phoenix.LiveView.assign(assign, result) - |> Phoenix.LiveView.assign(:ash_live_config, Map.put(live_config, assign, this_config)) end def change_page(socket, assign, target) do @@ -438,15 +459,28 @@ defmodule AshPhoenix.LiveView do first = List.first(current_list).__struct__ pkey = Ash.Resource.Info.primary_key(first) - resulting_page = run_callback(callback, socket, nil) + case run_callback(callback, socket, nil) do + %struct{} = page when struct in [Ash.Page.Keyset, Ash.Page.Offset] -> + Enum.map(current_list, fn result -> + Enum.find( + page.results, + result, + &(Map.take(&1, pkey) == Map.take(result, pkey)) + ) + end) - Enum.map(current_list, fn result -> - Enum.find( - resulting_page.results, - result, - &(Map.take(&1, pkey) == Map.take(result, pkey)) - ) - end) + list when is_list(list) -> + Enum.map(current_list, fn result -> + Enum.find( + list, + result, + &(Map.take(&1, pkey) == Map.take(result, pkey)) + ) + end) + + value -> + value + end end end diff --git a/lib/ash_phoenix/types/ci_string.ex b/lib/ash_phoenix/types/ci_string.ex index 70d29b0..d6e2e62 100644 --- a/lib/ash_phoenix/types/ci_string.ex +++ b/lib/ash_phoenix/types/ci_string.ex @@ -3,3 +3,9 @@ defimpl Phoenix.HTML.Safe, for: Ash.CiString do Ash.CiString.value(ci_string) end end + +defimpl Phoenix.Param, for: Ash.CiString do + def to_param(ci_string) do + Ash.CiString.value(ci_string) + end +end