defmodule AshPhoenix.Form do
@moduledoc """
A module to allow you to fluidly use resources with phoenix forms.
### Life cycle
The general workflow is, with either liveview or phoenix forms:
1. Create a form with `AshPhoenix.Form`
2. Render that form with Phoenix's `form_for` (or, if using surface, <Form>)
3. To validate the form (e.g with `on-change` for liveview), pass the input to `AshPhoenix.Form.validate(form, params)`
4. On form submission, pass the input to `AshPhoenix.Form.validate(form, params)` and then use `AshPhoenix.Form.submit(form)`
5. On success, use the result to redirect or assign. On failure, reassign the provided form.
The following keys exist on the form to show where in the lifecycle you are:
- `submitted_once?` - If the form has ever been submitted. Useful for not showing any errors on the first attempt to fill out a form.
- `just_submitted?` - If the form has just been submitted and *no validation* has happened since. Useful for things like
triggering a UI effect that should stop when the form is modified again.
- `.changed?` - If something about the form is different than it originally was. Note that in some cases this can yield a
false positive, specifically if a nested form is removed and then a new one is added with the exact same values.
- `.touched_forms` - A MapSet containing all keys in the form that have been modified. When submitting a form, only these keys are included in the parameters.
### Working with related data
If your resource action accepts related data, (for example a managed relationship argument, or an embedded resource attribute), you can
use Phoenix's `inputs_for` for that field, *but* you must do one of two things:
1. Tell AshPhoenix.Form to automatically derive this behavior from your action, for example:
```elixir
form =
user
|> AshPhoenix.Form.for_update(:update,
api: MyApi,
forms: [auto?: true]
])
```
2. Explicitly configure the behavior of it using the `forms` option. See `for_create/3` for more.
For example:
```elixir
form =
user
|> AshPhoenix.Form.for_update(:update,
api: MyApi,
forms: [
profile: [
resource: MyApp.Profile,
data: user.profile,
create_action: :create,
update_action: :update
forms: [
emails: [
data: user.profile.emails,
resource: MyApp.UserEmail,
create_action: :create,
update_action: :update
]
]
]
])
```
## LiveView
`AshPhoenix.Form` (unlike ecto changeset based forms) expects to be reused throughout the lifecycle of the liveview.
You can use phoenix events to add and remove form entries and `submit/2` to submit the form, like so:
```elixir
alias MyApp.MyApi.{Comment, Post}
def render(assigns) do
~L\"\"\"
<%= f = form_for @form, "#", [phx_change: :validate, phx_submit: :save] %>
<%= label f, :text %>
<%= text_input f, :text %>
<%= error_tag f, :text %>
<%= for comment_form <- inputs_for(f, :comments) do %>
<%= hidden_inputs_for(comment_form) %>
<%= text_input comment_form, :text %>
<%= for sub_comment_form <- inputs_for(comment_form, :sub_comments) do %>
<%= hidden_inputs_for(sub_comment_form) %>
<%= text_input sub_comment_form, :text %>
<button phx-click="remove_form" phx-value-path="<%= sub_comment_form.name %>">Add Comment</button>
<% end %>
<button phx-click="remove_form" phx-value-path="<%= comment_form.name %>">Add Comment</button>
<button phx-click="add_form" phx-value-path="<%= comment_form.name %>">Add Comment</button>
<% end %>
<button phx-click="add_form" phx-value-path="<%= comment_form.name %>">Add Comment</button>
<%= submit "Save" %>
</form>
\"\"\"
end
def mount(%{"post_id" => post_id}, _session, socket) do
post =
Post
|> MyApp.MyApi.get!(post_id)
|> MyApi.load!(comments: [:sub_comments])
form = AshPhoenix.Form.for_update(post,
api: MyApp.MyApi,
forms: [
comments: [
resource: Comment,
data: post.comments,
create_action: :create,
update_action: :update
forms: [
sub_comments: [
resource: Comment,
data: &(&1.sub_comments),
create_action: :create,
update_action: :update
]
]
]
])
{:ok, assign(socket, form: form)}
end
# In order to use the `add_form` and `remove_form` helpers, you
# need to make sure that you are validating the form on change
def handle_event("validate", %{"form" => params}, socket) do
form = AshPhoenix.Form.validate(socket.assigns.form, params)
# You can also skip errors by setting `errors: false` if you only want to show errors on submit
# form = AshPhoenix.Form.validate(socket.assigns.form, params, errors: false)
{:ok, assign(socket, :form, form)}
end
def handle_event("save", _params, socket) do
case AshPhoenix.Form.submit(socket.assigns.form) do
{:ok, result} ->
# Do something with the result, like redirect
{:error, form} ->
assign(socket, :form, form)
end
end
def handle_event("add_form", %{"path" => path}, socket) do
form = AshPhoenix.Form.add_form(socket.assigns.form, path)
{:noreply, assign(socket, :form, form)}
end
def handle_event("remove_form", %{"path" => path}) do
form = AshPhoenix.Form.remove_form(socket.assigns.form, path)
{:noreply, assign(socket, :form, form)}
end
```
"""
@derive {Inspect, except: [:opts]}
defstruct [
:resource,
:action,
:type,
:params,
:source,
:name,
:data,
:form_keys,
:forms,
:api,
:method,
:submit_errors,
:opts,
:id,
:transform_errors,
:original_data,
:transform_params,
:prepare_params,
:prepare_source,
warn_on_unhandled_errors?: true,
any_removed?: false,
added?: false,
changed?: false,
touched_forms: MapSet.new(),
valid?: false,
errors: false,
submitted_once?: false,
just_submitted?: false
]
alias AshPhoenix.Form.InvalidPath
@type t :: %__MODULE__{
resource: Ash.Resource.t(),
action: atom,
type: :create | :update | :destroy | :read,
params: map,
source: Ash.Changeset.t() | Ash.Query.t(),
transform_params: nil | (map -> term),
data: nil | Ash.Resource.record(),
form_keys: Keyword.t(),
forms: map,
method: String.t(),
submit_errors: Keyword.t() | nil,
prepare_source:
nil | (Ash.Changeset.t() -> Ash.Changeset.t()) | (Ash.Query.t() -> Ash.Query.t()),
opts: Keyword.t(),
transform_errors:
nil
| (Ash.Changeset.t() | Ash.Query.t(), error :: Ash.Error.t() ->
[{field :: atom, message :: String.t(), substituations :: Keyword.t()}]),
valid?: boolean,
errors: boolean,
submitted_once?: boolean,
just_submitted?: boolean
}
@for_opts [
forms: [
type: :keyword_list,
doc: "Nested form configurations. See `for_create/3` \"Nested Form Options\" docs for more."
],
warn_on_unhandled_errors?: [
type: :boolean,
default: true,
doc: """
Warns on any errors that don't match the form pattern of `{:field, "message", [replacement: :vars]}` or implement the `AshPhoenix.FormData.Error` protocol.
"""
],
api: [
type: :atom,
doc:
"The api module to use for form submission. If not set, calls to `Form.submit/2` will fail"
],
as: [
type: :string,
default: "form",
doc:
"The name of the form in the submitted params. You will need to pull the form params out using this key."
],
id: [
type: :string,
doc:
"The html id of the form. Defaults to the value of `:as` if provided, otherwise \"form\""
],
transform_errors: [
type: :any,
doc: """
Allows for manual manipulation and transformation of errors.
If possible, try to implement `AshPhoenix.FormData.Error` for the error (if it as a custom one, for example).
If that isn't possible, you can provide this function which will get the changeset and the error, and should
return a list of ash phoenix formatted errors, e.g `[{field :: atom, message :: String.t(), substituations :: Keyword.t()}]`
"""
],
prepare_source: [
type: :any,
doc: """
Takes a function over a changeset and runs it before calling the relevant changeset function. This can be used to do things like:
* set private argument values before the validations are run using `Ash.Changeset.set_arguments/2` or `Ash.Changeset.set_argument/3`
* set changeset context
* do any other pre-processing on the changeset
"""
],
prepare_params: [
type: {:or, [{:fun, 2}, {:in, [nil]}]},
doc: """
A function for pre-processing the form parameters before they are handled by the form.
"""
],
transform_params: [
type: {:or, [{:fun, 2}, {:fun, 3}, {:in, [nil]}]},
doc: """
A function for post-processing the form parameters before they are used for changeset validation/submission.
Use a 3 argument function to pattern match on the `AshPhoenix.Form` struct.
"""
],
method: [
type: :string,
doc:
"The http method to associate with the form. Defaults to `post` for creates, and `put` for everything else."
],
exclude_fields_if_empty: [
type: {:list, {:or, [:atom, :string, {:tuple, [:any, :any]}]}},
doc: """
These fields will be ignored if they are empty strings.
This list of fields supports dead view forms. When a form is submitted from dead view
empty fields are submitted as empty strings. This is problematic for fields that allow_nil
or those that have default values.
"""
]
]
@nested_form_opts [
type: [
type: {:one_of, [:list, :single]},
default: :single,
doc: "The cardinality of the nested form."
],
sparse?: [
type: :boolean,
doc: """
If the nested form is `sparse`, the form won't expect all inputs for all forms to be present.
Has no effect if the type is `:single`.
Normally, if you leave some forms out of a list of nested forms, they are removed from the parameters
passed to the action. For example, if you had a `post` with two comments `[%Comment{id: 1}, %Comment{id: 2}]`
and you passed down params like `comments[0][id]=1&comments[1][text]=new_text`, we would remove the second comment
from the input parameters, resulting in the following being passed into the action: `%{"comments" => [%{"id" => 1, "text" => "new"}]}`.
By setting it to sparse, you have to explicitly use `remove_form` for that removal to happen. So in the same scenario above, the parameters
that would be sent would actually be `%{"comments" => [%{"id" => 1, "text" => "new"}, %{"id" => 2}]}`.
One major difference with `sparse?` is that the form actually ignores the *index* provided, e.g `comments[0]...`, and instead uses the primary
key e.g `comments[0][id]` to match which form is being updated. This prevents you from having to find the index of the specific item you want to
update. Which could be very gnarly on deeply nested forms. If there is no primary key, or the primary key does not match anything, it is treated
as a new form.
REMEMBER: You need to use `hidden_inputs_for` (or `HiddenInputs` if using surface) for the id to be automatically placed into the form.
"""
],
forms: [
type: :keyword_list,
doc: "Forms nested inside the current nesting level in all cases"
],
for_type: [
type: {:list, {:one_of, [:read, :create, :update]}},
doc:
"What action types the form applies for. Leave blank for it to apply to all action types."
],
merge?: [
type: :boolean,
default: false,
doc:
"When building parameters, this input will be merged with its parent input. This allows for combining multiple forms into a single input."
],
for: [
type: :atom,
doc:
"When creating parameters for the action, the key that the forms should be gathered into. Defaults to the key used to configure the nested form. Ignored if `merge?` is `true`."
],
resource: [
type: :atom,
doc:
"The resource of the nested forms. Unnecessary if you are providing the `data` key, and not adding additional forms to this path."
],
create_action: [
type: :atom,
doc:
"The create action to use when building new forms. Only necessary if you want to use `add_form/3` with this path."
],
update_action: [
type: :atom,
doc:
"The update action to use when building forms for data. Only necessary if you supply the `data` key."
],
data: [
type: :any,
doc: """
The current value or values that should have update forms built by default.
You can also provide a single argument function that will return the data based on the
data of the parent form. This is important for multiple nesting levels of `:list` type
forms, because the data depends on which parent is being rendered.
"""
]
]
defp validate_opts_with_extra_keys(opts, schema) do
keys = Keyword.keys(schema)
{opts, extra} = Keyword.split(opts, keys)
opts = Spark.OptionsHelpers.validate!(opts, schema)
Keyword.merge(opts, extra)
end
import AshPhoenix.FormData.Helpers
@doc "Calls the corresponding `for_*` function depending on the action type"
def for_action(resource_or_data, action, opts) do
{resource, data} =
case resource_or_data do
module when is_atom(resource_or_data) -> {module, module.__struct__()}
%resource{} = data -> {resource, data}
end
type =
if is_atom(action) do
Ash.Resource.Info.action(resource, action).type
else
action.type
end
case type do
:create ->
for_create(resource, action, opts)
:update ->
for_update(data, action, opts)
:destroy ->
for_destroy(data, action, opts)
:read ->
for_read(resource, action, opts)
end
end
@doc """
Creates a form corresponding to a create action on a resource.
Options:
#{Spark.OptionsHelpers.docs(@for_opts)}
Any *additional* options will be passed to the underlying call to `Ash.Changeset.for_create/4`. This means
you can set things like the tenant/actor. These will be retained, and provided again when `Form.submit/3` is called.
## Nested Form Options
To automatically determine the nested forms available for a given form, use `forms: [auto?: true]`.
You can add additional nested forms by including them in the `forms` config alongside `auto?: true`.
See the module documentation of `AshPhoenix.Form.Auto` for more information. If you want to do some
manipulation of the auto forms, you can also call `AshPhoenix.Form.Auto.auto/2`, and then manipulate the
result and pass it to the `forms` option. To pass options, use `auto?: [option1: :value]`. See the
documentation of `AshPhoenix.Form.Auto` for more.
#{Spark.OptionsHelpers.docs(@nested_form_opts)}
"""
@spec for_create(Ash.Resource.t(), action :: atom, opts :: Keyword.t()) :: t()
def for_create(resource, action, opts \\ []) when is_atom(resource) do
opts =
opts
|> add_auto(resource, action)
|> update_opts()
|> validate_opts_with_extra_keys(@for_opts)
|> forms_for_type(:create)
changeset_opts =
Keyword.drop(opts, [
:forms,
:transform_errors,
:errors,
:id,
:method,
:for,
:as,
:transform_params,
:prepare_params,
:prepare_source
])
name = opts[:as] || "form"
id = opts[:id] || opts[:as] || "form"
{forms, params} =
handle_forms(
opts[:params] || %{},
opts[:forms] || [],
!!opts[:errors],
opts[:actor],
opts[:tenant],
[],
name,
id,
opts[:transform_errors],
opts[:warn_on_unhandled_errors?]
)
prepare_source = opts[:prepare_source] || (& &1)
%__MODULE__{
resource: resource,
action: action,
type: :create,
api: opts[:api],
params: params,
errors: opts[:errors],
transform_errors: opts[:transform_errors],
warn_on_unhandled_errors?: opts[:warn_on_unhandled_errors?],
name: name,
forms: forms,
form_keys: Keyword.new(List.wrap(opts[:forms])),
id: id,
touched_forms: touched_forms(forms, params, opts),
method: opts[:method] || form_for_method(:create),
transform_params: opts[:transform_params],
prepare_params: opts[:prepare_params],
prepare_source: opts[:prepare_source],
opts: opts,
source:
resource
|> Ash.Changeset.new()
|> prepare_source.()
|> Ash.Changeset.for_create(
action,
params,
changeset_opts
)
}
|> set_changed?()
|> set_validity()
end
@doc """
Creates a form corresponding to an update action on a record.
Options:
#{Spark.OptionsHelpers.docs(@for_opts)}
Any *additional* options will be passed to the underlying call to `Ash.Changeset.for_update/4`. This means
you can set things like the tenant/actor. These will be retained, and provided again when `Form.submit/3` is called.
"""
@spec for_update(Ash.Resource.record(), action :: atom, opts :: Keyword.t()) :: t()
def for_update(%resource{} = data, action, opts \\ []) do
opts =
opts
|> add_auto(resource, action)
|> update_opts()
|> validate_opts_with_extra_keys(@for_opts)
|> forms_for_type(:update)
changeset_opts =
Keyword.drop(opts, [
:forms,
:transform_errors,
:errors,
:id,
:method,
:for,
:as,
:transform_params,
:prepare_params,
:prepare_source
])
name = opts[:as] || "form"
id = opts[:id] || opts[:as] || "form"
prepare_source = opts[:prepare_source] || (& &1)
{forms, params} =
handle_forms(
opts[:params] || %{},
opts[:forms] || [],
!!opts[:errors],
opts[:actor],
opts[:tenant],
[
data | opts[:prev_data_trail] || []
],
name,
id,
opts[:transform_errors],
opts[:warn_on_unhandled_errors?],
[data]
)
%__MODULE__{
resource: resource,
data: data,
action: action,
type: :update,
api: opts[:api],
params: params,
errors: opts[:errors],
transform_errors: opts[:transform_errors],
warn_on_unhandled_errors?: opts[:warn_on_unhandled_errors?],
forms: forms,
form_keys: Keyword.new(List.wrap(opts[:forms])),
original_data: data,
method: opts[:method] || form_for_method(:update),
touched_forms: touched_forms(forms, params, opts),
transform_params: opts[:transform_params],
prepare_params: opts[:prepare_params],
prepare_source: opts[:prepare_source],
opts: opts,
id: id,
name: name,
source:
data
|> Ash.Changeset.new()
|> prepare_source.()
|> Ash.Changeset.for_update(
action,
params,
changeset_opts
)
}
|> set_changed?()
|> set_validity()
end
@doc """
Creates a form corresponding to a destroy action on a record.
Options:
#{Spark.OptionsHelpers.docs(@for_opts)}
Any *additional* options will be passed to the underlying call to `Ash.Changeset.for_destroy/4`. This means
you can set things like the tenant/actor. These will be retained, and provided again when `Form.submit/3` is called.
"""
@spec for_destroy(Ash.Resource.record(), action :: atom, opts :: Keyword.t()) :: t()
def for_destroy(%resource{} = data, action, opts \\ []) do
opts =
opts
|> add_auto(resource, action)
|> update_opts()
|> validate_opts_with_extra_keys(@for_opts)
|> forms_for_type(:destroy)
changeset_opts =
Keyword.drop(opts, [
:forms,
:transform_errors,
:errors,
:id,
:method,
:for,
:as,
:transform_params,
:prepare_params,
:prepare_source
])
name = opts[:as] || "form"
id = opts[:id] || opts[:as] || "form"
prepare_source = opts[:prepare_source] || (& &1)
{forms, params} =
handle_forms(
opts[:params] || %{},
opts[:forms] || [],
!!opts[:errors],
opts[:actor],
opts[:tenant],
[
data | opts[:prev_data_trail] || []
],
name,
id,
opts[:transform_errors],
opts[:warn_on_unhandled_errors?],
[data]
)
%__MODULE__{
resource: resource,
data: data,
action: action,
type: :destroy,
params: params,
errors: opts[:errors],
transform_errors: opts[:transform_errors],
warn_on_unhandled_errors?: opts[:warn_on_unhandled_errors?],
original_data: data,
forms: forms,
name: name,
id: id,
transform_params: opts[:transform_params],
prepare_params: opts[:prepare_params],
prepare_source: opts[:prepare_source],
api: opts[:api],
method: opts[:method] || form_for_method(:destroy),
touched_forms: touched_forms(forms, params, opts),
form_keys: Keyword.new(List.wrap(opts[:forms])),
opts: opts,
source:
data
|> Ash.Changeset.new()
|> prepare_source.()
|> Ash.Changeset.for_destroy(
action,
params,
changeset_opts
)
}
|> set_changed?()
|> set_validity()
end
@doc """
Creates a form corresponding to a read action on a resource.
Options:
#{Spark.OptionsHelpers.docs(@for_opts)}
Any *additional* options will be passed to the underlying call to `Ash.Query.for_read/4`. This means
you can set things like the tenant/actor. These will be retained, and provided again when `Form.submit/3` is called.
Keep in mind that the `source` of the form in this case is a query, not a changeset. This means that, very likely,
you would not want to use nested forms here. However, it could make sense if you had a query argument that was an
embedded resource, so the capability remains.
## Nested Form Options
#{Spark.OptionsHelpers.docs(@nested_form_opts)}
"""
@spec for_read(Ash.Resource.t(), action :: atom, opts :: Keyword.t()) :: t()
def for_read(resource, action, opts \\ []) when is_atom(resource) do
opts =
opts
|> add_auto(resource, action)
|> update_opts()
|> validate_opts_with_extra_keys(@for_opts)
|> forms_for_type(:read)
name = opts[:as] || "form"
id = opts[:id] || opts[:as] || "form"
{forms, params} =
handle_forms(
opts[:params] || %{},
opts[:forms] || [],
!!opts[:errors],
opts[:actor],
opts[:tenant],
[],
name,
id,
opts[:transform_errors],
opts[:warn_on_unhandled_errors?]
)
prepare_source = opts[:prepare_source] || (& &1)
query_opts =
Keyword.drop(opts, [
:forms,
:transform_errors,
:errors,
:id,
:method,
:for,
:as,
:transform_params,
:prepare_params,
:prepare_source
])
%__MODULE__{
resource: resource,
action: action,
type: :read,
data: opts[:data],
params: params,
errors: opts[:errors],
transform_errors: opts[:transform_errors],
warn_on_unhandled_errors?: opts[:warn_on_unhandled_errors?],
name: name,
forms: forms,
form_keys: Keyword.new(List.wrap(opts[:forms])),
id: id,
api: opts[:api],
method: opts[:method] || form_for_method(:create),
opts: opts,
touched_forms: touched_forms(forms, params, opts),
transform_params: opts[:transform_params],
prepare_params: opts[:prepare_params],
prepare_source: opts[:prepare_source],
source:
resource
|> Ash.Query.new()
|> prepare_source.()
|> Ash.Query.for_read(
action,
params || %{},
query_opts
)
|> add_errors_for_unhandled_params(params)
}
|> set_changed?()
|> set_validity()
end
defp add_errors_for_unhandled_params(%{action: nil} = query, _params), do: query
defp add_errors_for_unhandled_params(query, params) do
arguments = Enum.map(query.action.arguments, &to_string(&1.name))
remaining_params = Map.drop(params, arguments)
Enum.reduce(remaining_params, query, fn {key, value}, query ->
attribute = Ash.Resource.Info.public_attribute(query.resource, key)
if attribute do
case Ash.Type.Helpers.cast_input(attribute.type, value, attribute.constraints, query) do
{:ok, casted} ->
%{query | params: Map.put(query.params, key, casted)}
{:error, error} ->
messages =
if Keyword.keyword?(error) do
[error]
else
List.wrap(error)
end
messages
|> Enum.reduce(query, fn message, query ->
message
|> Ash.Type.Helpers.error_to_exception_opts(attribute)
|> Enum.reduce(query, fn opts, query ->
Ash.Query.add_error(query, Ash.Error.Changes.InvalidAttribute.exception(opts))
end)
end)
end
else
query
end
end)
end
@doc "A utility to get the list of attributes the action underlying the form accepts"
def attributes(form) do
form = to_form!(form)
AshPhoenix.Form.Auto.accepted_attributes(form.resource, form.source.action)
end
@doc "A utility to get the list of arguments the action underlying the form accepts"
def arguments(form) do
form = to_form!(form)
action =
case form.source.action do
action when is_atom(action) ->
Ash.Resource.Info.action(form.resource, action)
action ->
action
end
Enum.reject(action.arguments, & &1.private?)
end
@validate_opts [
errors: [
type: :boolean,
default: true,
doc: "Set to false to hide errors after validation"
]
]
@doc """
Validates the parameters against the form.
Options:
#{Spark.OptionsHelpers.docs(@validate_opts)}
"""
@spec validate(t(), map, Keyword.t()) :: t()
@spec validate(Phoenix.HTML.Form.t(), map, Keyword.t()) :: Phoenix.HTML.Form.t()
def validate(form, new_params, opts \\ [])
def validate(%Phoenix.HTML.Form{} = form, new_params, opts) do
form.source
|> validate(new_params, opts)
|> Phoenix.HTML.FormData.to_form(form.options)
end
def validate(form, new_params, opts) do
opts = validate_opts_with_extra_keys(opts, @validate_opts)
prepare_source = form.prepare_source || (& &1)
new_params =
if form.prepare_params do
form.prepare_params.(new_params, :validate)
else
new_params
end
matcher =
opts[:matcher] ||
fn nested_form, _params, root_form, key, index ->
nested_form.id == root_form.id <> "_#{key}_#{index}"
end
if new_params == form.params && !!opts[:errors] == form.errors do
%{form | submit_errors: nil}
|> set_validity()
|> set_changed?()
|> update_all_forms(fn form ->
%{form | just_submitted?: false}
end)
else
source_opts =
Keyword.drop(form.opts, [
:forms,
:transform_errors,
:errors,
:id,
:method,
:for,
:as
])
{forms, changeset_params} =
validate_nested_forms(
form,
new_params || %{},
!!opts[:errors],
(opts[:prev_data_trail] || []) ++ [form.data],
matcher
)
changeset_params =
if form.transform_params do
if is_function(form.transform_params, 2) do
form.transform_params.(changeset_params, :validate)
else
form.transform_params.(form, changeset_params, :validate)
end
else
changeset_params
end
new_source =
case form.type do
:create ->
form.resource
|> Ash.Changeset.new()
|> prepare_source.()
|> Ash.Changeset.for_create(
form.action,
changeset_params,
source_opts
)
:update ->
form.data
|> Ash.Changeset.new()
|> prepare_source.()
|> Ash.Changeset.for_update(
form.action,
changeset_params,
source_opts
)
:destroy ->
form.data
|> Ash.Changeset.new()
|> prepare_source.()
|> Ash.Changeset.for_destroy(
form.action,
changeset_params,
source_opts
)
:read ->
form.resource
|> Ash.Query.new()
|> prepare_source.()
|> Ash.Query.for_read(
form.action,
changeset_params,
source_opts
)
|> add_errors_for_unhandled_params(new_params)
end
%{
form
| source: new_source,
forms: forms,
params: new_params,
added?: form.added?,
errors: !!opts[:errors],
submit_errors: nil,
touched_forms: touched_forms(forms, new_params, touched_forms: form.touched_forms)
}
|> set_validity()
|> set_changed?()
|> update_all_forms(fn form ->
%{form | just_submitted?: false}
end)
end
end
@doc """
Merge the new options with the saved options on a form. See `update_options/2` for more.
"""
@spec merge_options(t(), Keyword.t()) :: t()
@spec merge_options(Phoenix.HTML.Form.t(), Keyword.t()) :: Phoenix.HTML.Form.t()
def merge_options(%Phoenix.HTML.Form{} = form, opts) do
form.source
|> update_options(opts)
|> Phoenix.HTML.FormData.to_form(form.options)
end
def merge_options(form, opts) do
update_options(form, &Keyword.merge(&1, opts))
end
@doc """
Update the saved options on a form.
When a form is created, options like `actor` and `authorize?` are stored in the `opts` key.
If you have a case where these options change over time, for example a select box that determines the actor, use this function to override those opts.
You may want to validate again after this has been changed if it can change the results of your form validation.
"""
def update_options(%Phoenix.HTML.Form{} = form, fun) do
form.source
|> update_options(fun)
|> Phoenix.HTML.FormData.to_form(form.options)
end
def update_options(form, fun) do
%{form | opts: fun.(form.opts)}
end
defp validate_nested_forms(
form,
params,
errors?,
prev_data_trail,
matcher,
trail \\ []
) do
form.form_keys
|> Enum.reduce({%{}, params}, fn {key, opts}, {forms, params} ->
case fetch_key(params, opts[:as] || key) do
{:ok, form_params} when form_params != nil ->
if opts[:type] == :list do
form_params =
if is_map(form_params) do
form_params
|> Enum.map(fn {key, value} ->
{value, String.to_integer(key)}
end)
|> Enum.sort_by(&elem(&1, 1))
else
Enum.with_index(form_params || [])
end
new_forms =
form_params
|> Enum.reduce(forms, fn {params, index}, forms ->
case Enum.find(form.forms[key] || [], &matcher.(&1, params, form, key, index)) do
nil ->
new_form =
cond do
!opts[:create_action] && !opts[:read_action] ->
raise AshPhoenix.Form.NoActionConfigured,
path: form.name <> "[#{key}][#{index}]",
action: :create_or_read
opts[:create_action] ->
create_action = opts[:create_action]
resource =
opts[:create_resource] || opts[:resource] ||
raise AshPhoenix.Form.NoResourceConfigured,
path: Enum.reverse(trail, [key])
for_action(resource, create_action,
actor: form.opts[:actor],
tenant: form.opts[:tenant],
params: params,
forms: opts[:forms] || [],
transform_params: opts[:transform_params],
errors: errors?,
warn_on_unhandled_errors?: form.warn_on_unhandled_errors?,
prev_data_trail: prev_data_trail,
transform_errors: form.transform_errors,
as: form.name <> "[#{key}][#{index}]",
id: form.id <> "_#{key}_#{index}"
)
opts[:read_action] ->
create_action = opts[:read_action]
resource =
opts[:read_resource] || opts[:resource] ||
raise AshPhoenix.Form.NoResourceConfigured,
path: Enum.reverse(trail, [key])
for_action(resource, create_action,
actor: form.opts[:actor],
tenant: form.opts[:tenant],
params: params,
transform_params: opts[:transform_params],
forms: opts[:forms] || [],
errors: errors?,
warn_on_unhandled_errors?: form.warn_on_unhandled_errors?,
prev_data_trail: prev_data_trail,
transform_errors: form.transform_errors,
as: form.name <> "[#{key}][#{index}]",
id: form.id <> "_#{key}_#{index}"
)
end
Map.update(forms, key, [new_form], &(&1 ++ [new_form]))
matching_form ->
validated =
validate(matching_form, params,
errors: errors?,
matcher: matcher,
prev_data_trail?: prev_data_trail
)
|> Map.put(:as, form.name <> "[#{key}][#{index}]")
|> Map.put(:id, form.id <> "_#{key}_#{index}")
Map.update(forms, key, [validated], fn nested_forms ->
nested_forms ++
[validated]
end)
end
end)
new_params =
if Map.has_key?(new_forms, opts[:as] || key) do
new_nested =
new_forms
|> Map.get(opts[:as] || key)
|> List.wrap()
|> Enum.with_index()
|> Map.new(fn {form, index} ->
{to_string(index),
apply_or_return(form, form.params, form.transform_params, :nested)}
end)
Map.put(params, to_string(opts[:as] || key), new_nested)
else
params
end
{new_forms, new_params}
else
if is_map(form_params) do
new_forms =
if form.forms[key] do
new_form =
validate(form.forms[key], form_params, errors: errors?, matcher: matcher)
Map.put(forms, key, new_form)
else
create_action =
opts[:create_action] ||
raise AshPhoenix.Form.NoActionConfigured,
path: form.name <> "[#{key}]",
action: :create
resource =
opts[:create_resource] || opts[:resource] ||
raise AshPhoenix.Form.NoResourceConfigured,
path: form.name <> "[#{key}]"
new_form =
for_action(resource, create_action,
actor: form.opts[:actor],
tenant: form.opts[:tenant],
params: form_params,
transform_params: opts[:transform_params],
warn_on_unhandled_errors?: form.warn_on_unhandled_errors?,
forms: opts[:forms] || [],
errors: errors?,
prev_data_trail: prev_data_trail,
transform_errors: form.transform_errors,
as: form.name <> "[#{key}]",
id: form.id <> "_#{key}"
)
Map.put(forms, key, new_form)
end
new_params =
Map.put(
params,
to_string(opts[:as] || key),
apply_or_return(
new_forms[key],
new_forms[key].params,
new_forms[key].transform_params,
:nested
)
)
{new_forms, new_params}
else
{forms, params}
end
end
_ ->
new_forms =
if Keyword.has_key?(opts, :data) do
cond do
opts[:update_action] ->
update_action = opts[:update_action]
data =
if opts[:data] do
if is_function(opts[:data]) do
if Enum.at(prev_data_trail, 0) do
case call_data(opts[:data], prev_data_trail) do
%Ash.NotLoaded{} ->
raise AshPhoenix.Form.NoDataLoaded,
path: Enum.reverse(trail, [key])
other ->
other
end
else
nil
end
else
opts[:data]
end
end
if data do
form_values =
if (opts[:type] || :single) == :single do
for_action(data, update_action,
actor: form.opts[:actor],
tenant: form.opts[:tenant],
errors: errors?,
transform_params: opts[:transform_params],
warn_on_unhandled_errors?: form.warn_on_unhandled_errors?,
prev_data_trail: prev_data_trail,
forms: opts[:forms] || [],
transform_errors: form.transform_errors,
as: form.name <> "[#{key}]",
id: form.id <> "_#{key}"
)
else
data
|> Enum.with_index()
|> Enum.map(fn {data, index} ->
for_action(data, update_action,
actor: form.opts[:actor],
tenant: form.opts[:tenant],
errors: errors?,
warn_on_unhandled_errors?: form.warn_on_unhandled_errors?,
transform_params: opts[:transform_params],
prev_data_trail: prev_data_trail,
forms: opts[:forms] || [],
transform_errors: form.transform_errors,
as: form.name <> "[#{key}][#{index}]",
id: form.id <> "_#{key}_#{index}"
)
end)
end
Map.put(forms, key, form_values)
else
forms
end
opts[:read_action] ->
read_action = opts[:read_action]
data =
if opts[:data] do
if is_function(opts[:data]) do
if Enum.at(prev_data_trail, 0) do
case call_data(opts[:data], prev_data_trail) do
%Ash.NotLoaded{} ->
raise AshPhoenix.Form.NoDataLoaded,
path: Enum.reverse(trail, [key])
other ->
other
end
else
nil
end
else
opts[:data]
end
end
if data do
form_values =
if (opts[:type] || :single) == :single do
pkey = Ash.Resource.Info.primary_key(data.__struct__)
for_action(data, read_action,
actor: form.opts[:actor],
tenant: form.opts[:tenant],
errors: errors?,
warn_on_unhandled_errors?: form.warn_on_unhandled_errors?,
transform_params: opts[:transform_params],
params: Map.new(pkey, &{to_string(&1), Map.get(data, &1)}),
prev_data_trail: prev_data_trail,
forms: opts[:forms] || [],
data: data,
transform_errors: form.transform_errors,
as: form.name <> "[#{key}]",
id: form.id <> "_#{key}"
)
else
pkey =
unless Enum.empty?(data) do
Ash.Resource.Info.primary_key(Enum.at(data, 0).__struct__)
end
data
|> Enum.with_index()
|> Enum.map(fn {data, index} ->
for_action(data, read_action,
actor: form.opts[:actor],
tenant: form.opts[:tenant],
errors: errors?,
prev_data_trail: prev_data_trail,
params: Map.new(pkey, &{to_string(&1), Map.get(data, &1)}),
transform_params: opts[:transform_params],
forms: opts[:forms] || [],
data: data,
transform_errors: form.transform_errors,
as: form.name <> "[#{key}][#{index}]",
id: form.id <> "_#{key}_#{index}"
)
end)
end
Map.put(forms, key, form_values)
else
forms
end
true ->
forms
end
else
forms
end
{new_forms, params}
end
end)
end
@submit_opts [
force?: [
type: :boolean,
default: false,
doc: "Submit the form even if it is invalid in its current state."
],
api_opts: [
type: :keyword_list,
default: [],
doc: "Opts to pass to the call to the api when submitting"
],
errors: [
type: :boolean,
default: true,
doc: "Wether or not to show errors after submitting."
],
override_params: [
type: :any,
doc: """
If specified, then the params are not extracted from the form.
How this different from `params`: providing `params` is simply results in calling `validate(form, params)` before proceeding.
The values that are passed into the action are then extracted from the form using `params/2`. With `override_params`, the form
is not validated again, and the `override_params` are passed directly into the action.
"""
],
params: [
type: :any,
doc: """
If specified, `validate/3` is called with the new params before submitting the form.
This is a shortcut to avoid needing to explicitly validate before every submit.
For example:
```elixir
form
|> AshPhoenix.Form.validate(params)
|> AshPhoenix.Form.submit()
```
Is the same as:
```elixir
form
|> AshPhoenix.Form.submit(params: params)
```
"""
],
read_one?: [
type: :boolean,
default: false,
doc: """
If submitting a read form, a single result will be returned (via read_one) instead of a list of results.
Ignored for non-read forms.
"""
],
before_submit: [
type: {:fun, 1},
doc:
"A function to apply to the source (changeset or query) just before submitting the action. Must return the modified changeset."
]
]
@doc """
Submits the form by calling the appropriate function on the configured api.
For example, a form created with `for_update/3` will call `api.update(changeset)`, where
changeset is the result of passing the `Form.params/3` into `Ash.Changeset.for_update/4`.
If the submission returns an error, the resulting form can simply be rerendered. Any nested
errors will be passed down to the corresponding form for that input.
Options:
#{Spark.OptionsHelpers.docs(@submit_opts)}
"""
@spec submit(t(), Keyword.t()) ::
{:ok, Ash.Resource.record() | nil | list(Ash.Notifier.Notification.t())}
| {:ok, Ash.Resource.record(), list(Ash.Notifier.Notification.t())}
| :ok
| {:error, t()}
@spec submit(Phoenix.HTML.Form.t(), Keyword.t()) ::
{:ok, Ash.Resource.record() | nil | list(Ash.Notifier.Notification.t())}
| {:ok, Ash.Resource.record(), list(Ash.Notifier.Notification.t())}
| :ok
| {:error, Phoenix.HTML.Form.t()}
def submit(form, opts \\ [])
def submit(%Phoenix.HTML.Form{} = form, opts) do
form.source
|> submit(opts)
|> case do
{:error, new_form} ->
{:error, Phoenix.HTML.FormData.to_form(new_form, form.options)}
other ->
other
end
end
def submit(form, opts) do
changeset_opts = Keyword.drop(form.opts, [:forms, :errors, :id, :method, :for, :as])
form =
if opts[:params] do
validate(
form,
opts[:params],
Keyword.take(opts, Keyword.keys(@validate_opts))
)
else
form
end
form =
if Keyword.get(opts, :errors, true) do
update_all_forms(form, &%{&1 | errors: true})
else
form
end
opts = validate_opts_with_extra_keys(opts, @submit_opts)
before_submit = opts[:before_submit] || (& &1)
if form.valid? || opts[:force?] do
form = clear_errors(form)
unless form.api do
raise """
No Api configured, but one is required to submit the form.
For example:
Form.for_create(Resource, :action, api: MyApp.MyApi)
"""
end
case Ash.Api.Info.resource(form.api, form.resource) do
{:ok, _} ->
:ok
{:error, error} ->
raise error
end
changeset_params = opts[:override_params] || params(form)
prepare_source = form.prepare_source || (& &1)
{original_changeset_or_query, result} =
case form.type do
:create ->
form.resource
|> Ash.Changeset.new()
|> prepare_source.()
|> Ash.Changeset.for_create(
form.source.action.name,
changeset_params,
changeset_opts
)
|> before_submit.()
|> with_changeset(&form.api.create(&1, opts[:api_opts] || []))
:update ->
form.original_data
|> Ash.Changeset.new()
|> prepare_source.()
|> Ash.Changeset.for_update(
form.source.action.name,
changeset_params,
changeset_opts
)
|> before_submit.()
|> with_changeset(&form.api.update(&1, opts[:api_opts] || []))
:destroy ->
form.original_data
|> Ash.Changeset.new()
|> prepare_source.()
|> Ash.Changeset.for_destroy(
form.source.action.name,
changeset_params,
changeset_opts
)
|> before_submit.()
|> with_changeset(&form.api.destroy(&1, opts[:api_opts] || []))
:read ->
if opts[:read_one?] do
form.resource
|> Ash.Query.new()
|> prepare_source.()
|> Ash.Query.for_read(
form.source.action.name,
changeset_params,
changeset_opts
)
|> before_submit.()
|> with_changeset(&form.api.read_one(&1, opts[:api_opts] || []))
else
form.resource
|> Ash.Query.for_read(
form.source.action.name,
opts[:override_params] || params(form),
changeset_opts
)
|> before_submit.()
|> with_changeset(&form.api.read(&1, opts[:api_opts] || []))
end
end
case result do
{:error, %Ash.Error.Invalid.NoSuchResource{resource: resource}} ->
raise """
Resource #{inspect(resource)} not found in api #{inspect(form.api)}
"""
{:error, %{query: query} = error} when form.type == :read ->
if opts[:raise?] do
raise Ash.Error.to_error_class(query.errors, query: query)
else
query = %{(query || original_changeset_or_query) | errors: []}
errors =
error
|> List.wrap()
|> Enum.flat_map(&expand_error/1)
{:error,
set_action_errors(
%{form | source: query},
errors
)
|> update_all_forms(fn form ->
%{form | just_submitted?: true, submitted_once?: true}
end)
|> set_changed?()}
end
{:error, %{changeset: changeset} = error} when form.type != :read ->
if opts[:raise?] do
raise Ash.Error.to_error_class(changeset.errors, changeset: changeset)
else
changeset = %{(changeset || original_changeset_or_query) | errors: []}
errors =
error
|> List.wrap()
|> Enum.flat_map(&expand_error/1)
{:error,
set_action_errors(
%{form | source: changeset},
errors
)
|> update_all_forms(fn form ->
%{form | just_submitted?: true, submitted_once?: true}
end)}
end
other ->
other
end
else
if opts[:raise?] do
case form.source do
%Ash.Query{} = query ->
raise Ash.Error.to_error_class(query.errors, query: query)
%Ash.Changeset{} = changeset ->
raise Ash.Error.to_error_class(changeset.errors, changeset: changeset)
end
else
{:error,
form
|> update_all_forms(fn form -> %{form | submitted_once?: true, just_submitted?: true} end)
|> synthesize_action_errors()}
end
end
end
defp with_changeset(changeset, func) do
{changeset, func.(changeset)}
end
@doc """
Same as `submit/2`, but raises an error if the submission fails.
"""
@spec submit!(t(), Keyword.t()) :: Ash.Resource.record() | :ok | no_return
def submit!(form, opts \\ []) do
case submit(form, Keyword.put(opts, :raise?, true)) do
{:ok, value} ->
value
{:ok, result, notifications} ->
{result, notifications}
:ok ->
:ok
_ ->
:error
end
end
@update_form_opts [
mark_as_touched?: [
type: :boolean,
default: true,
doc: "Whether or not to mark the path to the updating form as touched"
]
]
@doc """
Mark a field or fields as touched
To mark nested fields as touched use with `update_form/4` or `update_forms_at_path/4`
"""
def touch(form, fields) when is_list(fields) do
Enum.reduce(fields, form, &touch(&2, &1))
end
def touch(form, field) do
%{form | touched_forms: MapSet.put(form.touched_forms || MapSet.new(), to_string(field))}
end
@doc """
Updates the list of forms matching a given path. Does not validate that the path points at a single form like `update_form/4`.
Additionally, if it gets to a list of child forms and the next part of the path is not an integer,
it will update all of the forms at that path.
"""
def update_forms_at_path(form, path, func, opts \\ [])
def update_forms_at_path(nil, _, _, _), do: nil
def update_forms_at_path(forms, [] = path, func, opts) when is_list(forms) do
Enum.map(forms, &update_forms_at_path(&1, path, func, opts))
end
def update_forms_at_path(forms, [next | rest] = path, func, opts) when is_list(forms) do
case Integer.parse(next) do
{integer, ""} ->
List.update_at(forms, integer, &update_forms_at_path(&1, rest, func, opts))
_ ->
Enum.map(forms, &update_forms_at_path(&1, path, func, opts))
end
end
def update_forms_at_path(%Phoenix.HTML.Form{} = form, path, func, opts) do
form.source
|> update_forms_at_path(path, func, opts)
|> Phoenix.HTML.FormData.to_form(form.options)
end
def update_forms_at_path(form, path, func, opts) do
opts = Spark.OptionsHelpers.validate!(opts, @update_form_opts)
path =
case path do
[] ->
[]
path when is_list(path) ->
path
path ->
path
|> Plug.Conn.Query.decode()
|> decoded_to_list()
end
case path do
[] ->
func.(form)
[key | rest] ->
new_forms =
form.forms
|> Map.new(fn {key, value} ->
if to_string(key) == key do
{key, update_forms_at_path(value, rest, func, opts)}
else
{key, value}
end
end)
if opts[:mark_as_touched?] do
%{
form
| forms: new_forms,
touched_forms: MapSet.put(form.touched_forms, key)
}
else
%{
form
| forms: new_forms
}
end
end
end
@doc """
Updates the form at the provided path using the given function.
Marks all forms along the path as touched by default. To prevent it, provide `mark_as_touched?: false`.
This can be useful if you have a button that should modify a nested form in some way, for example.
"""
@spec update_form(t(), list(atom | integer) | String.t(), (t() -> t())) :: t()
@spec update_form(Phoenix.HTML.Form.t(), list(atom | integer) | String.t(), (t() -> t())) ::
Phoenix.HTML.Form.t()
def update_form(form, path, func, opts \\ [])
def update_form(%Phoenix.HTML.Form{} = form, path, func, opts) do
form.source
|> update_form(path, func, opts)
|> Phoenix.HTML.FormData.to_form(form.options)
end
def update_form(form, path, func, opts) do
opts = Spark.OptionsHelpers.validate!(opts, @update_form_opts)
path =
case path do
[] ->
[]
path when is_list(path) ->
path
path ->
parse_path!(form, path)
end
case path do
[] ->
func.(form)
[atom, integer | rest] when is_atom(atom) and is_integer(integer) ->
new_forms =
form.forms
|> Map.update!(atom, fn nested_forms ->
List.update_at(nested_forms, integer, &update_form(&1, rest, func))
end)
if opts[:mark_as_touched?] do
%{
form
| forms: new_forms,
touched_forms: MapSet.put(form.touched_forms, to_string(atom))
}
else
%{
form
| forms: new_forms
}
end
[atom | rest] ->
new_forms =
form.forms
|> Map.update!(atom, &update_form(&1, rest, func, opts))
if opts[:mark_as_touched?] do
%{
form
| forms: new_forms,
touched_forms: MapSet.put(form.touched_forms, to_string(atom))
}
else
%{
form
| forms: new_forms
}
end
end
end
@doc """
Returns true if a given form path exists in the form
"""
@spec has_form?(t(), list(atom | integer) | String.t()) :: boolean
def has_form?(form, path) do
form = to_form!(form)
not is_nil(get_form(form, path))
rescue
InvalidPath ->
false
end
@doc """
Gets the form at the specified path
"""
@spec get_form(t() | Phoenix.HTML.Form.t(), list(atom | integer) | String.t()) :: t() | nil
def get_form(form, path) do
form = to_form!(form)
path =
case path do
[] ->
[]
path when is_list(path) ->
path
path ->
parse_path!(form, path)
end
case path do
[] ->
form
[atom, integer | rest] when is_atom(atom) and is_integer(integer) ->
form.forms
|> Map.get(atom)
|> List.wrap()
|> find_form(integer, form.form_keys[atom])
|> case do
nil ->
nil
form ->
get_form(form, rest)
end
[atom | rest] ->
form.forms
|> Map.get(atom)
|> case do
%__MODULE__{} = form ->
get_form(form, rest)
_ ->
nil
end
end
end
defp to_form!(%__MODULE__{} = form), do: form
defp to_form!(%Phoenix.HTML.Form{source: %__MODULE__{} = form}), do: form
defp to_form!(%Phoenix.HTML.Form{source: inner_form}) do
raise ArgumentError, """
Expected to receive either an `%AshPhoenix.Form{}` or a `%Phoenix.HTML.Form{}` with `%AshPhoenix.Form{}` as its source.
Got a `%Phoenix.HTML.Form{}` with source: #{inspect(inner_form)}
"""
end
defp to_form!(form) do
raise ArgumentError, """
Expected to receive either an `%AshPhoenix.Form{}` or a `%Phoenix.HTML.Form{}` with `%AshPhoenix.Form{}` as its source.
Got: #{inspect(form)}
"""
end
defp add_index(form_params, index, opts) do
if opts[:sparse?] do
Map.put(form_params, "_index", to_string(index))
else
form_params
end
end
defp find_form(forms, index, config) do
if config[:sparse?] do
Enum.find(forms, fn form ->
form.params["_index"] == to_string(index)
end) ||
Enum.at(forms, index)
else
Enum.at(forms, index)
end
end
@errors_opts [
format: [
type: {:one_of, [:simple, :raw, :plaintext]},
default: :simple,
doc: """
Values:
- `:raw` - `[field:, {message, substitutions}}]` (for translation)
- `:simple` - `[field: "message w/ variables substituted"]`
- `:plaintext` - `["field: message w/ variables substituted"]`
"""
],
for_path: [
type: :any,
default: [],
doc: """
The path of the form you want errors for, either as a list or as a string, e.g `[:comments, 0]` or `form[comments][0]`
Passing `:all` will cause this function to return a map of path to its errors, like so:
`%{[:comments, 0] => [body: "is invalid"], ...}`
"""
]
]
@doc """
Returns the errors on the form.
By default, only errors on the form being passed in (not nested forms) are provided.
Use `for_path` to get errors for nested forms.
#{Spark.OptionsHelpers.docs(@errors_opts)}
"""
@spec errors(t() | Phoenix.HTML.Form.t(), Keyword.t()) ::
([{atom, {String.t(), Keyword.t()}}]
| [String.t()]
| [{atom, String.t()}])
| %{
list => [{atom, {String.t(), Keyword.t()}}] | [String.t()] | [{atom, String.t()}]
}
def errors(form, opts \\ []) do
form = to_form!(form)
opts = validate_opts_with_extra_keys(opts, @errors_opts)
case opts[:for_path] do
:all ->
gather_errors(form, opts[:format])
[] ->
errors =
if form.errors do
if form.just_submitted? do
form.submit_errors
else
transform_errors(form, form.source.errors, [], form.form_keys)
end
else
[]
end
errors
|> List.wrap()
|> format_errors(opts[:format])
path ->
form
|> gather_errors(opts[:format])
|> Map.get(path)
|> List.wrap()
end
end
defp format_errors(errors, :raw), do: errors
defp format_errors(errors, :simple) do
Enum.map(errors, fn {field, {message, vars}} ->
message = replace_vars(message, vars)
{field, message}
end)
end
defp format_errors(errors, :plaintext) do
Enum.map(errors, fn {field, {message, vars}} ->
message = replace_vars(message, vars)
"#{field}: " <> message
end)
end
defp gather_errors(form, format, acc \\ %{}, trail \\ []) do
errors = errors(form, format: format)
acc =
if Enum.empty?(errors) do
acc
else
Map.put(acc, trail, errors)
end
Enum.reduce(form.forms, acc, fn {key, forms}, acc ->
case forms do
[] ->
acc
nil ->
acc
forms when is_list(forms) ->
forms
|> Enum.with_index()
|> Enum.reduce(acc, fn {form, i}, acc ->
gather_errors(form, format, acc, trail ++ [key, i])
end)
form ->
gather_errors(form, format, acc, trail ++ [key])
end
end)
end
@doc false
@spec errors_for(
t() | Phoenix.HTML.Form.t(),
list(atom | integer) | String.t(),
type :: :simple | :raw | :plaintext
) ::
[{atom, {String.t(), Keyword.t()}}] | [String.t()] | map | nil
@deprecated "Use errors/2 instead"
def errors_for(form, path, type \\ :raw) do
form = to_form!(form)
path =
case path do
[] ->
[]
path when is_list(path) ->
path
path ->
parse_path!(form, path)
end
case path do
[] ->
if form.submit_errors do
case type do
:raw ->
form.submit_errors || []
:simple ->
Map.new(form.submit_errors || [], fn {field, {message, vars}} ->
message = replace_vars(message, vars)
{field, message}
end)
:plaintext ->
Enum.map(form.submit_errors || [], fn {field, {message, vars}} ->
message = replace_vars(message, vars)
"#{field}: " <> message
end)
end
else
[]
end
[atom, integer | rest] when is_atom(atom) and is_integer(integer) ->
form.forms
|> Map.get(atom)
|> Enum.at(integer)
|> errors_for(rest, type)
[atom | rest] ->
form.forms
|> Map.get(atom)
|> errors_for(rest, type)
end
end
@doc """
Sets the data of the form, in addition to the data of the underlying source, if applicable.
Queries do not track data (because that wouldn't make sense), so this will not update the data
for read actions
"""
def set_data(%Phoenix.HTML.Form{} = form, data) do
form.source
|> set_data(data)
|> Phoenix.HTML.FormData.to_form(form.options)
end
def set_data(form, data) do
case form.source do
%Ash.Changeset{} = source ->
%{form | data: data, source: %{source | data: data}}
%Ash.Query{} ->
%{form | data: data}
end
end
@doc """
Clears a given input's value on a form.
Accepts a field (atom) or a list of fields (atoms) as a second argument.
"""
@spec clear_value(t(), atom | [atom]) :: t()
def clear_value(%Phoenix.HTML.Form{} = form, field_or_fields) do
form.source
|> clear_value(field_or_fields)
|> Phoenix.HTML.FormData.to_form(form.options)
end
def clear_value(form, field_or_fields) when is_list(field_or_fields) do
Enum.reduce(field_or_fields, form, &clear_value(&2, &1))
end
def clear_value(form, field) do
string_and_atom = [field, to_string(field)]
common_dropped = %{
form
| params: Map.drop(form.params, string_and_atom),
source: %{
form.source
| params: Map.drop(form.source.params, string_and_atom),
arguments: Map.drop(form.source.arguments, string_and_atom)
}
}
case form.source do
%Ash.Changeset{} = _source ->
%{
common_dropped
| source: %{
common_dropped.source
| attributes: Map.drop(common_dropped.source.attributes, string_and_atom)
}
}
_ ->
common_dropped
end
end
@doc """
Gets the value for a given field in the form.
"""
@spec value(t() | Phoenix.HTML.Form.t(), atom) :: any()
def value(form, field) do
form = to_form!(form)
do_value(form, field)
end
defp do_value(%{source: %Ash.Changeset{} = changeset} = form, field) do
with :error <- get_changing_value(changeset, field),
:error <- Ash.Changeset.fetch_argument(changeset, field),
:error <- get_non_attribute_non_argument_param(changeset, form, field),
:error <- Map.fetch(changeset.data, field) do
nil
else
{:ok, %Ash.NotLoaded{}} ->
nil
{:ok, value} ->
value
end
end
defp do_value(%{source: %Ash.Query{} = query, data: data}, field) do
case Ash.Query.fetch_argument(query, field) do
{:ok, value} ->
value
:error ->
case Map.fetch(query.params, to_string(field)) do
{:ok, value} ->
value
:error ->
if data do
Map.get(data, field)
end
end
end
end
defp get_changing_value(changeset, field) do
Map.fetch(changeset.attributes, field)
end
defp get_non_attribute_non_argument_param(changeset, form, field) do
if Ash.Resource.Info.attribute(changeset.resource, field) ||
Enum.any?(changeset.action.arguments, &(&1.name == field)) do
:error
else
Map.fetch(AshPhoenix.Form.params(form), Atom.to_string(field))
end
end
@doc """
Toggles the form to be ignored or not ignored.
To set this manually in an html form, use the field `:_ignored` and set it
to the string "true". Any other value will not result in the form being ignored.
"""
@spec ignore(t()) :: t()
def ignore(%Phoenix.HTML.Form{} = form) do
form.source
|> ignore()
|> Phoenix.HTML.FormData.to_form(form.options)
end
def ignore(form) do
if ignored?(form) do
%{form | params: Map.delete(form.params, "_ignore")}
else
%{form | params: Map.put(form.params, "_ignore", "true")}
end
end
@doc """
Returns true if the form is ignored
"""
@spec ignored?(t() | Phoenix.HTML.Form.t()) :: boolean
def ignored?(form) do
form = to_form!(form)
form.params["_ignore"] == "true"
end
@doc """
Returns the parameters from the form that would be submitted to the action.
This can be useful if you want to get the parameters and manipulate them/build a custom changeset
afterwards.
"""
@spec params(t()) :: map
def params(form, opts \\ []) do
form = to_form!(form)
# These options aren't documented because they are still experimental
hidden? = Keyword.get(opts, :hidden?, true)
excluded_empty_fields =
Keyword.get(
opts,
:exclude_fields_if_empty,
Keyword.get(form.opts, :exclude_fields_if_empty, [])
)
indexer = opts[:indexer]
indexed_lists? = opts[:indexed_lists?] || not is_nil(indexer) || false
transform = opts[:transform]
transform? = Keyword.get(opts, :transform?, true)
produce = opts[:produce]
set_params = opts[:set_params]
only_touched? = Keyword.get(opts, :only_touched?, true)
filter = opts[:filter] || fn _ -> true end
opts = Keyword.put(opts, :transform?, false)
form_keys =
form.form_keys
|> Keyword.keys()
|> Enum.flat_map(&[&1, to_string(&1)])
params =
form.params
|> Map.drop(form_keys)
|> exclude_empty_fields(excluded_empty_fields)
params =
if only_touched? do
Map.take(params, Enum.to_list(form.touched_forms))
else
params
end
params =
if hidden? do
hidden = hidden_fields(form)
hidden_stringified = hidden |> Map.new(fn {field, value} -> {to_string(field), value} end)
Map.merge(hidden_stringified, params)
else
params
end
untransformed_params =
form.form_keys
|> only_touched(form, only_touched?)
|> Enum.reduce(params, fn {key, config}, params ->
for_name = to_string(config[:for] || key)
case config[:type] || :single do
:single ->
nested_form = form.forms[key]
if nested_form && filter.(nested_form) do
opts =
Keyword.put(
opts,
:exclude_fields_if_empty,
Keyword.get(excluded_empty_fields, key, [])
)
nested_params = params(nested_form, opts)
if nested_params["_ignore"] == "true" do
Map.put(params, for_name, nil)
else
if form.form_keys[key][:merge?] do
Map.merge(nested_params || %{}, params)
else
nested_params =
apply_or_return(
nested_form,
nested_params,
nested_form.transform_params,
:nested,
transform?
)
Map.put(params, for_name, nested_params)
end
end
else
if is_touched?(form, key) || !only_touched? do
Map.put(params, for_name, nil)
else
params
end
end
:list ->
if form.forms[key] do
forms =
form.forms[key]
|> Kernel.||([])
|> Enum.filter(fn form ->
filter.(form) && form.params["_ignore"] != "true"
end)
if indexed_lists? do
params
|> Map.put_new(for_name, %{})
|> Map.update!(for_name, fn current ->
if indexer do
Enum.reduce(forms, current, fn form, current ->
nested_params =
apply_or_return(
form,
params(form, opts),
form.transform_params,
:nested,
transform?
)
Map.put(current, indexer.(form), nested_params)
end)
else
max =
current
|> Map.keys()
|> Enum.map(&String.to_integer/1)
|> Enum.max(fn -> -1 end)
forms
|> Enum.reduce({current, max + 1}, fn form, {current, i} ->
nested_params =
apply_or_return(
form,
params(form, opts),
form.transform_params,
:nested,
transform?
)
{Map.put(current, to_string(i), nested_params), i + 1}
end)
|> elem(0)
end
end)
else
params
|> Map.put_new(for_name, [])
|> Map.update!(for_name, fn current ->
current ++
Enum.map(forms, fn form ->
apply_or_return(
form,
params(form, opts),
form.transform_params,
:nested,
transform?
)
end)
end)
end
else
if is_touched?(form, key) || !only_touched? do
Map.put(params, for_name, [])
else
params
end
end
end
end)
with_produced_params =
if produce do
Map.merge(
produce.(form),
untransformed_params
)
else
untransformed_params
end
with_set_params =
if set_params do
Map.merge(with_produced_params, set_params.(form))
else
with_produced_params
end
transformed_via_option =
if transform do
Map.new(with_set_params, transform)
else
with_set_params
end
apply_or_return(form, transformed_via_option, form.transform_params, :validate, transform?)
end
defp only_touched(form_keys, form, true) do
Enum.filter(form_keys, fn {key, _} ->
is_touched?(form, key)
end)
end
defp only_touched(form_keys, _, _), do: form_keys
defp is_touched?(form, key), do: MapSet.member?(form.touched_forms, to_string(key))
@add_form_opts [
prepend: [
type: :boolean,
default: false,
doc:
"If specified, the form is placed at the beginning of the list instead of the end of the list"
],
params: [
type: :any,
default: %{},
doc: "The initial parameters to add the form with."
],
validate?: [
type: :boolean,
default: true,
doc: "Validates the new full form."
],
validate_opts: [
type: :any,
default: [],
doc:
"Options to pass to `validate`. Only used if `validate?` is set to `true` (the default)"
],
type: [
type: {:one_of, [:read, :create, :update, :destroy]},
default: :create,
doc:
"If `type` is set to `:read`, the form will be created for a read action. A hidden field will be set in the form called `_form_type` to track this information."
],
data: [
type: :any,
doc: """
The data to set backing the form. Generally you'd only want to do this if you are adding a form with `type: :read` additionally.
"""
]
]
@doc """
Adds a new form at the provided path.
Doing this requires that the form has a `create_action` and a `resource` configured.
`path` can be one of two things:
1. A list of atoms and integers that lead to a form in the `forms` option provided. `[:posts, 0, :comments]` to add a comment to the first post.
2. The html name of the form, e.g `form[posts][0][comments]` to mimic the above
If you pass parameters to this function, keep in mind that, unless they are string keyed in
the same shape they might come from your form, then the result of `params/1` will reflect that,
i.e `add_form(form, "foo", %{bar: 10})`, could produce params like `%{"field" => value, "foo" => [%{bar: 10}]}`"}.
Notice how they are not string keyed as you would expect. However, once the form is changed (in liveview) and a call
to `validate/2` is made with that input, then the parameters would become what you'd expect. In this way, if you are using
`add_form` with not string keys/values you may not be able to depend on the shape of the `params` map (which you should ideally
not depend on anyway).
#{Spark.OptionsHelpers.docs(@add_form_opts)}
"""
@spec add_form(t(), String.t() | atom | list(atom | integer), Keyword.t()) :: t()
@spec add_form(Phoenix.HTML.Form.t(), String.t() | atom | list(atom | integer), Keyword.t()) ::
Phoenix.HTML.Form.t()
def add_form(form, path, opts \\ [])
def add_form(%Phoenix.HTML.Form{} = form, path, opts) do
form.source
|> add_form(path, opts)
|> Phoenix.HTML.FormData.to_form(form.options)
end
def add_form(form, path, opts) do
opts = Spark.OptionsHelpers.validate!(opts, @add_form_opts)
form =
if is_binary(path) do
path = parse_path!(form, path)
do_add_form(form, path, opts, [], form.transform_errors)
else
path = List.wrap(path)
do_add_form(form, path, opts, [], form.transform_errors)
end
if opts[:validate?] do
validate(form, params(form, transform?: false), opts[:validate_opts] || [])
else
set_changed?(form)
end
end
@remove_form_opts [
validate?: [
type: :boolean,
default: true,
doc: "Validates the new full form."
],
validate_opts: [
type: :any,
default: [],
doc:
"Options to pass to `validate`. Only used if `validate?` is set to `true` (the default)"
]
]
@doc """
Removes a form at the provided path.
See `add_form/3` for more information on the `path` argument.
If you are not using liveview, and you want to support removing forms that were created based on the `data`
option from the browser, you'll need to include in the form submission a custom list of strings to remove, and
then manually iterate over them in your controller, for example:
```elixir
Enum.reduce(removed_form_paths, form, &AshPhoenix.Form.remove_form(&2, &1))
```
#{Spark.OptionsHelpers.docs(@remove_form_opts)}
"""
def remove_form(form, path, opts \\ [])
def remove_form(%Phoenix.HTML.Form{} = form, path, opts) do
form.source
|> remove_form(path, opts)
|> Phoenix.HTML.FormData.to_form(form.options)
end
def remove_form(form, path, opts) do
opts = Spark.OptionsHelpers.validate!(opts, @remove_form_opts)
if has_form?(form, path) do
form =
if is_binary(path) do
path = parse_path!(form, path)
do_remove_form(form, path, [])
else
path = List.wrap(path)
do_remove_form(form, path, [])
end
form = set_changed?(form)
if opts[:validate?] do
validate(form, params(form, transform?: false), opts[:validate_opts] || [])
else
form
end
else
form
end
end
defp forms_for_type(opts, type) do
if opts[:forms] do
Keyword.update!(opts, :forms, fn forms ->
Enum.filter(forms, fn {_key, config} ->
is_nil(config[:for_type]) || type in config[:for_type]
end)
end)
else
opts
end
end
defp set_changed?(form) do
%{form | changed?: changed?(form)}
end
defp changed?(form) do
form.any_removed? ||
is_changed?(form) ||
Enum.any?(form.forms, fn {_key, forms} ->
forms
|> List.wrap()
|> Enum.any?(&(&1.changed? || &1.added?))
end)
end
defp is_changed?(form) do
attributes_changed?(form) || arguments_changed?(form)
end
defp attributes_changed?(%{source: %Ash.Query{}}), do: false
defp attributes_changed?(form) do
changeset = form.source
changeset.attributes
|> Map.drop(Enum.map(form.form_keys, &elem(&1, 0)))
|> Map.delete(:last_editor_save)
|> Enum.any?(fn {key, value} ->
original_value =
case Map.get(changeset.data, key) do
nil ->
default(changeset.resource, key)
value ->
value
end
try do
Comp.not_equal?(value, original_value)
rescue
_ ->
true
end
end)
end
@doc false
def arguments_changed?(form) do
form = to_form!(form)
changeset = form.source
changeset.arguments
|> Map.drop(Enum.map(form.form_keys, &elem(&1, 0)))
|> Enum.any?(fn {key, value} ->
action =
if is_atom(changeset.action) do
Ash.Resource.Info.action(changeset.resource, changeset.action)
else
changeset.action
end
original_value = default_argument(action, key)
value != original_value
end)
end
# if the value is the same as the default, we don't want to consider it as changed
defp default_argument(action, key) do
action.arguments
|> Enum.find(&(&1.name == key))
|> case do
nil ->
nil
argument ->
cond do
is_nil(argument.default) ->
nil
is_function(argument.default) ->
argument.default.()
true ->
argument.default
end
end
end
defp default(resource, key) do
attribute = Ash.Resource.Info.attribute(resource, key)
cond do
is_nil(attribute.default) ->
nil
is_function(attribute.default) ->
attribute.default.()
true ->
attribute.default
end
end
defp apply_or_return(_form, value, function, type, condition \\ true)
defp apply_or_return(_form, value, _function, _type, false), do: value
defp apply_or_return(_form, value, nil, _type, _), do: value
defp apply_or_return(_, value, function, type, _) when is_function(function, 2),
do: function.(value, type)
defp apply_or_return(form, value, function, type, _) when is_function(function, 3),
do: function.(form, value, type)
@doc """
Returns the hidden fields for a form as a keyword list
"""
@spec hidden_fields(t() | Phoenix.HTML.Form.t()) :: Keyword.t()
def hidden_fields(form) do
form = to_form!(form)
hidden =
if form.type in [:read, :update, :destroy] && form.data do
pkey =
form.resource
|> Ash.Resource.Info.public_attributes()
|> Enum.filter(& &1.primary_key?)
|> Enum.reject(& &1.private?)
|> Enum.map(& &1.name)
form.data
|> Map.take(pkey)
|> Enum.to_list()
else
[]
end
hidden = Keyword.put(hidden, :_form_type, to_string(form.type))
hidden =
case form.touched_forms |> Enum.join(",") do
"" -> hidden
fields -> Keyword.put(hidden, :_touched, fields)
end
if form.params["_index"] && form.params["_index"] != "" do
Keyword.put(hidden, :_index, form.params["_index"])
else
hidden
end
end
@doc false
def update_opts(opts) do
if opts[:forms] do
Keyword.update!(opts, :forms, fn forms ->
Enum.map(forms, fn
{:auto?, value} ->
{:auto?, value}
{key, opts} ->
if opts[:updater] do
{key, Keyword.delete(opts[:updater].(opts), :updater)}
else
{key, opts}
end
end)
end)
else
opts
end
end
defp replace_vars(message, vars) do
Enum.reduce(vars || [], message, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end
defp touched_forms(forms, params, opts) do
touched_forms = opts[:touched_forms] || MapSet.new()
touched_forms =
Enum.reduce(forms, touched_forms, fn {key, _form_or_forms}, touched_forms ->
if Map.has_key?(params, to_string(key)) do
MapSet.put(touched_forms, to_string(key))
else
touched_forms
end
end)
touched_forms =
if is_map(params) do
Enum.reduce(Map.keys(params) -- ["_touched"], touched_forms, &MapSet.put(&2, &1))
else
touched_forms
end
form_touched =
if is_map(params) do
params["_touched"]
end
if is_binary(form_touched) do
form_touched
|> String.split(",")
|> Enum.concat(Map.keys(params) -- [""])
|> Enum.reduce(touched_forms, fn key, touched_forms ->
MapSet.put(touched_forms, key)
end)
else
touched_forms
end
end
defp update_all_forms(form, func) do
form
|> func.()
|> Map.update!(:forms, fn forms ->
Map.new(forms, fn {key, value} ->
case value do
%__MODULE__{} = form ->
{key, update_all_forms(form, func)}
list when is_list(list) ->
{key, Enum.map(list, &update_all_forms(&1, func))}
other ->
{key, update_all_forms(other, func)}
end
end)
end)
end
defp do_remove_form(form, [key], trail) when not is_integer(key) do
unless form.form_keys[key] do
raise AshPhoenix.Form.NoFormConfigured,
field: key,
available: Keyword.keys(form.form_keys || []),
path: Enum.reverse(trail)
end
found_form = form.forms[key]
any_removed? =
if found_form && !found_form.added? do
true
else
false
end
new_config =
form.form_keys
|> Keyword.update!(key, fn config ->
if config[:data] do
Keyword.put(config, :data, nil)
else
config
end
end)
new_forms =
if (form.form_keys[:type] || :single) == :single do
Map.put(form.forms, key, nil)
else
Map.put(form.forms, key, [])
end
%{
form
| forms: new_forms,
any_removed?: form.any_removed? || any_removed?,
form_keys: new_config,
touched_forms: MapSet.put(form.touched_forms, to_string(key)),
opts: Keyword.put(form.opts, :forms, new_config)
}
end
defp do_remove_form(form, [key, i], trail) when is_integer(i) do
unless form.form_keys[key] do
raise AshPhoenix.Form.NoFormConfigured,
field: key,
available: Keyword.keys(form.form_keys || []),
path: Enum.reverse(trail)
end
new_config = do_remove_data(form, key, i)
found_form = Enum.at(form.forms[key] || [], i)
any_removed? =
if found_form && !found_form.added? do
true
else
false
end
new_forms =
form.forms
|> Map.put_new(key, [])
|> Map.update!(key, fn forms ->
forms
|> Kernel.||([])
|> List.delete_at(i)
|> Enum.with_index()
|> Enum.map(fn {nested_form, i} ->
%{nested_form | name: form.name <> "[#{key}][#{i}]", id: form.id <> "_#{key}_#{i}"}
end)
end)
%{
form
| forms: new_forms,
any_removed?: form.any_removed? || any_removed?,
touched_forms: MapSet.put(form.touched_forms, to_string(key)),
form_keys: new_config,
opts: Keyword.put(form.opts, :forms, new_config)
}
end
defp do_remove_form(form, [key, i | rest], trail) when is_integer(i) do
unless form.form_keys[key] do
raise AshPhoenix.Form.NoFormConfigured,
field: key,
available: Keyword.keys(form.form_keys || []),
path: Enum.reverse(trail)
end
new_forms =
form.forms
|> Map.put_new(key, [])
|> Map.update!(key, fn forms ->
List.update_at(forms, i, &do_remove_form(&1, rest, [i, key | trail]))
end)
%{form | forms: new_forms, touched_forms: MapSet.put(form.touched_forms, to_string(key))}
end
defp do_remove_form(form, [key | rest], trail) do
unless form.form_keys[key] do
raise AshPhoenix.Form.NoFormConfigured,
field: key,
available: Keyword.keys(form.form_keys || []),
path: Enum.reverse(trail)
end
new_forms =
form.forms
|> Map.put_new(key, [])
|> Map.update!(key, &do_remove_form(&1, rest, [key | trail]))
%{form | forms: new_forms, touched_forms: MapSet.put(form.touched_forms, to_string(key))}
end
defp do_remove_form(_form, path, trail) do
raise InvalidPath, path: Enum.reverse(trail, path)
end
defp do_add_form(form, [key, i | rest], opts, trail, transform_errors) when is_integer(i) do
unless form.form_keys[key] do
raise AshPhoenix.Form.NoFormConfigured,
field: key,
available: Keyword.keys(form.form_keys || []),
path: Enum.reverse(trail)
end
new_forms =
form.forms
|> Map.put_new(key, [])
|> Map.update!(key, fn forms ->
index =
if form.form_keys[key][:sparse?] do
Enum.find_index(forms, fn form ->
form.params["_index"] == to_string(i)
end) || i
else
i
end
List.update_at(
forms,
index,
&do_add_form(&1, rest, opts, [i, key | trail], transform_errors)
)
end)
%{form | forms: new_forms, touched_forms: MapSet.put(form.touched_forms, to_string(key))}
end
defp do_add_form(form, [key], opts, trail, transform_errors) do
config =
form.form_keys[key] ||
raise AshPhoenix.Form.NoFormConfigured,
field: key,
available: Keyword.keys(form.form_keys || []),
path: Enum.reverse(trail)
default =
case config[:type] || :single do
:single ->
nil
:list ->
[]
end
new_config =
if opts[:prepend] && config[:type] == :list do
do_prepend_data(form, key)
else
form.form_keys
end
new_forms =
form.forms
|> Map.put_new(key, default)
|> Map.update!(key, fn forms ->
{resource, action} = add_form_resource_and_action(opts, config, key, trail)
data_or_resource =
if opts[:data] do
opts[:data]
else
resource
end
new_form =
for_action(
data_or_resource,
action,
Keyword.merge(opts[:validate_opts] || [],
params: opts[:params] || %{},
actor: form.opts[:actor],
tenant: form.opts[:tenant],
transform_params: config[:transform_params],
warn_on_unhandled_errors?: form.warn_on_unhandled_errors?,
forms: config[:forms] || [],
data: opts[:data],
transform_errors: transform_errors
)
)
case config[:type] || :single do
:single ->
%{new_form | name: form.name <> "[#{key}]", id: form.id <> "_#{key}", added?: true}
:list ->
forms = List.wrap(forms)
if opts[:prepend] do
[%{new_form | added?: true} | forms]
else
forms ++ [%{new_form | added?: true}]
end
|> Enum.with_index()
|> Enum.map(fn {nested_form, index} ->
%{
nested_form
| name: form.name <> "[#{key}][#{index}]",
id: form.id <> "_#{key}_#{index}"
}
end)
end
end)
%{
form
| forms: new_forms,
form_keys: new_config,
opts: Keyword.put(form.opts, :forms, new_config),
touched_forms: MapSet.put(form.touched_forms, to_string(key))
}
end
defp do_add_form(form, [key | rest], opts, trail, transform_errors) do
unless form.form_keys[key] do
raise AshPhoenix.Form.NoFormConfigured,
field: key,
available: Keyword.keys(form.form_keys || []),
path: Enum.reverse(trail)
end
new_forms =
form.forms
|> Map.put_new(key, [])
|> Map.update!(key, &do_add_form(&1, rest, opts, [key | trail], transform_errors))
%{form | forms: new_forms, touched_forms: MapSet.put(form.touched_forms, to_string(key))}
end
defp do_add_form(_form, path, _opts, trail, _) do
raise InvalidPath, path: Enum.reverse(trail, List.wrap(path))
end
defp do_prepend_data(form, key) do
form.form_keys
|> Keyword.update!(key, fn config ->
if config[:data] do
Keyword.update!(config, :data, fn data ->
cond do
is_function(data, 1) ->
fn original_data -> [nil | data.(original_data)] end
is_function(data, 2) ->
fn original_data, trail -> [nil | data.(original_data, trail)] end
true ->
[nil | data]
end
end)
else
config
end
end)
end
defp do_remove_data(form, key, i) do
form.form_keys
|> Keyword.update!(key, fn config ->
if config[:data] do
Keyword.update!(config, :data, fn data ->
cond do
is_function(data, 1) ->
fn original_data -> List.delete_at(data.(original_data), i) end
is_function(data, 2) ->
fn original_data, trail -> List.delete_at(data.(original_data, trail), i) end
true ->
List.delete_at(data, i)
end
end)
else
config
end
end)
end
defp add_form_resource_and_action(opts, config, key, trail) do
default =
cond do
config[:create_action] && (config[:create_resource] || config[:resource]) ->
:create
config[:read_action] && (config[:read_resource] || config[:resource]) ->
:read
config[:update_action] && (config[:update_resource] || config[:resource]) ->
:update
config[:destroy_action] && (config[:destroy_resource] || config[:resource]) ->
:destroy
true ->
:create
end
action =
case opts[:type] || default do
:create ->
config[:create_action] ||
raise AshPhoenix.Form.NoActionConfigured,
path: Enum.reverse(trail, [key]),
action: :create
:update ->
config[:update_action] ||
raise AshPhoenix.Form.NoActionConfigured,
path: Enum.reverse(trail, [key]),
action: :update
:destroy ->
config[:destroy_action] ||
raise AshPhoenix.Form.NoActionConfigured,
path: Enum.reverse(trail, [key]),
action: :destroy
:read ->
config[:read_action] ||
raise AshPhoenix.Form.NoActionConfigured,
path: Enum.reverse(trail, [key]),
action: :read
end
resource =
case opts[:type] || default do
:create ->
config[:create_resource] || config[:resource] ||
raise AshPhoenix.Form.NoResourceConfigured, path: Enum.reverse(trail, [key])
:update ->
config[:update_resource] || config[:resource] ||
raise AshPhoenix.Form.NoResourceConfigured, path: Enum.reverse(trail, [key])
:destroy ->
config[:destroy_resource] || config[:resource] ||
raise AshPhoenix.Form.NoResourceConfigured, path: Enum.reverse(trail, [key])
:read ->
config[:read_resource] || config[:resource] ||
raise AshPhoenix.Form.NoResourceConfigured, path: Enum.reverse(trail, [key])
end
{<