diff --git a/dev/resources/accounts/calculations/concat.ex b/dev/resources/accounts/calculations/concat.ex new file mode 100644 index 0000000..f9e6cfd --- /dev/null +++ b/dev/resources/accounts/calculations/concat.ex @@ -0,0 +1,30 @@ +defmodule Demo.Calculations.Concat do + use Ash.Resource.Calculation + + @impl true + def init(opts) do + if opts[:keys] && is_list(opts[:keys]) && Enum.all?(opts[:keys], &is_atom/1) do + {:ok, opts} + else + {:error, "Expected a `keys` option for which keys to concat"} + end + end + + @impl true + def load(_query, opts, _context) do + opts[:keys] + end + + @impl true + def calculate(records, opts, %{arguments: %{separator: separator}}) when is_bitstring(separator) do + Enum.map(records, fn record -> + Enum.map_join(opts[:keys], separator, fn key -> + to_string(Map.get(record, key)) + end) + end) + end + + def calculate(_records, _opts, _context) do + {:error, "Argument separator invalid"} + end +end diff --git a/dev/resources/accounts/resources/user.ex b/dev/resources/accounts/resources/user.ex index b2a5254..4bd1a9d 100644 --- a/dev/resources/accounts/resources/user.ex +++ b/dev/resources/accounts/resources/user.ex @@ -20,6 +20,8 @@ defmodule Demo.Accounts.User do read_actions [:me, :read, :by_id, :by_name] table_columns [:id, :first_name, :last_name, :representative, :admin, :full_name, :api_key, :date_of_birth] + + show_calculations [:multi_arguments, :full_name] end multitenancy do @@ -82,7 +84,28 @@ defmodule Demo.Accounts.User do end calculations do - calculate :full_name, :string, expr(first_name <> " " <> last_name) + calculate :full_name, :string, {Demo.Calculations.Concat, keys: [:first_name, :last_name]} do + argument :separator, :string do + allow_nil? false + constraints [allow_empty?: true, trim?: false] + default " " + end + end + + calculate :is_super_admin?, :boolean, expr(admin && representative) + + calculate :multi_arguments, :string, expr("Arg1: " <> ^arg(:arg1) <> ", Arg2: " <> (if ^arg(:arg2), do: "Yes", else: "No") <> ", Arg3: " <> ^arg(:arg3)) do + argument :arg1, :string do + allow_nil? false + constraints [allow_empty?: false] + end + + argument :arg2, :boolean + + argument :arg3, :float do + allow_nil? true + end + end end attributes do diff --git a/lib/ash_admin/components/resource/form.ex b/lib/ash_admin/components/resource/form.ex index ec32906..d772fcb 100644 --- a/lib/ash_admin/components/resource/form.ex +++ b/lib/ash_admin/components/resource/form.ex @@ -759,7 +759,7 @@ defmodule AshAdmin.Components.Resource.Form do prompt={allow_nil_option(@attribute, @value)} name={@name || @form.name <> "[#{@attribute.name}]"} /> - <% markdown?(@form.source.resource, @attribute) -> %> + <% markdown?(@resource, @attribute) -> %>
"_container", else: @form.id <> "_#{@attribute.name}_container"} @@ -773,7 +773,7 @@ defmodule AshAdmin.Components.Resource.Form do name={@name || @form.name <> "[#{@attribute.name}]"} ><%= value(@value, @form, @attribute) || "" %>
- <% long_text?(@form.source.resource, @attribute) -> %> + <% long_text?(@resource, @attribute) -> %> - <% short_text?(@form.source.resource, @attribute) -> %> + <% short_text?(@resource, @attribute) -> %> <.input - type={text_input_type(@form.source.resource, @attribute)} + type={text_input_type(@resource, @attribute)} id={@id || @form.id <> "_#{@attribute.name}"} value={value(@value, @form, @attribute)} class="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md" @@ -793,7 +793,7 @@ defmodule AshAdmin.Components.Resource.Form do /> <% true -> %> <.input - type={text_input_type(@form.source.resource, @attribute)} + type={text_input_type(@resource, @attribute)} placeholder={placeholder(@default)} id={@id || @form.id <> "_#{@attribute.name}"} value={value(@value, @form, @attribute)} @@ -1374,8 +1374,17 @@ defmodule AshAdmin.Components.Resource.Form do defp value(%Ash.Union{value: value}, _form, _attribute, _) when not is_nil(value), do: value defp value(value, _form, _attribute, _) when not is_nil(value), do: value - defp value(_value, form, attribute, _default) do - case AshPhoenix.Form.value(form.source, attribute.name) do + defp value( + _value, + %{source: %AshPhoenix.FilterForm.Arguments{input: input}}, + %{name: attribute_name}, + _default + ) do + Map.get(input, attribute_name, nil) + end + + defp value(_value, %{source: form}, attribute, _default) do + case AshPhoenix.Form.value(form, attribute.name) do %Ash.Union{value: value} -> value value -> value end @@ -1835,6 +1844,10 @@ defmodule AshAdmin.Components.Resource.Form do def attributes(resource, action, exactly \\ nil) + def attributes(resource, %Ash.Resource.Calculation{arguments: arguments}, _exacly) do + sort_attributes(arguments, resource) + end + def attributes(resource, %{type: :read, arguments: arguments}, exactly) when not is_nil(exactly) do resource diff --git a/lib/ash_admin/components/resource/show.ex b/lib/ash_admin/components/resource/show.ex index f05b996..dc2e186 100644 --- a/lib/ash_admin/components/resource/show.ex +++ b/lib/ash_admin/components/resource/show.ex @@ -17,6 +17,28 @@ defmodule AshAdmin.Components.Resource.Show do attr :prefix, :any, required: true def render(assigns) do + assigns = + assign_new(assigns, :calculations, fn %{resource: resource} -> + calculations = + AshAdmin.Resource.show_calculations(resource) + + resource + |> Ash.Resource.Info.calculations() + |> Enum.filter(&(&1.name in calculations)) + |> Enum.sort_by( + &Enum.find_index(calculations, fn name -> + name == &1.name + end) + ) + |> Enum.map(fn calculation -> + form = + AshPhoenix.FilterForm.Arguments.new(%{}, calculation.arguments) + |> to_form() + + {calculation, form} + end) + end) + ~H"""
@@ -24,6 +46,11 @@ defmodule AshAdmin.Components.Resource.Show do {render_show(assigns, @record, @resource)}
+
+
+ {render_calculations(assigns, @record, @resource)} +
+
{render_relationships(assigns, @record, @resource)} @@ -75,6 +102,65 @@ defmodule AshAdmin.Components.Resource.Show do """ end + @spec render_calculations(any(), any(), any()) :: Phoenix.LiveView.Rendered.t() + def render_calculations(assigns, record, resource) do + assigns = assign(assigns, record: record, resource: resource) + + ~H""" +
+
+
{to_name(calculation.name)}
+
+ {render_maybe_sensitive_attribute( + assigns, + @resource, + @record, + calculation + )} +
+
+ <.form + :let={form} + :if={length(calculation.arguments)} + as={calculation.name} + for={calculation_form} + phx-submit="calculate" + phx-target={@myself} + > + <.input type="hidden" name="calculation" value={calculation.name} /> + {AshAdmin.Components.Resource.Form.render_attributes( + assigns, + @resource, + calculation, + form + )} + <.error :if={is_exception(@calculation_errors[calculation.name])}> + {Exception.message(@calculation_errors[calculation.name])} + + <.error :if={ + @calculation_errors[calculation.name] && + !is_exception(@calculation_errors[calculation.name]) + }> + {inspect(@calculation_errors[calculation.name])} + +
+ +
+ +
+
+
+ """ + end + defp render_relationships(assigns, _record, resource) do assigns = assign(assigns, resource: resource) @@ -117,7 +203,12 @@ defmodule AshAdmin.Components.Resource.Show do end def mount(socket) do - {:ok, assign_new(socket, :load_errors, fn -> %{} end)} + assign = + socket + |> assign_new(:load_errors, fn -> %{} end) + |> assign_new(:calculation_errors, fn -> %{} end) + + {:ok, assign} end defp render_relationship_data(assigns, record, %{ @@ -289,7 +380,13 @@ defmodule AshAdmin.Components.Resource.Show do """ end - defp render_maybe_sensitive_attribute(assigns, resource, record, attribute, relationship_name) do + defp render_maybe_sensitive_attribute( + assigns, + resource, + record, + attribute, + relationship_name \\ nil + ) do assigns = assign(assigns, attribute: attribute, relationship_name: relationship_name) show_sensitive_fields = AshAdmin.Resource.show_sensitive_fields(resource) @@ -582,6 +679,38 @@ defmodule AshAdmin.Components.Resource.Show do end end + def handle_event("calculate", %{"calculation" => calculation} = event, socket) do + record = socket.assigns.record + domain = socket.assigns.domain + + arguments = + event + |> Map.get(calculation, []) + |> Enum.map(fn {attr, value} -> {String.to_atom(attr), value} end) + + calculation = String.to_atom(calculation) + + calculations = + [{calculation, arguments}] + + case Ash.load( + record, + calculations, + domain: domain, + actor: socket.assigns[:actor], + authorize?: socket.assigns[:authorizing] + ) do + {:ok, loaded} -> + {:noreply, assign(socket, record: loaded)} + + {:error, errors} -> + {:noreply, + assign(socket, + calculation_errors: Map.put(socket.assigns.calculation_errors, calculation, errors) + )} + end + end + def handle_event("load", %{"relationship" => relationship}, socket) do record = socket.assigns.record domain = socket.assigns.domain diff --git a/lib/ash_admin/resource/resource.ex b/lib/ash_admin/resource/resource.ex index 9107cf7..debf04f 100644 --- a/lib/ash_admin/resource/resource.ex +++ b/lib/ash_admin/resource/resource.ex @@ -95,6 +95,11 @@ defmodule AshAdmin.Resource do type: {:list, :atom}, doc: "The list of fields that should not be redacted in the admin UI even if they are marked as sensitive." + ], + show_calculations: [ + type: {:list, :atom}, + doc: + "A list of calculation that can be calculate when this resource is shown. By default, all calculations are included." ] ] } @@ -190,6 +195,11 @@ defmodule AshAdmin.Resource do end end + def show_calculations(resource) do + Spark.Dsl.Extension.get_opt(resource, [:admin], :show_calculations, nil, true) || + calculations(resource) + end + def fields(resource) do Spark.Dsl.Extension.get_entities(resource, [:admin, :form]) end @@ -219,4 +229,11 @@ defmodule AshAdmin.Resource do |> Enum.sort_by(&(!Map.get(&1, :primary?))) |> Enum.map(& &1.name) end + + defp calculations(resource) do + resource + |> Ash.Resource.Info.calculations() + |> Enum.map(& &1.name) + |> Enum.sort_by(& &1) + end end