mirror of
https://github.com/ash-project/ash_phoenix.git
synced 2024-09-20 07:12:49 +12:00
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:
parent
cc814a5b71
commit
c1d00e54ae
8 changed files with 374 additions and 107 deletions
|
@ -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, []},
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
37
lib/ash_phoenix/form_data/error.ex
Normal file
37
lib/ash_phoenix/form_data/error.ex
Normal 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
|
80
lib/ash_phoenix/form_data/helpers.ex
Normal file
80
lib/ash_phoenix/form_data/helpers.ex
Normal 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
|
120
lib/ash_phoenix/form_data/query.ex
Normal file
120
lib/ash_phoenix/form_data/query.ex
Normal 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
|
|
@ -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,20 +130,31 @@ defmodule AshPhoenix.LiveView do
|
|||
def keep_live(socket, assign, callback, opts \\ []) do
|
||||
opts = NimbleOptions.validate!(opts, @opts)
|
||||
|
||||
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),
|
||||
|
@ -146,6 +166,7 @@ defmodule AshPhoenix.LiveView do
|
|||
|> Phoenix.LiveView.assign(assign, result)
|
||||
|> Phoenix.LiveView.assign(:ash_live_config, Map.put(live_config, assign, this_config))
|
||||
end
|
||||
end
|
||||
|
||||
def change_page(socket, assign, target) do
|
||||
live_config = socket.assigns.ash_live_config
|
||||
|
@ -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(
|
||||
resulting_page.results,
|
||||
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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue