diff --git a/.formatter.exs b/.formatter.exs index c6610424..8dd511d3 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -27,6 +27,7 @@ spark_locals_without_parens = [ show?: 1, show_action: 1, show_calculations: 1, + show_relationship_fields: 1, show_resources: 1, show_sensitive_fields: 1, table_columns: 1, diff --git a/config/config.exs b/config/config.exs index 7fe87821..1284e03d 100644 --- a/config/config.exs +++ b/config/config.exs @@ -87,6 +87,7 @@ if config_env() == :test do config :ash_admin, ash_domains: [ AshAdmin.Test.Domain, - AshAdmin.Test.ManyToManyDomain + AshAdmin.Test.ManyToManyDomain, + AshAdmin.Test.RelationshipFieldsDomain ] end diff --git a/documentation/dsls/DSL-AshAdmin.Resource.md b/documentation/dsls/DSL-AshAdmin.Resource.md index 8dfe51cc..7d9044ad 100644 --- a/documentation/dsls/DSL-AshAdmin.Resource.md +++ b/documentation/dsls/DSL-AshAdmin.Resource.md @@ -41,6 +41,7 @@ Configure the admin dashboard for a given resource. | [`resource_group`](#admin-resource_group){: #admin-resource_group } | `atom` | | The group in the top resource dropdown that the resource appears in. | | [`show_sensitive_fields`](#admin-show_sensitive_fields){: #admin-show_sensitive_fields } | `list(atom)` | | The list of fields that should not be redacted in the admin UI even if they are marked as sensitive. | | [`show_calculations`](#admin-show_calculations){: #admin-show_calculations } | `list(atom)` | | A list of calculations that can be loaded when this resource is shown. By default, no calculations are included. | +| [`show_relationship_fields`](#admin-show_relationship_fields){: #admin-show_relationship_fields } | `keyword` | | The fields to render for each relationship on this resource's show page, keyed by relationship name. For example: `show_relationship_fields organizations: [:name, :slug]`. Fields are shown in the given order. Relationships that are not listed show every attribute. | | [`label_field`](#admin-label_field){: #admin-label_field } | `atom` | | The field to use as the label when the resource appears in a relationship select or typeahead field on another resource's form. | | [`relationship_select_max_items`](#admin-relationship_select_max_items){: #admin-relationship_select_max_items } | `integer` | `50` | The maximum number of items to show in a select field when this resource is shown as a relationship on another resource's form. If the number of related resources is higher, a typeahead selector will be used. | diff --git a/lib/ash_admin/components/resource/show.ex b/lib/ash_admin/components/resource/show.ex index fa862bf7..8c66fdb7 100644 --- a/lib/ash_admin/components/resource/show.ex +++ b/lib/ash_admin/components/resource/show.ex @@ -257,12 +257,13 @@ defmodule AshAdmin.Components.Resource.Show do name: name, destination: destination, context: context, - destination_domain: destination_domain + destination_domain: destination_domain, + fields: AshAdmin.Resource.show_relationship_fields(assigns.resource, name) ) ~H"""
- {render_attributes(assigns, @record, @destination, @name)} + {render_attributes(assigns, @record, @destination, @name, @fields)}
<.link :if={AshAdmin.Resource.show_action(@destination)} @@ -306,7 +307,8 @@ defmodule AshAdmin.Components.Resource.Show do destination_format_fields: AshAdmin.Resource.format_fields(destination), context: context, destination_attribute: destination_attribute, - relationship_name: name + relationship_name: name, + fields: AshAdmin.Resource.show_relationship_fields(assigns.resource, name) ) ~H""" @@ -314,6 +316,7 @@ defmodule AshAdmin.Components.Resource.Show do + {attributes, flags, bottom_attributes} + + _ -> + # An explicit field list keeps the given order in a single grid. + {Enum.map(fields, &Ash.Resource.Info.attribute(resource, &1)), [], []} + end assigns = assign(assigns, diff --git a/lib/ash_admin/resource/resource.ex b/lib/ash_admin/resource/resource.ex index c659e196..2a6ca110 100644 --- a/lib/ash_admin/resource/resource.ex +++ b/lib/ash_admin/resource/resource.ex @@ -124,6 +124,12 @@ defmodule AshAdmin.Resource do doc: "A list of calculations that can be loaded when this resource is shown. By default, no calculations are included." ], + show_relationship_fields: [ + type: :keyword_list, + doc: """ + The fields to render for each relationship on this resource's show page, keyed by relationship name. For example: `show_relationship_fields organizations: [:name, :slug]`. Fields are shown in the given order. Relationships that are not listed show every attribute. + """ + ], label_field: [ type: :atom, doc: @@ -145,7 +151,8 @@ defmodule AshAdmin.Resource do AshAdmin.Resource.Transformers.AddPositionSortCalculation ], verifiers: [ - AshAdmin.Resource.Verifiers.VerifyFileArgumentsExist + AshAdmin.Resource.Verifiers.VerifyFileArgumentsExist, + AshAdmin.Resource.Verifiers.VerifyShowRelationshipFields ] @moduledoc """ @@ -271,6 +278,12 @@ defmodule AshAdmin.Resource do Spark.Dsl.Extension.get_opt(resource, [:admin], :show_calculations, [], true) end + def show_relationship_fields(resource, relationship) do + resource + |> Spark.Dsl.Extension.get_opt([:admin], :show_relationship_fields, [], true) + |> Keyword.get(relationship) + end + def fields(resource) do Spark.Dsl.Extension.get_entities(resource, [:admin, :form]) end diff --git a/lib/ash_admin/resource/verifiers/verify_show_relationship_fields.ex b/lib/ash_admin/resource/verifiers/verify_show_relationship_fields.ex new file mode 100644 index 00000000..9c20640c --- /dev/null +++ b/lib/ash_admin/resource/verifiers/verify_show_relationship_fields.ex @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: 2020 Zach Daniel +# SPDX-FileCopyrightText: 2020 ash_admin contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshAdmin.Resource.Verifiers.VerifyShowRelationshipFields do + @moduledoc """ + Ensures that every key of `show_relationship_fields` is a relationship of the + resource, and that every field is an attribute of the destination resource. + """ + + use Spark.Dsl.Verifier + + alias Spark.Dsl.Verifier + + def verify(dsl_state) do + dsl_state + |> Verifier.get_option([:admin], :show_relationship_fields, []) + |> Enum.reduce_while(:ok, fn {relationship_name, fields}, :ok -> + case check(dsl_state, relationship_name, fields) do + :ok -> {:cont, :ok} + {:error, message} -> {:halt, {:error, error(dsl_state, message)}} + end + end) + end + + defp check(dsl_state, relationship_name, fields) do + case Ash.Resource.Info.relationship(dsl_state, relationship_name) do + nil -> + {:error, "`#{inspect(relationship_name)}` is not a relationship of this resource."} + + %{destination: destination} when is_list(fields) -> + valid = destination |> Ash.Resource.Info.attributes() |> Enum.map(& &1.name) + + case Enum.reject(fields, &(&1 in valid)) do + [] -> + :ok + + invalid -> + {:error, + """ + Invalid fields for relationship `#{inspect(relationship_name)}`: #{inspect(invalid)} + Only attributes of #{inspect(destination)} are allowed. + Valid fields: #{inspect(valid)} + """} + end + + _ -> + {:error, + "Fields for relationship `#{inspect(relationship_name)}` must be a list of atoms."} + end + end + + defp error(dsl_state, message) do + Spark.Error.DslError.exception( + message: message, + path: [:admin, :show_relationship_fields], + module: Verifier.get_persisted(dsl_state, :module) + ) + end +end diff --git a/test/components/resource/show_relationship_fields_test.exs b/test/components/resource/show_relationship_fields_test.exs new file mode 100644 index 00000000..30a29b60 --- /dev/null +++ b/test/components/resource/show_relationship_fields_test.exs @@ -0,0 +1,107 @@ +# SPDX-FileCopyrightText: 2020 ash_admin contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshAdmin.Test.Components.Resource.ShowRelationshipFieldsTest do + use ExUnit.Case, async: false + + import Phoenix.ConnTest + import Phoenix.LiveViewTest + @endpoint AshAdmin.Test.Endpoint + + alias AshAdmin.Test.{ + RelationshipFieldsArticle, + RelationshipFieldsAuthor, + RelationshipFieldsBook + } + + setup do + favorite = Ash.create!(RelationshipFieldsBook, %{title: "Favorite", isbn: "978-1"}) + + author = + Ash.create!(RelationshipFieldsAuthor, %{name: "Ada", favorite_book_id: favorite.id}) + + Ash.create!(RelationshipFieldsBook, %{title: "Notes", isbn: "978-0", author_id: author.id}) + + Ash.create!(RelationshipFieldsArticle, %{ + headline: "Analytical engine", + summary: "A summary", + author_id: author.id + }) + + {:ok, view, _html} = live(build_conn(), show_path(author)) + + %{view: view} + end + + defp show_path(author) do + AshAdmin.Helpers.admin_path("/api/admin", + domain: AshAdmin.Domain.name(AshAdmin.Test.RelationshipFieldsDomain), + resource: "RelationshipFieldsAuthor", + primary_key: author.id, + action_type: "read" + ) + end + + defp load(view, relationship) do + tab = "#relationship-tab-#{relationship}" + if has_element?(view, tab), do: view |> element(tab) |> render_click() + + view + |> element(~s|button[phx-click=load][phx-value-relationship="#{relationship}"]|) + |> render_click() + end + + test "a has_many relationship only shows the configured columns", %{view: view} do + html = load(view, :books) + + assert html =~ "Title" + assert html =~ "Notes" + refute html =~ "Isbn" + refute html =~ "978-0" + end + + test "a belongs_to relationship only shows the configured fields", %{view: view} do + html = render(view) + + assert html =~ "Favorite" + refute html =~ "Isbn" + refute html =~ "978-1" + end + + test "a relationship without configuration shows every attribute", %{view: view} do + html = load(view, :articles) + + assert html =~ "Headline" + assert html =~ "Summary" + assert html =~ "A summary" + end + + test "the verifier rejects an unknown relationship" do + dsl = + put_in( + RelationshipFieldsAuthor.spark_dsl_config(), + [[:admin], :opts, :show_relationship_fields], + nope: [:title] + ) + + assert {:error, %Spark.Error.DslError{message: message}} = + AshAdmin.Resource.Verifiers.VerifyShowRelationshipFields.verify(dsl) + + assert message =~ "is not a relationship" + end + + test "the verifier rejects a field that is not an attribute of the destination" do + dsl = + put_in( + RelationshipFieldsAuthor.spark_dsl_config(), + [[:admin], :opts, :show_relationship_fields], + books: [:nope] + ) + + assert {:error, %Spark.Error.DslError{message: message}} = + AshAdmin.Resource.Verifiers.VerifyShowRelationshipFields.verify(dsl) + + assert message =~ "Invalid fields" + end +end diff --git a/test/support/resources/relationship_fields.ex b/test/support/resources/relationship_fields.ex new file mode 100644 index 00000000..b22e9b83 --- /dev/null +++ b/test/support/resources/relationship_fields.ex @@ -0,0 +1,102 @@ +# SPDX-FileCopyrightText: 2020 ash_admin contributors +# +# SPDX-License-Identifier: MIT + +# Fixtures for `show_relationship_fields`: the fields rendered for each +# relationship on a show page. + +defmodule AshAdmin.Test.RelationshipFieldsDomain do + @moduledoc false + use Ash.Domain, + extensions: [AshAdmin.Domain] + + admin do + show? true + end + + resources do + resource(AshAdmin.Test.RelationshipFieldsAuthor) + resource(AshAdmin.Test.RelationshipFieldsBook) + resource(AshAdmin.Test.RelationshipFieldsArticle) + end +end + +defmodule AshAdmin.Test.RelationshipFieldsAuthor do + @moduledoc false + use Ash.Resource, + domain: AshAdmin.Test.RelationshipFieldsDomain, + data_layer: Ash.DataLayer.Ets, + extensions: [AshAdmin.Resource] + + attributes do + uuid_primary_key(:id) + attribute(:name, :string, public?: true) + end + + actions do + default_accept(:*) + defaults([:read, :create]) + end + + admin do + show_relationship_fields(books: [:title], favorite_book: [:title]) + end + + relationships do + has_many(:books, AshAdmin.Test.RelationshipFieldsBook, + public?: true, + destination_attribute: :author_id + ) + + has_many(:articles, AshAdmin.Test.RelationshipFieldsArticle, + public?: true, + destination_attribute: :author_id + ) + + belongs_to(:favorite_book, AshAdmin.Test.RelationshipFieldsBook, public?: true) + end +end + +defmodule AshAdmin.Test.RelationshipFieldsBook do + @moduledoc false + use Ash.Resource, + domain: AshAdmin.Test.RelationshipFieldsDomain, + data_layer: Ash.DataLayer.Ets + + attributes do + uuid_primary_key(:id) + attribute(:title, :string, public?: true) + attribute(:isbn, :string, public?: true) + end + + actions do + default_accept(:*) + defaults([:read, :create]) + end + + relationships do + belongs_to(:author, AshAdmin.Test.RelationshipFieldsAuthor, public?: true) + end +end + +defmodule AshAdmin.Test.RelationshipFieldsArticle do + @moduledoc false + use Ash.Resource, + domain: AshAdmin.Test.RelationshipFieldsDomain, + data_layer: Ash.DataLayer.Ets + + attributes do + uuid_primary_key(:id) + attribute(:headline, :string, public?: true) + attribute(:summary, :string, public?: true) + end + + actions do + default_accept(:*) + defaults([:read, :create]) + end + + relationships do + belongs_to(:author, AshAdmin.Test.RelationshipFieldsAuthor, public?: true) + end +end