feat: more testing resources + relationship argument forms!

This commit is contained in:
Zach Daniel 2021-03-28 14:07:09 -04:00
parent 535560751e
commit eb25cc92ff
18 changed files with 837 additions and 264 deletions

View file

@ -18,6 +18,7 @@ Application.put_env(:ash_admin, DemoWeb.Endpoint,
]
],
live_reload: [
iframe_attrs: [class: "hidden"],
patterns: [
~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
~r"lib/ash_admin/(components|templates/pages)/.*(ex)$"

View file

@ -15,6 +15,7 @@ defmodule Demo.Accounts.User do
field :first_name, type: :short_text
field :last_name, type: :short_text
end
table_columns [:id, :first_name, :last_name, :representative, :admin]
end
policies do

View file

@ -2,12 +2,13 @@ defmodule Demo.Tickets.Api do
@moduledoc false
use Ash.Api
alias Demo.Tickets.{Comment, Customer, Representative, Ticket}
alias Demo.Tickets.{Comment, Customer, Representative, Ticket, TicketLink}
resources do
resource(Customer)
resource(Representative)
resource(Ticket)
resource(Comment)
resource(TicketLink)
end
end

View file

@ -45,7 +45,7 @@ defmodule Demo.Tickets.Representative do
update :update do
primary? true
accept [:first_name, :last_name, :assigned_tickets]
accept [:first_name, :last_name]
end
end

View file

@ -61,13 +61,47 @@ defmodule Demo.Tickets.Ticket do
end
create :open do
accept [:subject, :reporter]
accept [:subject]
primary? true
argument :representative, :map, allow_nil?: false
argument :tickets, {:array, :map}, allow_nil?: false
change manage_relationship(:representative, type: :append)
change manage_relationship(:tickets, :source_links, on_lookup: {:relate_and_update, :create, :read, :all})
end
update :update, primary?: true
update :assign do
accept [:representative]
accept []
argument :representative, :map
argument :reassignment_comment, :map, allow_nil?: false
change manage_relationship(:representative, type: :append)
change manage_relationship(:reassignment_comment, :comments, type: :create)
end
update :link do
accept []
argument :tickets, {:array, :map}, allow_nil?: false
argument :link_comment, :map, type: :create
# Uses the defult create action of the join table, which accepts the `type`
change manage_relationship(:tickets, :source_links, on_lookup: {:relate_and_update, :create, :read, :all})
change manage_relationship(:link_comment, :comments, type: :create)
end
update :nested_example do
accept [:subject]
argument :tickets, {:array, :map}
change manage_relationship(
:tickets,
:source_links,
type: :direct_control,
on_match: {:update, :nested_example, :update, [:type]},
on_no_match: {:create, :nested_example, :update, [:type]}
)
end
destroy :destroy
@ -108,5 +142,17 @@ defmodule Demo.Tickets.Ticket do
context %{data_layer: %{table: "ticket_comments"}}
destination_field :resource_id
end
many_to_many :source_links, Demo.Tickets.Ticket do
through Demo.Tickets.TicketLink
source_field_on_join_table :source_id
destination_field_on_join_table :destination_id
end
many_to_many :destination_links, Demo.Tickets.Ticket do
through Demo.Tickets.TicketLink
source_field_on_join_table :destination_id
destination_field_on_join_table :source_id
end
end
end

View file

@ -0,0 +1,27 @@
defmodule Demo.Tickets.TicketLink do
use Ash.Resource,
data_layer: AshPostgres.DataLayer
postgres do
table "ticket_links"
repo Demo.Repo
end
attributes do
attribute :type, :atom, constraints: [
one_of: [:causes, :caused_by, :fixes, :fixed_by]
], allow_nil?: false
end
relationships do
belongs_to :source, Demo.Tickets.Ticket do
primary_key? true
required? true
end
belongs_to :destination, Demo.Tickets.Ticket do
primary_key? true
required? true
end
end
end

View file

@ -10,7 +10,6 @@ defmodule AshAdmin.Components.Resource.Form do
Checkbox,
ErrorTag,
FieldContext,
HiddenInput,
HiddenInputs,
Inputs,
Label,
@ -173,7 +172,7 @@ defmodule AshAdmin.Components.Resource.Form do
<FieldContext name={{ attribute.name }}>
<Label class="block text-sm font-medium text-gray-700">{{ to_name(attribute.name) }}</Label>
{{ render_attribute_input(assigns, attribute, form) }}
<ErrorTag field={{ attribute.name }} />
<ErrorTag :if={{!Ash.Type.embedded_type?(attribute.type)}} field={{ attribute.name }} />
</FieldContext>
</div>
</div>
@ -194,7 +193,7 @@ defmodule AshAdmin.Components.Resource.Form do
<FieldContext name={{ attribute.name }}>
<Label class="block text-sm font-medium text-gray-700">{{ to_name(attribute.name) }}</Label>
{{ render_attribute_input(assigns, attribute, form) }}
<ErrorTag field={{ attribute.name }} />
<ErrorTag :if={{!Ash.Type.embedded_type?(attribute.type)}} field={{ attribute.name }} />
</FieldContext>
</div>
</div>
@ -215,15 +214,14 @@ defmodule AshAdmin.Components.Resource.Form do
<FieldContext name={{ attribute.name }}>
<Label class="block text-sm font-medium text-gray-700">{{ to_name(attribute.name) }}</Label>
{{ render_attribute_input(assigns, attribute, form) }}
<ErrorTag field={{ attribute.name }} />
<ErrorTag :if={{!Ash.Type.embedded_type?(attribute.type)}} field={{ attribute.name }} />
</FieldContext>
</div>
</div>
<div :for={{{relationship, argument, opts} <- relationship_args}}>
<FieldContext name={{argument.name}} :if={{relationship not in skip and argument.name not in skip}}>
<Label class="block text-sm font-medium text-gray-700">{{ to_name(argument.name)}}</Label>
{{ render_relationship_input(assigns, Ash.Resource.Info.relationship(form.source.resource, relationship), form, argument.name, relationship_path <> "[#{relationship}]", opts) }}
<ErrorTag field={{ relationship }} />
{{ render_relationship_input(assigns, Ash.Resource.Info.relationship(form.source.resource, relationship), form, argument, relationship_path <> "[#{relationship}]", opts) }}
</FieldContext>
</div>
</Context>
@ -232,70 +230,9 @@ defmodule AshAdmin.Components.Resource.Form do
defp render_relationship_input(
assigns,
%{cardinality: :one} = relationship,
relationship,
form,
as,
relationship_path,
opts
) do
~H"""
<div :if={{ loaded?(form.source, relationship.name) }}>
<Inputs
form={{ form }}
for={{ relationship.name }}
:let={{ form: inner_form }}
opts={{ use_data?: true, as: form.name <> "[#{as}]" }}
>
<HiddenInputs for={{inner_form}} />
<button
type="button"
:on-click="remove_related"
phx-value-path={{ inner_form.name }}
class="flex h-6 w-6 mt-2 border-gray-600 hover:bg-gray-400 rounded-md justify-center items-center"
>
{{ {:safe, Heroicons.Solid.minus(class: "h-4 w-4 text-gray-500")} }}
</button>
<div class="shadow-lg p-4" :for={{{new_form, fields} <- relationship_forms(inner_form, relationship, opts, @actor) }}>
{{ render_attributes(
assigns,
relationship.destination,
new_form.source.action,
new_form,
fields,
[],
relationship_path
) }}
</div>
</Inputs>
<button
type="button"
:on-click="append_related"
:if={{ could_lookup?(opts) && !relationship_set?(form.source, relationship.name, relationship.name) }}
phx-value-path={{ form.name <> "[#{as}]" }}
phx-value-type={{ "lookup" }}
class="flex h-6 w-6 m-2 border-gray-600 hover:bg-gray-400 rounded-md justify-center items-center"
>
{{ {:safe, Heroicons.Solid.search_circle(class: "h-4 w-4 text-gray-500")} }}
</button>
<button
type="button"
:on-click="append_related"
:if={{ could_create?(opts) && !relationship_set?(form.source, relationship.name, relationship.name) }}
phx-value-path={{ form.name <> "[#{as}]" }}
phx-value-type={{ "create" }}
class="flex h-6 w-6 m-2 border-gray-600 hover:bg-gray-400 rounded-md justify-center items-center"
>
{{ {:safe, Heroicons.Solid.plus(class: "h-4 w-4 text-gray-500")} }}
</button>
</div>
"""
end
defp render_relationship_input(
assigns,
%{cardinality: :many} = relationship,
form,
as,
%{type: {:array, _}} = argument,
relationship_path,
opts
) do
@ -303,59 +240,60 @@ defmodule AshAdmin.Components.Resource.Form do
<div :if={{ !needs_to_load?(opts) || loaded?(form.source, relationship.name) }}>
<Inputs
form={{ form }}
for={{ relationship.name }}
for={{ argument.name }}
:let={{ form: inner_form }}
opts={{ use_data?: true, as: form.name <> "[#{as}]" }}
opts={{ form_opts(form, opts, argument.name, relationship, @actor) }}
>
<HiddenInputs for={{inner_form}} />
<button
type="button"
:on-click="remove_related"
:if={{ relationship_set?(form.source, relationship.name, relationship.name) }}
:if={{ can_remove_related?(opts) && relationship_set?(form.source, relationship.name, argument.name) }}
phx-value-path={{ inner_form.name }}
class="flex h-6 w-6 m-2 border-gray-600 hover:bg-gray-400 rounded-md justify-center items-center"
>
{{ {:safe, Heroicons.Solid.minus(class: "h-4 w-4 text-gray-500")} }}
</button>
<div class="shadow-lg p-4" :for={{{new_form, fields} <- relationship_forms(inner_form, relationship, opts, @actor) }}>
<HiddenInput form={{inner_form}} :if={{inner_form.source.params["_lookup"] == "true"}} field="_lookup" value="true"/>
{{ render_attributes(
assigns,
relationship.destination,
new_form.source.action,
new_form,
fields,
[],
relationship_path
) }}
<div class="shadow-lg p-4">
<div :for={{{inner_form, field_limit, relationship} <- relationship_forms(form, inner_form, relationship, opts, @actor)}}>
{{ render_attributes(
assigns,
relationship.destination,
inner_form.source.action || :_lookup,
maybe_clear_errors(inner_form), # We clear errors from lookup forms
relationship_fields(inner_form, field_limit),
skip_related(relationship, is_nil(inner_form.source.action)),
relationship_path
) }}
</div>
</div>
</Inputs>
<button
type="button"
:on-click="append_related"
:if={{ could_lookup?(opts) }}
phx-value-path={{ form.name <> "[#{as}]" }}
:if={{ could_create?(opts) }}
phx-value-path={{ form.name <> "[#{argument.name}]" }}
class="flex h-6 w-6 m-2 border-gray-600 hover:bg-gray-400 rounded-md justify-center items-center"
>
{{ {:safe, Heroicons.Solid.plus(class: "h-4 w-4 text-gray-500")} }}
</button>
<button
type="button"
:on-click="append_related"
:if={{ could_lookup?(opts) && !relationship_set?(form.source, relationship.name, argument.name) }}
phx-value-path={{ form.name <> "[#{argument.name}]" }}
phx-value-type={{ "lookup" }}
class="flex h-6 w-6 m-2 border-gray-600 hover:bg-gray-400 rounded-md justify-center items-center"
>
{{ {:safe, Heroicons.Solid.search_circle(class: "h-4 w-4 text-gray-500")} }}
</button>
<button
type="button"
:on-click="append_related"
:if={{ could_create?(opts) }}
phx-value-path={{ form.name <> "[#{as}]" }}
phx-value-type={{ "create" }}
class="flex h-6 w-6 m-2 border-gray-600 hover:bg-gray-400 rounded-md justify-center items-center"
>
{{ {:safe, Heroicons.Solid.plus(class: "h-4 w-4 text-gray-500")} }}
</button>
</div>
<div :if={{ needs_to_load?(opts) && !loaded?(form.source, relationship.name) }}>
<button
:on-click="load"
phx-value-relationship={{ relationship_path }}
phx-value-path={{form.name <> "[#{as}]"}}
phx-value-path={{form.name <> "[#{argument.name}]"}}
type="button"
class="flex py-2 ml-4 px-4 mt-2 bg-indigo-600 text-white border-gray-600 hover:bg-gray-400 rounded-md justify-center items-center"
>
@ -371,143 +309,422 @@ defmodule AshAdmin.Components.Resource.Form do
"""
end
defp relationship_forms(form, relationship, opts, actor) do
forms =
cond do
form.source.params["_lookup"] == "true" ->
relationship_forms_for_lookup(form, relationship, opts, actor)
defp render_relationship_input(
assigns,
relationship,
form,
argument,
relationship_path,
opts
) do
~H"""
<div :if={{ !(needs_to_load?(opts) && !loaded?(form.source, relationship.name)) }}>
<Inputs
form={{ form }}
for={{ argument.name }}
:let={{ form: inner_form }}
opts={{ form_opts(form, opts, argument.name, relationship, @actor) }}
>
<HiddenInputs for={{inner_form}} />
<button
type="button"
:on-click="remove_related"
:if={{can_remove_related?(opts)}}
phx-value-path={{ inner_form.name }}
class="flex h-6 w-6 mt-2 border-gray-600 hover:bg-gray-400 rounded-md justify-center items-center"
>
{{ {:safe, Heroicons.Solid.minus(class: "h-4 w-4 text-gray-500")} }}
</button>
<div class="shadow-lg p-4">
<div :for={{{inner_form, type, relationship} <- relationship_forms(form, inner_form, relationship, opts, @actor)}}>
{{ render_attributes(
assigns,
relationship.destination,
inner_form.source.action || :_lookup,
maybe_clear_errors(inner_form), # We clear errors from lookup forms
relationship_fields(inner_form, type),
skip_related(relationship, is_nil(inner_form.source.action)),
relationship_path
) }}
</div>
</div>
</Inputs>
<button
type="button"
:on-click="append_related"
:if={{ could_lookup?(opts) && !relationship_set?(form.source, relationship.name, argument.name) }}
phx-value-path={{ form.name <> "[#{argument.name}]" }}
phx-value-type={{ "lookup" }}
class="flex h-6 w-6 m-2 border-gray-600 hover:bg-gray-400 rounded-md justify-center items-center"
>
{{ {:safe, Heroicons.Solid.search_circle(class: "h-4 w-4 text-gray-500")} }}
</button>
<button
type="button"
:on-click="append_related"
:if={{ could_create?(opts) && !relationship_set?(form.source, relationship.name, argument.name) }}
phx-value-path={{ form.name <> "[#{argument.name}]" }}
phx-value-type={{ "create" }}
class="flex h-6 w-6 m-2 border-gray-600 hover:bg-gray-400 rounded-md justify-center items-center"
>
{{ {:safe, Heroicons.Solid.plus(class: "h-4 w-4 text-gray-500")} }}
</button>
</div>
"""
end
form.source.action_type == :update ->
relationship_forms_for_update(form, relationship, opts, actor)
defp relationship_forms(form, inner_form, relationship, opts, actor) do
cond do
is_nil(inner_form.source.action) ->
with_lookup_forms =
[{inner_form, nil, relationship}] ++
lookup_forms(form, inner_form, opts, relationship, actor)
form.source.action_type == :create ->
relationship_forms_for_create(form, relationship, opts, actor)
case inner_form.source.action_type do
:create ->
with_lookup_forms ++
create_forms(form, inner_form, opts, relationship, actor)
:update ->
with_lookup_forms ++
update_forms(form, inner_form, opts, relationship, actor)
:destroy ->
with_lookup_forms ++
destroy_forms(form, inner_form, opts, relationship, actor)
end
inner_form.source.action_type == :update ->
[{inner_form, nil, relationship}] ++
update_forms(form, inner_form, opts, relationship, actor)
inner_form.source.action_type == :destroy ->
[{inner_form, nil, relationship}] ++
destroy_forms(form, inner_form, opts, relationship, actor)
true ->
[{inner_form, nil, relationship}] ++
create_forms(form, inner_form, opts, relationship, actor)
end
end
defp lookup_forms(form, inner_form, opts, relationship, actor) do
opts
|> Ash.Changeset.ManagedRelationshipHelpers.on_lookup_update_action(relationship)
|> action_form(form, inner_form, relationship, actor)
|> List.wrap()
end
defp update_forms(form, inner_form, opts, relationship, actor) do
opts
|> Ash.Changeset.ManagedRelationshipHelpers.on_match_destination_actions(relationship)
|> Kernel.||([])
|> drop_destination_form()
|> Enum.map(&action_form(&1, form, inner_form, relationship, actor))
end
defp create_forms(form, inner_form, opts, relationship, actor) do
opts
|> Ash.Changeset.ManagedRelationshipHelpers.on_no_match_destination_actions(relationship)
|> Kernel.||([])
|> drop_destination_form()
|> Enum.map(&action_form(&1, form, inner_form, relationship, actor))
end
defp destroy_forms(form, inner_form, opts, relationship, actor) do
opts
|> Ash.Changeset.ManagedRelationshipHelpers.on_missing_destination_actions(relationship)
|> drop_destination_form()
|> Enum.map(&action_form(&1, form, inner_form, relationship, actor))
end
defp drop_destination_form([{:destination, _} | rest]), do: rest
defp drop_destination_form(other), do: other
defp action_form(nil, _, _, _, _), do: nil
defp action_form({:source, action_name}, form, inner_form, relationship, actor) do
new_inner_form =
form.source.data
|> Ash.Changeset.for_update(action_name, inner_form.params, actor: actor)
|> retain_hiding_errors(inner_form.source)
|> Phoenix.HTML.FormData.to_form(as: inner_form.name)
{new_inner_form, nil, relationship}
end
defp action_form({:destination, action_name}, _form, inner_form, relationship, actor) do
new_inner_form =
inner_form.data
|> Ash.Changeset.for_update(action_name, inner_form.params, actor: actor)
|> retain_hiding_errors(inner_form.source)
|> Phoenix.HTML.FormData.to_form(as: inner_form.name)
{new_inner_form, nil, relationship}
end
defp action_form({:join, action_name, keys}, form, inner_form, relationship, actor) do
limit =
if keys == :all do
nil
else
keys
end
List.wrap(forms)
end
new_inner_form =
if inner_form.source.action_type == :update do
value = find_join(form.source.data, inner_form.source.data, relationship)
defp relationship_forms_for_lookup(form, relationship, opts, actor) do
case opts[:on_lookup] do
{key, create, read, fields} when relationship.type == :many_to_many ->
query =
relationship.destination
|> Ash.Query.for_read(read, form.source.params, actor: actor)
|> Phoenix.HTML.FormData.to_form(as: form.name)
changeset =
relationship.through
|> Ash.Changeset.for_create(create, form.source.params, actor: actor)
|> Phoenix.HTML.FormData.to_form(as: form.name)
[{query, Ash.Resource.Info.primary_key(relationship.destination)}, {changeset, fields}] ++
lookup_update(key, form, relationship, opts, actor)
{key, _update, read} ->
query =
relationship.destination
|> Ash.Query.for_read(read, form.source.params, actor: actor)
|> Phoenix.HTML.FormData.to_form(as: form.name)
[{query, Ash.Resource.Info.primary_key(relationship.destination)}] ++
lookup_update(key, form, relationship, opts, actor)
{key, create, read, fields} ->
query =
relationship.destination
|> Ash.Query.for_read(read, form.source.params, actor: actor)
|> Phoenix.HTML.FormData.to_form(as: form.name)
changeset =
relationship.through
|> Ash.Changeset.for_create(create, form.source.params, actor: actor)
|> Phoenix.HTML.FormData.to_form(as: form.name)
[{query, Ash.Resource.Info.primary_key(relationship.destination)}, {changeset, fields}] ++
lookup_update(key, form, relationship, opts, actor)
end
end
defp lookup_update(:relate_and_update, form, relationship, opts, actor) do
relationship_forms_for_update(form, relationship, opts, actor)
end
defp lookup_update(_, _, _, _, _), do: []
defp relationship_forms_for_create(form, relationship, opts, actor) do
case opts[:on_no_match] do
{:create, action_name, join_action_name, fields} ->
join_form =
relationship.through
|> Ash.Changeset.for_create(join_action_name, form.source.params, actor: actor)
|> Phoenix.HTML.FormData.to_form(as: form.name)
destination_form =
relationship.destination
|> Ash.Changeset.for_create(action_name, form.source.params, actor: actor)
|> Phoenix.HTML.FormData.to_form(as: form.name)
[{destination_form, nil}, {join_form, fields}]
{:create, action_name} ->
destination_form =
relationship.destination
|> Ash.Changeset.for_create(action_name, form.source.params, actor: actor)
|> Phoenix.HTML.FormData.to_form(as: form.name)
[{destination_form, nil}]
:error ->
[]
end
end
defp relationship_forms_for_update(form, relationship, opts, actor) do
case opts[:on_match] do
{:update, update, join_update, fields} ->
join_form =
form.source.data
|> Ash.Changeset.for_update(update, form.source.params, actor: actor)
|> Phoenix.HTML.FormData.to_form(as: form.name)
destination_form =
if value do
value
|> Ash.Changeset.for_update(action_name, inner_form.params, actor: actor)
|> retain_hiding_errors(inner_form.source)
|> Phoenix.HTML.FormData.to_form(as: inner_form.name)
else
relationship.through.__struct__
|> Ash.Changeset.for_update(join_update, form.source.params, actor: actor)
|> Phoenix.HTML.FormData.to_form(as: form.name)
[{destination_form, nil}, {join_form, fields}]
{:update, action_name} ->
destination_form =
form.source.data
|> Ash.Changeset.for_update(action_name, form.source.params, actor: actor)
|> Phoenix.HTML.FormData.to_form(as: form.name)
[{destination_form, nil}]
{:unrelate, _action} ->
changeset =
form.source.data
|> Ash.Changeset.new()
|> Map.put(:params, form.source.params)
|> Phoenix.HTML.FormData.to_form(as: form.name)
|> retain_hiding_errors(inner_form.source)
|> Map.put(:params, inner_form.params)
end
else
relationship.through
|> Ash.Changeset.for_create(action_name, inner_form.params, actor: actor)
|> retain_hiding_errors(inner_form.source)
|> Phoenix.HTML.FormData.to_form(as: inner_form.name)
end
{changeset, Ash.Resource.Info.primary_key(relationship.destination)}
{new_inner_form, limit,
Ash.Resource.Info.relationship(relationship.source, relationship.join_relationship)}
end
value when value in [:ignore, :error] ->
changeset =
form.source.data
|> Ash.Changeset.new()
|> Map.put(:params, form.source.params)
|> Phoenix.HTML.FormData.to_form(as: form.name)
defp retain_hiding_errors(changeset, source_changeset) do
if AshPhoenix.hiding_errors?(source_changeset) do
AshPhoenix.hide_errors(changeset)
else
changeset
end
end
{changeset, Ash.Resource.Info.primary_key(relationship.destination)}
defp find_join(source, destination, relationship) do
case Map.get(source, relationship.join_relationship) do
%Ash.NotLoaded{} ->
source.__struct__.__struct__
related ->
related
|> List.wrap()
|> Enum.find(
source.__struct__.__struct__,
fn candidate ->
Map.get(candidate, relationship.destination_field_on_join_table) ==
Map.get(destination, relationship.destination_field)
end
)
end
end
defp maybe_clear_errors(form) do
if form.source.action do
form
else
%{form | source: AshPhoenix.hide_errors(form.source), errors: []}
end
end
defp relationship_fields(inner_form, limit) do
if is_nil(inner_form.source.action) do
Ash.Resource.Info.primary_key(inner_form.source.resource)
else
limit
end
end
defp create_action(opts, relationship) do
case Ash.Changeset.ManagedRelationshipHelpers.on_no_match_destination_actions(
opts,
relationship
) do
[{:destination, action} | _rest] ->
# do something with rest here
action
_ ->
relationship_forms_for_create(form, relationship, opts, actor)
:_raw
end
end
defp update_action(opts, relationship) do
case Ash.Changeset.ManagedRelationshipHelpers.on_match_destination_actions(opts, relationship) do
[{:destination, action} | _rest] ->
# do something with rest here
action
_ ->
:_raw
end
end
defp form_opts(form, opts, as, relationship, actor) do
[
use_data?: use_data?(opts),
as: form.name <> "[#{as}]",
create_action: create_action(opts, relationship),
update_action: update_action(opts, relationship),
actor: actor
]
end
defp use_data?(opts) do
Ash.Changeset.ManagedRelationshipHelpers.must_load?(opts)
end
defp skip_related(_, true) do
[]
end
defp skip_related(relationship, _) do
if relationship.type == :belongs_to do
[]
else
[relationship.destination_field]
end
end
# defp relationship_forms(form, relationship, opts, actor) do
# forms =
# cond do
# form.source.action_type == :destroy ->
# form.source.action_type == :update ->
# relationship_forms_for_update(form, relationship, opts, actor)
# form.source.action_type == :create ->
# relationship_forms_for_create(form, relationship, opts, actor)
# end
# List.wrap(forms)
# end
# defp relationship_forms_for_lookup(form, relationship, opts, actor) do
# case opts[:on_lookup] do
# {key, create, read, fields} when relationship.type == :many_to_many ->
# query =
# relationship.destination
# |> Ash.Query.for_read(read, form.source.params, actor: actor)
# |> Phoenix.HTML.FormData.to_form(as: form.name)
# changeset =
# relationship.through
# |> Ash.Changeset.for_create(create, form.source.params, actor: actor)
# |> Phoenix.HTML.FormData.to_form(as: form.name)
# [{query, Ash.Resource.Info.primary_key(relationship.destination)}, {changeset, fields}] ++
# lookup_update(key, form, relationship, opts, actor)
# {key, _update, read} ->
# query =
# relationship.destination
# |> Ash.Query.for_read(read, form.source.params, actor: actor)
# |> Phoenix.HTML.FormData.to_form(as: form.name)
# [{query, Ash.Resource.Info.primary_key(relationship.destination)}] ++
# lookup_update(key, form, relationship, opts, actor)
# {key, create, read, fields} ->
# query =
# relationship.destination
# |> Ash.Query.for_read(read, form.source.params, actor: actor)
# |> Phoenix.HTML.FormData.to_form(as: form.name)
# changeset =
# relationship.through
# |> Ash.Changeset.for_create(create, form.source.params, actor: actor)
# |> Phoenix.HTML.FormData.to_form(as: form.name)
# [{query, Ash.Resource.Info.primary_key(relationship.destination)}, {changeset, fields}] ++
# lookup_update(key, form, relationship, opts, actor)
# end
# end
# defp lookup_update(:relate_and_update, form, relationship, opts, actor) do
# relationship_forms_for_update(form, relationship, opts, actor)
# end
# defp lookup_update(_, _, _, _, _), do: []
# defp relationship_forms_for_create(form, relationship, opts, actor) do
# case opts[:on_no_match] do
# {:create, action_name, join_action_name, fields} ->
# join_form =
# relationship.through
# |> Ash.Changeset.for_create(join_action_name, form.source.params, actor: actor)
# |> Phoenix.HTML.FormData.to_form(as: form.name)
# destination_form =
# relationship.destination
# |> Ash.Changeset.for_create(action_name, form.source.params, actor: actor)
# |> Phoenix.HTML.FormData.to_form(as: form.name)
# [{destination_form, nil}, {join_form, fields}]
# {:create, action_name} ->
# destination_form =
# relationship.destination
# |> Ash.Changeset.for_create(action_name, form.source.params, actor: actor)
# |> Phoenix.HTML.FormData.to_form(as: form.name)
# [{destination_form, nil}]
# :error ->
# []
# end
# end
# defp relationship_forms_for_update(form, relationship, opts, actor) do
# case opts[:on_match] do
# {:update, update, join_update, fields} ->
# join_form =
# form.source.data
# |> Ash.Changeset.for_update(update, form.source.params, actor: actor)
# |> Phoenix.HTML.FormData.to_form(as: form.name)
# destination_form =
# relationship.through.__struct__
# |> Ash.Changeset.for_update(join_update, form.source.params, actor: actor)
# |> Phoenix.HTML.FormData.to_form(as: form.name)
# [{destination_form, nil}, {join_form, fields}]
# {:update, action_name} ->
# destination_form =
# form.source.data
# |> Ash.Changeset.for_update(action_name, form.source.params, actor: actor)
# |> Phoenix.HTML.FormData.to_form(as: form.name)
# [{destination_form, nil}]
# {:unrelate, _action} ->
# changeset =
# form.source.data
# |> Ash.Changeset.new()
# |> Map.put(:params, form.source.params)
# |> Phoenix.HTML.FormData.to_form(as: form.name)
# {changeset, Ash.Resource.Info.primary_key(relationship.destination)}
# value when value in [:ignore, :error] ->
# changeset =
# form.source.data
# |> Ash.Changeset.new()
# |> Map.put(:params, form.source.params)
# |> Phoenix.HTML.FormData.to_form(as: form.name)
# {changeset, Ash.Resource.Info.primary_key(relationship.destination)}
# _ ->
# relationship_forms_for_create(form, relationship, opts, actor)
# end
# end
defp needs_to_load?(opts) do
Ash.Changeset.ManagedRelationshipHelpers.must_load?(opts)
end
@ -541,7 +758,10 @@ defmodule AshAdmin.Components.Resource.Form do
manage not in [[], nil]
end
end)
|> Kernel.||(Map.get(changeset.data, relationship) not in [nil, []])
end
defp can_remove_related?(opts) do
Ash.Changeset.ManagedRelationshipHelpers.could_handle_missing?(opts)
end
defp could_lookup?(opts) do
@ -599,7 +819,7 @@ defmodule AshAdmin.Components.Resource.Form do
form
) do
~H"""
<Select form={{ form }} field={{ name }} options={{ Nil: nil, True: "true", False: "false" }} />
<Select form={{ form }} field={{ name }} options={{ Nil: nil, True: "true", False: "false" }} selected={{boolean_selected(Phoenix.HTML.FormData.input_value(form.source, form, name))}} />
"""
end
@ -619,7 +839,8 @@ defmodule AshAdmin.Components.Resource.Form do
<Select
form={{ form }}
field={{ name }}
options={{ Enum.map(attribute.constraints[:one_of], &{to_name(&1), &1}) }}
options={{ Enum.map(attribute.constraints[:one_of], &{to_name(&1), &1}) ++ allow_nil_option(attribute) }}
selected={{Phoenix.HTML.FormData.input_value(form.source, form, attribute.name)}}
/>
"""
@ -679,6 +900,7 @@ defmodule AshAdmin.Components.Resource.Form do
<button
type="button"
:on-click="append_embed"
:if={{can_append_embed?(form.source, attribute.name)}}
phx-value-path={{ form.name <> "[#{attribute.name}]" }}
class="flex h-6 w-6 mt-2 border-gray-600 hover:bg-gray-400 rounded-md justify-center items-center"
>
@ -697,6 +919,27 @@ defmodule AshAdmin.Components.Resource.Form do
end
end
defp allow_nil_option(%{allow_nil?: true}), do: [{"", nil}]
defp allow_nil_option(%{default: nil}), do: [{"", nil}]
defp allow_nil_option(_), do: []
defp can_append_embed?(changeset, attribute) do
case Ash.Changeset.get_attribute(changeset, attribute) do
nil ->
true
value when is_list(value) ->
true
_ ->
false
end
end
defp boolean_selected(nil), do: :Nil
defp boolean_selected(true), do: :True
defp boolean_selected(false), do: :False
defp placeholder(value) when is_function(value) do
"DEFAULT"
end
@ -900,32 +1143,24 @@ defmodule AshAdmin.Components.Resource.Form do
end
end
def handle_event("append_related", %{"path" => path, "type" => type}, socket) do
def handle_event("append_related", %{"path" => path}, socket) do
decoded_path = AshPhoenix.decode_path(path)
socket =
socket
|> add_target(decoded_path)
|> add_target(decoded_path ++ ["~", "_lookup"])
|> add_target(decoded_path ++ ["*"])
# |> add_target(decoded_path ++ ["*"])
initial =
if type == "lookup" do
%{"_lookup" => "true"}
else
%{}
end
new_changeset = AshPhoenix.add_related(socket.assigns.changeset, path, "change")
{:noreply,
socket
|> assign(
changeset: AshPhoenix.add_related(socket.assigns.changeset, path, "change", add: initial)
)}
|> assign(changeset: new_changeset)}
end
def handle_event("remove_related", %{"path" => path}, socket) do
socket = add_target(socket, AshPhoenix.decode_path(path))
{record, changeset} = AshPhoenix.remove_related(socket.assigns.changeset, path, "change")
{:noreply,
@ -936,11 +1171,25 @@ defmodule AshAdmin.Components.Resource.Form do
end
def handle_event("append_embed", %{"path" => path}, socket) do
decoded_path = AshPhoenix.decode_path(path)
socket =
socket
|> add_target(decoded_path)
|> add_target(decoded_path ++ ["*"])
{:noreply,
assign(socket, changeset: AshPhoenix.add_embed(socket.assigns.changeset, path, "change"))}
end
def handle_event("remove_embed", %{"path" => path}, socket) do
decoded_path = AshPhoenix.decode_path(path)
socket =
socket
|> add_target(decoded_path)
|> add_target(decoded_path ++ ["*"])
{:noreply,
assign(socket,
changeset: AshPhoenix.remove_embed(socket.assigns.changeset, path, "change")
@ -963,7 +1212,10 @@ defmodule AshAdmin.Components.Resource.Form do
changeset
|> set_table(socket.assigns.table)
|> socket.assigns.api.create()
|> socket.assigns.api.create(
authorize?: socket.assigns[:authorizing],
actor: socket.assigns[:actor]
)
|> case do
{:ok, created} ->
redirect_to(socket, created)
@ -981,7 +1233,10 @@ defmodule AshAdmin.Components.Resource.Form do
tenant: socket.assigns[:tenant]
)
|> set_table(socket.assigns.table)
|> socket.assigns.api.update()
|> socket.assigns.api.update(
authorize?: socket.assigns[:authorizing],
actor: socket.assigns[:actor]
)
|> case do
{:ok, updated} ->
redirect_to(socket, updated)
@ -999,7 +1254,10 @@ defmodule AshAdmin.Components.Resource.Form do
tenant: socket.assigns[:tenant]
)
|> set_table(socket.assigns.table)
|> socket.assigns.api.destroy()
|> socket.assigns.api.destroy(
authorize?: socket.assigns[:authorizing],
actor: socket.assigns[:actor]
)
|> case do
:ok ->
{:noreply,
@ -1076,7 +1334,10 @@ defmodule AshAdmin.Components.Resource.Form do
defp load(record_or_records, [path], socket) when is_binary(path) do
path = String.to_existing_atom(path)
case socket.assigns.api.load(record_or_records, [path], actor: socket.assigns.actor) do
case socket.assigns.api.load(record_or_records, [path],
actor: socket.assigns.actor,
authorize?: socket.assigns[:authorizing]
) do
{:ok, loaded} ->
{:ok, Map.get(loaded, path), loaded}
@ -1180,11 +1441,13 @@ defmodule AshAdmin.Components.Resource.Form do
resource
|> Ash.Resource.Info.relationships()
|> Enum.filter(&(&1.name in exactly))
|> sort_relationships()
end
def relationships(resource, :show, _) do
resource
|> Ash.Resource.Info.relationships()
|> sort_relationships()
end
def relationships(_resource, %{type: :destroy}, _) do
@ -1201,22 +1464,32 @@ defmodule AshAdmin.Components.Resource.Form do
end
defp sort_relationships(relationships) do
[:belongs_to, :has_one, :has_many, :many_to_many]
|> Enum.map(fn type ->
Enum.filter(relationships, &(&1.type == type))
end)
|> Enum.concat()
{join_assocs, regular_assocs} =
[:belongs_to, :has_one, :has_many, :many_to_many]
|> Enum.map(fn type ->
Enum.filter(relationships, &(&1.type == type))
end)
|> Enum.concat()
|> Enum.split_with(&String.ends_with?(to_string(&1.name), "join_assoc"))
regular_assocs ++ join_assocs
end
def attributes(resource, action, exactly \\ nil)
def attributes(resource, :_lookup, _exactly) do
resource
|> Ash.Resource.Info.attributes()
|> Enum.filter(& &1.primary_key?)
|> Enum.map(&Map.put(&1, :default, nil))
|> sort_attributes(resource)
end
def attributes(resource, %{type: :read, arguments: arguments}, exactly)
when not is_nil(exactly) do
resource
|> Ash.Resource.Info.attributes()
|> Enum.map(fn attribute ->
%{attribute | default: nil}
end)
|> Enum.map(&Map.put(&1, :default, nil))
|> Enum.concat(arguments)
|> Enum.filter(&(&1.name in exactly))
|> sort_attributes(resource)

View file

@ -50,7 +50,7 @@ defmodule AshAdmin.Components.TopNav do
</div>
<div class="ml-10 flex items-center">
<ActorSelect
:if={{ show_actor_select?(@actor_resources) }}
:if={{ @actor_resources != []}}
actor_resources={{ @actor_resources }}
authorizing={{ @authorizing }}
actor_paused={{ @actor_paused }}
@ -104,7 +104,7 @@ defmodule AshAdmin.Components.TopNav do
<div class="relative px-2 pt-2 pb-3 sm:px-3">
<div class="block px-4 py-2 text-sm">
<ActorSelect
:if={{ show_actor_select?(@actor_resources) }}
:if={{ @actor_resources != [] }}
actor_resources={{ @actor_resources }}
authorizing={{ @authorizing }}
actor_paused={{ @actor_paused }}
@ -163,7 +163,4 @@ defmodule AshAdmin.Components.TopNav do
end)
end)
end
defp show_actor_select?([]), do: false
defp show_actor_select?(_), do: true
end

View file

@ -19,9 +19,9 @@ defmodule AshAdmin.Components.TopNav.ActorSelect do
def render(assigns) do
~H"""
<div id="actor-hook" class="relative mr-5 text-white" phx-hook="Actor">
<div :if={{ @actor }}>
<span :if={{ @actor }}>
<div id="actor-hook" class="flex items-center mr-5 text-white" phx-hook="Actor">
<div>
<span>
<button :on-click={{ @toggle_authorizing }} type="button">
<svg
:if={{ @authorizing }}
@ -50,7 +50,7 @@ defmodule AshAdmin.Components.TopNav.ActorSelect do
/>
</svg>
</button>
<button :on-click={{ @toggle_actor_paused }} type="button">
<button :if={{@actor}} :on-click={{ @toggle_actor_paused }} type="button">
<svg
:if={{ @actor_paused }}
width="1em"
@ -73,6 +73,7 @@ defmodule AshAdmin.Components.TopNav.ActorSelect do
</svg>
</button>
<LiveRedirect
:if={{@actor}}
class="hover:text-blue-400 hover:underline"
to={{ash_show_path(
@prefix,
@ -84,7 +85,7 @@ defmodule AshAdmin.Components.TopNav.ActorSelect do
>
{{ user_display(@actor) }}
</LiveRedirect>
<button :on-click={{ @clear_actor }} type="button">
<button :if={{@actor}} :on-click={{ @clear_actor }} type="button">
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="white" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"

View file

@ -74,6 +74,8 @@ defmodule AshAdmin.Router do
path
end
prefix = String.trim_trailing(prefix, "/")
apis = opts[:apis]
Enum.each(apis, &Code.ensure_compiled/1)
api = List.first(opts[:apis])

View file

@ -88,8 +88,8 @@ defmodule AshAdmin.MixProject do
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:ash, "~> 1.37"},
{:ash_phoenix, "~> 0.4 and >= 0.4.6"},
{:ash, "~> 1.37 and >= 1.37.1"},
{:ash_phoenix, "~> 0.4 and >= 0.4.7"},
{:surface, "~> 0.3.2"},
{:phoenix_live_view, "~> 0.15.4"},
{:phoenix_html, "~> 2.14.1 or ~> 2.15"},

View file

@ -1,6 +1,6 @@
%{
"ash": {:hex, :ash, "1.37.0", "c3a60bef3a4d429f127d9392f533c8958e153990c8dd69d8d3e029f2a3ce24cc", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.3.5", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.1.5", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:timex, ">= 3.0.0", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm", "d09b1f82d3e20dd3dd6f16f3dcbf4bc183b0c903c1343858fdbb0069aec969bc"},
"ash_phoenix": {:hex, :ash_phoenix, "0.4.6", "a484b582416f7045159194bed2e4aa24d0a624187015f8e26d2872af459490d8", [:mix], [{:ash, "~> 1.37", [hex: :ash, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.15", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "b320a51694ae62b60629d997cd2a0965b58de2c250985a24e492b35db07a4a40"},
"ash": {:hex, :ash, "1.37.1", "b9138bc129603d66e7c1826bc061bfb6e7cbafcb94fbc55e2f27825a6b354e3b", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.3.5", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.1.5", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:timex, ">= 3.0.0", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm", "0d5911191c247ea7170145ee765d939864375a484972a2c259f1d65c9a72ce29"},
"ash_phoenix": {:hex, :ash_phoenix, "0.4.7", "90315781a97accf0cd26367573ae39673d98b71370bf11d14b7208fbc272fbfa", [:mix], [{:ash, "~> 1.37", [hex: :ash, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.15", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "733abb6829b828a4f412479281fb9e172c8b0be8917a408a12fa53091c5ec41f"},
"ash_policy_authorizer": {:hex, :ash_policy_authorizer, "0.16.0", "18b2352facf8f860458bf81aeed481ced9a7e13e8eb2826e7ae714bf0fbd4792", [:mix], [{:ash, "~> 1.35 and >= 1.35.1", [hex: :ash, repo: "hexpm", optional: false]}], "hexpm", "40b2fd1071a60da1404c5a133f43bf416f5cb2c4f7313a7949b638340f482a61"},
"ash_postgres": {:hex, :ash_postgres, "0.35.4", "f72b0780c839f588888985578aa0a03ae027f4e460fbeab4ddf209f97f794c77", [:mix], [{:ash, "~> 1.34 and >= 1.34.6", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.5", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "cba9e3ad50b1888dbb4623d80b88b75997f94912e088e69554259f9f00543a98"},
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"},

View file

@ -0,0 +1,20 @@
defmodule Demo.Repo.Migrations.MigrateResources6 do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
create table(:ticket_links, primary_key: false) do
add :source_id, references(:tickets, type: :uuid, column: :id), primary_key: true
add :destination_id, references(:tickets, type: :uuid, column: :id), primary_key: true
end
end
def down do
drop table(:ticket_links)
end
end

View file

@ -0,0 +1,21 @@
defmodule Demo.Repo.Migrations.MigrateResources7 do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:ticket_links) do
add :type, :text
end
end
def down do
alter table(:ticket_links) do
remove :type
end
end
end

View file

@ -0,0 +1,21 @@
defmodule Demo.Repo.Migrations.MigrateResources8 do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:ticket_links) do
modify :type, :text, null: false
end
end
def down do
alter table(:ticket_links) do
modify :type, :text, null: true
end
end
end

View file

@ -0,0 +1,48 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"name": "destination_id",
"primary_key?": true,
"references": {
"destination_field": "id",
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"table": "tickets"
},
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"name": "source_id",
"primary_key?": true,
"references": {
"destination_field": "id",
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"table": "tickets"
},
"type": "uuid"
}
],
"base_filter": null,
"hash": "366F7BEE4E14C4BE0D18E45E655650E3FA327F53AE9AD730E09382896E4C2A6A",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Demo.Repo",
"table": "ticket_links"
}

View file

@ -0,0 +1,57 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"name": "destination_id",
"primary_key?": true,
"references": {
"destination_field": "id",
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"table": "tickets"
},
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"name": "source_id",
"primary_key?": true,
"references": {
"destination_field": "id",
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"table": "tickets"
},
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"name": "type",
"primary_key?": false,
"references": null,
"type": "text"
}
],
"base_filter": null,
"hash": "53C30FF7B236DF0866D9DE5D166C6AB5AF1CD86B6DAC48D93653D70E259F4B1C",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Demo.Repo",
"table": "ticket_links"
}

View file

@ -0,0 +1,57 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"name": "destination_id",
"primary_key?": true,
"references": {
"destination_field": "id",
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"table": "tickets"
},
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"name": "source_id",
"primary_key?": true,
"references": {
"destination_field": "id",
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"table": "tickets"
},
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"name": "type",
"primary_key?": false,
"references": null,
"type": "text"
}
],
"base_filter": null,
"hash": "DF68E19931FD501968359AD5E18F6366F494A39E658968C708E2522B3BC9C81C",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Demo.Repo",
"table": "ticket_links"
}