improvement: first edition of auto forms

This commit is contained in:
Zach Daniel 2021-07-16 16:50:36 -04:00
parent 1eee011e26
commit defbf581dc
11 changed files with 1222 additions and 1994 deletions

View file

@ -1,89 +1,23 @@
defmodule AshPhoenix do
@moduledoc """
Various helpers and utilities for working with Ash changesets and queries and phoenix.
General helpers for AshPhoenix.
These will be deprecated at some point, once the work on `AshPhoenix.Form` is complete.
"""
import AshPhoenix.FormData.Helpers
@doc false
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
def hide_errors(%Ash.Changeset{} = changeset) do
Ash.Changeset.put_context(changeset, :private, %{ash_phoenix: %{hide_errors: true}})
end
@doc """
Allows for manually transforming errors to modify or enable error messages in the form.
By default, only errors that implement the `AshPhoenix.FormData.Error` protocol will show
their errors in forms. This is to protect you from showing strange errors to the user. Using
this function, you can intercept those errors (as well as ones that *do* implement the protocol)
and return custom form-ready messages for them.
Example:
```elixir
AshPhoenix.transform_errors(changeset, fn changeset, %MyApp.CustomError{message: message} ->
{:id, "Something went wrong while doing the %{thing}", [thing: "request"]}
end)
# Could potentially be used for translation, although not quite ergonomic yet
defp translate_error(key, msg, vars) do
if vars[:count] do
Gettext.dngettext(MyApp.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(MyApp.Gettext, "errors", msg, opts)
end
def hide_errors(%Ash.Query{} = query) do
Ash.Query.put_context(query, :private, %{ash_phoenix: %{hide_errors: true}})
end
AshPhoenix.transform_errors(changeset, fn
changeset, %MyApp.CustomError{message: message, field: field} ->
translate_error(field, message, [foo: :bar])
changeset, any_error ->
if AshPhoenix.FormData.Error.impl_for(any_error) do
any_error
|> AshPhoenix.FormData.error.to_form_error()
|> List.wrap()
|> Enum.map(fn {key, msg, vars} ->
translate_error(key, msg, vars)
end)
end
end)
```
"""
@spec transform_errors(
Ash.Changeset.t() | Ash.Query.t(),
(Ash.Query.t() | Ash.Changeset.t(), error :: Ash.Error.t() ->
[{field :: atom, message :: String.t(), substituations :: Keyword.t()}])
) :: Ash.Query.t() | Ash.Changeset.t()
def transform_errors(%Ash.Changeset{} = changeset, transform_errors) do
Ash.Changeset.put_context(changeset, :private, %{
ash_phoenix: %{transform_errors: transform_errors}
})
def hiding_errors?(%Ash.Changeset{} = changeset) do
changeset.context[:private][:ash_phoenix][:hide_errors] == true
end
def transform_errors(%Ash.Query{} = changeset, transform_errors) do
Ash.Query.put_context(changeset, :private, %{
ash_phoenix: %{transform_errors: transform_errors}
})
def hiding_errors?(%Ash.Query{} = query) do
query.context[:private][:ash_phoenix][:hide_errors] == true
end
@doc """
@ -96,11 +30,11 @@ defmodule AshPhoenix do
[{atom, {String.t(), Keyword.t()}}] | [String.t()] | map
def errors_for(changeset_or_query, opts \\ []) do
errors =
if hiding_errors?(changeset_or_query) do
if AshPhoenix.hiding_errors?(changeset_or_query) do
[]
else
changeset_or_query.errors
|> Enum.flat_map(&transform_error(changeset_or_query, &1))
|> Enum.flat_map(&AshPhoenix.FormData.Helpers.transform_error(changeset_or_query, &1))
|> Enum.filter(fn
error when is_exception(error) ->
AshPhoenix.FormData.Error.impl_for(error)
@ -157,867 +91,4 @@ defmodule AshPhoenix do
String.replace(acc, "%{#{key}}", to_string(value))
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_errors] do
transformer when is_function(transformer, 2) ->
case transformer.(query, error) do
error when is_exception(error) ->
if AshPhoenix.FormData.Error.impl_for(error) do
List.wrap(AshPhoenix.to_form_error(error))
else
[]
end
{key, value, vars} ->
[{key, value, vars}]
list when is_list(list) ->
Enum.flat_map(list, fn
error when is_exception(error) ->
if AshPhoenix.FormData.Error.impl_for(error) do
List.wrap(AshPhoenix.to_form_error(error))
else
[]
end
{key, value, vars} ->
[{key, value, vars}]
end)
end
nil ->
if AshPhoenix.FormData.Error.impl_for(error) do
List.wrap(AshPhoenix.to_form_error(error))
else
[]
end
end
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
def set_up_relationship_updates(changeset, relationships) when is_list(relationships) do
Enum.reduce(relationships, changeset, &set_up_relationship_updates(&2, &1))
end
@add_value_opts [
add: [
type: :any,
doc: "the value to add to the list",
default: nil
]
]
@doc """
A utility to support "add" buttons on list attributes and arguments used in forms.
To use, simply pass in the form name of the attribute/argument form as well as the name of the primary/outer form.
```elixir
# In your template, inside a form called `:change`
<button phx-click="append_thing" phx-value-path={{form.path <> "[attribute_name]"}}>
</button>
# In the view/component
def handle_event("append_thing", %{"path" => path}, socket) do
changeset = add_value(socket.assigns.changeset, path, "change")
{:noreply, assign(socket, changeset: changeset)}
end
```
## Options
#{Ash.OptionsHelpers.docs(@add_value_opts)}
"""
@spec add_value(Ash.Changeset.t(), String.t(), String.t(), Keyword.t()) :: Ash.Changeset.t()
def add_value(changeset, original_path, outer_form_name, opts \\ []) do
opts = Ash.OptionsHelpers.validate!(opts, @add_value_opts)
add = opts[:add]
[^outer_form_name, key | path] = decode_path(original_path)
if match?({x, y} when not is_nil(x) and not is_nil(y), argument_and_manages(changeset, key)) do
add_related(changeset, original_path, outer_form_name, add: add)
else
attribute_or_argument =
Ash.Resource.Info.attribute(changeset.resource, key) ||
find_argument(changeset, key)
if attribute_or_argument do
value =
case attribute_or_argument do
%Ash.Resource.Actions.Argument{} = argument ->
changeset
|> Ash.Changeset.get_argument(argument.name)
attribute ->
changeset
|> Ash.Changeset.get_attribute(attribute.name)
end
new_value = add_to_path(value, path, add)
case attribute_or_argument do
%Ash.Resource.Actions.Argument{} = argument ->
Ash.Changeset.set_argument(changeset, argument.name, new_value)
attribute ->
Ash.Changeset.change_attribute(changeset, attribute.name, new_value)
end
else
changeset
end
end
end
@doc """
A utility to support "remove" buttons on list attributes and arguments used in forms.
"""
@spec remove_value(Ash.Changeset.t(), String.t(), String.t()) :: Ash.Changeset.t()
def remove_value(changeset, original_path, outer_form_name) do
[^outer_form_name, key | path] = decode_path(original_path)
attribute_or_argument =
Ash.Resource.Info.attribute(changeset.resource, key) ||
find_argument(changeset, key)
if match?({x, y} when not is_nil(x) and not is_nil(y), argument_and_manages(changeset, key)) do
{_, changeset} = remove_related(changeset, original_path, outer_form_name)
changeset
else
if attribute_or_argument do
value =
case attribute_or_argument do
%Ash.Resource.Actions.Argument{} = argument ->
changeset
|> Ash.Changeset.get_argument(argument.name)
|> List.wrap()
attribute ->
changeset
|> Ash.Changeset.get_attribute(attribute.name)
|> List.wrap()
end
new_value = remove_from_path(value, path)
case attribute_or_argument do
%Ash.Resource.Actions.Argument{} = argument ->
Ash.Changeset.set_argument(changeset, argument.name, new_value)
attribute ->
Ash.Changeset.change_attribute(changeset, attribute.name, new_value)
end
else
changeset
end
end
end
defp find_argument(changeset, key) do
changeset.action && Enum.find(changeset.action.arguments, &(to_string(&1.name) == key))
end
@add_related_opts [
use_data?: [
type: :boolean,
doc: "Provide this if you are using `use_data?` with `inputs_for` for this relationship",
default: false
],
add: [
type: :any,
doc: "the value to add to the relationship",
default: %{}
],
relationship: [
type: :atom,
doc: "The relationship being updated, in case it can't be determined from the path"
],
id: [
type: :any,
doc:
"The value that should be in `meta[:id]` in the manage changeset opts. Defaults to the relationship name. This only needs to be set if an id is also provided for `inputs_for`."
]
]
@doc """
A utility to support "add" buttons on relationships used in forms.
To use, simply pass in the form name of the relationship form as well as the name of the primary/outer form.
```elixir
# In your template, inside a form called `:change`
<button phx-click="append_thing" phx-value-path={{form.path}}>
</button>
# In the view/component
def handle_event("append_thing", %{"path" => path}, socket) do
changeset = add_related(socket.assigns.changeset, path, "change")
{:noreply, assign(socket, changeset: changeset)}
end
```
## Options
#{Ash.OptionsHelpers.docs(@add_related_opts)}
"""
@spec add_related(Ash.Changeset.t(), String.t(), String.t(), Keyword.t()) :: Ash.Changeset.t()
def add_related(changeset, original_path, outer_form_name, opts \\ []) do
opts = Ash.OptionsHelpers.validate!(opts, @add_related_opts)
add = Keyword.get(opts, :add, %{})
[^outer_form_name, key | path] =
if is_list(original_path) do
original_path
else
decode_path(original_path)
end
{argument, argument_manages} = argument_and_manages(changeset, key)
changeset.resource
|> Ash.Resource.Info.relationships()
|> Enum.find_value(fn relationship ->
if relationship.name == opts[:relationship] || to_string(relationship.name) == key ||
relationship.name == argument_manages do
manage = changeset.relationships[relationship.name] || []
to_manage =
Enum.find_index(manage, fn {_manage, opts} ->
opts[:id] == opts[:key] || opts[:id] == opts[:relationship] ||
(argument && opts[:id] == argument.name)
end)
{relationship.name,
opts[:id] || opts[:relationship] || (argument && argument.name) || relationship.name,
to_manage}
end
end)
|> case do
nil ->
changeset
{rel, id, nil} ->
new_relationships =
changeset.relationships
|> Map.put_new(rel, [])
|> Map.update!(rel, fn manages ->
to_add =
if opts[:use_data?] && loaded?(changeset.data, rel) do
changeset.data
|> Map.get(rel)
|> List.wrap()
|> Enum.reduce(nil, fn _, acc ->
add_to_path(acc, path, %{})
end)
|> add_to_path(path, add)
else
add_to_path(nil, path, add)
end
manages ++ [{to_add, [meta: [id: id]]}]
end)
%{changeset | relationships: new_relationships}
{rel, _id, index} ->
new_relationships =
changeset.relationships
|> Map.put_new(rel, [])
|> Map.update!(rel, fn manages ->
List.update_at(manages, index, fn {manage, opts} ->
{add_to_path(manage, path, add), opts}
end)
end)
%{changeset | relationships: new_relationships}
end
end
defp loaded?(data, key) do
case Map.get(data, key) do
%Ash.NotLoaded{} -> false
_ -> true
end
end
@remove_related_opts [
add: [
type: :any,
doc: "the value to add to the relationship, defaults to `%{}`",
default: %{}
],
relationship: [
type: :atom,
doc: "The relationship being updated, in case it can't be determined from the path"
],
id: [
type: :any,
doc:
"The value that should be in `meta[:id]` in the manage changeset opts. Defaults to the relationship name. This only needs to be set if an id is also provided for `inputs_for`."
]
]
@doc """
A utility to support "remove" buttons on relationships used in forms.
To use, simply pass in the form name of the related form as well as the name of the primary/outer form.
```elixir
# In your template, inside a form called `:change`
<button phx-click="remove_thing" phx-value-path={{form.path}}>
</button>
# In the view/component
def handle_event("remove_thing", %{"path" => path}, socket) do
{record, changeset} = remove_related(socket.assigns.changeset, path, "change")
{:noreply, assign(socket, record: record, changeset: changeset)}
end
```
## Options
#{Ash.OptionsHelpers.docs(@remove_related_opts)}
"""
@spec remove_related(Ash.Changeset.t(), String.t(), String.t(), Keyword.t()) ::
{Ash.Resource.record(), Ash.Changeset.t()}
def remove_related(changeset, path, outer_form_name, opts \\ []) do
[^outer_form_name, key | path] = decode_path(path)
{argument, argument_manages} = argument_and_manages(changeset, key)
changeset.resource
|> Ash.Resource.Info.relationships()
|> Enum.find_value(fn relationship ->
if relationship.name == opts[:relationship] || to_string(relationship.name) == key ||
relationship.name == argument_manages do
manage = changeset.relationships[relationship.name] || []
to_manage =
Enum.find_index(manage, fn {_manage, opts} ->
opts[:id] == opts[:key] || opts[:id] == opts[:relationship] ||
(argument && opts[:id] == argument.name)
end)
{relationship.name, to_manage}
end
end)
|> case do
nil ->
{changeset.data, changeset}
{rel, index} ->
{changeset, index} =
if index == nil do
{%{changeset | relationships: Map.put(changeset.relationships, rel, [])}, 0}
else
{changeset, index}
end
new_relationships =
changeset.relationships
|> Map.put_new(rel, [])
|> Map.update!(rel, fn manages ->
if path == [] do
List.delete_at(manages, index)
else
List.update_at(manages, index, fn {manage, opts} ->
{remove_from_path(manage, path), opts}
end)
end
end)
new_value =
cond do
path == [] ->
nil
is_nil(changeset.relationships[rel]) ->
nil
true ->
case Enum.at(changeset.relationships[rel], index) do
nil ->
nil
{value, _opts} ->
value
end
end
changeset = %{changeset | relationships: new_relationships}
{new_data, new_value} =
if changeset.action_type in [:destroy, :update] do
case Map.get(changeset.data, rel) do
%Ash.NotLoaded{} ->
{[], new_value}
value ->
cond do
path == [] and is_list(value) ->
{Map.update!(changeset.data, rel, fn related ->
Enum.map(related, &hide/1)
end), []}
match?([i | _] when is_integer(i), path) and is_list(value) ->
[i | _] = path
new_value = hide_at_not_hidden(Map.get(changeset.data, rel), i)
{Map.put(changeset.data, rel, new_value), new_value}
path == [] || match?([i] when is_integer(i), path) ->
{Map.update!(changeset.data, rel, &hide/1), nil}
true ->
{changeset.data, new_value}
end
end
else
{changeset.data, []}
end
changeset = mark_removed(changeset, new_value, (argument && argument.name) || rel.name)
{new_data, %{changeset | data: new_data}}
end
end
defp hide_at_not_hidden(values, i) do
values
|> Enum.reduce({0, []}, fn value, {counter, acc} ->
if hidden?(value) do
{counter, [value | acc]}
else
if counter == i do
{counter + 1, [hide(value) | acc]}
else
{counter + 1, [value | acc]}
end
end
end)
|> elem(1)
|> Enum.reverse()
end
@doc """
A utility to support "add" buttons on embedded types used in forms.
To use, simply pass in the form name of the embedded form as well as the name of the primary/outer form.
```elixir
# In your template, inside a form called `:change`
<button phx-click="append_thing" phx-value-path={{form.path}}>
</button>
# In the view/component
def handle_event("append_thing", %{"path" => path}, socket) do
changeset = add_embed(socket.assigns.changeset, path, "change")
{:noreply, assign(socket, changeset: changeset)}
end
```
You can also pass a specific value to be added, to seed the changes in a customized way.
By default, `%{}` is used.
"""
def add_embed(query, path, outer_form_name, add \\ %{})
def add_embed(%Ash.Changeset{} = changeset, original_path, outer_form_name, add) do
[^outer_form_name, key | path] = decode_path(original_path)
cond do
match?({x, y} when not is_nil(x) and not is_nil(y), argument_and_manages(changeset, key)) ->
add_related(changeset, original_path, outer_form_name, add: add)
attr = Ash.Resource.Info.attribute(changeset.resource, key) ->
current_value = Ash.Changeset.get_attribute(changeset, attr.name)
new_value = add_to_path(current_value, path, add)
new_value =
case attr.type do
{:array, _} -> List.wrap(new_value)
_ -> new_value
end
changeset
|> Ash.Changeset.change_attribute(attr.name, new_value)
|> mark_removed(new_value, attr.name)
arg = Enum.find(changeset.action.arguments, &(&1.name == key || to_string(&1.name) == key)) ->
current_value = Ash.Changeset.get_argument(changeset, arg.name)
new_value = add_to_path(current_value, path, add)
new_value =
case arg.type do
{:array, _} -> List.wrap(new_value)
_ -> new_value
end
changeset
|> Ash.Changeset.set_argument(arg.name, new_value)
|> mark_removed(new_value, arg.name)
true ->
changeset
end
end
def add_embed(%Ash.Query{} = query, path, outer_form_name, add) do
[^outer_form_name, key | path] = decode_path(path)
arg = Enum.find(query.action.arguments, &(&1.name == key || to_string(&1.name) == key))
if arg do
current_value = Ash.Query.get_argument(query, arg.name)
new_value = add_to_path(current_value, path, add)
new_value =
case arg.type do
{:array, _} -> List.wrap(new_value)
_ -> new_value
end
query
|> Ash.Changeset.set_argument(arg.name, new_value)
|> mark_removed(new_value, arg.name)
else
query
end
end
@doc """
A utility to support "remove" buttons on embedded types used in forms.
To use, simply pass in the form name of the embedded form as well as the name of the primary/outer form.
```elixir
# In your template, inside a form called `:change`
<button phx-click="remove_thing" phx-value-path={{form.path}}>
</button>
# In the view/component
def handle_event("remove_thing", %{"path" => path}, socket) do
changeset = remove_embed(socket.assigns.changeset, path, "change")
{:noreply, assign(socket, changeset: changeset)}
end
```
"""
def remove_embed(%Ash.Changeset{} = changeset, original_path, outer_form_name) do
[^outer_form_name, key | path] = decode_path(original_path)
cond do
match?({x, y} when not is_nil(x) and not is_nil(y), argument_and_manages(changeset, key)) ->
{_, changeset} = remove_related(changeset, original_path, outer_form_name)
changeset
attr = Ash.Resource.Info.attribute(changeset.resource, key) ->
current_value = Ash.Changeset.get_attribute(changeset, attr.name)
new_value =
if path == [] do
nil
else
new_value = remove_from_path(current_value, path)
case attr.type do
{:array, _} -> List.wrap(new_value)
_ -> new_value
end
end
changeset
|> Ash.Changeset.change_attribute(attr.name, new_value)
|> mark_removed(new_value, attr.name)
arg = Enum.find(changeset.action.arguments, &(&1.name == key || to_string(&1.name) == key)) ->
current_value = Ash.Changeset.get_argument(changeset, arg.name)
new_value =
if path == [] do
nil
else
new_value = remove_from_path(current_value, path)
case arg.type do
{:array, _} -> List.wrap(new_value)
_ -> new_value
end
end
changeset
|> Ash.Changeset.set_argument(arg.name, new_value)
|> mark_removed(new_value, arg.name)
true ->
changeset
end
end
def remove_embed(%Ash.Query{} = query, path, outer_form_name) do
[^outer_form_name, key | path] = decode_path(path)
arg = Enum.find(query.action.arguments, &(&1.name == key || to_string(&1.name) == key))
if arg do
current_value = Ash.Query.get_argument(query, arg.name)
new_value = remove_from_path(current_value, path)
new_value =
case arg.type do
{:array, _} -> List.wrap(new_value)
_ -> new_value
end
query
|> Ash.Changeset.set_argument(arg.name, new_value)
|> mark_removed(new_value, arg.name)
else
query
end
end
defp mark_removed(%Ash.Query{} = query, value, name) do
Ash.Query.put_context(query, :private, %{removed_keys: %{name => removed?(value)}})
end
defp mark_removed(%Ash.Changeset{} = changeset, value, name) do
Ash.Changeset.put_context(changeset, :private, %{
removed_keys: %{name => removed?(value)}
})
end
defp removed?(nil), do: true
defp removed?([]), do: true
defp removed?(other) do
other
|> List.wrap()
|> Enum.all?(&hidden?/1)
end
def add_to_path(nil, [], nil) do
[nil]
end
def add_to_path(nil, [], add) do
add
end
def add_to_path(value, [], nil) when is_list(value) do
value ++ [nil]
end
def add_to_path(value, [], add) when is_list(value) do
value ++ List.wrap(add)
end
def add_to_path(value, [], add) when is_map(value) do
case last_index(value) do
:error ->
%{"0" => value, "1" => add}
{:ok, index} ->
Map.put(value, index, add)
end
end
def add_to_path(value, [key | rest], add) when is_integer(key) and is_list(value) do
List.update_at(value, key, &add_to_path(&1, rest, add))
end
def add_to_path(empty, [key | rest], add) when is_integer(key) and empty in [nil, []] do
[add_to_path(nil, rest, add)]
end
def add_to_path(value, [0 | rest], add) when is_map(value) do
add_to_path(value, rest, add)
end
def add_to_path(value, [key | rest] = path, add) when is_integer(key) and is_map(value) do
case last_index(value) do
:error ->
add_to_path(%{"0" => value}, path, add)
_ ->
add_to_path(value, [to_string(key) | rest], add)
end
end
def add_to_path(value, [key | rest], add)
when (is_binary(key) or is_atom(key)) and is_map(value) do
cond do
Map.has_key?(value, key) ->
Map.update!(value, key, &add_to_path(&1, rest, add))
is_atom(key) && Map.has_key?(value, to_string(key)) ->
Map.update!(value, to_string(key), &add_to_path(&1, rest, add))
is_binary(key) && Enum.any?(Map.keys(value), &(to_string(&1) == key)) ->
Map.update!(value, String.to_existing_atom(key), &add_to_path(&1, rest, add))
true ->
Map.put(value, key, add_to_path(nil, rest, add))
end
end
def add_to_path(nil, [key | rest], add) when is_binary(key) or is_atom(key) do
%{key => add_to_path(nil, rest, add)}
end
def add_to_path([item], [key | _] = path, add) when is_binary(key) do
[add_to_path(item, path, add)]
end
def add_to_path(_, _, add), do: add
defp last_index(map) do
map
|> Map.keys()
|> Enum.map(&String.to_integer/1)
|> max_plus_one()
|> case do
nil ->
:error
value ->
{:ok, to_string(value)}
end
rescue
_ ->
:error
end
defp max_plus_one([]) do
nil
end
defp max_plus_one(list) do
list
|> Enum.max()
|> Kernel.+(1)
end
defp remove_from_path(value, [key]) when is_integer(key) and is_list(value) do
List.delete_at(value, key)
end
defp remove_from_path(value, [key]) when is_map(value) and (is_binary(key) or is_atom(key)) do
cond do
is_atom(key) ->
if is_struct(value) do
Map.put(value, key, nil)
else
Map.drop(value, [key, to_string(key)])
end
is_binary(key) && Enum.any?(Map.keys(value), &(is_atom(&1) && to_string(&1) == key)) ->
if is_struct(value) do
Map.put(value, String.to_existing_atom(key), nil)
else
Map.drop(value, [key, String.to_existing_atom(key)])
end
true ->
Map.delete(value, key)
end
|> case do
empty when empty == %{} ->
nil
value ->
value
end
end
defp remove_from_path(value, [key | rest]) when is_list(value) and is_integer(key) do
List.update_at(value, key, &remove_from_path(&1, rest))
end
defp remove_from_path(value, [key | rest]) when is_integer(key) and is_map(value) do
remove_from_path(value, [to_string(key) | rest])
end
defp remove_from_path(value, [key | rest])
when is_map(value) and (is_binary(key) or is_atom(key)) do
cond do
Map.has_key?(value, key) ->
Map.update!(value, key, &remove_from_path(&1, rest))
is_atom(key) && Map.has_key?(value, to_string(key)) ->
Map.update!(value, to_string(key), &remove_from_path(&1, rest))
is_binary(key) && Enum.any?(Map.keys(value), &(is_atom(&1) && to_string(&1) == key)) ->
Map.update!(value, String.to_existing_atom(key), &remove_from_path(&1, rest))
true ->
Map.put(value, key, remove_from_path(nil, rest))
end
end
defp remove_from_path([item], [key | _] = path) when is_binary(key) do
[remove_from_path(item, path)]
end
defp remove_from_path(value, _), do: value
@doc """
A utility for decoding the path of a form into a list.
For example:
```elixir
change[posts][0][comments][1]
["change", "posts", 0, "comments", 1]
```
"""
def decode_path(path) do
path = Plug.Conn.Query.decode(path)
do_decode_path(path)
end
defp do_decode_path(path) when is_map(path) and path != %{} do
path_part = Enum.at(path, 0)
rest = do_decode_path(elem(path_part, 1))
path_part
|> elem(0)
|> Integer.parse()
|> case do
{integer, ""} ->
[integer | rest]
_ ->
[elem(path_part, 0) | rest]
end
end
defp do_decode_path(""), do: []
defp do_decode_path(other) do
[other]
end
end

View file

@ -0,0 +1,393 @@
defmodule AshPhoenix.Form.Auto do
@moduledoc """
An experimental tool to automatically generate available nested forms based on a resource and action.
"""
def auto(resource, action) do
related(resource, action) ++ embedded(resource, action)
end
def related(resource, action, cycle_preventer \\ nil) do
action =
if is_atom(action) do
Ash.Resource.Info.action(resource, action)
else
action
end
cycle_preventer = cycle_preventer || MapSet.new()
if MapSet.member?(cycle_preventer, [resource, action]) do
[]
else
cycle_preventer = MapSet.put(cycle_preventer, [resource, action])
action.arguments
|> Enum.reject(& &1.private?)
|> Enum.flat_map(fn arg ->
case find_manage_change(arg, action) do
nil ->
[]
manage_opts ->
[{arg, manage_opts}]
end
end)
|> Enum.map(fn {arg, manage_opts} ->
relationship = Ash.Resource.Info.relationship(resource, manage_opts[:relationship])
defaults =
if manage_opts[:opts][:type] do
Ash.Changeset.manage_relationship_opts(manage_opts[:opts][:type])
else
[]
end
manage_opts =
Ash.Changeset.ManagedRelationshipHelpers.sanitize_opts(
relationship,
Keyword.merge(defaults, manage_opts)
)
opts = [
type: cardinality_to_type(relationship.cardinality),
data: relationship_fetcher(relationship),
forms: [],
updater: fn opts ->
opts
|> add_create_action(manage_opts, relationship, cycle_preventer)
|> add_read_action(manage_opts, relationship, cycle_preventer)
|> add_update_action(manage_opts, relationship, cycle_preventer)
|> add_nested_forms()
end
]
{arg.name, opts}
end)
end
end
defp add_nested_forms(opts) do
Keyword.update!(opts, :forms, fn forms ->
forms =
if forms[:update_action] do
forms ++ set_for_type(auto(opts[:resource], opts[:update_action]), :update)
else
forms
end
forms =
if forms[:create_action] do
forms ++ set_for_type(auto(opts[:resource], opts[:create_action]), :create)
else
forms
end
forms =
if forms[:destroy_action] do
forms ++ set_for_type(auto(opts[:resource], opts[:destroy_action]), :destroy)
else
forms
end
if forms[:read_action] do
forms ++ set_for_type(auto(opts[:resource], opts[:read_action]), :read)
else
forms
end
end)
end
defp set_for_type(forms, type) do
Enum.map(forms, fn {key, value} ->
{key, Keyword.put(value, :for_type, type)}
end)
end
defp add_read_action(opts, manage_opts, relationship, cycle_preventer) do
manage_opts
|> Ash.Changeset.ManagedRelationshipHelpers.on_lookup_update_action(relationship)
|> List.wrap()
|> Enum.sort_by(&(elem(&1, 0) == :join))
|> case do
[] ->
opts
[{source_dest_or_join, action_name} | rest] ->
resource =
case source_dest_or_join do
:source ->
relationship.source
:destination ->
relationship.destination
:join ->
relationship.through
end
opts
|> Keyword.put(:read_resource, resource)
|> Keyword.put(:read_action, action_name)
|> Keyword.update!(
:forms,
&(&1 ++
related(resource, action_name, cycle_preventer))
)
|> add_join_form(relationship, rest)
end
end
defp add_create_action(opts, manage_opts, relationship, cycle_preventer) do
manage_opts
|> Ash.Changeset.ManagedRelationshipHelpers.on_no_match_destination_actions(relationship)
|> List.wrap()
|> Enum.sort_by(&(elem(&1, 0) == :join))
|> case do
[] ->
opts
[{source_dest_or_join, action_name} | rest] ->
resource =
case source_dest_or_join do
:source ->
relationship.source
:destination ->
relationship.destination
:join ->
relationship.through
end
opts
|> Keyword.put(:create_resource, resource)
|> Keyword.put(:create_action, action_name)
|> Keyword.update!(
:forms,
&(&1 ++
related(resource, action_name, cycle_preventer))
)
|> add_join_form(relationship, rest)
end
end
defp add_update_action(opts, manage_opts, relationship, cycle_preventer) do
manage_opts
|> Ash.Changeset.ManagedRelationshipHelpers.on_match_destination_actions(relationship)
|> List.wrap()
|> Enum.sort_by(&(elem(&1, 0) == :join))
|> case do
[] ->
opts
[{source_dest_or_join, action_name} | rest] ->
resource =
case source_dest_or_join do
:source ->
relationship.source
:destination ->
relationship.destination
:join ->
relationship.through
end
opts
|> Keyword.put(:update_resource, resource)
|> Keyword.put(:update_action, action_name)
|> Keyword.update!(
:forms,
&(&1 ++
related(resource, action_name, cycle_preventer))
)
|> add_join_form(relationship, rest)
end
end
defp add_join_form(opts, _relationship, []), do: opts
defp add_join_form(opts, relationship, [{:join, action, _}]) do
action = Ash.Resource.Info.action(relationship.through, action)
case action.type do
:update ->
Keyword.update!(opts, :forms, fn forms ->
Keyword.put(forms, :_join,
resource: relationship.through,
data: &get_join(&1, &2, relationship),
update_action: action.name
)
end)
:create ->
Keyword.update!(opts, :forms, fn forms ->
Keyword.put(forms, :_join,
resource: relationship.through,
data: &get_join(&1, &2, relationship),
create_action: action.name
)
end)
:destroy ->
Keyword.update!(opts, :forms, fn forms ->
Keyword.put(forms, :_join,
resource: relationship.through,
data: &get_join(&1, &2, relationship),
create_action: action.name,
merge?: true
)
end)
end
end
defp get_join(parent, prev_path, relationship) do
case Enum.find(prev_path, &(&1.__struct__ == relationship.source)) do
nil ->
nil
root ->
case Map.get(root, relationship.join_relationship) do
value when is_list(value) ->
Enum.find(value, fn join ->
Map.get(join, relationship.destination_field_on_join_table) ==
Map.get(parent, relationship.destination_field)
end)
_ ->
nil
end
end
end
defp relationship_fetcher(relationship) do
fn parent ->
case Map.get(parent, relationship.name) do
%Ash.NotLoaded{} ->
if relationship.cardinality == :many do
[]
end
value ->
value
end
end
end
defp cardinality_to_type(:many), do: :list
defp cardinality_to_type(:one), do: :single
def embedded(resource, action, cycle_preventer \\ nil) do
action =
if is_atom(action) do
Ash.Resource.Info.action(resource, action)
else
action
end
cycle_preventer = cycle_preventer || MapSet.new()
if MapSet.member?(cycle_preventer, [resource, action]) do
[]
else
cycle_preventer = MapSet.put(cycle_preventer, [resource, action])
resource
|> accepted_attributes(action)
|> Enum.concat(action.arguments)
|> Enum.filter(&Ash.Type.embedded_type?(&1.type))
|> Enum.reject(&match?({:array, {:array, _}}, &1.type))
|> Enum.map(fn attr ->
type =
case attr.type do
{:array, _} ->
:list
_ ->
:single
end
embed = unwrap_type(attr.type)
data =
case type do
:list ->
fn parent ->
if parent do
Map.get(parent, attr.name) || []
else
[]
end
end
:single ->
fn parent ->
if parent do
Map.get(parent, attr.name)
end
end
end
create_action =
if attr.constraints[:create_action] do
Ash.Resource.Info.action(embed, attr.constraints[:create_action])
else
Ash.Resource.Info.primary_action(embed, :create)
end
update_action =
if attr.constraints[:update_action] do
Ash.Resource.Info.action(embed, attr.constraints[:update_action])
else
Ash.Resource.Info.primary_action(embed, :update)
end
{attr.name,
[
type: type,
resource: embed,
create_action: create_action.name,
update_action: update_action.name,
data: data,
forms:
embedded(embed, create_action, cycle_preventer) ++
embedded(embed, update_action, cycle_preventer)
]}
end)
end
end
defp unwrap_type({:array, type}), do: unwrap_type(type)
defp unwrap_type(type), do: type
@doc false
def accepted_attributes(resource, action) do
resource
|> Ash.Resource.Info.public_attributes()
|> only_accepted(action)
end
defp only_accepted(_attributes, %{type: :read}), do: []
defp only_accepted(attributes, %{accept: nil, reject: reject}) do
Enum.filter(attributes, &(&1.name not in reject || []))
end
defp only_accepted(attributes, %{accept: accept, reject: reject}) do
attributes
|> Enum.filter(&(&1.name in accept))
|> Enum.filter(&(&1.name not in reject || []))
end
defp find_manage_change(argument, action) do
Enum.find_value(action.changes, fn
%{change: {Ash.Resource.Change.ManageRelationship, opts}} ->
if opts[:argument] == argument.name do
opts
end
_ ->
nil
end)
end
end

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,29 @@ defmodule AshPhoenix.Form.NoActionConfigured do
%__MODULE__{action: opts[:action], path: opts[:path]}
end
def message(%{action: :read, path: path}) do
"""
Attempted to add a form at path: #{inspect(path)}, but no `read_action` was configured.
For example:
Form.for_create(
Resource,
:action,
params,
forms: [
# For forms over existing data
nested_form: [
type: :list,
as: "form_name",
read_action: :read_action_name,
resource: RelatedResource,
update_action: :create_action_name
]
]
)
"""
end
def message(%{action: :create, path: path}) do
"""
Attempted to add a form at path: #{inspect(path)}, but no `create_action` was configured.

