improvement: expose paths for filters

improvement: simple error handling patterns for filter forms
This commit is contained in:
Zach Daniel 2021-12-02 03:12:18 -05:00
parent a61a7763cc
commit 0687568299
4 changed files with 244 additions and 77 deletions

View file

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

View file

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

View file

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

View file

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