mirror of
https://github.com/ash-project/ash_phoenix.git
synced 2024-09-20 07:12:49 +12:00
improvement: expose paths for filters
improvement: simple error handling patterns for filter forms
This commit is contained in:
parent
a61a7763cc
commit
0687568299
4 changed files with 244 additions and 77 deletions
|
@ -2,6 +2,7 @@ defmodule AshPhoenix.FilterForm do
|
|||
defstruct [
|
||||
:id,
|
||||
:resource,
|
||||
:transform_errors,
|
||||
valid?: false,
|
||||
negated?: false,
|
||||
params: %{},
|
||||
|
@ -19,6 +20,16 @@ defmodule AshPhoenix.FilterForm do
|
|||
doc: "Initial parameters to create the form with",
|
||||
default: %{}
|
||||
],
|
||||
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 predicate and the error, and should
|
||||
return a list of ash phoenix formatted errors, e.g `[{field :: atom, message :: String.t(), substituations :: Keyword.t()}]`
|
||||
"""
|
||||
],
|
||||
remove_empty_groups?: [
|
||||
type: :boolean,
|
||||
doc: """
|
||||
|
@ -55,17 +66,21 @@ defmodule AshPhoenix.FilterForm do
|
|||
|> params_to_list()
|
||||
|> add_ids()
|
||||
|
||||
%__MODULE__{
|
||||
form = %__MODULE__{
|
||||
id: params["id"] || params[:id],
|
||||
resource: resource,
|
||||
params: params,
|
||||
remove_empty_groups?: opts[:remove_empty_groups?],
|
||||
components:
|
||||
parse_components(resource, params["components"] || params[:components],
|
||||
remove_empty_groups?: opts[:remove_empty_groups?]
|
||||
),
|
||||
operator: to_existing_atom(params["operator"] || params[:operator] || :and)
|
||||
}
|
||||
|
||||
%{
|
||||
form
|
||||
| components:
|
||||
parse_components(resource, form, params["components"] || params[:components],
|
||||
remove_empty_groups?: opts[:remove_empty_groups?]
|
||||
)
|
||||
}
|
||||
|> set_validity()
|
||||
end
|
||||
|
||||
|
@ -142,11 +157,17 @@ defmodule AshPhoenix.FilterForm do
|
|||
@doc """
|
||||
Returns a flat list of all errors on all predicates in the filter.
|
||||
"""
|
||||
def errors(%__MODULE__{components: components}) do
|
||||
Enum.flat_map(components, &errors/1)
|
||||
def errors(form, opts \\ [])
|
||||
|
||||
def errors(%__MODULE__{components: components, transform_errors: transform_errors}, opts) do
|
||||
Enum.flat_map(
|
||||
components,
|
||||
&errors(&1, Keyword.put_new(opts, :handle_errors, transform_errors))
|
||||
)
|
||||
end
|
||||
|
||||
def errors(%Predicate{errors: errors}), do: errors
|
||||
def errors(%Predicate{} = predicate, opts),
|
||||
do: AshPhoenix.FilterForm.Predicate.errors(predicate, opts[:transform_errors])
|
||||
|
||||
defp do_to_filter(%__MODULE__{components: []}, _), do: {:ok, true}
|
||||
|
||||
|
@ -186,11 +207,16 @@ defmodule AshPhoenix.FilterForm do
|
|||
end
|
||||
|
||||
defp do_to_filter(
|
||||
%Predicate{field: field, value: value, operator: operator, negated?: negated?} =
|
||||
predicate,
|
||||
%Predicate{
|
||||
field: field,
|
||||
value: value,
|
||||
operator: operator,
|
||||
negated?: negated?,
|
||||
path: path
|
||||
} = predicate,
|
||||
resource
|
||||
) do
|
||||
ref = Ash.Query.expr(ref(^field, []))
|
||||
ref = Ash.Query.expr(ref(^field, ^path))
|
||||
|
||||
expr =
|
||||
if Ash.Filter.get_function(operator, resource) do
|
||||
|
@ -202,18 +228,10 @@ defmodule AshPhoenix.FilterForm do
|
|||
{:ok, operator}
|
||||
|
||||
{:error, error} ->
|
||||
{:error,
|
||||
Ash.Error.Query.InvalidQuery.exception(
|
||||
field: field,
|
||||
message: "Error constructing operator: #{error}"
|
||||
)}
|
||||
{:error, error}
|
||||
end
|
||||
else
|
||||
{:error,
|
||||
Ash.Error.Query.InvalidQuery.exception(
|
||||
field: field,
|
||||
message: "No such function or operator #{operator}"
|
||||
)}
|
||||
{:error, {:operator, "No such function or operator #{operator}"}, []}
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -308,31 +326,54 @@ defmodule AshPhoenix.FilterForm do
|
|||
end
|
||||
end
|
||||
|
||||
defp parse_components(resource, component_params, form_opts) do
|
||||
defp parse_components(resource, parent, component_params, form_opts) do
|
||||
component_params
|
||||
|> Kernel.||([])
|
||||
|> Enum.map(&parse_component(resource, &1, form_opts))
|
||||
|> Enum.map(&parse_component(resource, parent, &1, form_opts))
|
||||
end
|
||||
|
||||
defp parse_component(resource, params, form_opts) do
|
||||
defp parse_component(resource, parent, params, form_opts) do
|
||||
if is_operator?(params) do
|
||||
# Eventually, components may have references w/ paths
|
||||
# also, we should validate references here
|
||||
new_predicate(params)
|
||||
new_predicate(params, parent)
|
||||
else
|
||||
new(resource, Keyword.put(form_opts, :params, params))
|
||||
end
|
||||
end
|
||||
|
||||
defp new_predicate(params) do
|
||||
%AshPhoenix.FilterForm.Predicate{
|
||||
defp new_predicate(params, form) do
|
||||
predicate = %AshPhoenix.FilterForm.Predicate{
|
||||
id: params[:id] || params["id"] || Ash.UUID.generate(),
|
||||
field: to_existing_atom(params["field"] || params[:field]),
|
||||
value: params["value"] || params[:value],
|
||||
path: parse_path(params),
|
||||
params: params,
|
||||
negated?: negated?(params),
|
||||
operator: to_existing_atom(params["operator"] || params[:operator] || :eq)
|
||||
}
|
||||
|
||||
%{predicate | errors: predicate_errors(predicate, form.resource)}
|
||||
end
|
||||
|
||||
defp parse_path(params) do
|
||||
path = params[:path] || params["path"]
|
||||
|
||||
case path do
|
||||
"" ->
|
||||
[]
|
||||
|
||||
nil ->
|
||||
[]
|
||||
|
||||
path when is_list(path) ->
|
||||
Enum.map(path, &to_existing_atom/1)
|
||||
|
||||
path ->
|
||||
path
|
||||
|> String.split()
|
||||
|> Enum.map(&to_existing_atom/1)
|
||||
end
|
||||
end
|
||||
|
||||
defp negated?(params) do
|
||||
|
@ -365,7 +406,7 @@ defmodule AshPhoenix.FilterForm do
|
|||
)
|
||||
|
||||
%Predicate{} ->
|
||||
new_predicate(params)
|
||||
new_predicate(params, form)
|
||||
end
|
||||
else
|
||||
component
|
||||
|
@ -375,7 +416,7 @@ defmodule AshPhoenix.FilterForm do
|
|||
else
|
||||
component =
|
||||
if is_operator?(params) do
|
||||
new_predicate(params)
|
||||
new_predicate(params, form)
|
||||
else
|
||||
new(form.resource, params: params, remove_empty_groups?: form.remove_empty_groups?)
|
||||
end
|
||||
|
@ -389,7 +430,12 @@ defmodule AshPhoenix.FilterForm do
|
|||
end
|
||||
|
||||
defp to_existing_atom(value) when is_atom(value), do: value
|
||||
defp to_existing_atom(value), do: String.to_existing_atom(value)
|
||||
|
||||
defp to_existing_atom(value) do
|
||||
String.to_existing_atom(value)
|
||||
rescue
|
||||
_ -> value
|
||||
end
|
||||
|
||||
@doc "Returns the list of available predicates for the given resource, which may be functions or operators."
|
||||
def predicates(resource) do
|
||||
|
@ -410,12 +456,12 @@ defmodule AshPhoenix.FilterForm do
|
|||
end)
|
||||
end
|
||||
|
||||
@doc "Returns the list of available fields, which may be attribuets, calculations, or aggregates."
|
||||
def fields(form) do
|
||||
form.resource
|
||||
@doc "Returns the list of available fields, which may be attributes, calculations, or aggregates."
|
||||
def fields(resource) do
|
||||
resource
|
||||
|> Ash.Resource.Info.public_aggregates()
|
||||
|> Enum.concat(Ash.Resource.Info.public_calculations(form.resource))
|
||||
|> Enum.concat(Ash.Resource.Info.public_attributes(form.resource))
|
||||
|> Enum.concat(Ash.Resource.Info.public_calculations(resource))
|
||||
|> Enum.concat(Ash.Resource.Info.public_attributes(resource))
|
||||
|> Enum.map(& &1.name)
|
||||
end
|
||||
|
||||
|
@ -439,14 +485,16 @@ defmodule AshPhoenix.FilterForm do
|
|||
|
||||
predicate_id = Ash.UUID.generate()
|
||||
|
||||
predicate = %Predicate{
|
||||
id: predicate_id,
|
||||
field: field,
|
||||
value: value,
|
||||
operator: operator_or_function
|
||||
}
|
||||
|
||||
predicate = %{predicate | errors: predicate_errors(predicate, form.resource)}
|
||||
predicate =
|
||||
new_predicate(
|
||||
%{
|
||||
id: predicate_id,
|
||||
field: field,
|
||||
value: value,
|
||||
operator: operator_or_function
|
||||
},
|
||||
form
|
||||
)
|
||||
|
||||
if opts[:to] && opts[:to] != form.id do
|
||||
{set_validity(%{
|
||||
|
@ -493,23 +541,35 @@ defmodule AshPhoenix.FilterForm do
|
|||
end
|
||||
|
||||
defp predicate_errors(predicate, resource) do
|
||||
errors =
|
||||
case Ash.Resource.Info.public_field(resource, predicate.field) do
|
||||
nil ->
|
||||
[Ash.Error.Query.NoSuchAttribute.exception(resource: resource, name: predicate.field)]
|
||||
case Ash.Resource.Info.related(resource, predicate.path) do
|
||||
nil ->
|
||||
[
|
||||
{:operator, "Invalid path #{Enum.join(predicate.path, ".")}", []}
|
||||
]
|
||||
|
||||
_ ->
|
||||
[]
|
||||
end
|
||||
resource ->
|
||||
errors =
|
||||
case Ash.Resource.Info.public_field(resource, predicate.field) do
|
||||
nil ->
|
||||
[
|
||||
{:field, "No such field #{predicate.field}", []}
|
||||
]
|
||||
|
||||
if Ash.Filter.get_function(predicate.operator, resource) do
|
||||
errors
|
||||
else
|
||||
if Ash.Filter.get_operator(predicate.operator) do
|
||||
errors
|
||||
else
|
||||
[Ash.Error.Query.NoSuchOperator.exception(name: predicate.operator) | errors]
|
||||
end
|
||||
_ ->
|
||||
[]
|
||||
end
|
||||
|
||||
if Ash.Filter.get_function(predicate.operator, resource) do
|
||||
errors
|
||||
else
|
||||
if Ash.Filter.get_operator(predicate.operator) do
|
||||
errors
|
||||
else
|
||||
[
|
||||
{:operator, "No such operator #{predicate.operator}", []} | errors
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -626,7 +686,10 @@ defmodule AshPhoenix.FilterForm do
|
|||
|
||||
@impl true
|
||||
def to_form(form, _, :components, _opts) do
|
||||
Enum.map(form.components, &Phoenix.HTML.Form.form_for(&1, "action"))
|
||||
Enum.map(
|
||||
form.components,
|
||||
&Phoenix.HTML.Form.form_for(&1, "action", transform_errors: form.transform_errors)
|
||||
)
|
||||
end
|
||||
|
||||
def to_form(_, _, other, _) do
|
||||
|
|
|
@ -10,21 +10,65 @@ defmodule AshPhoenix.FilterForm.Predicate do
|
|||
operator: :eq,
|
||||
params: %{},
|
||||
negated?: false,
|
||||
path: [],
|
||||
errors: [],
|
||||
valid?: false
|
||||
]
|
||||
|
||||
def errors(predicate, transform_errors) do
|
||||
predicate.errors
|
||||
|> Enum.filter(fn
|
||||
error when is_exception(error) ->
|
||||
AshPhoenix.FormData.Error.impl_for(error)
|
||||
|
||||
{_key, _value, _vars} ->
|
||||
true
|
||||
|
||||
_ ->
|
||||
false
|
||||
end)
|
||||
|> Enum.flat_map(
|
||||
&AshPhoenix.FormData.Helpers.transform_predicate_error(
|
||||
predicate,
|
||||
&1,
|
||||
transform_errors
|
||||
)
|
||||
)
|
||||
|> Enum.map(fn
|
||||
{field, message, vars} ->
|
||||
vars =
|
||||
vars
|
||||
|> List.wrap()
|
||||
|> Enum.flat_map(fn {key, value} ->
|
||||
try do
|
||||
if is_integer(value) do
|
||||
[{key, value}]
|
||||
else
|
||||
[{key, to_string(value)}]
|
||||
end
|
||||
rescue
|
||||
_ ->
|
||||
[]
|
||||
end
|
||||
end)
|
||||
|
||||
{field, {message || "", vars}}
|
||||
end)
|
||||
end
|
||||
|
||||
defimpl Phoenix.HTML.FormData do
|
||||
@impl true
|
||||
def to_form(predicate, opts) do
|
||||
hidden = [id: predicate.id]
|
||||
|
||||
errors = AshPhoenix.FilterForm.Predicate.errors(predicate, opts[:transform_errors])
|
||||
|
||||
%Phoenix.HTML.Form{
|
||||
source: predicate,
|
||||
impl: __MODULE__,
|
||||
id: predicate.id,
|
||||
name: predicate.id,
|
||||
errors: [],
|
||||
errors: errors,
|
||||
data: predicate,
|
||||
params: predicate.params,
|
||||
hidden: hidden,
|
||||
|
@ -43,9 +87,10 @@ defmodule AshPhoenix.FilterForm.Predicate do
|
|||
def input_value(%{value: value}, _, :value), do: value
|
||||
def input_value(%{operator: operator}, _, :operator), do: operator
|
||||
def input_value(%{negated?: negated?}, _, :negated), do: negated?
|
||||
def input_value(%{path: path}, _, :path), do: Enum.join(path, ".")
|
||||
|
||||
def input_value(_, _, field) do
|
||||
raise "Invalid filter form field #{field}. Only :negated, :operator, :field and :value are supported"
|
||||
raise "Invalid filter form field #{field}. Only :negated, :operator, :field, :path, :value are supported"
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
|
@ -150,7 +150,7 @@ defmodule AshPhoenix.FormData.Helpers do
|
|||
nil ->
|
||||
case error do
|
||||
{_key, _value, _vars} = error ->
|
||||
error
|
||||
[error]
|
||||
|
||||
error ->
|
||||
if AshPhoenix.FormData.Error.impl_for(error) do
|
||||
|
@ -162,22 +162,48 @@ defmodule AshPhoenix.FormData.Helpers do
|
|||
end
|
||||
end
|
||||
|
||||
# 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 transform_predicate_error(predicate, error, transform_errors) do
|
||||
case transform_errors do
|
||||
transformer when is_function(transformer, 2) ->
|
||||
case transformer.(predicate, error) do
|
||||
error when is_exception(error) ->
|
||||
if AshPhoenix.FormData.Error.impl_for(error) do
|
||||
List.wrap(to_form_error(error))
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
# value ->
|
||||
# Ash.Changeset.set_context(changeset, %{
|
||||
# manage_relationship_source:
|
||||
# value ++ [{relationship.source, relationship.name, original_changeset}]
|
||||
# })
|
||||
# end
|
||||
# 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(to_form_error(error))
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
{key, value, vars} ->
|
||||
[{key, value, vars}]
|
||||
end)
|
||||
end
|
||||
|
||||
nil ->
|
||||
case error do
|
||||
{_key, _value, _vars} = error ->
|
||||
[error]
|
||||
|
||||
error ->
|
||||
if AshPhoenix.FormData.Error.impl_for(error) do
|
||||
List.wrap(to_form_error(error))
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp to_form_error(exception) when is_exception(exception) do
|
||||
case AshPhoenix.FormData.Error.to_form_error(exception) do
|
||||
|
|
|
@ -110,6 +110,23 @@ defmodule AshPhoenix.FilterFormTest do
|
|||
contains(title, "new")
|
||||
)
|
||||
end
|
||||
|
||||
test "predicates can reference paths" do
|
||||
form =
|
||||
FilterForm.new(Post,
|
||||
params: %{
|
||||
field: :text,
|
||||
operator: :contains,
|
||||
path: "comments",
|
||||
value: "new"
|
||||
}
|
||||
)
|
||||
|
||||
assert Ash.Query.equivalent_to?(
|
||||
FilterForm.filter!(Post, form),
|
||||
contains(comments.text, "new")
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe "form_data implementation" do
|
||||
|
@ -175,5 +192,21 @@ defmodule AshPhoenix.FilterFormTest do
|
|||
assert input_value(predicate_form, :operator) == :eq
|
||||
assert input_value(predicate_form, :negated) == false
|
||||
end
|
||||
|
||||
test "using an unknown operator shows an error" do
|
||||
assert [predicate_form] =
|
||||
Post
|
||||
|> FilterForm.new(
|
||||
params: %{
|
||||
field: :title,
|
||||
operator: "what_on_earth",
|
||||
value: "new post"
|
||||
}
|
||||
)
|
||||
|> form_for("action")
|
||||
|> inputs_for(:components)
|
||||
|
||||
assert [{:operator, {"No such operator what_on_earth", []}}] = predicate_form.errors
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in a new issue