View file

@ -1,14 +1,20 @@
defmodule AshPhoenix.Form.NoFormConfigured do
defexception [:field]
defexception [:field, :available]
def exception(opts) do
%__MODULE__{field: opts[:field]}
%__MODULE__{field: opts[:field], available: opts[:available]}
end
def message(%{field: field}) do
def message(%{field: field, available: available}) do
"""
#{field} must be configured in the form to be used with `inputs_for`. For example:
Available forms:
#{available |> Enum.map(&"* #{&1}") |> Enum.join("\n")}
Example Setup:
Form.for_create(
Resource,
:action,

View file

@ -58,28 +58,6 @@ defimpl Phoenix.HTML.FormData, for: Ash.Changeset do
[]
end
hidden =
changeset.resource
|> Ash.Resource.Info.attributes()
|> Enum.filter(&Ash.Type.embedded_type?(&1.type))
|> Enum.reduce(hidden, fn attribute, hidden ->
case Ash.Changeset.fetch_change(changeset, attribute.name) do
{:ok, empty} when empty in [nil, []] ->
Keyword.put(hidden, attribute.name, nil)
_ ->
hidden
end
end)
removed_embed_values =
changeset.context[:private][:removed_keys]
|> Kernel.||(%{})
|> Enum.filter(&elem(&1, 1))
|> Enum.map(fn {name, _} -> {name, nil} end)
hidden = hidden ++ removed_embed_values
%Phoenix.HTML.Form{
source: changeset,
impl: __MODULE__,
@ -94,122 +72,13 @@ defimpl Phoenix.HTML.FormData, for: Ash.Changeset do
end
@impl true
def to_form(changeset, form, field, opts) do
{name, opts} = Keyword.pop(opts, :as)
{id, opts} = Keyword.pop(opts, :id)
{prepend, opts} = Keyword.pop(opts, :prepend, [])
{append, opts} = Keyword.pop(opts, :append, [])
{use_data?, opts} = Keyword.pop(opts, :use_data?, false)
id = to_string(id || form.id <> "_#{field}")
name = to_string(name || form.name <> "[#{field}]")
{source, resource, data} =
cond do
arg = changeset.action && get_argument(changeset.action, field) ->
case argument_and_manages(changeset, arg.name) do
{nil, _} ->
case get_embedded(arg.type) do
nil ->
raise "Cannot use `form_for` with an argument unless the type is an embedded resource or that argument manages a relationship"
resource ->
data = Ash.Changeset.get_argument(changeset, arg.name)
data =
case arg.type do
{:array, _} ->
List.wrap(data)
_ ->
data
end
{arg, resource, data}
end
{argument, rel} ->
if rel do
rel = Ash.Resource.Info.relationship(changeset.resource, rel)
data =
relationship_data(
changeset,
rel,
use_data?,
opts[:id] || argument.name || rel.name
)
data =
case argument.type do
{:array, _} ->
List.wrap(data)
_ ->
if is_list(data) do
List.last(data)
else
data
end
end
{rel, rel.destination, data}
else
raise "Cannot use `form_for` with an argument unless the type is an embedded resource or that argument manages a relationship"
end
end
rel = Ash.Resource.Info.relationship(changeset.resource, field) ->
data = relationship_data(changeset, rel, use_data?, opts[:id] || rel.name)
data =
if rel.cardinality == :many && data do
List.wrap(data)
else
data
end
{rel, rel.destination, data}
attr = Ash.Resource.Info.attribute(changeset.resource, field) ->
case get_embedded(attr.type) do
nil ->
raise "Cannot use `form_for` with an attribute unless the type is an embedded resource"
resource ->
data = Ash.Changeset.get_attribute(changeset, attr.name)
data =
case attr.type do
{:array, _} ->
List.wrap(data)
_ ->
data
end
{attr, resource, data}
end
true ->
raise "Cannot use `form_for` with anything except embedded resources in attributes/arguments"
end
data =
if is_list(data) do
prepend ++ data ++ append
else
unwrap(prepend) || unwrap(append) || data
end
data
|> to_nested_form(changeset, source, resource, id, name, opts)
|> List.wrap()
def to_form(_changeset, _form, _field, _opts) do
raise """
Using `inputs_for` with an `Ash.Query` is no longer supported.
See the documentation for `AshPhoenix.Form` for more information on the new implementation.
"""
end
defp unwrap([]), do: nil
defp unwrap([value | _]), do: value
defp unwrap(value), do: value
@impl true
def input_validations(changeset, _, field) do
attribute_or_argument =

View file

@ -10,31 +10,6 @@ defmodule AshPhoenix.FormData.Helpers do
Enum.find(action.arguments, &(to_string(&1.name) == field))
end
def argument_and_manages(changeset, key) do
with action when not is_nil(action) <- changeset.action,
argument when not is_nil(argument) <-
Enum.find(changeset.action.arguments, &(&1.name == key || to_string(&1.name) == key)),
manage_change when not is_nil(manage_change) <-
find_manage_change(argument, changeset.action) do
{argument, manage_change}
else
_ ->
{nil, nil}
end
end
defp find_manage_change(argument, action) do
Enum.find_value(action.changes, fn
%{change: {Ash.Resource.Change.ManageRelationship, opts}} ->
if opts[:argument] == argument.name do
opts[:relationship]
end
_ ->
nil
end)
end
def type_to_form_type(type) do
case Ash.Type.ecto_type(type) do
:integer -> :number_input
@ -95,15 +70,15 @@ defmodule AshPhoenix.FormData.Helpers do
end)
end
defp transform_error(_form, {_key, _value, _vars} = error), do: error
def transform_error(_form, {_key, _value, _vars} = error), do: error
defp transform_error(form, error) do
def transform_error(form, error) do
case form.transform_errors do
transformer when is_function(transformer, 2) ->
case transformer.(form.source, error) do
error when is_exception(error) ->
if AshPhoenix.FormData.Error.impl_for(error) do
List.wrap(AshPhoenix.to_form_error(error))
List.wrap(to_form_error(error))
else
[]
end
@ -115,7 +90,7 @@ defmodule AshPhoenix.FormData.Helpers do
Enum.flat_map(list, fn
error when is_exception(error) ->
if AshPhoenix.FormData.Error.impl_for(error) do
List.wrap(AshPhoenix.to_form_error(error))
List.wrap(to_form_error(error))
else
[]
end
@ -127,497 +102,51 @@ defmodule AshPhoenix.FormData.Helpers do
nil ->
if AshPhoenix.FormData.Error.impl_for(error) do
List.wrap(AshPhoenix.to_form_error(error))
List.wrap(to_form_error(error))
else
[]
end
end
end
def get_embedded({:array, type}), do: get_embedded(type)
# defp set_source_context(changeset, {relationship, original_changeset}) do
# case original_changeset.context[:manage_relationship_source] do
# nil ->
# Ash.Changeset.set_context(changeset, %{
# manage_relationship_source: [
# {relationship.source, relationship.name, original_changeset}
# ]
# })
def get_embedded(type) when is_atom(type) do
if Ash.Resource.Info.embedded?(type) do
type
end
end
# value ->
# Ash.Changeset.set_context(changeset, %{
# manage_relationship_source:
# value ++ [{relationship.source, relationship.name, original_changeset}]
# })
# end
# end
def get_embedded(_), do: nil
def relationship_data(changeset, %{cardinality: :one} = rel, use_data?, id) do
case get_managed(changeset, rel.name, id) do
defp to_form_error(exception) when is_exception(exception) do
case AshPhoenix.FormData.Error.to_form_error(exception) do
nil ->
if use_data? do
changeset_data(changeset, rel)
else
nil
end
nil
{manage, _opts} ->
case manage do
nil ->
nil
{field, message} ->
{field, message, []}
[] ->
nil
{field, message, vars} ->
{field, message, vars}
value ->
value =
if is_list(value) do
List.last(value)
else
value
end
list when is_list(list) ->
Enum.map(list, fn item ->
case item do
{field, message} ->
{field, message, []}
if use_data? do
data = changeset_data(changeset, rel)
if data do
data
|> Ash.Changeset.new()
|> Map.put(:params, value)
else
value
end
else
value
end
end
end
end
def relationship_data(changeset, rel, use_data?, id) do
case get_managed(changeset, rel.name, id) do
nil ->
if use_data? do
changeset_data(changeset, rel)
else
[]
end
{manage, _opts} ->
manage =
if is_map(manage) do
case map_input_to_list(manage) do
:error ->
manage
{:ok, manage} ->
manage
end
{field, message, vars} ->
{field, message, vars}
end
if use_data? do
changeset
|> changeset_data(rel)
|> zip_changes(manage)
else
manage
end
end
end
defp zip_changes(data, manage) when not is_list(data) do
zip_changes(List.wrap(data), manage)
end
defp zip_changes(data, manage) when not is_list(manage) do
zip_changes(data, List.wrap(manage))
end
defp zip_changes([], manage) do
manage
end
defp zip_changes([record | rest_data], [manage | rest_manage]) do
[
Ash.Changeset.new(record, %{})
|> Map.put(:params, manage)
] ++
zip_changes(rest_data, rest_manage)
end
defp zip_changes(records, []) do
records
end
defp changeset_data(changeset, rel) do
data = Map.get(changeset.data, rel.name)
case data do
%Ash.NotLoaded{} ->
default_data(rel)
data ->
if is_list(data) do
Enum.reject(data, &hidden?/1)
else
if hidden?(data) do
nil
else
data
end
end
end
end
defp map_input_to_list(input) when input == %{} do
:error
end
defp map_input_to_list(input) do
input
|> Enum.reduce_while({:ok, []}, fn
{key, value}, {:ok, acc} when is_integer(key) ->
{:cont, {:ok, [{key, value} | acc]}}
{key, value}, {:ok, acc} when is_binary(key) ->
case Integer.parse(key) do
{int, ""} ->
{:cont, {:ok, [{int, value} | acc]}}
_ ->
{:halt, :error}
end
_, _ ->
{:halt, :error}
end)
|> case do
{:ok, value} ->
{:ok,
value
|> Enum.sort_by(&elem(&1, 0))
|> Enum.map(&elem(&1, 1))}
:error ->
:error
end
end
defp default_data(%{cardinality: :many}), do: []
defp default_data(%{cardinality: :one}), do: nil
defp get_managed(changeset, relationship_name, id) do
manage = changeset.relationships[relationship_name] || []
Enum.find(manage, fn {_, opts} -> opts[:meta][:id] == id end)
end
@doc false
def to_nested_form(
data,
original_changeset,
%{cardinality: _} = relationship,
resource,
id,
name,
opts
)
when is_list(data) do
changesets =
Enum.map(
data,
&related_data_to_changeset(resource, &1, opts, relationship, original_changeset)
)
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)
hidden =
if changeset.action_type in [:update, :destroy] do
changeset.data
|> Map.take(Ash.Resource.Info.primary_key(changeset.resource))
|> Enum.to_list()
else
[]
end
%Phoenix.HTML.Form{
source: changeset,
impl: Phoenix.HTML.FormData.impl_for(changeset),
id: id <> "_" <> index_string,
name: name <> "[" <> index_string <> "]",
index: index,
errors: form_for_errors(changeset, opts),
data: changeset.data,
params: changeset.params,
hidden: hidden,
options: opts
}
end
end
def to_nested_form(nil, _, _, _, _, _, _) do
nil
end
def to_nested_form(
data,
original_changeset,
%{cardinality: _} = relationship,
resource,
id,
name,
opts
) do
changeset = related_data_to_changeset(resource, data, opts, relationship, original_changeset)
changeset =
if AshPhoenix.hiding_errors?(original_changeset) do
AshPhoenix.hide_errors(changeset)
else
changeset
end
hidden =
if changeset.action_type in [:update, :destroy] do
changeset.data
|> Map.take(Ash.Resource.Info.primary_key(changeset.resource))
|> Enum.to_list()
else
[]
end
%Phoenix.HTML.Form{
source: changeset,
impl: Phoenix.HTML.FormData.impl_for(changeset),
id: id,
name: name,
errors: form_for_errors(changeset, opts),
data: changeset.data,
params: changeset.params,
hidden: hidden,
options: opts
}
end
def to_nested_form(
data,
original_changeset,
attribute,
resource,
id,
name,
opts
)
when is_list(data) do
create_action =
action!(resource, :create, attribute.constraints[:create_action] || opts[:create_action]).name
update_action =
action!(resource, :update, attribute.constraints[:update_action] || opts[:update_action]).name
changesets =
data
|> Enum.map(fn data ->
if is_struct(data) do
Ash.Changeset.for_update(data, update_action, params(data), actor: opts[:actor])
else
Ash.Changeset.for_create(resource, create_action, data, actor: opts[:actor])
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)
hidden =
if changeset.action_type in [:update, :destroy] do
changeset.data
|> Map.take(Ash.Resource.Info.primary_key(changeset.resource))
|> Enum.to_list()
else
[]
end
%Phoenix.HTML.Form{
source: changeset,
impl: Phoenix.HTML.FormData.impl_for(changeset),
id: id <> "_" <> index_string,
name: name <> "[" <> index_string <> "]",
index: index,
errors: form_for_errors(changeset, opts),
data: changeset.data,
params: changeset.params,
hidden: hidden,
options: opts
}
end
end
def to_nested_form(
data,
original_changeset,
attribute,
resource,
id,
name,
opts
) do
create_action =
action!(resource, :create, attribute.constraints[:create_action] || opts[:create_action]).name
update_action =
action!(resource, :update, attribute.constraints[:update_action] || opts[:update_action]).name
changeset =
cond do
is_struct(data) ->
Ash.Changeset.for_update(data, update_action, params(data), actor: opts[:actor])
is_nil(data) ->
nil
true ->
Ash.Changeset.for_create(resource, create_action, params(data), actor: opts[:actor])
end
if changeset do
changeset =
if AshPhoenix.hiding_errors?(original_changeset) do
AshPhoenix.hide_errors(changeset)
else
changeset
end
hidden =
if changeset.action_type in [:update, :destroy] do
changeset.data
|> Map.take(Ash.Resource.Info.primary_key(changeset.resource))
|> Enum.to_list()
else
[]
end
%Phoenix.HTML.Form{
source: changeset,
impl: Phoenix.HTML.FormData.impl_for(changeset),
id: id,
name: name,
errors: form_for_errors(changeset, opts),
data: changeset.data,
params: changeset.params,
hidden: hidden,
options: opts
}
end
end
def hidden?(nil), do: false
def hidden?(%_{} = data) do
Ash.Resource.Info.get_metadata(data, :private)[:hidden?]
end
def hidden?(_), do: false
def hide(nil), do: nil
def hide(record) do
Ash.Resource.Info.put_metadata(record, :private, %{hidden?: true})
end
defp related_data_to_changeset(resource, data, opts, relationship, source_changeset) do
if is_struct(data) do
if opts[:update_action] == :_raw do
Ash.Changeset.new(data)
else
update_action = action!(resource, :update, opts[:update_action])
data
|> case do
%Ash.Changeset{} = changeset ->
changeset
other ->
Ash.Changeset.new(other)
end
|> set_source_context({relationship, source_changeset})
|> Ash.Changeset.for_update(update_action.name, params(data), actor: opts[:actor])
end
else
if opts[:create_action] == :_raw do
resource
|> Ash.Changeset.new(take_attributes(data, resource))
|> set_source_context({relationship, source_changeset})
|> Map.put(:params, data)
else
create_action = action!(resource, :create, opts[:create_action])
resource
|> Ash.Changeset.new()
|> set_source_context({relationship, source_changeset})
|> Ash.Changeset.for_create(create_action.name, data, actor: opts[:actor])
end
end
end
defp params(%Ash.Changeset{params: params}), do: params
defp params(_), do: nil
defp set_source_context(changeset, {relationship, original_changeset}) do
case original_changeset.context[:manage_relationship_source] do
nil ->
Ash.Changeset.set_context(changeset, %{
manage_relationship_source: [
{relationship.source, relationship.name, original_changeset}
]
})
value ->
Ash.Changeset.set_context(changeset, %{
manage_relationship_source:
value ++ [{relationship.source, relationship.name, original_changeset}]
})
end
end
def take_attributes(data, resource) do
attributes =
resource
|> Ash.Resource.Info.attributes()
|> Enum.map(&to_string(&1.name))
Map.take(data, attributes)
end
def action!(resource, type, nil) do
case Ash.Resource.Info.primary_action(resource, type) do
nil ->
raise """
No `#{type}_action` configured, and no primary action of type #{type} found on #{
inspect(resource)
}
"""
action ->
action
end
end
def action!(resource, _type, action) do
case Ash.Resource.Info.action(resource, action) do
nil ->
raise """
No such action #{action} on resource #{inspect(resource)}
"""
action ->
action
end)
end
end
end

View file

@ -31,72 +31,11 @@ defimpl Phoenix.HTML.FormData, for: Ash.Query do
end
@impl true
def to_form(query, form, field, opts) do
{name, opts} = Keyword.pop(opts, :as)
{id, opts} = Keyword.pop(opts, :id)
{prepend, opts} = Keyword.pop(opts, :prepend, [])
{append, opts} = Keyword.pop(opts, :append, [])
id = to_string(id || form.id <> "_#{field}")
name = to_string(name || form.name <> "[#{field}]")
arguments =
if query.action do
query.action.arguments
else
[]
end
arg =
Enum.find(
arguments,
&(&1.name == field || to_string(&1.name) == field)
)
{source, resource, data} =
if arg do
case get_embedded(arg.type) do
nil ->
raise "Cannot use `form_for` with an argument unless the type is an embedded resource"
resource ->
data = Ash.Query.get_argument(query, arg.name)
data =
case arg.type do
{:array, _} ->
List.wrap(data)
_ ->
data
end
{arg, resource, data}
end
else
raise "Cannot use `form_for` with anything except embedded resources in attributes/arguments"
end
data =
if is_list(data) do
Enum.reject(prepend ++ data ++ append, &(&1 == ""))
else
if data == "" do
nil
else
data
end
end
data
|> to_nested_form(
query,
source,
resource,
id,
name,
opts
)
|> List.wrap()
def to_form(_query, _form, _field, _opts) do
raise """
Using `inputs_for` with an `Ash.Query` is no longer supported.
See the documentation for `AshPhoenix.Form` for more information on the new implementation.
"""
end
@impl true

View file

@ -1,43 +1,4 @@
defmodule AshPhoenixTest do
use ExUnit.Case
doctest AshPhoenix
describe "add_to_path/3" do
import AshPhoenix, only: [add_to_path: 3]
test "a simple key is added to a map" do
assert add_to_path(%{}, ["key"], 1) == %{"key" => 1}
end
test "when the value is nil, the result is a map" do
assert add_to_path(nil, ["key"], 1) == %{"key" => 1}
end
test "when the value is an empty list, the result is the value" do
assert add_to_path([], ["key"], 1) == 1
end
test "when the value is a non-empty list (as a map), the value is added to the list" do
assert add_to_path(%{"0" => 1}, [], 1) == %{"0" => 1, "1" => 1}
end
test "when the value is a non-empty list, the value is added to the list" do
assert add_to_path([1], [], 1) == [1, 1]
end
test "when the value is a list and the key is an integer and the index is not present, the result is the original list" do
assert add_to_path([%{}], [1, "key"], 1) == [%{}]
end
test "when the add is a map and the value is a list, the map is added to the list" do
assert add_to_path(%{}, ["key"], %{"foo" => "bar"}) == %{"key" => %{"foo" => "bar"}}
end
test "when the 0th index is modified on a list but the value is not yet in a list, it is modified" do
assert add_to_path(%{"field_id" => 1}, [0, "value"], %{}) == %{
"field_id" => 1,
"value" => %{}
}
end
end
end

24
test/auto_form_test.exs Normal file
View file

@ -0,0 +1,24 @@
defmodule AshPhoenix.AutoFormTest do
use ExUnit.Case
alias AshPhoenix.Form.Auto
alias AshPhoenix.Test.{Api, Comment, Post}
import AshPhoenix.Form, only: [update_opts: 1]
test "it works for simple relationships" do
forms =
Post
|> auto_forms(:create)
|> update_opts()
|> Keyword.get(:forms)
assert forms[:comments][:update_action] == :update
assert forms[:comments][:create_action] == :create
assert forms[:linked_posts][:update_action] == :update
assert forms[:linked_posts][:create_action] == :create
end
defp auto_forms(resource, action) do
[forms: Auto.auto(resource, action)]
end
end

View file

@ -1,9 +1,9 @@
defmodule AshPhoenix.ChangesetTest do
use ExUnit.Case
import Phoenix.HTML.Form, only: [form_for: 2, inputs_for: 2, inputs_for: 3]
import Phoenix.HTML.Form, only: [form_for: 2]
alias Phoenix.HTML.FormData
alias AshPhoenix.Test.{Api, Comment, Post}
alias AshPhoenix.Test.{Post}
describe "form_for fields" do
test "it should show simple field values" do
@ -15,112 +15,4 @@ defmodule AshPhoenix.ChangesetTest do
assert FormData.input_value(form.source, form, :text) == "text"
end
end
describe "form_for relationships belongs_to" do
test "it should show nothing in `inputs_for` by default" do
form =
Comment
|> Ash.Changeset.for_create(:create, %{text: "text"})
|> form_for("action")
assert inputs_for(form, :post) == []
end
test "when a value has been appended to the relationship, a form is created" do
form =
Comment
|> Ash.Changeset.for_create(:create, %{text: "text"})
|> AshPhoenix.add_related("change[post]", "change")
|> form_for("action")
assert [
%Phoenix.HTML.Form{source: %Ash.Changeset{resource: AshPhoenix.Test.Post}}
] = inputs_for(form, :post)
end
test "it will use the data if configured" do
post = Post |> Ash.Changeset.for_create(:create, %{text: "text"}) |> Api.create!()
form =
Comment
|> Ash.Changeset.for_create(:create, %{text: "text"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
|> Ash.Changeset.for_update(:update)
|> form_for("action")
assert [%Phoenix.HTML.Form{source: %Ash.Changeset{resource: AshPhoenix.Test.Post}}] =
inputs_for(form, :post, use_data?: true)
end
test "adding from the relationship works in conjunction with `use_data`" do
form =
Comment
|> Ash.Changeset.for_create(:create, %{text: "text"})
|> Api.create!()
|> Ash.Changeset.for_update(:update)
|> AshPhoenix.add_related("change[post]", "change")
|> form_for("action")
assert [%Phoenix.HTML.Form{source: %Ash.Changeset{resource: AshPhoenix.Test.Post}}] =
inputs_for(form, :post, use_data?: true)
end
test "removing from the relationship works in conjunction with `use_data`" do
post = Post |> Ash.Changeset.for_create(:create, %{text: "text"}) |> Api.create!()
{_record, changeset} =
Comment
|> Ash.Changeset.for_create(:create, %{text: "text"})
|> Ash.Changeset.replace_relationship(:post, post)
|> Api.create!()
|> Ash.Changeset.for_update(:update)
|> AshPhoenix.remove_related("change[post]", "change")
form = form_for(changeset, "action")
assert [] = inputs_for(form, :post, use_data?: true)
end
end
describe "form_for relationships has_many" do
test "it should show nothing in `inputs_for` by default" do
form =
Post
|> Ash.Changeset.for_create(:create, %{text: "text"})
|> form_for("action")
assert inputs_for(form, :comments) == []
end
test "when a value has been appended to the relationship, a form is created" do
form =
Post
|> Ash.Changeset.for_create(:create, %{text: "text"})
|> AshPhoenix.add_related("change[comments]", "change")
|> form_for("action")
assert [
%Phoenix.HTML.Form{source: %Ash.Changeset{resource: AshPhoenix.Test.Comment}}
] = inputs_for(form, :comments)
end
test "adding from the relationship works in conjunction with `use_data`" do
comment = Comment |> Ash.Changeset.for_create(:create, %{text: "text"}) |> Api.create!()
form =
Post
|> Ash.Changeset.for_create(:create, %{text: "text"})
|> Ash.Changeset.append_to_relationship(:comments, comment)
|> Api.create!()
|> Ash.Changeset.for_update(:update)
|> AshPhoenix.add_related("change[comments]", "change", use_data?: true)
|> form_for("action")
assert [
%Phoenix.HTML.Form{source: %Ash.Changeset{resource: AshPhoenix.Test.Comment}},
%Phoenix.HTML.Form{source: %Ash.Changeset{resource: AshPhoenix.Test.Comment}}
] = inputs_for(form, :comments, use_data?: true)
end
end
end