feat: support queries as form targets

fix: various fixes
fix: a whole new error paradigm
feat: new helpers in `AshPhoenix`
This commit is contained in:
Zach Daniel 2021-03-04 23:08:43 -05:00
parent cc814a5b71
commit c1d00e54ae
8 changed files with 374 additions and 107 deletions

View file

@ -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, []},

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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