diff --git a/.formatter.exs b/.formatter.exs index 05a826e1..3d74c0dc 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -6,6 +6,7 @@ spark_locals_without_parens = [ field: 1, field: 2, format_fields: 1, + generic_actions: 1, name: 1, polymorphic_actions: 1, polymorphic_tables: 1, diff --git a/CHANGELOG.md b/CHANGELOG.md index 75c055f1..fc02a754 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,53 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline +## [v0.11.11](https://github.com/ash-project/ash_admin/compare/v0.11.10...v0.11.11) (2024-10-30) + + + + +### Bug Fixes: + +* properly update any kind of form data + +* fix relationship loading on Resource Update form (#220) (#221) + +## [v0.11.10](https://github.com/ash-project/ash_admin/compare/v0.11.9...v0.11.10) (2024-10-29) + + + + +### Bug Fixes: + +* various fixes for unions & form mutations + +## [v0.11.9](https://github.com/ash-project/ash_admin/compare/v0.11.8...v0.11.9) (2024-10-17) + + + + +### Improvements: + +* make generic actions list properly configurable + +## [v0.11.8](https://github.com/ash-project/ash_admin/compare/v0.11.7...v0.11.8) (2024-10-17) + + + + +### Bug Fixes: + +* clean up remaining generic action necessities + +## [v0.11.7](https://github.com/ash-project/ash_admin/compare/v0.11.6...v0.11.7) (2024-10-17) + + + + +### Improvements: + +* support for generic actions + ## [v0.11.6](https://github.com/ash-project/ash_admin/compare/v0.11.5...v0.11.6) (2024-09-19) diff --git a/README.md b/README.md index 3924dca0..bdedb88e 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,13 @@ Welcome! This is a super-admin UI dashboard for [Ash Framework](https://hexdocs.pm/ash) applications, built with Phoenix LiveView. +If you are using Phoenix LiveView 1.0.0 release candidate, you will need to use the live_view_1.0 branch of this repo. + ## Tutorials - [Getting Started with AshAdmin](documentation/tutorials/getting-started-with-ash-admin.md) ## Reference -- [AshAdmin.Domain DSL](documentation/dsls/DSL:-AshAdmin.Domain.md) -- [AshAdmin.Resource DSL](documentation/dsls/DSL:-AshAdmin.Resource.md) +- [AshAdmin.Domain DSL](documentation/dsls/DSL-AshAdmin.Domain.md) +- [AshAdmin.Resource DSL](documentation/dsls/DSL-AshAdmin.Resource.md) diff --git a/assets/js/app.js b/assets/js/app.js index a2445cde..dc9137c1 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -26,7 +26,7 @@ Hooks.JsonEditor = { target.dispatchEvent( new Event("change", { bubbles: true, target: this.el.name }), ); - } catch (_e) {} + } catch (_e) { } }, onChange: () => { try { @@ -37,7 +37,7 @@ Hooks.JsonEditor = { target.dispatchEvent( new Event("change", { bubbles: true, target: this.el.name }), ); - } catch (_e) {} + } catch (_e) { } }, onModeChange: (newMode) => { hook.mode = newMode; @@ -63,7 +63,7 @@ Hooks.JsonEditorSource = { } else { } } - } catch (_e) {} + } catch (_e) { } }, }; @@ -163,6 +163,43 @@ Hooks.MaintainAttrs = { }, }; +Hooks.Typeahead = { + mounted() { + const target_id = this.el.getAttribute("data-target-id"); + const target_el = document.getElementById(target_id); + + switch (this.el.tagName) { + case "INPUT": + this.el.addEventListener("keydown", e => { + if (e.key === "Enter") { + e.preventDefault(); + } + }); + this.el.addEventListener("keyup", e => { + switch (e.key) { + case "Enter": + case "Escape": + this.el.blur(); + window.setTimeout(function () { target_el.dispatchEvent(new Event("input", { bubbles: true })) }, 750); + break; + } + }); + break; + + case "LI": + this.el.addEventListener("click", e => { + window.setTimeout(function () { target_el.dispatchEvent(new Event("input", { bubbles: true })) }, 750); + }); + break; + } + }, + updated() { + if (this.el.tagName === "INPUT" && this.el.name.match(/suggest$/) && this.el.value.length === 0) { + this.el.focus(); + } + } +}; + function getCookie(name) { var re = new RegExp(name + "=([^;]+)"); var value = re.exec(document.cookie); diff --git a/config/config.exs b/config/config.exs index 53cd51f1..3dedfe40 100644 --- a/config/config.exs +++ b/config/config.exs @@ -2,6 +2,7 @@ import Config config :ash, :validate_domain_resource_inclusion?, false config :ash, :validate_domain_config_inclusion?, false +config :ash, :custom_expressions, [AshAdmin.Expressions.Position] pg_url = System.get_env("PG_URL") || "postgres:postgres@127.0.0.1" pg_database = System.get_env("PG_DATABASE") || "ash_admin_dev" diff --git a/dev/resources/tickets/resources/ticket/ticket.ex b/dev/resources/tickets/resources/ticket/ticket.ex index 80d87636..7b42c39c 100644 --- a/dev/resources/tickets/resources/ticket/ticket.ex +++ b/dev/resources/tickets/resources/ticket/ticket.ex @@ -46,6 +46,26 @@ defmodule Demo.Tickets.Ticket do pagination offset?: true, countable: true, required?: false, default_limit: 25 end + action :ticket_count, :integer do + run fn _, context -> + Ash.count(__MODULE__, Ash.Context.to_opts(context)) + end + end + + action :fake_ticket, :struct do + constraints instance_of: __MODULE__ + run fn _, context -> + {:ok, %__MODULE__{id: Ash.UUID.generate()}} + end + end + + action :map_type, :map do + constraints fields: [foo: [type: :integer], bar: [type: :string]] + run fn _, context -> + {:ok, %{foo: 10, bar: "hello"}} + end + end + read :assigned do filter representative: actor(:id) pagination offset?: true, countable: true, required?: false, default_limit: 25 @@ -83,9 +103,11 @@ defmodule Demo.Tickets.Ticket do update :update do primary? true argument :organization_id, :uuid + argument :comments, {:array, :map} require_atomic? false change manage_relationship(:organization_id, :organization, type: :append_and_remove) + change manage_relationship(:comments, :comments, type: :direct_control) end update :assign do diff --git a/documentation/dsls/DSL:-AshAdmin.Domain.md b/documentation/dsls/DSL-AshAdmin.Domain.md similarity index 100% rename from documentation/dsls/DSL:-AshAdmin.Domain.md rename to documentation/dsls/DSL-AshAdmin.Domain.md diff --git a/documentation/dsls/DSL:-AshAdmin.Resource.md b/documentation/dsls/DSL-AshAdmin.Resource.md similarity index 91% rename from documentation/dsls/DSL:-AshAdmin.Resource.md rename to documentation/dsls/DSL-AshAdmin.Resource.md index 30091bd2..1085d8e2 100644 --- a/documentation/dsls/DSL:-AshAdmin.Resource.md +++ b/documentation/dsls/DSL-AshAdmin.Resource.md @@ -25,13 +25,14 @@ Configure the admin dashboard for a given resource. | [`actor?`](#admin-actor?){: #admin-actor? } | `boolean` | | Whether or not this resource can be used as the actor for requests. | | [`show_action`](#admin-show_action){: #admin-show_action } | `atom` | | The action to use when linking to the resource/viewing a single record. Defaults to the primary read action. | | [`read_actions`](#admin-read_actions){: #admin-read_actions } | `list(atom)` | | A list of read actions that can be used to show resource details. By default, all actions are included. | +| [`generic_actions`](#admin-generic_actions){: #admin-generic_actions } | `list(atom)` | | A list of generic actions that can be used to show resource details. By default, all actions are included. | | [`create_actions`](#admin-create_actions){: #admin-create_actions } | `list(atom)` | | A list of create actions that can create records. By default, all actions are included. | | [`update_actions`](#admin-update_actions){: #admin-update_actions } | `list(atom)` | | A list of update actions that can be used to update records. By default, all actions are included. | | [`destroy_actions`](#admin-destroy_actions){: #admin-destroy_actions } | `list(atom)` | | A list of destroy actions that can be used to destroy records. By default, all actions are included. | | [`polymorphic_tables`](#admin-polymorphic_tables){: #admin-polymorphic_tables } | `list(String.t)` | | For resources that use ash_postgres' polymorphism capabilities, you can provide a list of tables that should be available to select. These will be added to the list of derivable tables based on scanning all domains and resources provided to ash_admin. | | [`polymorphic_actions`](#admin-polymorphic_actions){: #admin-polymorphic_actions } | `list(atom)` | | For resources that use ash_postgres' polymorphism capabilities, you can provide a list of actions that should require a table to be set. If this is not set, then *all* actions will require tables. | | [`table_columns`](#admin-table_columns){: #admin-table_columns } | `list(atom)` | | The list of attributes to render on the table view. | -| [`format_fields`](#admin-format_fields){: #admin-format_fields } | `list(any)` | | The list of fields and their formats. | +| [`format_fields`](#admin-format_fields){: #admin-format_fields } | `list(any)` | | The list of fields and their formats represented as a MFA. For example: `updated_at: {Timex, :format!, ["{0D}-{0M}-{YYYY} {h12}:{m} {AM}"]}`. | | [`relationship_display_fields`](#admin-relationship_display_fields){: #admin-relationship_display_fields } | `list(atom)` | | The list of attributes to render when this resource is shown as a relationship on another resource's datatable. | | [`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. | diff --git a/documentation/tutorials/getting-started-with-ash-admin.md b/documentation/tutorials/getting-started-with-ash-admin.md index cca6962d..d4253891 100644 --- a/documentation/tutorials/getting-started-with-ash-admin.md +++ b/documentation/tutorials/getting-started-with-ash-admin.md @@ -20,7 +20,7 @@ Ensure your domains are configured in `config.exs`: config :my_app, ash_domains: [MyApp.Foo, MyApp.Bar] ``` -Add the `AshAdmin.Domain` extension to each domain you want to show in the AshAdmin dashboard, and configure it to show. See [DSL: AshAdmin.Domain](/documentation/dsls/DSL:-AshAdmin.Domain.md) for more configuration options. +Add the `AshAdmin.Domain` extension to each domain you want to show in the AshAdmin dashboard, and configure it to show. See [DSL: AshAdmin.Domain](/documentation/dsls/DSL-AshAdmin.Domain.md) for more configuration options. ```elixir # In your Domain(s) @@ -32,7 +32,7 @@ admin do end ``` -All resources in each Domain will automatically be included in AshAdmin. To configure a resource, use the `AshAdmin.Resource` extension, and then use the [DSL: AshAdmin.Resource](/documentation/dsls/DSL:-AshAdmin.Resource.md) configuration options. Specifically, if your app has an actor you will want to configure that. +All resources in each Domain will automatically be included in AshAdmin. To configure a resource, use the `AshAdmin.Resource` extension, and then use the [DSL: AshAdmin.Resource](/documentation/dsls/DSL-AshAdmin.Resource.md) configuration options. Specifically, if your app has an actor you will want to configure that. ```elixir # In your resource that acts as an actor (e.g. User) @@ -58,6 +58,8 @@ defmodule MyAppWeb.Router do # Most applications will not need this: admin_browser_pipeline :browser + # NOTE: `scope/2` here does not have a second argument. + # If it looks like `scope "/", MyAppWeb`, create a *new* scope, don't copy the contents into your scope scope "/" do # Pipe it through your browser pipeline pipe_through [:browser] @@ -100,9 +102,11 @@ This will allow AshAdmin-generated inline CSS and JS blocks to execute normally. ## Troubleshooting #### UI issues + If your admin UI is not responding as expected, check your browser's developer console for content-security-policy violations (see above). #### Router issues + If you are seeing the following error `(UndefinedFunctionError) function YourAppWeb.AshAdmin.PageLive.__live__/0 is undefined (module YourAppWeb.AshAdmin.PageLive is not available)` it likely means that you added the ash admin route macro under a scope with a prefix. Make sure that you add it under a scope without any prefixes. ```elixir diff --git a/lib/ash_admin/components/resource/form.ex b/lib/ash_admin/components/resource/form.ex index d72d1df1..db4a19e7 100644 --- a/lib/ash_admin/components/resource/form.ex +++ b/lib/ash_admin/components/resource/form.ex @@ -7,6 +7,8 @@ defmodule AshAdmin.Components.Resource.Form do require Logger + alias AshAdmin.Components.Resource.RelationshipField + attr :resource, :any, required: true attr :domain, :any, required: true attr :record, :any, default: nil @@ -26,13 +28,15 @@ defmodule AshAdmin.Components.Resource.Form do {:ok, socket |> assign_new(:load_errors, fn -> %{} end) - |> assign_new(:loaded, fn -> %{} end)} + |> assign_new(:loaded, fn -> %{} end) + |> assign(:params, %{})} end def update(assigns, socket) do {:ok, socket |> assign(assigns) + |> assign(:typeahead_options, []) |> assign_form() |> assign(:initialized, true)} end @@ -196,7 +200,12 @@ defmodule AshAdmin.Components.Resource.Form do class="block text-sm font-medium text-gray-700" for={@form.name <> "[#{attribute.name}]"} > - <%= to_name(attribute.name) %> + <% related_resource = get_related_resource(@resource, attribute) %> + <%= if related_resource && AshAdmin.Resource.label_field(related_resource) do %> + <%= RelationshipField.form_control_label(related_resource) %> + <% else %> + <%= to_name(attribute.name) %> + <% end %> <%= render_attribute_input(assigns, attribute, @form) %> <.error_tag @@ -621,6 +630,37 @@ defmodule AshAdmin.Components.Resource.Form do end end + defp get_related_resource( + resource, + attribute = %Ash.Resource.Attribute{primary_key?: true} + ) do + with relationships <- Ash.Resource.Info.relationships(resource), + %{source: source} <- + Enum.find(relationships, fn + %Ash.Resource.Relationships.BelongsTo{destination_attribute: destination_attribute} -> + destination_attribute == attribute.name + + _other -> + false + end) do + source + end + end + + defp get_related_resource(resource, attribute) do + with relationships <- Ash.Resource.Info.relationships(resource), + %{destination: destination} <- + Enum.find(relationships, fn + %Ash.Resource.Relationships.BelongsTo{source_attribute: source_attribute} -> + source_attribute == attribute.name + + _other -> + false + end) do + destination + end + end + defp unwrap_type({:array, type}), do: unwrap_type(type) defp unwrap_type(type), do: type @@ -744,7 +784,8 @@ defmodule AshAdmin.Components.Resource.Form do type: type, name: name, default: default, - id: id + id: id, + related_resource: get_related_resource(form.source.resource, attribute) ) ~H""" @@ -790,6 +831,15 @@ defmodule AshAdmin.Components.Resource.Form do name={@name || @form.name <> "[#{@attribute.name}]"} placeholder={placeholder(@default)} /> + <% @related_resource && AshAdmin.Resource.label_field(@related_resource) -> %> + <.live_component + module={AshAdmin.Components.Resource.RelationshipField} + id={@id || "#{@form.name}-#{@attribute.name}"} + value={value(@value, @form, @attribute)} + resource={@related_resource} + form={@form} + attribute={@attribute} + /> <% true -> %> <.input type={text_input_type(@form.source.resource, @attribute)} @@ -1430,7 +1480,7 @@ defmodule AshAdmin.Components.Resource.Form do socket |> redirect( to: - "#{socket.assigns.prefix || "/"}?domain=#{AshAdmin.Domain.name(socket.assigns.domain)}&resource=#{AshAdmin.Resource.name(socket.assigns.resource)}&tab=show&table=#{socket.assigns.table}&primary_key=#{encode_primary_key(record)}" + "#{socket.assigns.prefix || "/"}?domain=#{AshAdmin.Domain.name(socket.assigns.domain)}&resource=#{AshAdmin.Resource.name(socket.assigns.resource)}&table=#{socket.assigns.table}&primary_key=#{encode_primary_key(record)}" )} else case AshAdmin.Helpers.primary_action(socket.assigns.resource, :update) do @@ -1446,7 +1496,7 @@ defmodule AshAdmin.Components.Resource.Form do socket |> redirect( to: - "#{socket.assigns.prefix || "/"}?domain=#{AshAdmin.Domain.name(socket.assigns.domain)}&resource=#{AshAdmin.Resource.name(socket.assigns.resource)}&action_type=update&tab=update&table=#{socket.assigns.table}&primary_key=#{encode_primary_key(record)}" + "#{socket.assigns.prefix || "/"}?domain=#{AshAdmin.Domain.name(socket.assigns.domain)}&resource=#{AshAdmin.Resource.name(socket.assigns.resource)}&action_type=update&table=#{socket.assigns.table}&primary_key=#{encode_primary_key(record)}" )} end end @@ -1462,11 +1512,14 @@ defmodule AshAdmin.Components.Resource.Form do new_union_types = (socket.assigns[:union_types] || %{}) |> Map.put(path, new_type) if AshPhoenix.Form.has_form?(socket.assigns.form, path) do + nested_form = AshPhoenix.Form.get_form(socket.assigns.form, path) + form = socket.assigns.form |> AshPhoenix.Form.remove_form(path) |> AshPhoenix.Form.add_form(path, - params: %{"_new_union_type" => new_type, "_union_type" => new_type} + params: %{"_new_union_type" => new_type, "_union_type" => new_type}, + type: nested_form.type ) {:noreply, assign(socket, form: form, union_types: new_union_types)} @@ -1574,7 +1627,7 @@ defmodule AshAdmin.Components.Resource.Form do params = put_in_creating( - socket.assigns.form.params || %{}, + socket.assigns.form.source.raw_params || %{}, Enum.map(AshPhoenix.Form.parse_path!(socket.assigns.form, path) ++ [field], &to_string/1), list ) @@ -1595,20 +1648,45 @@ defmodule AshAdmin.Components.Resource.Form do path, fn adding_form -> if adding_form.data do - new_data = Ash.load!(adding_form.data, relationship, domain: socket.assigns.domain) + new_data = + Ash.load!(adding_form.data, relationship, + actor: socket.assigns.actor, + tenant: socket.assigns.tenant, + domain: socket.assigns.domain + ) + + updated_form = + adding_form + |> Map.put(:data, new_data) + |> AshPhoenix.Form.validate(adding_form.raw_params, errors: false) + |> AshPhoenix.Form.update_options(fn opts -> + Keyword.update(opts, :forms, [], fn forms -> + Keyword.new(forms, fn {key, val} -> + if val[:managed_relationship] == {adding_form.resource, relationship} do + new_data = + case val[:type] do + :single -> Enum.at(List.wrap(Map.get(new_data, relationship)), 0) + _ -> new_data + end + + {key, Keyword.put(val, :data, new_data)} + else + {key, val} + end + end) + end) + end) if Map.has_key?(adding_form.source, :data) do - %{adding_form | data: new_data, source: %{adding_form.source | data: new_data}} + %{updated_form | data: new_data, source: %{adding_form.source | data: new_data}} else - %{adding_form | data: new_data} + updated_form end - |> AshPhoenix.Form.validate(adding_form.params, errors: false) else adding_form end end ) - |> AshPhoenix.Form.validate(AshPhoenix.Form.params(socket.assigns.form)) {:noreply, socket @@ -1655,23 +1733,13 @@ defmodule AshAdmin.Components.Resource.Form do end def handle_event("validate", %{"form" => params} = event, socket) do - params = - case event["_target"] do - [_, target | _] -> - put_in_creating( - socket.assigns.form.params || %{}, - [target], - get_in(params, [target]) - ) - - _ -> - socket.assigns.form.params - end - form = - AshPhoenix.Form.validate(socket.assigns.form, replace_new_union_stubs(params)) + AshPhoenix.Form.validate(socket.assigns.form, replace_new_union_stubs(params), + only_touched?: true, + target: event["_target"] + ) - {:noreply, assign(socket, :form, form)} + {:noreply, assign(socket, form: form)} end defp replace_new_union_stubs(value) when is_list(value) do @@ -1759,7 +1827,7 @@ defmodule AshAdmin.Components.Resource.Form do new_value end - new_params = Map.put(form.params, field, new_value) + new_params = Map.put(form.raw_params, field, new_value) AshPhoenix.Form.validate(form, new_params) end @@ -1946,7 +2014,7 @@ defmodule AshAdmin.Components.Resource.Form do end defp manages_relationship(argument, action) do - if action.changes do + if Map.get(action, :changes) do Enum.find_value(action.changes, fn %{change: {Ash.Resource.Change.ManageRelationship, opts}} -> if opts[:argument] == argument.name do @@ -1960,6 +2028,7 @@ defmodule AshAdmin.Components.Resource.Form do end defp only_accepted(attributes, %{type: :read}), do: attributes + defp only_accepted(_, %{type: :action}), do: [] defp only_accepted(attributes, %{accept: accept}) do Enum.filter(attributes, &(&1.name in accept)) diff --git a/lib/ash_admin/components/resource/generic_action.ex b/lib/ash_admin/components/resource/generic_action.ex new file mode 100644 index 00000000..435a078c --- /dev/null +++ b/lib/ash_admin/components/resource/generic_action.ex @@ -0,0 +1,495 @@ +defmodule AshAdmin.Components.Resource.GenericAction do + @moduledoc false + use Phoenix.LiveComponent + + import AshAdmin.Helpers + + attr :resource, :atom + attr :domain, :atom + attr :action, :any + attr :authorizing, :boolean + attr :actor, :any + attr :url_path, :any + attr :params, :any + attr :tenant, :any, required: true + attr :table, :any + attr :prefix, :any + + def render(assigns) do + ~H""" +
+ <%= if Enum.empty?(@action.arguments) do %> + <.form + :let={form} + as={:form} + for={@form} + class="flex flex-row justify-items-center pt-4" + phx-change="validate" + phx-submit="save" + phx-target={@myself} + > +
+ +
+ <%= AshAdmin.Components.Resource.Form.render_attributes( + assigns, + @resource, + @action, + form + ) %> + + + <% else %> +
+
+
+
+ <.form + :let={form} + as={:form} + for={@form} + class="flex flex-row" + phx-change="validate" + phx-submit="save" + phx-target={@myself} + > +
+
    +
  • + + <%= field %>: + + + <%= message %> + +
  • +
+
+ <%= AshAdmin.Components.Resource.Form.render_attributes( + assigns, + @resource, + @action, + form + ) %> +
+ +
+ +
+
+
+ <% end %> + + <%= case @result do %> + <% :pending -> %> + <% :ok -> %> + Success + <% {:ok, result} -> %> +
+

Success

+ <%= render_value(assigns, result, @action.returns, @action.constraints) %> +
+ <% :error -> %> + Action failed + <% end %> +
+ """ + end + + defp render_value(assigns, nil, _, _) do + ~H""" + None + """ + end + + defp render_value(assigns, value, {:array, type}, constraints) do + assigns = assign(assigns, value: value, type: type, constraints: constraints[:items] || []) + + ~H""" + <%= for inner_value <- List.wrap(@value) do %> + <%= render_value(assigns, inner_value, @type, @constraints) %> +
+ <% end %> + """ + end + + defp render_value(assigns, value, type, constraints) do + assigns = assign(assigns, value: value, type: type) + + cond do + Ash.Type.NewType.new_type?(type) -> + inner_type = Ash.Type.NewType.subtype_of(type) + constraints = Ash.Type.NewType.constraints(type, constraints) + render_value(assigns, value, inner_type, constraints) + + Ash.Type.embedded_type?(type) -> + AshAdmin.Components.Resource.Show.render_show(assigns, value, type, nil, false) + + type == Ash.Type.Struct and + constraints[:instance_of] && Ash.Resource.Info.resource?(constraints[:instance_of]) -> + AshAdmin.Components.Resource.Show.render_show( + assigns, + value, + constraints[:instance_of], + nil, + false + ) + + type in [Ash.Type.Map, Ash.Type.Keyword, Ash.Type.Struct] -> + render_keyed_value(assigns, value, type, constraints) + + type == Ash.Type.Union -> + case value do + %Ash.Union{type: type, value: value} -> + type = constraints[type][:type] + union_constraints = constraints[type][:constraints] || [] + + if type do + render_value(assigns, value, type, union_constraints) + else + raw_value(value) + end + + value -> + raw_value(value) + end + + true -> + raw_value(value) + end + end + + defp raw_value(value) when is_binary(value), do: value + + defp raw_value(nil), do: "None" + + defp raw_value(value) do + inspect(value) + end + + defp render_keyed_value(assigns, value, _type, constraints) do + assigns = assign(assigns, value: value, constraints: constraints) + + if (is_map(value) || Keyword.keyword?(value)) && Keyword.keyword?(constraints[:fields]) do + ~H""" + <%= for {key, config} <- @constraints[:fields] do %> +
+ <%= to_name(key) %> +
+
+ <%= render_value(assigns, get_key(@value, key), config[:type], config[:constraints]) %> +
+ <% end %> + """ + else + raw_value(value) + end + end + + defp get_key(value, key) when is_map(value), do: Map.get(value, key) + + defp get_key(value, key) do + if Keyword.keyword?(value) do + value[key] + else + nil + end + end + + def mount(socket) do + {:ok, + socket + |> assign_new(:result, fn -> :pending end) + |> assign_new(:initialized, fn -> false end)} + end + + def update(assigns, socket) do + if assigns[:initialized] do + {:ok, socket} + else + socket = assign(socket, assigns) + + context = + if table = socket.assigns[:table] do + %{ + data_layer: %{ + table: table + } + } + else + %{} + end + + form = + AshPhoenix.Form.for_action(socket.assigns.resource, socket.assigns.action.name, + domain: socket.assigns[:domain], + actor: socket.assigns[:actor], + tenant: socket.assigns[:tenant], + authorize?: socket.assigns[:authorizing], + context: context + ) + + {:ok, assign(socket, initialized: true, form: form)} + end + + # else + # socket = assign(socket, assigns) + # params = socket.assigns[:params] || %{} + # arguments = params["args"] + + # query = + # socket.assigns[:resource] + # |> AshPhoenix.Form.for_read(socket.assigns.action.name, + # as: "query", + # ) + + # {query, run_now?} = + # if arguments do + # {Map.put(AshPhoenix.Form.validate(query, arguments), :submitted_once?, true), true} + # else + # {query, socket.assigns.action.arguments == []} + # end + + # socket = assign(socket, :query, query) + + # socket = + # if params["page"] && socket.assigns.action.pagination do + # default_limit = + # socket.assigns.action.pagination.default_limit || + # socket.assigns.action.pagination.max_page_size || 25 + + # count? = !!socket.assigns.action.pagination.countable + + # page_params = + # AshPhoenix.LiveView.page_from_params(params["page"], default_limit, count?) + + # socket + # |> assign( + # :page_params, + # page_params + # ) + # |> assign( + # :page_num, + # page_num_from_page_params(page_params) + # ) + # else + # socket + # |> assign(:page_params, nil) + # |> assign(:page_num, 1) + # end + + # socket = + # if run_now? do + # if socket.assigns[:tables] not in [[], nil] && !socket.assigns[:table] do + # assign(socket, :data, {:ok, []}) + # else + # action_opts = + # if page_params = socket.assigns[:page_params] do + # [page: page_params] + # else + # [] + # end + + # case AshPhoenix.Form.submit(socket.assigns.query, action_opts: action_opts) do + # {:ok, data} -> assign(socket, :data, {:ok, data}) + # {:error, query} -> assign(socket, data: {:error, all_errors(query)}, query: query) + # end + # end + # else + # assign(socket, :data, :loading) + # end + + # {:ok, + # socket + # |> assign(:initialized, true)} + # end + end + + # defp load_fields(query) do + # query + # |> Ash.Query.select([]) + # |> Ash.Query.load(AshAdmin.Resource.table_columns(query.resource)) + # end + + def handle_event("validate", params, socket) do + params = params["form"] || %{} + form = AshPhoenix.Form.validate(socket.assigns.form, params) + + {:noreply, assign(socket, form: form)} + end + + def handle_event("save", params, socket) do + params = params["form"] || %{} + + case AshPhoenix.Form.submit(socket.assigns.form, params: params) do + :ok -> {:noreply, assign(socket, result: :ok)} + {:ok, res} -> {:noreply, assign(socket, result: {:ok, res})} + {:error, form} -> {:noreply, assign(socket, form: form, result: :error)} + end + end + + def handle_event("add_form", %{"path" => path} = params, socket) do + type = + case params["type"] do + "lookup" -> :read + _ -> :create + end + + form = AshPhoenix.Form.add_form(socket.assigns.form, path, type: type) + + {:noreply, + socket + |> assign(:form, form)} + end + + def handle_event("remove_form", %{"path" => path}, socket) do + form = AshPhoenix.Form.remove_form(socket.assigns.form, path) + + {:noreply, + socket + |> assign(:form, form)} + end + + def handle_event("remove_value", %{"path" => path, "field" => field, "index" => index}, socket) do + form = + AshPhoenix.Form.update_form( + socket.assigns.form, + path, + &remove_value(&1, field, index) + ) + + {:noreply, + socket + |> assign(:form, form)} + end + + def handle_event("append_value", %{"path" => path, "field" => field}, socket) do + list = + AshPhoenix.Form.get_form(socket.assigns.form, path) + |> AshPhoenix.Form.value(String.to_existing_atom(field)) + |> Kernel.||([]) + |> indexed_list() + |> append_to_and_map(nil) + + params = + put_in_creating( + socket.assigns.form.params || %{}, + Enum.map( + AshPhoenix.Form.parse_path!(socket.assigns.form, path) ++ [field], + &to_string/1 + ), + list + ) + + form = AshPhoenix.Form.validate(socket.assigns.form, params) + + {:noreply, + socket + |> assign(:form, form)} + end + + defp indexed_list(map) when is_map(map) do + map + |> Map.keys() + |> Enum.map(&String.to_integer/1) + |> Enum.sort() + |> Enum.map(&map[to_string(&1)]) + rescue + _ -> + List.wrap(map) + end + + defp indexed_list(other), do: List.wrap(other) + + defp append_to_and_map(list, value) do + list + |> Enum.concat([value]) + |> Enum.with_index() + |> Map.new(fn {v, i} -> + {"#{i}", v} + end) + end + + defp put_in_creating(map, [key], value) do + Map.put(map || %{}, key, value) + end + + defp put_in_creating(list, [key | rest], value) when is_list(list) do + List.update_at(list, String.to_integer(key), &put_in_creating(&1, rest, value)) + end + + defp put_in_creating(map, [key | rest], value) do + map + |> Kernel.||(%{}) + |> Map.put_new(key, %{}) + |> Map.update!(key, &put_in_creating(&1, rest, value)) + end + + defp remove_value(form, field, index) do + current_value = + form + |> AshPhoenix.Form.value(String.to_existing_atom(field)) + |> case do + map when is_map(map) -> + map + + list -> + list + |> List.wrap() + |> Enum.with_index() + |> Map.new(fn {value, index} -> + {to_string(index), value} + end) + end + + new_value = Map.delete(current_value, index) + + new_value = + if new_value == %{} do + nil + else + new_value + end + + new_params = Map.put(form.params, field, new_value) + + AshPhoenix.Form.validate(form, new_params) + end + + defp all_errors(form) do + form + |> AshPhoenix.Form.errors(for_path: :all) + |> Enum.flat_map(fn {path, errors} -> + Enum.map(errors, fn {field, message} -> + path = List.wrap(path) + + case Enum.reject(path ++ List.wrap(field), &is_nil/1) do + [] -> + {nil, message} + + items -> + {Enum.join(items, "."), message} + end + end) + end) + end +end diff --git a/lib/ash_admin/components/resource/nav.ex b/lib/ash_admin/components/resource/nav.ex index 957147d8..34392c8c 100644 --- a/lib/ash_admin/components/resource/nav.ex +++ b/lib/ash_admin/components/resource/nav.ex @@ -2,10 +2,10 @@ defmodule AshAdmin.Components.Resource.Nav do @moduledoc false use Phoenix.Component alias AshAdmin.Components.TopNav.Dropdown + import AshAdmin.Helpers attr :resource, :any, required: true attr :domain, :any, required: true - attr :tab, :string, required: true attr :action, :any attr :table, :any, default: nil attr :prefix, :any, default: nil @@ -27,8 +27,16 @@ defmodule AshAdmin.Components.Resource.Nav do
<.link - navigate={"#{@prefix}?domain=#{AshAdmin.Domain.name(@domain)}&resource=#{AshAdmin.Resource.name(@resource)}&action_type=create&action=#{create_action(@resource).name}&tab=create&table=#{@table}"} - class="inline-flex justify-center w-full rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-indigo-500" + navigate={"#{@prefix}?domain=#{AshAdmin.Domain.name(@domain)}&resource=#{AshAdmin.Resource.name(@resource)}&action_type=create&action=#{create_action(@resource).name}&table=#{@table}"} + class={ + classes([ + "inline-flex justify-center w-full rounded-md border border-gray-300 shadow-sm px-4 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-indigo-500", + "bg-gray-800 hover:bg-gray-900 text-white": + @action && @action.type == :create, + "bg-white text-gray-700 hover:bg-gray-300": + !@action || @action.type != :create + ]) + } > Create @@ -36,11 +44,29 @@ defmodule AshAdmin.Components.Resource.Nav do <.live_component module={Dropdown} - name="Read" + name={tab_name(@action && @action.name, @action && @action.type == :read, "Read")} id={"#{@resource}_data_dropdown"} - active={@tab == "data"} + active={@action && @action.type == :read} groups={data_groups(@prefix, @domain, @resource, @action, @table)} /> + + <%= case action_groups(@prefix, @domain, @resource, @action, @table) do %> + <% [[]] -> %> + <% groups -> %> + <.live_component + module={Dropdown} + name={ + tab_name( + @action && @action.name, + @action && @action.type == :action, + "Actions" + ) + } + id={"#{@resource}_actions_dropdown"} + active={@action && @action.type == :action} + groups={groups} + /> + <% end %>
@@ -50,6 +76,14 @@ defmodule AshAdmin.Components.Resource.Nav do """ end + defp tab_name(label, true, _) when not is_nil(label) and label != false do + Phoenix.Naming.humanize(label) + end + + defp tab_name(_, _, default) do + default + end + defp create_action(resource) do case AshAdmin.Helpers.primary_action(resource, :create) || Enum.find(Ash.Resource.Info.actions(resource), &(&1.type == :create)) do @@ -92,6 +126,26 @@ defmodule AshAdmin.Components.Resource.Nav do ] end + defp action_groups(prefix, domain, resource, current_action, table) do + generic_actions = AshAdmin.Resource.generic_actions(resource) + + [ + resource + |> Ash.Resource.Info.actions() + |> Enum.filter( + &(&1.type == :action && (is_nil(generic_actions) || &1.name in generic_actions)) + ) + |> Enum.map(fn action -> + %{ + text: action_name(action), + to: + "#{prefix}?domain=#{AshAdmin.Domain.name(domain)}&resource=#{AshAdmin.Resource.name(resource)}&table=#{table}&action_type=action&action=#{action.name}", + active: current_action == action + } + end) + ] + end + defp has_create_action?(resource) do case AshAdmin.Resource.create_actions(resource) do nil -> diff --git a/lib/ash_admin/components/resource/relationship_field.ex b/lib/ash_admin/components/resource/relationship_field.ex new file mode 100644 index 00000000..bb8f3dba --- /dev/null +++ b/lib/ash_admin/components/resource/relationship_field.ex @@ -0,0 +1,300 @@ +defmodule AshAdmin.Components.Resource.RelationshipField do + @moduledoc """ + This module defines a LiveComponent for rendering a relationship field in an AshAdmin resource form. + It handles the logic for displaying a select dropdown or a typeahead input field, fetching and + displaying suggestions, and updating the selected value. + """ + + use Phoenix.LiveComponent + + import AshAdmin.CoreComponents + import Ash.Expr + + require Ash.Query + + alias Phoenix.LiveView.JS + + def form_control_label(resource) do + Ash.Resource.Info.short_name(resource) |> to_string |> String.capitalize() + end + + def mount(socket) do + {:ok, + assign(socket, + suggestions: [], + query: "", + selected_id: nil, + current_suggestion_id: nil, + highlighted_index: -1, + errors: [] + )} + end + + def update(assigns, socket) do + pk_field = Ash.Resource.Info.primary_key(assigns.resource) |> List.first() + label_field = AshAdmin.Resource.label_field(assigns.resource) + current_label = get_current_label(assigns.resource, assigns.value, label_field) + + {:ok, + assign(socket, assigns) + |> assign( + pk_field: pk_field, + label_field: label_field, + current_label: current_label, + selected_id: assigns.value, + max_items: AshAdmin.Resource.relationship_select_max_items(assigns.resource) + )} + end + + defp get_current_label(_, nil, _) do + "" + end + + defp get_current_label(resource, value, label_field) do + case resource |> Ash.get(value) do + {:ok, record} -> + record + |> Ash.load!(label_field) + |> Map.get(label_field) + + _ -> + "" + end + end + + @spec render(atom() | %{:resource => atom() | Ash.Query.t(), optional(any()) => any()}) :: + Phoenix.LiveView.Rendered.t() + def render(assigns) do + select_options = + select_options!(assigns.resource, assigns.max_items + 1) + + assigns = + assign(assigns, + limited_select_options: select_options, + field_type: field_type(select_options, assigns.max_items), + label: form_control_label(assigns.resource), + query: assigns.query + ) + + ~H""" +
+ <.input + :if={@field_type == :select} + type="select" + options={@limited_select_options} + prompt={"Select #{@label}"} + id={@id} + name={@form.name <> "[#{@attribute.name}]"} + value={@value} + /> +
+ +
+ = 0, do: "#{@id}-option-#{@highlighted_index}", else: "" + } + autocomplete="off" + /> + +
+ + "[#{@attribute.name}]"} + value={@selected_id || ""} + /> + + +
+ <.error :for={msg <- @errors}><%= msg %> +
+ """ + end + + # Handle user input and fetch suggestions + def handle_event( + "suggest", + %{"value" => query, "key" => key}, + socket + ) do + cond do + key == "ArrowDown" and length(socket.assigns.suggestions) > 0 -> + new_index = + min(socket.assigns.highlighted_index + 1, length(socket.assigns.suggestions) - 1) + + {_new_suggestion_name, new_suggestion_id} = Enum.at(socket.assigns.suggestions, new_index) + + {:noreply, + assign(socket, + highlighted_index: new_index, + current_suggestion_id: new_suggestion_id + )} + + key == "ArrowUp" and length(socket.assigns.suggestions) > 0 -> + new_index = + max(socket.assigns.highlighted_index - 1, 0) + + {_new_suggestion_name, new_suggestion_id} = + Enum.at(socket.assigns.suggestions, new_index) + + {:noreply, + assign(socket, highlighted_index: new_index, current_suggestion_id: new_suggestion_id)} + + key == "Enter" -> + if length(socket.assigns.suggestions) == 0 do + {:noreply, socket.assigns} + end + + {suggestion_name, suggestion_id} = + Enum.at(socket.assigns.suggestions, socket.assigns.highlighted_index) + + {:noreply, + assign(socket, + query: suggestion_name, + current_label: suggestion_name, + selected_id: suggestion_id, + suggestions: [], + highlighted_index: -1 + )} + + key == "Escape" -> + field_name = Map.get(socket.assigns.attribute, :name) + original_value = Map.get(socket.assigns.form.data, field_name) + + original_label = + get_current_label( + socket.assigns.resource, + original_value, + socket.assigns.label_field + ) + + {:noreply, + assign(socket, + selected_id: original_value, + current_label: original_label, + suggestions: [], + highlighted_index: -1 + )} + + true -> + suggestions = fetch_suggestions(socket.assigns, query) + {:noreply, assign(socket, suggestions: suggestions, query: query, highlighted_index: -1)} + end + end + + # Handle suggestion selection via click + def handle_event("select", %{"id" => id, "name" => name}, socket) do + {:noreply, + assign(socket, + query: name, + current_label: name, + selected_id: id, + suggestions: [], + highlighted_index: -1 + )} + end + + def handle_event("clear", _, socket) do + {:noreply, assign(socket, query: "", current_label: "")} + end + + defp field_type(options, max_items) when length(options) <= max_items, do: :select + defp field_type(_options, _max_items), do: :typeahead + + defp select_options!(resource, limit) do + label_field = AshAdmin.Resource.label_field(resource) + pk_field = Ash.Resource.Info.primary_key(resource) + + resource + |> Ash.Query.new() + |> Ash.Query.load([pk_field, label_field]) + |> Ash.Query.limit(limit) + |> Ash.read!() + |> Enum.map(&{Map.get(&1, label_field), &1.id}) + end + + defp fetch_suggestions(_assigns, "") do + [] + end + + defp fetch_suggestions(assigns, query) do + assigns.resource + |> Ash.Query.new() + |> Ash.Query.load([ + assigns.pk_field, + assigns.label_field + ]) + |> Ash.Query.load(:ash_admin_position_sort) + |> Ash.Query.filter( + contains( + ^ref(assigns.label_field), + ^%Ash.CiString{string: query} + ) + ) + |> Ash.Query.sort(ash_admin_position_sort: {%{search_term: query}, :asc}) + |> Ash.Query.limit(assigns.max_items) + |> Ash.read!() + |> Enum.map(&{Map.get(&1, assigns.label_field), &1.id}) + end +end diff --git a/lib/ash_admin/components/resource/resource.ex b/lib/ash_admin/components/resource/resource.ex index 648b2fcd..55c1348c 100644 --- a/lib/ash_admin/components/resource/resource.ex +++ b/lib/ash_admin/components/resource/resource.ex @@ -4,12 +4,11 @@ defmodule AshAdmin.Components.Resource do require Ash.Query - alias AshAdmin.Components.Resource.{DataTable, Form, Info, Nav, Show} + alias AshAdmin.Components.Resource.{DataTable, Form, GenericAction, Info, Nav, Show} # prop hide_filter, :boolean, default: true attr :resource, :any, required: true attr :domain, :any, required: true - attr :tab, :string, required: true attr :action, :any attr :actor, :any, required: true attr :authorizing, :boolean, required: true @@ -27,18 +26,11 @@ defmodule AshAdmin.Components.Resource do def render(assigns) do ~H"""
- +
<% {:ok, record} = @record %> <.live_component @@ -61,7 +53,7 @@ defmodule AshAdmin.Components.Resource do />
<% {:ok, record} = @record %> <.live_component @@ -84,7 +76,7 @@ defmodule AshAdmin.Components.Resource do />
<.live_component - :if={@tab == "show" && match?({:ok, %_{}}, @record)} + :if={match?({:ok, %_{}}, @record) && @action_type == :read} module={Show} resource={@resource} domain={@domain} @@ -96,14 +88,9 @@ defmodule AshAdmin.Components.Resource do table={@table} prefix={@prefix} /> - + <.live_component - :if={@tab == "create"} + :if={@action_type == :create} module={Form} type={:create} resource={@resource} @@ -121,7 +108,7 @@ defmodule AshAdmin.Components.Resource do polymorphic_actions={@polymorphic_actions} /> <.live_component - :if={@action_type == :read && @tab != "show"} + :if={@action_type == :read && !match?({:ok, %_{}}, @record)} module={DataTable} polymorphic_actions={@polymorphic_actions} resource={@resource} @@ -137,6 +124,21 @@ defmodule AshAdmin.Components.Resource do prefix={@prefix} tenant={@tenant} /> + <.live_component + :if={@action_type == :action} + module={GenericAction} + id={action_id(@resource)} + resource={@resource} + action={@action} + actor={@actor} + domain={@domain} + url_path={@url_path} + params={@params} + authorizing={@authorizing} + table={@table} + prefix={@prefix} + tenant={@tenant} + />
""" end @@ -151,6 +153,10 @@ defmodule AshAdmin.Components.Resource do "#{resource}_table" end + defp action_id(resource) do + "#{resource}_action" + end + defp create_id(resource) do "#{resource}_create" end diff --git a/lib/ash_admin/components/resource/show.ex b/lib/ash_admin/components/resource/show.ex index 0285051f..522dd2ed 100644 --- a/lib/ash_admin/components/resource/show.ex +++ b/lib/ash_admin/components/resource/show.ex @@ -55,7 +55,7 @@ defmodule AshAdmin.Components.Resource.Show do
<.link :if={destroy?(@resource)} - navigate={"#{@prefix}?domain=#{AshAdmin.Domain.name(@domain)}&resource=#{AshAdmin.Resource.name(@resource)}&action_type=destroy&tab=destroy&table=#{@table}&primary_key=#{encode_primary_key(@record)}"} + navigate={"#{@prefix}?domain=#{AshAdmin.Domain.name(@domain)}&resource=#{AshAdmin.Resource.name(@resource)}&action_type=destroy&table=#{@table}&primary_key=#{encode_primary_key(@record)}"} class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" > Destroy @@ -63,7 +63,7 @@ defmodule AshAdmin.Components.Resource.Show do <.link :if={update?(@resource)} - navigate={"#{@prefix}?domain=#{AshAdmin.Domain.name(@domain)}&resource=#{AshAdmin.Resource.name(@resource)}&action_type=update&tab=update&table=#{@table}&primary_key=#{encode_primary_key(@record)}"} + navigate={"#{@prefix}?domain=#{AshAdmin.Domain.name(@domain)}&resource=#{AshAdmin.Resource.name(@resource)}&action_type=update&table=#{@table}&primary_key=#{encode_primary_key(@record)}"} class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" > Update @@ -147,7 +147,7 @@ defmodule AshAdmin.Components.Resource.Show do
<.link :if={AshAdmin.Resource.show_action(@destination)} - navigate={"#{@prefix}?domain=#{AshAdmin.Domain.name(@destination_domain || @domain)}&resource=#{AshAdmin.Resource.name(@destination)}&tab=show&table=#{@context[:data_layer][:table]}&primary_key=#{encode_primary_key(@record)}"} + navigate={"#{@prefix}?domain=#{AshAdmin.Domain.name(@destination_domain || @domain)}&resource=#{AshAdmin.Resource.name(@destination)}&table=#{@context[:data_layer][:table]}&primary_key=#{encode_primary_key(@record)}"} class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" > Show diff --git a/lib/ash_admin/components/resource/table.ex b/lib/ash_admin/components/resource/table.ex index be01907d..29ca1332 100644 --- a/lib/ash_admin/components/resource/table.ex +++ b/lib/ash_admin/components/resource/table.ex @@ -45,19 +45,19 @@ defmodule AshAdmin.Components.Resource.Table do
- <.link navigate={"#{@prefix}?domain=#{AshAdmin.Domain.name(@domain)}&resource=#{AshAdmin.Resource.name(@resource)}&tab=show&table=#{@table}&primary_key=#{encode_primary_key(record)}"}> + <.link navigate={"#{@prefix}?domain=#{AshAdmin.Domain.name(@domain)}&resource=#{AshAdmin.Resource.name(@resource)}&table=#{@table}&primary_key=#{encode_primary_key(record)}"}> <.icon name="hero-information-circle-solid" class="h-5 w-5 text-gray-500" />
- <.link navigate={"#{@prefix}?domain=#{AshAdmin.Domain.name(@domain)}&resource=#{AshAdmin.Resource.name(@resource)}&action_type=update&tab=update&table=#{@table}&primary_key=#{encode_primary_key(record)}"}> + <.link navigate={"#{@prefix}?domain=#{AshAdmin.Domain.name(@domain)}&resource=#{AshAdmin.Resource.name(@resource)}&action_type=update&table=#{@table}&primary_key=#{encode_primary_key(record)}"}> <.icon name="hero-pencil-solid" class="h-5 w-5 text-gray-500" />
- <.link navigate={"#{@prefix}?domain=#{AshAdmin.Domain.name(@domain)}&resource=#{AshAdmin.Resource.name(@resource)}&action_type=destroy&tab=destroy&table=#{@table}&primary_key=#{encode_primary_key(record)}"}> + <.link navigate={"#{@prefix}?domain=#{AshAdmin.Domain.name(@domain)}&resource=#{AshAdmin.Resource.name(@resource)}&action_type=destroy&table=#{@table}&primary_key=#{encode_primary_key(record)}"}> <.icon name="hero-x-circle-solid" class="h-5 w-5 text-gray-500" />
diff --git a/lib/ash_admin/components/top_nav/actor_select.ex b/lib/ash_admin/components/top_nav/actor_select.ex index 0d8f1995..7178685c 100644 --- a/lib/ash_admin/components/top_nav/actor_select.ex +++ b/lib/ash_admin/components/top_nav/actor_select.ex @@ -74,7 +74,7 @@ defmodule AshAdmin.Components.TopNav.ActorSelect do <.link :if={@actor} class="hover:text-blue-400 hover:underline" - target={"#{@prefix}?domain=#{AshAdmin.Domain.name(@actor_domain)}&resource=#{AshAdmin.Resource.name(@actor.__struct__)}&tab=show&primary_key=#{encode_primary_key(@actor)}"} + target={"#{@prefix}?domain=#{AshAdmin.Domain.name(@actor_domain)}&resource=#{AshAdmin.Resource.name(@actor.__struct__)}&primary_key=#{encode_primary_key(@actor)}"} > <%= user_display(@actor, @actor_tenant) %> diff --git a/lib/ash_admin/expressions/position.ex b/lib/ash_admin/expressions/position.ex new file mode 100644 index 00000000..c2a4ac94 --- /dev/null +++ b/lib/ash_admin/expressions/position.ex @@ -0,0 +1,49 @@ +defmodule AshAdmin.Expressions.Position do + @moduledoc """ + Provides an expression for finding the position of a substring within a string. + + This expression can be used in Ash queries to perform case-insensitive substring matching and return the position of the substring within the string, or `nil` if the substring is not found. + + The expression supports the following data layers: + - `AshPostgres.DataLayer` + - `Ash.DataLayer.Ets` and `Ash.DataLayer.Simple` + + """ + use Ash.CustomExpression, + name: :position, + arguments: [ + [:string, :string] + ] + + alias AshAdmin.Expressions.Position, as: Position + + def expression(AshPostgres.DataLayer, [substring, string]) do + {:ok, + expr( + fragment( + "CASE WHEN POSITION(UPPER(?) IN UPPER(?)) = 0 THEN NULL ELSE POSITION(UPPER(?) IN UPPER(?)) END", + ^substring, + ^string, + ^substring, + ^string + ) + )} + end + + def expression(data_layer, [substring, string]) + when data_layer in [ + Ash.DataLayer.Ets, + Ash.DataLayer.Simple + ] do + {:ok, expr(fragment(&Position.find_substring_position/2, ^substring, ^string))} + end + + def expression(_data_layer, _args), do: :unknown + + def find_substring_position(substring, string) do + case String.split(string, substring, parts: 2) do + [before, _after] -> String.length(before) + _ -> nil + end + end +end diff --git a/lib/ash_admin/pages/page_live.ex b/lib/ash_admin/pages/page_live.ex index 85525906..60260247 100644 --- a/lib/ash_admin/pages/page_live.ex +++ b/lib/ash_admin/pages/page_live.ex @@ -92,7 +92,6 @@ defmodule AshAdmin.PageLive do primary_key={@primary_key} record={@record} domain={@domain} - tab={@tab} action_type={@action_type} url_path={@url_path} params={@params} @@ -156,6 +155,9 @@ defmodule AshAdmin.PageLive do "destroy" -> :destroy + "action" -> + :action + nil -> if AshAdmin.Domain.default_resource_page(socket.assigns.domain) == :primary_read, do: :read, @@ -176,6 +178,9 @@ defmodule AshAdmin.PageLive do :destroy -> AshAdmin.Resource.destroy_actions(socket.assigns.resource) + + :action -> + AshAdmin.Resource.generic_actions(socket.assigns.resource) end action = @@ -250,7 +255,7 @@ defmodule AshAdmin.PageLive do |> assign_resource(params["resource"]) |> assign_action(params["action"], params["action_type"]) |> assign_tables(params["table"]) - |> assign(primary_key: params["primary_key"], tab: params["tab"]) + |> assign(primary_key: params["primary_key"]) socket = if socket.assigns[:primary_key] do diff --git a/lib/ash_admin/resource/resource.ex b/lib/ash_admin/resource/resource.ex index 1e82b242..759029bc 100644 --- a/lib/ash_admin/resource/resource.ex +++ b/lib/ash_admin/resource/resource.ex @@ -40,6 +40,11 @@ defmodule AshAdmin.Resource do doc: "A list of read actions that can be used to show resource details. By default, all actions are included." ], + generic_actions: [ + type: {:list, :atom}, + doc: + "A list of generic actions that can be used to show resource details. By default, all actions are included." + ], create_actions: [ type: {:list, :atom}, doc: @@ -58,15 +63,13 @@ defmodule AshAdmin.Resource do polymorphic_tables: [ type: {:list, :string}, doc: """ - For resources that use ash_postgres' polymorphism capabilities, you can provide a list of tables that should be available to - select. These will be added to the list of derivable tables based on scanning all domains and resources provided to ash_admin. + For resources that use ash_postgres' polymorphism capabilities, you can provide a list of tables that should be available to select. These will be added to the list of derivable tables based on scanning all domains and resources provided to ash_admin. """ ], polymorphic_actions: [ type: {:list, :atom}, doc: """ - For resources that use ash_postgres' polymorphism capabilities, you can provide a list of actions that should require a table to be set. - If this is not set, then *all* actions will require tables. + For resources that use ash_postgres' polymorphism capabilities, you can provide a list of actions that should require a table to be set. If this is not set, then *all* actions will require tables. """ ], table_columns: [ @@ -75,7 +78,9 @@ defmodule AshAdmin.Resource do ], format_fields: [ type: {:list, :any}, - doc: "The list of fields and their formats." + doc: """ + The list of fields and their formats represented as a MFA. For example: `updated_at: {Timex, :format!, ["{0D}-{0M}-{YYYY} {h12}:{m} {AM}"]}`. + """ ], relationship_display_fields: [ type: {:list, :atom}, @@ -90,13 +95,26 @@ 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." + ], + label_field: [ + type: :atom, + doc: + "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: [ + type: :integer, + doc: + "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." ] ] } use Spark.Dsl.Extension, sections: [@admin], - transformers: [AshAdmin.Resource.Transformers.ValidateTableColumns] + transformers: [ + AshAdmin.Resource.Transformers.ValidateTableColumns, + AshAdmin.Resource.Transformers.AddPositionSortCalculation + ] @moduledoc """ A resource extension to alter the behaviour of a resource in the admin UI. @@ -145,6 +163,14 @@ defmodule AshAdmin.Resource do Spark.Dsl.Extension.get_opt(resource, [:admin], :show_sensitive_fields, [], true) end + def label_field(resource) do + Spark.Dsl.Extension.get_opt(resource, [:admin], :label_field, nil, true) + end + + def relationship_select_max_items(resource) do + Spark.Dsl.Extension.get_opt(resource, [:admin], :relationship_select_max_items, 18, true) + end + def actor?(resource) do Spark.Dsl.Extension.get_opt(resource, [:admin], :actor?, false, true) end @@ -154,6 +180,11 @@ defmodule AshAdmin.Resource do actions_with_primary_first(resource, :read) end + def generic_actions(resource) do + Spark.Dsl.Extension.get_opt(resource, [:admin], :generic_actions, nil, true) || + actions_with_primary_first(resource, :action) + end + def create_actions(resource) do Spark.Dsl.Extension.get_opt(resource, [:admin], :create_actions, nil, true) || actions_with_primary_first(resource, :create) @@ -206,7 +237,7 @@ defmodule AshAdmin.Resource do resource |> Ash.Resource.Info.actions() |> Enum.filter(&(&1.type == type)) - |> Enum.sort_by(&(!&1.primary?)) + |> Enum.sort_by(&(!Map.get(&1, :primary?))) |> Enum.map(& &1.name) end end diff --git a/lib/ash_admin/resource/transformers/add_position_sort_calculation.ex b/lib/ash_admin/resource/transformers/add_position_sort_calculation.ex new file mode 100644 index 00000000..6a33a29b --- /dev/null +++ b/lib/ash_admin/resource/transformers/add_position_sort_calculation.ex @@ -0,0 +1,34 @@ +defmodule AshAdmin.Resource.Transformers.AddPositionSortCalculation do + @moduledoc """ + Adds a `ash_admin_position_sort` calculation to resources with admin.label_field defined. + """ + + use Spark.Dsl.Transformer + use Ash.Resource.Calculation + + alias Spark.Dsl.Transformer + + @impl true + def transform(dsl) do + case Transformer.get_option(dsl, [:admin], :label_field) do + nil -> + {:ok, dsl} + + label_field -> + with opts <- [ + name: :ash_admin_position_sort, + type: :integer, + calculation: expr(position(^arg(:search_term), ^ref(label_field))), + arguments: [ + %{name: :search_term, type: :string, constraints: [], default: ""} + ], + sortable?: true, + load: [field: label_field] + ], + {:ok, calculation} <- + Transformer.build_entity(Ash.Resource.Dsl, [:calculations], :calculate, opts) do + {:ok, Transformer.add_entity(dsl, [:calculations], calculation)} + end + end + end +end diff --git a/mix.exs b/mix.exs index e941fa35..37b5b2ae 100644 --- a/mix.exs +++ b/mix.exs @@ -5,7 +5,7 @@ defmodule AshAdmin.MixProject do A super-admin UI for Ash Framework, built with Phoenix LiveView. """ - @version "0.11.6" + @version "0.11.11" def project do [ @@ -58,8 +58,8 @@ defmodule AshAdmin.MixProject do "README.md", "documentation/tutorials/getting-started-with-ash-admin.md", "documentation/tutorials/contributing-to-ash-admin.md", - "documentation/dsls/DSL:-AshAdmin.Domain.md", - "documentation/dsls/DSL:-AshAdmin.Resource.md", + "documentation/dsls/DSL-AshAdmin.Domain.md", + "documentation/dsls/DSL-AshAdmin.Resource.md", "CHANGELOG.md" ], groups_for_extras: [ @@ -116,7 +116,7 @@ defmodule AshAdmin.MixProject do defp deps do [ {:ash, "~> 3.0"}, - {:ash_phoenix, "~> 2.1"}, + {:ash_phoenix, "~> 2.1 and >= 2.1.8"}, {:phoenix_view, "~> 2.0"}, {:phoenix, "~> 1.7"}, {:phoenix_live_view, "~> 0.20"}, diff --git a/mix.lock b/mix.lock index 55726229..9935138c 100644 --- a/mix.lock +++ b/mix.lock @@ -1,33 +1,33 @@ %{ - "ash": {:hex, :ash, "3.4.21", "d97b060c64084613ca8317272864be908d591aaa30671d1b04de41f82f8ce368", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.3.36 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.9", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1fedea9b994c4b1d18722d49333fd8f30db4af058c9d56cd8cc438b420e6a6a8"}, - "ash_phoenix": {:hex, :ash_phoenix, "2.1.2", "7215cf3a1ebc82ca0e5317a8449e1725fa753354674a0e8cd7fc1c8ffd1181c7", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "b591bd731a0855f670b5bc3f48c364b1694d508071f44d57bcd508c82817c51e"}, - "ash_postgres": {:hex, :ash_postgres, "2.4.2", "929413129d1140b345c156a8a92db955b2b2cf61b192f70709098f8e86a941ec", [:mix], [{:ash, ">= 3.4.9 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.2.30 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, ">= 3.12.1 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.3.42 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:inflex, "~> 2.1", [hex: :inflex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "fb3f14fcec003b806db4114b1eecf9cb356086f1d5824960efcae7045c4466a3"}, - "ash_sql": {:hex, :ash_sql, "0.2.32", "de99255becfb9daa7991c18c870e9f276bb372acda7eda3e05c3e2ff2ca8922e", [:mix], [{:ash, ">= 3.1.7 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "43773bcd33d21319c11804d76fe11f1a1b7c8faba7aaedeab6f55fde3d2405db"}, + "ash": {:hex, :ash, "3.4.36", "a5987bbdc86e8e7dbe28cf470ad0a33d6240869e842101471da2ffb7b84c8a27", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.3.61 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.9", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "22075b11e1873c4a7cc8548bc477187143bbae3cbcc1e890bfc3aecf4ca493a4"}, + "ash_phoenix": {:hex, :ash_phoenix, "2.1.8", "0cb387338305a6f1cc3a76800aa49b3c914a24dbb3eff645344551c14082a52e", [:mix], [{:ash, ">= 3.4.31 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "912829a8bf6f8cafc1f3eab35c9eadfd6e5d23dffb5db042d4bdcce0931f7bf7"}, + "ash_postgres": {:hex, :ash_postgres, "2.4.11", "816b79c0c4797386bb809a5d8d6810700a6f906af533d0dc79b104a6d2237223", [:mix], [{:ash, ">= 3.4.28 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.2.30 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, ">= 3.12.1 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.3.42 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:inflex, "~> 2.1", [hex: :inflex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "d203712aaaa02acef04b49143eea34af969ddc3cb1f2b423af0823206bb4a126"}, + "ash_sql": {:hex, :ash_sql, "0.2.36", "e5722123de5b726ad3185ef8c8ce5ef17b78d3409e822cadeadc6ba5110601fe", [:mix], [{:ash, ">= 3.1.7 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "a95b5ebccfe5e74d7fc4e46b104abae4d1003b53cbc8418fcb5fa3c6e0c081a9"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, - "castore": {:hex, :castore, "1.0.8", "dedcf20ea746694647f883590b82d9e96014057aff1d44d03ec90f36a5c0dc6e", [:mix], [], "hexpm", "0b2b66d2ee742cb1d9cb8c8be3b43c3a70ee8651f37b75a8b982e036752983f1"}, + "castore": {:hex, :castore, "1.0.9", "5cc77474afadf02c7c017823f460a17daa7908e991b0cc917febc90e466a375c", [:mix], [], "hexpm", "5ea956504f1ba6f2b4eb707061d8e17870de2bee95fb59d512872c2ef06925e7"}, "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, - "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, + "credo": {:hex, :credo, "1.7.8", "9722ba1681e973025908d542ec3d95db5f9c549251ba5b028e251ad8c24ab8c5", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cb9e87cc64f152f3ed1c6e325e7b894dea8f5ef2e41123bd864e3cd5ceb44968"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, - "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, + "dialyxir": {:hex, :dialyxir, "1.4.4", "fb3ce8741edeaea59c9ae84d5cec75da00fa89fe401c72d6e047d11a61f65f70", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "cd6111e8017ccd563e65621a4d9a4a1c5cd333df30cebc7face8029cacb4eff6"}, "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, - "ecto": {:hex, :ecto, "3.12.3", "1a9111560731f6c3606924c81c870a68a34c819f6d4f03822f370ea31a582208", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9efd91506ae722f95e48dc49e70d0cb632ede3b7a23896252a60a14ac6d59165"}, - "ecto_sql": {:hex, :ecto_sql, "3.12.0", "73cea17edfa54bde76ee8561b30d29ea08f630959685006d9c6e7d1e59113b7d", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dc9e4d206f274f3947e96142a8fdc5f69a2a6a9abb4649ef5c882323b6d512f0"}, - "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, - "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, + "ecto": {:hex, :ecto, "3.12.4", "267c94d9f2969e6acc4dd5e3e3af5b05cdae89a4d549925f3008b2b7eb0b93c3", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ef04e4101688a67d061e1b10d7bc1fbf00d1d13c17eef08b71d070ff9188f747"}, + "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, + "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, + "esbuild": {:hex, :esbuild, "0.8.2", "5f379dfa383ef482b738e7771daf238b2d1cfb0222bef9d3b20d4c8f06c7a7ac", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "558a8a08ed78eb820efbfda1de196569d8bfa9b51e8371a1934fbb31345feda7"}, "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, "ex_check": {:hex, :ex_check, "0.16.0", "07615bef493c5b8d12d5119de3914274277299c6483989e52b0f6b8358a26b5f", [:mix], [], "hexpm", "4d809b72a18d405514dda4809257d8e665ae7cf37a7aee3be6b74a34dec310f5"}, "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, "expo": {:hex, :expo, "1.0.0", "647639267e088717232f4d4451526e7a9de31a3402af7fcbda09b27e9a10395a", [:mix], [], "hexpm", "18d2093d344d97678e8a331ca0391e85d29816f9664a25653fd7e6166827827c"}, - "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, - "floki": {:hex, :floki, "0.36.2", "a7da0193538c93f937714a6704369711998a51a6164a222d710ebd54020aa7a3", [:mix], [], "hexpm", "a8766c0bc92f074e5cb36c4f9961982eda84c5d2b8e979ca67f5c268ec8ed580"}, + "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, + "floki": {:hex, :floki, "0.36.3", "1102f93b16a55bc5383b85ae3ec470f82dee056eaeff9195e8afdf0ef2a43c30", [:mix], [], "hexpm", "fe0158bff509e407735f6d40b3ee0d7deb47f3f3ee7c6c182ad28599f9f6b27a"}, "gettext": {:hex, :gettext, "0.26.1", "38e14ea5dcf962d1fc9f361b63ea07c0ce715a8ef1f9e82d3dfb8e67e0416715", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "01ce56f188b9dc28780a52783d6529ad2bc7124f9744e571e1ee4ea88bf08734"}, "git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"}, - "git_ops": {:hex, :git_ops, "2.6.1", "cc7799a68c26cf814d6d1a5121415b4f5bf813de200908f930b27a2f1fe9dad5", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "ce62d07e41fe993ec22c35d5edb11cf333a21ddaead6f5d9868fcb607d42039e"}, - "glob_ex": {:hex, :glob_ex, "0.1.8", "f7ef872877ca2ae7a792ab1f9ff73d9c16bf46ecb028603a8a3c5283016adc07", [:mix], [], "hexpm", "9e39d01729419a60a937c9260a43981440c43aa4cadd1fa6672fecd58241c464"}, - "igniter": {:hex, :igniter, "0.3.45", "f487138ed0c5cbf8f1bdd53360cdc2ac40c18ff379c8a575a7a34e526b4ba846", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rewrite, "~> 0.9", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "fbe663e3f4566fb358c64bdddf0ceb05cf9175cea34cccf45a9aa5532ea967f8"}, + "git_ops": {:hex, :git_ops, "2.6.3", "38c6e381b8281b86e2911fa39bea4eab2d171c86d7428786566891efb73b68c3", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a81cb6c6a2a026a4d48cb9a2e1dfca203f9283a3a70aa0c7bc171970c44f23f8"}, + "glob_ex": {:hex, :glob_ex, "0.1.10", "d819a368637495a5c1962ef34f48fe4e9a09032410b96ade5758f2cd1cc5fcde", [:mix], [], "hexpm", "c75357e57d71c85ef8ef7269b6e787dce3f0ff71e585f79a90e4d5477c532b90"}, + "igniter": {:hex, :igniter, "0.3.76", "ff283416402f4d1ef3f79ab57d38aac08389b3768fc81da03795ce5347f1167f", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rewrite, "~> 0.9", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "4692f874f969dc4856167469a3415a5a57a362314f37e1cdb14431316a74e896"}, "inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, @@ -37,9 +37,8 @@ "makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"}, "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, "mix_audit": {:hex, :mix_audit, "2.1.4", "0a23d5b07350cdd69001c13882a4f5fb9f90fbd4cbf2ebc190a2ee0d187ea3e9", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "fd807653cc8c1cada2911129c7eb9e985e3cc76ebf26f4dd628bb25bbcaa7099"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, - "owl": {:hex, :owl, "0.11.0", "2cd46185d330aa2400f1c8c3cddf8d2ff6320baeff23321d1810e58127082cae", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "73f5783f0e963cc04a061be717a0dbb3e49ae0c4bfd55fb4b78ece8d33a65efe"}, + "owl": {:hex, :owl, "0.12.0", "0c4b48f90797a7f5f09ebd67ba7ebdc20761c3ec9c7928dfcafcb6d3c2d25c99", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "241d85ae62824dd72f9b2e4a5ba4e69ebb9960089a3c68ce6c1ddf2073db3c15"}, "phoenix": {:hex, :phoenix, "1.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"}, "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, @@ -50,20 +49,19 @@ "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, "plug_cowboy": {:hex, :plug_cowboy, "2.7.2", "fdadb973799ae691bf9ecad99125b16625b1c6039999da5fe544d99218e662e4", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "245d8a11ee2306094840c000e8816f0cbed69a23fc0ac2bcf8d7835ae019bb2f"}, "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, - "postgrex": {:hex, :postgrex, "0.19.1", "73b498508b69aded53907fe48a1fee811be34cc720e69ef4ccd568c8715495ea", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8bac7885a18f381e091ec6caf41bda7bb8c77912bb0e9285212829afe5d8a8f8"}, + "postgrex": {:hex, :postgrex, "0.19.2", "34d6884a332c7bf1e367fc8b9a849d23b43f7da5c6e263def92784d03f9da468", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "618988886ab7ae8561ebed9a3c7469034bf6a88b8995785a3378746a4b9835ec"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "reactor": {:hex, :reactor, "0.10.0", "1206113c21ba69b889e072b2c189c05a7aced523b9c3cb8dbe2dab7062cb699a", [:mix], [{:igniter, "~> 0.2", [hex: :igniter, repo: "hexpm", optional: false]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4003c33e4c8b10b38897badea395e404d74d59a31beb30469a220f2b1ffe6457"}, "rewrite": {:hex, :rewrite, "0.10.5", "6afadeae0b9d843b27ac6225e88e165884875e0aed333ef4ad3bf36f9c101bed", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "51cc347a4269ad3a1e7a2c4122dbac9198302b082f5615964358b4635ebf3d4f"}, "simple_sat": {:hex, :simple_sat, "0.1.3", "f650fc3c184a5fe741868b5ac56dc77fdbb428468f6dbf1978e14d0334497578", [:mix], [], "hexpm", "a54305066a356b7194dc81db2a89232bacdc0b3edaef68ed9aba28dcbc34887b"}, "sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"}, "sourceror": {:hex, :sourceror, "1.6.0", "9907884e1449a4bd7dbaabe95088ed4d9a09c3c791fb0103964e6316bc9448a7", [:mix], [], "hexpm", "e90aef8c82dacf32c89c8ef83d1416fc343cd3e5556773eeffd2c1e3f991f699"}, - "spark": {:hex, :spark, "2.2.30", "811977b274cdad9d7668f934d44e8a013d7d9a059b63bcb6f2afb434dfcec458", [:mix], [{:igniter, ">= 0.3.36 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "476f2d463463ae14734f1e0c48cd642090cf3fb4082b8e73ea08118ad0da24ce"}, + "spark": {:hex, :spark, "2.2.35", "1c0bb30f340151eca24164885935de39e6ada4010555f444c813d0488990f8f3", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "f242d6385c287389034a0e146d8f025b5c9ab777f1ae5cf0fdfc9209db6ae748"}, "spitfire": {:hex, :spitfire, "0.1.3", "7ea0f544005dfbe48e615ed90250c9a271bfe126914012023fd5e4b6b82b7ec7", [:mix], [], "hexpm", "d53b5107bcff526a05c5bb54c95e77b36834550affd5830c9f58760e8c543657"}, "splode": {:hex, :splode, "0.2.4", "71046334c39605095ca4bed5d008372e56454060997da14f9868534c17b84b53", [:mix], [], "hexpm", "ca3b95f0d8d4b482b5357954fec857abd0fa3ea509d623334c1328e7382044c2"}, - "stream_data": {:hex, :stream_data, "1.1.1", "fd515ca95619cca83ba08b20f5e814aaf1e5ebff114659dc9731f966c9226246", [:mix], [], "hexpm", "45d0cd46bd06738463fd53f22b70042dbb58c384bb99ef4e7576e7bb7d3b8c8c"}, - "tailwind": {:hex, :tailwind, "0.2.3", "277f08145d407de49650d0a4685dc062174bdd1ae7731c5f1da86163a24dfcdb", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "8e45e7a34a676a7747d04f7913a96c770c85e6be810a1d7f91e713d3a3655b5d"}, + "stream_data": {:hex, :stream_data, "1.1.2", "05499eaec0443349ff877aaabc6e194e82bda6799b9ce6aaa1aadac15a9fdb4d", [:mix], [], "hexpm", "129558d2c77cbc1eb2f4747acbbea79e181a5da51108457000020a906813a1a9"}, + "tailwind": {:hex, :tailwind, "0.2.4", "5706ec47182d4e7045901302bf3a333e80f3d1af65c442ba9a9eed152fb26c2e", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "c6e4a82b8727bab593700c998a4d98cf3d8025678bfde059aed71d0000c3e463"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, - "ucwidth": {:hex, :ucwidth, "0.2.0", "1f0a440f541d895dff142275b96355f7e91e15bca525d4a0cc788ea51f0e3441", [:mix], [], "hexpm", "c1efd1798b8eeb11fb2bec3cafa3dd9c0c3647bee020543f0340b996177355bf"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.7", "65fa74042530064ef0570b75b43f5c49bb8b235d6515671b3d250022cb8a1f9e", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d0f478ee64deddfec64b800673fd6e0c8888b079d9f3444dd96d2a98383bdbd1"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, diff --git a/priv/static/assets/app.css b/priv/static/assets/app.css index 9464b91a..d4697df5 100644 --- a/priv/static/assets/app.css +++ b/priv/static/assets/app.css @@ -1089,6 +1089,11 @@ select { margin-bottom: 1rem; } +.my-auto { + margin-top: auto; + margin-bottom: auto; +} + .-mr-1 { margin-right: -0.25rem; } @@ -1379,6 +1384,10 @@ select { grid-template-columns: repeat(6, minmax(0, 1fr)); } +.flex-row { + flex-direction: row; +} + .items-center { align-items: center; } @@ -1648,6 +1657,11 @@ select { padding-right: 1rem; } +.px-8 { + padding-left: 2rem; + padding-right: 2rem; +} + .py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; diff --git a/priv/static/assets/app.css.gz b/priv/static/assets/app.css.gz index 40560a1f..ccc57422 100644 Binary files a/priv/static/assets/app.css.gz and b/priv/static/assets/app.css.gz differ diff --git a/priv/static/assets/app.js.gz b/priv/static/assets/app.js.gz index 8296dee6..e85a3a4c 100644 Binary files a/priv/static/assets/app.js.gz and b/priv/static/assets/app.js.gz differ diff --git a/priv/static/cache_manifest.json b/priv/static/cache_manifest.json index d9844d9b..9d0c71cb 100644 --- a/priv/static/cache_manifest.json +++ b/priv/static/cache_manifest.json @@ -1,6 +1,6 @@ { "!comment!":"This file was auto-generated by `mix phx.digest`. Remove it and all generated artefacts with `mix phx.digest.clean --all`", "version":1, - "latest":{"assets/app.css":"assets/app-fd8b8b15fab22a70dc800c2f99dde37b.css","assets/app.js":"assets/app-59a15220eea2e31a1f34360ac7d7d431.js"}, - "digests":{"assets/app-4cac13ea03859a04cf1f2fa606cff89e.js":{"digest":"4cac13ea03859a04cf1f2fa606cff89e","logical_path":"assets/app.js","mtime":63879798009,"sha512":"pcer+1xelCFA0cuQK4KPthTRv/rbol9OfeY35P478rKCfloiCvG23OWSqJj/0mRngGRW9CCVkDXYatJekvuTcA==","size":115419},"assets/app-4d6d69c9c6433f419bbe0af63996c265.js":{"digest":"4d6d69c9c6433f419bbe0af63996c265","logical_path":"assets/app.js","mtime":63879533267,"sha512":"RO031AhESP5o2YS54a9D2heGKZzcvLdMVTnY7BrOMotlWU2UDd3WoPBPEDthTnaXh06KhKXgb6VktWvDLWAibQ==","size":115385},"assets/app-59a15220eea2e31a1f34360ac7d7d431.js":{"size":117055,"sha512":"8RcjgiZrLVNqvv39ujTrwGYidGB1WsDDT/Do0A66QGd/mHD0ug8O8PKcl0tRKKxdfgdXi242msL6aY4LZSEqbw==","digest":"59a15220eea2e31a1f34360ac7d7d431","logical_path":"assets/app.js","mtime":63888377343},"assets/app-74aff53593bb5fd5b1ac16ed19d10fd0.js":{"digest":"74aff53593bb5fd5b1ac16ed19d10fd0","logical_path":"assets/app.js","mtime":63879533438,"sha512":"Ho5Lw3q3qKm1NE5M8vgNM230KvNXAQt++QpRqE6vvGK/XKyOftug+MTakyH0uGl1k5/xINeZdAya+75PNPckyQ==","size":115417},"assets/app-7ac0a883bdd7fba54b64127a4326af23.js":{"digest":"7ac0a883bdd7fba54b64127a4326af23","logical_path":"assets/app.js","mtime":63888376633,"sha512":"pMdPYKABmXN0x52PSB1R4ZSsEyRQqeZcuoJd2RKJRnCOd3toWd7CW4xLl+vU63IrTGPp9Ui4zG0X1w9UsIHRKw==","size":117173},"assets/app-aac45a999218cb837d0d4006c02b7dff.js":{"digest":"aac45a999218cb837d0d4006c02b7dff","logical_path":"assets/app.js","mtime":63867771918,"sha512":"Q/jSWd9SG+3D9BjNSTSo3XNU/Q0xBrdwB/OgT7W4clywBHa5VxbBNh0QBT5ZsBfTZ7MANggW8XW9rOM5UVteYg==","size":108631},"assets/app-dd7097dcf40a37e608bbcbd54509199a.js":{"digest":"dd7097dcf40a37e608bbcbd54509199a","logical_path":"assets/app.js","mtime":63871616612,"sha512":"40KTAfshu6/MtQVeWppL2ccJbHBaVGloBOLOOkiM7VzZqGG4NBzvXc/+PvvU43RcxELv21EMbtKAvdq1xjdG0A==","size":108651},"assets/app-f480ad9fb2a3744d668ac31cc919441f.css":{"digest":"f480ad9fb2a3744d668ac31cc919441f","logical_path":"assets/app.css","mtime":63871616612,"sha512":"g15iWMXrhNqfFRVkrKH4c9XonkZnDWFEdGuzX0BW319sucBZFfD0lZ8GTgPJcKl3gyhUqH0Lm4jy80LgfFao7w==","size":37300},"assets/app-fd8b8b15fab22a70dc800c2f99dde37b.css":{"size":37263,"sha512":"eXoMVVXitKrrU57VnxvPTh1ZeP9r6SyNxCUOJYjVlNnAKN4kJ9b4Rc7X85kumUsyNRD3HmO4clolMMozIlnCbA==","digest":"fd8b8b15fab22a70dc800c2f99dde37b","logical_path":"assets/app.css","mtime":63888377343}} + "latest":{"assets/app.css":"assets/app-1f85b607e5d3b3557bfff0e2c7dc2ce3.css","assets/app.js":"assets/app-7ac0a883bdd7fba54b64127a4326af23.js"}, + "digests":{"assets/app-1f85b607e5d3b3557bfff0e2c7dc2ce3.css":{"size":37423,"sha512":"8+ilAd2Dxc6niupFXy73tryTGWrCwvD/nCmhko8ElXfr0yhR9B3QrzL/EDoTzcHWgSU00nCjBB+DzAwC7d7O9A==","mtime":63896408255,"digest":"1f85b607e5d3b3557bfff0e2c7dc2ce3","logical_path":"assets/app.css"},"assets/app-4cac13ea03859a04cf1f2fa606cff89e.js":{"digest":"4cac13ea03859a04cf1f2fa606cff89e","logical_path":"assets/app.js","mtime":63879798009,"sha512":"pcer+1xelCFA0cuQK4KPthTRv/rbol9OfeY35P478rKCfloiCvG23OWSqJj/0mRngGRW9CCVkDXYatJekvuTcA==","size":115419},"assets/app-4d6d69c9c6433f419bbe0af63996c265.js":{"digest":"4d6d69c9c6433f419bbe0af63996c265","logical_path":"assets/app.js","mtime":63879533267,"sha512":"RO031AhESP5o2YS54a9D2heGKZzcvLdMVTnY7BrOMotlWU2UDd3WoPBPEDthTnaXh06KhKXgb6VktWvDLWAibQ==","size":115385},"assets/app-59a15220eea2e31a1f34360ac7d7d431.js":{"digest":"59a15220eea2e31a1f34360ac7d7d431","logical_path":"assets/app.js","mtime":63888377343,"sha512":"8RcjgiZrLVNqvv39ujTrwGYidGB1WsDDT/Do0A66QGd/mHD0ug8O8PKcl0tRKKxdfgdXi242msL6aY4LZSEqbw==","size":117055},"assets/app-74aff53593bb5fd5b1ac16ed19d10fd0.js":{"digest":"74aff53593bb5fd5b1ac16ed19d10fd0","logical_path":"assets/app.js","mtime":63879533438,"sha512":"Ho5Lw3q3qKm1NE5M8vgNM230KvNXAQt++QpRqE6vvGK/XKyOftug+MTakyH0uGl1k5/xINeZdAya+75PNPckyQ==","size":115417},"assets/app-7ac0a883bdd7fba54b64127a4326af23.js":{"size":117173,"sha512":"pMdPYKABmXN0x52PSB1R4ZSsEyRQqeZcuoJd2RKJRnCOd3toWd7CW4xLl+vU63IrTGPp9Ui4zG0X1w9UsIHRKw==","mtime":63896408255,"digest":"7ac0a883bdd7fba54b64127a4326af23","logical_path":"assets/app.js"},"assets/app-aac45a999218cb837d0d4006c02b7dff.js":{"digest":"aac45a999218cb837d0d4006c02b7dff","logical_path":"assets/app.js","mtime":63867771918,"sha512":"Q/jSWd9SG+3D9BjNSTSo3XNU/Q0xBrdwB/OgT7W4clywBHa5VxbBNh0QBT5ZsBfTZ7MANggW8XW9rOM5UVteYg==","size":108631},"assets/app-d54a95ca3792fdeadf5d6da4085ef26b.css":{"digest":"d54a95ca3792fdeadf5d6da4085ef26b","logical_path":"assets/app.css","mtime":63896406723,"sha512":"1kFpHfxc3KgjN/SnS1xZKe5+toNPSes6XadfgGBs04DjucQFSjtDTZ3sym8yGHAx/J1zuf/+ezrn9805B54xog==","size":37379},"assets/app-dd7097dcf40a37e608bbcbd54509199a.js":{"digest":"dd7097dcf40a37e608bbcbd54509199a","logical_path":"assets/app.js","mtime":63871616612,"sha512":"40KTAfshu6/MtQVeWppL2ccJbHBaVGloBOLOOkiM7VzZqGG4NBzvXc/+PvvU43RcxELv21EMbtKAvdq1xjdG0A==","size":108651},"assets/app-f480ad9fb2a3744d668ac31cc919441f.css":{"digest":"f480ad9fb2a3744d668ac31cc919441f","logical_path":"assets/app.css","mtime":63871616612,"sha512":"g15iWMXrhNqfFRVkrKH4c9XonkZnDWFEdGuzX0BW319sucBZFfD0lZ8GTgPJcKl3gyhUqH0Lm4jy80LgfFao7w==","size":37300},"assets/app-fd8b8b15fab22a70dc800c2f99dde37b.css":{"digest":"fd8b8b15fab22a70dc800c2f99dde37b","logical_path":"assets/app.css","mtime":63888377343,"sha512":"eXoMVVXitKrrU57VnxvPTh1ZeP9r6SyNxCUOJYjVlNnAKN4kJ9b4Rc7X85kumUsyNRD3HmO4clolMMozIlnCbA==","size":37263}} }