From 90a8f57fe7a5cdef20a74c2eef3ef21b37c72a8d Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 9 Jul 2025 20:04:26 +0200 Subject: [PATCH 1/6] feat: migration to phoenix 1.8 - merge changed files --- assets/css/app.css | 1 - config/config.exs | 13 +- config/dev.exs | 12 +- config/runtime.exs | 2 +- lib/mv/application.ex | 2 - lib/mv_web.ex | 13 +- lib/mv_web/components/core_components.ex | 539 ++++++------------- lib/mv_web/components/layouts.ex | 9 + lib/mv_web/components/layouts/app.html.heex | 39 -- lib/mv_web/components/layouts/root.html.heex | 26 +- lib/mv_web/endpoint.ex | 7 +- mix.exs | 21 +- mix.lock | 10 +- 13 files changed, 237 insertions(+), 457 deletions(-) delete mode 100644 lib/mv_web/components/layouts/app.html.heex diff --git a/assets/css/app.css b/assets/css/app.css index 1abadc7..0417463 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -5,7 +5,6 @@ @source "../css"; @source "../js"; @source "../../lib/mv_web"; -@source "../../deps/ash_authentication_phoenix"; /* A Tailwind plugin that makes "hero-#{ICON}" classes available. The heroicons installation itself is managed by your mix.exs */ diff --git a/config/config.exs b/config/config.exs index 43c8cf8..29a4211 100644 --- a/config/config.exs +++ b/config/config.exs @@ -76,25 +76,24 @@ config :esbuild, version: "0.17.11", mv: [ args: - ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), + ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/*), cd: Path.expand("../assets", __DIR__), env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} ] # Configure tailwind (the version is required) config :tailwind, - version: "3.4.3", + version: "4.0.9", mv: [ args: ~w( - --config=tailwind.config.js - --input=css/app.css - --output=../priv/static/assets/app.css + --input=assets/css/app.css + --output=priv/static/assets/css/app.css ), - cd: Path.expand("../assets", __DIR__) + cd: Path.expand("..", __DIR__) ] # Configures Elixir's Logger -config :logger, :console, +config :logger, :default_formatter, format: "$time $metadata[$level] $message\n", metadata: [:request_id] diff --git a/config/dev.exs b/config/dev.exs index 17b4ce1..51ed2f1 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -17,10 +17,10 @@ config :mv, Mv.Repo, # The watchers configuration can be used to run external # watchers to your application. For example, we can use it # to bundle .js and .css sources. -# Binding to loopback ipv4 address prevents access from other machines. config :mv, MvWeb.Endpoint, + # Binding to loopback ipv4 address prevents access from other machines. # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. - http: [ip: {127, 0, 0, 1}, port: 4000], + http: [ip: {127, 0, 0, 1}, port: String.to_integer(System.get_env("PORT") || "4000")], check_origin: false, code_reloader: true, debug_errors: true, @@ -56,10 +56,11 @@ config :mv, MvWeb.Endpoint, # Watch static and templates for browser reloading. config :mv, MvWeb.Endpoint, live_reload: [ + web_console_logger: true, patterns: [ ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", ~r"priv/gettext/.*(po)$", - ~r"lib/mv_web/(controllers|live|components)/.*(ex|heex)$" + ~r"lib/mv_web/(?:controllers|live|components|router)/?.*\.(ex|heex)$" ] ] @@ -67,7 +68,7 @@ config :mv, MvWeb.Endpoint, config :mv, dev_routes: true # Do not include metadata nor timestamps in development logs -config :logger, :console, format: "[$level] $message\n" +config :logger, :default_formatter, format: "[$level] $message\n" # Set a higher stacktrace during development. Avoid configuring such # in production as building large stacktraces may be expensive. @@ -77,7 +78,8 @@ config :phoenix, :stacktrace_depth, 20 config :phoenix, :plug_init_mode, :runtime config :phoenix_live_view, - # Include HEEx debug annotations as HTML comments in rendered markup + # Include HEEx debug annotations as HTML comments in rendered markup. + # Changing this configuration will require mix clean and a full recompile. debug_heex_annotations: true, # Enable helpful, but potentially expensive runtime checks enable_expensive_runtime_checks: true diff --git a/config/runtime.exs b/config/runtime.exs index e8ab249..464d8cd 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -116,7 +116,7 @@ if config_env() == :prod do # domain: System.get_env("MAILGUN_DOMAIN") # # For this example you need include a HTTP client required by Swoosh API client. - # Swoosh supports Hackney and Finch out of the box: + # Swoosh supports Hackney, Req and Finch out of the box: # # config :swoosh, :api_client, Swoosh.ApiClient.Hackney # diff --git a/lib/mv/application.ex b/lib/mv/application.ex index e0bf462..b77107e 100644 --- a/lib/mv/application.ex +++ b/lib/mv/application.ex @@ -12,8 +12,6 @@ defmodule Mv.Application do Mv.Repo, {DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: Mv.PubSub}, - # Start the Finch HTTP client for sending emails - {Finch, name: Mv.Finch}, {AshAuthentication.Supervisor, otp_app: :my}, # Start a worker by calling: Mv.Worker.start_link(arg) # {Mv.Worker, arg}, diff --git a/lib/mv_web.ex b/lib/mv_web.ex index 4254449..f743377 100644 --- a/lib/mv_web.ex +++ b/lib/mv_web.ex @@ -38,9 +38,7 @@ defmodule MvWeb do def controller do quote do - use Phoenix.Controller, - formats: [:html, :json], - layouts: [html: MvWeb.Layouts] + use Phoenix.Controller, formats: [:html, :json] use Gettext, backend: MvWeb.Gettext @@ -52,10 +50,10 @@ defmodule MvWeb do def live_view do quote do - use Phoenix.LiveView, - layout: {MvWeb.Layouts, :app} - + use Phoenix.LiveView + on_mount MvWeb.LiveHelpers + unquote(html_helpers()) end end @@ -91,8 +89,9 @@ defmodule MvWeb do # Core UI components import MvWeb.CoreComponents - # Shortcut for generating JS commands + # Common modules used in templates alias Phoenix.LiveView.JS + alias MvWeb.Layouts # Routes generation with the ~p sigil unquote(verified_routes()) diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index c35f1ce..656d3c0 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -3,92 +3,34 @@ defmodule MvWeb.CoreComponents do Provides core UI components. At first glance, this module may seem daunting, but its goal is to provide - core building blocks for your application, such as modals, tables, and - forms. The components consist mostly of markup and are well-documented + core building blocks for your application, such as tables, forms, and + inputs. The components consist mostly of markup and are well-documented with doc strings and declarative assigns. You may customize and style them in any way you want, based on your application growth and needs. - The default components use Tailwind CSS, a utility-first CSS framework. - See the [Tailwind CSS documentation](https://tailwindcss.com) to learn - how to customize them or feel free to swap in another framework altogether. + The foundation for styling is Tailwind CSS, a utility-first CSS framework, + augmented with daisyUI, a Tailwind CSS plugin that provides UI components + and themes. Here are useful references: + + * [daisyUI](https://daisyui.com/docs/intro/) - a good place to get + started and see the available components. + + * [Tailwind CSS](https://tailwindcss.com) - the foundational framework + we build on. You will use it for layout, sizing, flexbox, grid, and + spacing. + + * [Heroicons](https://heroicons.com) - see `icon/1` for usage. + + * [Phoenix.Component](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html) - + the component system used by Phoenix. Some components, such as `<.link>` + and `<.form>`, are defined there. - Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage. """ use Phoenix.Component use Gettext, backend: MvWeb.Gettext alias Phoenix.LiveView.JS - @doc """ - Renders a modal. - - ## Examples - - <.modal id="confirm-modal"> - This is a modal. - - - JS commands may be passed to the `:on_cancel` to configure - the closing/cancel event, for example: - - <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}> - This is another modal. - - - """ - attr :id, :string, required: true - attr :show, :boolean, default: false - attr :on_cancel, JS, default: %JS{} - slot :inner_block, required: true - - def modal(assigns) do - ~H""" - - """ - end - - @doc """ - Shows the flash group with standard titles and content. - - ## Examples - - <.flash_group flash={@flash} /> - """ - attr :flash, :map, required: true, doc: "the map of flash messages" - attr :id, :string, default: "flash-group", doc: "the optional id of flash container" - - def flash_group(assigns) do - ~H""" -
- <.flash kind={:info} title={gettext("Success!")} flash={@flash} /> - <.flash kind={:error} title={gettext("Error!")} flash={@flash} /> - <.flash - id="client-error" - kind={:error} - title={gettext("We can't find the internet")} - phx-disconnected={show(".phx-client-error #client-error")} - phx-connected={hide("#client-error")} - hidden - > - {gettext("Attempting to reconnect")} - <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" /> - - - <.flash - id="server-error" - kind={:error} - title={gettext("Something went wrong!")} - phx-disconnected={show(".phx-server-error #server-error")} - phx-connected={hide("#server-error")} - hidden - > - {gettext("Hang in there while we get back on track")} - <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" /> - -
- """ - end - - @doc """ - Renders a simple form. - - ## Examples - - <.simple_form for={@form} phx-change="validate" phx-submit="save"> - <.input field={@form[:email]} label="Email"/> - <.input field={@form[:username]} label="Username" /> - <:actions> - <.button>Save - - - """ - attr :for, :any, required: true, doc: "the data structure for the form" - attr :as, :any, default: nil, doc: "the server side parameter to collect all input under" - - attr :rest, :global, - include: ~w(autocomplete name rel action enctype method novalidate target multipart), - doc: "the arbitrary HTML attributes to apply to the form tag" - - slot :inner_block, required: true - slot :actions, doc: "the slot for form actions, such as a submit button" - - def simple_form(assigns) do - ~H""" - <.form :let={f} for={@for} as={@as} {@rest}> -
- {render_slot(@inner_block, f)} -
- {render_slot(action, f)} +
+ <.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" /> + <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" /> +
+

{@title}

+

{msg}

+
+
- +
""" end @doc """ - Renders a button. + Renders a button with navigation support. ## Examples <.button>Send! - <.button phx-click="go" class="ml-2">Send! + <.button phx-click="go" variant="primary">Send! + <.button navigate={~p"/"}>Home """ - attr :type, :string, default: nil - attr :class, :string, default: nil - attr :rest, :global, include: ~w(disabled form name value) - + attr :rest, :global, include: ~w(href navigate patch method) + attr :variant, :string, values: ~w(primary) slot :inner_block, required: true - def button(assigns) do - ~H""" - - """ + def button(%{rest: rest} = assigns) do + variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"} + assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant])) + + if rest[:href] || rest[:navigate] || rest[:patch] do + ~H""" + <.link class={["btn", @class]} {@rest}> + {render_slot(@inner_block)} + + """ + else + ~H""" + + """ + end end @doc """ @@ -276,7 +145,7 @@ defmodule MvWeb.CoreComponents do attr :type, :string, default: "text", values: ~w(checkbox color date datetime-local email file month number password - range search select tel text textarea time url week) + search select tel text textarea time url week) attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form, for example: @form[:email]" @@ -286,6 +155,8 @@ defmodule MvWeb.CoreComponents do attr :prompt, :string, default: nil, doc: "the prompt for select inputs" attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2" attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs" + attr :class, :string, default: nil, doc: "the input class to use over defaults" + attr :error_class, :string, default: nil, doc: "the input error class to use over defaults" attr :rest, :global, include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength @@ -309,108 +180,95 @@ defmodule MvWeb.CoreComponents do end) ~H""" -
-
+ """ end def input(%{type: "select"} = assigns) do ~H""" -
- <.label for={@id}>{@label} - +
+ <.error :for={msg <- @errors}>{msg} -
+ """ end def input(%{type: "textarea"} = assigns) do ~H""" -
- <.label for={@id}>{@label} - +
+ <.error :for={msg <- @errors}>{msg} -
+ """ end # All other inputs text, datetime-local, url, password, etc. are handled here... def input(assigns) do ~H""" -
- <.label for={@id}>{@label} - +
+ <.error :for={msg <- @errors}>{msg} -
+ """ end - @doc """ - Renders a label. - """ - attr :for, :string, default: nil - slot :inner_block, required: true - - def label(assigns) do + # Helper used by inputs to generate form errors + defp error(assigns) do ~H""" - - """ - end - - @doc """ - Generates a generic error message. - """ - slot :inner_block, required: true - - def error(assigns) do - ~H""" -

- <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" /> +

+ <.icon name="hero-exclamation-circle" class="size-5" /> {render_slot(@inner_block)}

""" @@ -427,12 +285,12 @@ defmodule MvWeb.CoreComponents do def header(assigns) do ~H""" -
+
-

+

{render_slot(@inner_block)}

-

+

{render_slot(@subtitle)}

@@ -473,49 +331,34 @@ defmodule MvWeb.CoreComponents do end ~H""" -
- - - - - - - - - - - - - -
{col[:label]} - {gettext("Actions")} -
-
- - - {render_slot(col, @row_item.(row))} - -
-
-
- - - {render_slot(action, @row_item.(row))} - -
-
-
+ + + + + + + + + + + + + +
{col[:label]} + {gettext("Actions")} +
+ {render_slot(col, @row_item.(row))} + +
+ <%= for action <- @action do %> + {render_slot(action, @row_item.(row))} + <% end %> +
+
""" end @@ -535,38 +378,14 @@ defmodule MvWeb.CoreComponents do def list(assigns) do ~H""" -
-
-
-
{item.title}
-
{render_slot(item)}
+
    +
  • +
    +
    {item.title}
    +
    {render_slot(item)}
    -
-
- """ - end - - @doc """ - Renders a back navigation link. - - ## Examples - - <.back navigate={~p"/posts"}>Back to posts - """ - attr :navigate, :any, required: true - slot :inner_block, required: true - - def back(assigns) do - ~H""" -
- <.link - navigate={@navigate} - class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700" - > - <.icon name="hero-arrow-left-solid" class="h-3 w-3" /> - {render_slot(@inner_block)} - -
+ + """ end @@ -581,15 +400,15 @@ defmodule MvWeb.CoreComponents do width, height, and background color classes. Icons are extracted from the `deps/heroicons` directory and bundled within - your compiled app.css by the plugin in your `assets/tailwind.config.js`. + your compiled app.css by the plugin in `assets/vendor/heroicons.js`. ## Examples - <.icon name="hero-x-mark-solid" /> - <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" /> + <.icon name="hero-x-mark" /> + <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" /> """ attr :name, :string, required: true - attr :class, :string, default: nil + attr :class, :string, default: "size-4" def icon(%{name: "hero-" <> _} = assigns) do ~H""" @@ -604,7 +423,7 @@ defmodule MvWeb.CoreComponents do to: selector, time: 300, transition: - {"transition-all transform ease-out duration-300", + {"transition-all ease-out duration-300", "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", "opacity-100 translate-y-0 sm:scale-100"} ) @@ -615,37 +434,11 @@ defmodule MvWeb.CoreComponents do to: selector, time: 200, transition: - {"transition-all transform ease-in duration-200", - "opacity-100 translate-y-0 sm:scale-100", + {"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100", "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} ) end - def show_modal(js \\ %JS{}, id) when is_binary(id) do - js - |> JS.show(to: "##{id}") - |> JS.show( - to: "##{id}-bg", - time: 300, - transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"} - ) - |> show("##{id}-container") - |> JS.add_class("overflow-hidden", to: "body") - |> JS.focus_first(to: "##{id}-content") - end - - def hide_modal(js \\ %JS{}, id) do - js - |> JS.hide( - to: "##{id}-bg", - transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"} - ) - |> hide("##{id}-container") - |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"}) - |> JS.remove_class("overflow-hidden", to: "body") - |> JS.pop_focus() - end - @doc """ Translates an error message using gettext. """ diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex index 1ced765..ba8ec67 100644 --- a/lib/mv_web/components/layouts.ex +++ b/lib/mv_web/components/layouts.ex @@ -40,6 +40,15 @@ defmodule MvWeb.Layouts do
    +
  • +
    + + +
    +
  • Website
  • diff --git a/lib/mv_web/components/layouts/app.html.heex b/lib/mv_web/components/layouts/app.html.heex deleted file mode 100644 index 54258db..0000000 --- a/lib/mv_web/components/layouts/app.html.heex +++ /dev/null @@ -1,39 +0,0 @@ -
    -
    -
    - - - -

    - v{Application.spec(:phoenix, :vsn)} -

    -
    -
    -
    - - -
    - - @elixirphoenix - - - GitHub - - - Get Started - -
    -
    -
    -
    -
    - <.flash_group flash={@flash} /> - {@inner_content} -
    -
    diff --git a/lib/mv_web/components/layouts/root.html.heex b/lib/mv_web/components/layouts/root.html.heex index 9857506..5ee0fef 100644 --- a/lib/mv_web/components/layouts/root.html.heex +++ b/lib/mv_web/components/layouts/root.html.heex @@ -1,5 +1,5 @@ - + {Application.get_env(:live_debugger, :live_debugger_tags)} @@ -9,11 +9,29 @@ <.live_title default="Mv" suffix=" ยท Phoenix Framework"> {assigns[:page_title]} - - + - + {@inner_content} diff --git a/lib/mv_web/endpoint.ex b/lib/mv_web/endpoint.ex index 090e54c..97dcae4 100644 --- a/lib/mv_web/endpoint.ex +++ b/lib/mv_web/endpoint.ex @@ -17,12 +17,13 @@ defmodule MvWeb.Endpoint do # Serve at "/" the static files from "priv/static" directory. # - # You should set gzip to true if you are running phx.digest - # when deploying your static files in production. + # When code reloading is disabled (e.g., in production), + # the `gzip` option is enabled to serve compressed + # static files generated by running `phx.digest`. plug Plug.Static, at: "/", from: :mv, - gzip: false, + gzip: not code_reloading?, only: MvWeb.static_paths() if Code.ensure_loaded?(Tidewave) do diff --git a/mix.exs b/mix.exs index c16e11b..143bbfc 100644 --- a/mix.exs +++ b/mix.exs @@ -5,12 +5,13 @@ defmodule Mv.MixProject do [ app: :mv, version: "0.1.0", - elixir: "~> 1.14", + elixir: "~> 1.15", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, consolidate_protocols: Mix.env() != :dev, aliases: aliases(), - deps: deps() + deps: deps(), + listeners: [Phoenix.CodeReloader] ] end @@ -44,31 +45,31 @@ defmodule Mv.MixProject do {:ash_authentication, "~> 4.9"}, {:ash_authentication_phoenix, "~> 2.10"}, {:igniter, "~> 0.6", only: [:dev, :test]}, - {:phoenix, "~> 1.7.20"}, + {:phoenix, "~> 1.8.0-rc.3", override: true}, {:phoenix_ecto, "~> 4.5"}, {:ecto_sql, "~> 3.10"}, {:postgrex, ">= 0.0.0"}, {:phoenix_html, "~> 4.1"}, {:phoenix_live_reload, "~> 1.2", only: :dev}, - {:phoenix_live_view, "~> 1.0.0"}, + {:phoenix_live_view, "~> 1.0.9"}, {:floki, ">= 0.30.0", only: :test}, {:phoenix_live_dashboard, "~> 0.8.3"}, - {:esbuild, "~> 0.10", runtime: Mix.env() == :dev}, - {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, + {:esbuild, "~> 0.9", runtime: Mix.env() == :dev}, + {:tailwind, "~> 0.3", runtime: Mix.env() == :dev}, {:heroicons, github: "tailwindlabs/heroicons", - tag: "v2.2.0", + tag: "v2.1.1", sparse: "optimized", app: false, compile: false, depth: 1}, - {:swoosh, "~> 1.5"}, - {:finch, "~> 0.13"}, + {:swoosh, "~> 1.16"}, + {:req, "~> 0.5"}, {:telemetry_metrics, "~> 1.0"}, {:telemetry_poller, "~> 1.0"}, {:gettext, "~> 0.26"}, {:jason, "~> 1.2"}, - {:dns_cluster, "~> 0.2.0"}, + {:dns_cluster, "~> 0.1.1"}, {:bandit, "~> 1.5"}, {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}, {:sobelow, "~> 0.14", only: [:dev, :test], runtime: false}, diff --git a/mix.lock b/mix.lock index 51b4604..84f9bd1 100644 --- a/mix.lock +++ b/mix.lock @@ -16,7 +16,7 @@ "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [: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", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, "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.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, - "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, + "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, "ecto": {:hex, :ecto, "3.12.6", "8bf762dc5b87d85b7aca7ad5fe31ef8142a84cea473a3381eb933bd925751300", [: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", "4c0cba01795463eebbcd9e4b5ef53c1ee8e68b9c482baef2a80de5a61e7a57fe"}, "ecto_commons": {:hex, :ecto_commons, "0.3.6", "7b1d9e59396cf8c8cbe5a26d50d03f9b6d0fe6c640210dd503622f276f1e59bb", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.2", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "3f12981a1e398f206c5d2014e7b732b7ec91b110b9cb84875cb5b28fc75d7a0a"}, "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"}, @@ -30,7 +30,7 @@ "floki": {:hex, :floki, "0.37.1", "d7aaee758c8a5b4a7495799a4260754fec5530d95b9c383c03b27359dea117cf", [:mix], [], "hexpm", "673d040cb594d31318d514590246b6dd587ed341d3b67e17c1c0eb8ce7ca6f04"}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, - "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, + "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "igniter": {:hex, :igniter, "0.6.7", "4e183afc59d89289e223c4282fd3e9bb39b82e28d0aa6d3369f70fbd3e21a243", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [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", "43b0a584dc84fd1320772c87047355b604ed2bcdd25392b17f7da8bdd09b61ac"}, "inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"}, @@ -47,7 +47,7 @@ "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "owl": {:hex, :owl, "0.12.2", "65906b525e5c3ef51bab6cba7687152be017aebe1da077bb719a5ee9f7e60762", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "6398efa9e1fea70a04d24231e10dcd66c1ac1aa2da418d20ef5357ec61de2880"}, - "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [: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", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, + "phoenix": {:hex, :phoenix, "1.8.0-rc.3", "6ae19e57b9c109556f1b8abdb992d96d443b0ae28e03b604f3dc6c75d9f7d35f", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {: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", "419422afc33e965c0dbf181cbedc77b4cfd024dac0db7d9d2287656043d48e24"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.4", "dcf3483ab45bab4c15e3a47c34451392f64e433846b08469f5d16c2a4cd70052", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "f5b8584c36ccc9b903948a696fc9b8b81102c79c7c0c751a9f00cdec55d5f2d7"}, "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, @@ -57,7 +57,7 @@ "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, - "plug": {:hex, :plug, "1.18.0", "d78df36c41f7e798f2edf1f33e1727eae438e9dd5d809a9997c463a108244042", [: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", "819f9e176d51e44dc38132e132fe0accaf6767eab7f0303431e404da8476cfa2"}, + "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [: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", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [: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", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, "reactor": {:hex, :reactor, "0.15.4", "ef0c56a901c132529a14ab59fed0ccb4fcecb24308fb189a94c908255d4fdafc", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, 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]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "783bf62fd0c72ded033afabdb8b6190b7048769771a2a97256e6f0bf4fb0a891"}, @@ -70,8 +70,8 @@ "spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"}, "splode": {:hex, :splode, "0.2.9", "3a2776e187c82f42f5226b33b1220ccbff74f4bcc523dd4039c804caaa3ffdc7", [:mix], [], "hexpm", "8002b00c6e24f8bd1bcced3fbaa5c33346048047bb7e13d2f3ad428babbd95c3"}, "stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"}, - "swoosh": {:hex, :swoosh, "1.19.2", "b2325aa7cd2bcd63ba023fa07a73dfc4f80660a592d40912975a879966ed9b7b", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cab7ef7c2c94c68fe21d3da26f6b86db118fdf4e7024ccb5842a4972c1056837"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, + "swoosh": {:hex, :swoosh, "1.19.2", "b2325aa7cd2bcd63ba023fa07a73dfc4f80660a592d40912975a879966ed9b7b", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cab7ef7c2c94c68fe21d3da26f6b86db118fdf4e7024ccb5842a4972c1056837"}, "tailwind": {:hex, :tailwind, "0.3.1", "a89d2835c580748c7a975ad7dd3f2ea5e63216dc16d44f9df492fbd12c094bed", [:mix], [], "hexpm", "98a45febdf4a87bc26682e1171acdedd6317d0919953c353fcd1b4f9f4b676a2"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, From a35d62c084350777472a3e001753cc697cc5a444 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 9 Jul 2025 20:47:04 +0200 Subject: [PATCH 2/6] feat: migration to phoenix 1.8 - generate new ash live views --- lib/mv_web/live/member_live/form.ex | 107 ++++++++++++++++++++ lib/mv_web/live/member_live/index.ex | 60 +++++++++++ lib/mv_web/live/member_live/show.ex | 36 +++++++ lib/mv_web/live/property_live/form.ex | 87 ++++++++++++++++ lib/mv_web/live/property_live/index.ex | 60 +++++++++++ lib/mv_web/live/property_live/show.ex | 36 +++++++ lib/mv_web/live/property_type_live/form.ex | 95 +++++++++++++++++ lib/mv_web/live/property_type_live/index.ex | 64 ++++++++++++ lib/mv_web/live/property_type_live/show.ex | 43 ++++++++ 9 files changed, 588 insertions(+) create mode 100644 lib/mv_web/live/member_live/form.ex create mode 100644 lib/mv_web/live/member_live/index.ex create mode 100644 lib/mv_web/live/member_live/show.ex create mode 100644 lib/mv_web/live/property_live/form.ex create mode 100644 lib/mv_web/live/property_live/index.ex create mode 100644 lib/mv_web/live/property_live/show.ex create mode 100644 lib/mv_web/live/property_type_live/form.ex create mode 100644 lib/mv_web/live/property_type_live/index.ex create mode 100644 lib/mv_web/live/property_type_live/show.ex diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex new file mode 100644 index 0000000..df5a2c1 --- /dev/null +++ b/lib/mv_web/live/member_live/form.ex @@ -0,0 +1,107 @@ +defmodule MvWeb.MemberLive.Form do + use MvWeb, :live_view + + @impl true + def render(assigns) do + ~H""" + + <.header> + {@page_title} + <:subtitle>Use this form to manage member records in your database. + + + <.form for={@form} id="member-form" phx-change="validate" phx-submit="save"> + <.input field={@form[:properties]} type="select" multiple label="Properties" options={[]} /> + <.input field={@form[:first_name]} type="text" label="First name" /><.input + field={@form[:last_name]} + type="text" + label="Last name" + /><.input field={@form[:email]} type="text" label="Email" /><.input + field={@form[:birth_date]} + type="date" + label="Birth date" + /><.input field={@form[:paid]} type="checkbox" label="Paid" /><.input + field={@form[:phone_number]} + type="text" + label="Phone number" + /><.input field={@form[:join_date]} type="date" label="Join date" /><.input + field={@form[:exit_date]} + type="date" + label="Exit date" + /><.input field={@form[:notes]} type="text" label="Notes" /><.input + field={@form[:city]} + type="text" + label="City" + /><.input field={@form[:street]} type="text" label="Street" /><.input + field={@form[:house_number]} + type="text" + label="House number" + /><.input field={@form[:postal_code]} type="text" label="Postal code" /> + + <.button phx-disable-with="Saving..." variant="primary">Save Member + <.button navigate={return_path(@return_to, @member)}>Cancel + + + """ + end + + @impl true + def mount(params, _session, socket) do + member = + case params["id"] do + nil -> nil + id -> Ash.get!(Mv.Membership.Member, id) + end + + action = if is_nil(member), do: "New", else: "Edit" + page_title = action <> " " <> "Member" + + {:ok, + socket + |> assign(:return_to, return_to(params["return_to"])) + |> assign(member: member) + |> assign(:page_title, page_title) + |> assign_form()} + end + + defp return_to("show"), do: "show" + defp return_to(_), do: "index" + + @impl true + def handle_event("validate", %{"member" => member_params}, socket) do + {:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, member_params))} + end + + def handle_event("save", %{"member" => member_params}, socket) do + case AshPhoenix.Form.submit(socket.assigns.form, params: member_params) do + {:ok, member} -> + notify_parent({:saved, member}) + + socket = + socket + |> put_flash(:info, "Member #{socket.assigns.form.source.type}d successfully") + |> push_navigate(to: return_path(socket.assigns.return_to, member)) + + {:noreply, socket} + + {:error, form} -> + {:noreply, assign(socket, form: form)} + end + end + + defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) + + defp assign_form(%{assigns: %{member: member}} = socket) do + form = + if member do + AshPhoenix.Form.for_update(member, :update_member, as: "member") + else + AshPhoenix.Form.for_create(Mv.Membership.Member, :create_member, as: "member") + end + + assign(socket, form: to_form(form)) + end + + defp return_path("index", _member), do: ~p"/members" + defp return_path("show", member), do: ~p"/members/#{member.id}" +end diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex new file mode 100644 index 0000000..79db12f --- /dev/null +++ b/lib/mv_web/live/member_live/index.ex @@ -0,0 +1,60 @@ +defmodule MvWeb.MemberLive.Index do + use MvWeb, :live_view + + @impl true + def render(assigns) do + ~H""" + + <.header> + Listing Members + <:actions> + <.button variant="primary" navigate={~p"/members/new"}> + <.icon name="hero-plus" /> New Member + + + + + <.table + id="members" + rows={@streams.members} + row_click={fn {_id, member} -> JS.navigate(~p"/members/#{member}") end} + > + <:col :let={{_id, member}} label="Id">{member.id} + + <:action :let={{_id, member}}> +
    + <.link navigate={~p"/members/#{member}"}>Show +
    + + <.link navigate={~p"/members/#{member}/edit"}>Edit + + + <:action :let={{id, member}}> + <.link + phx-click={JS.push("delete", value: %{id: member.id}) |> hide("##{id}")} + data-confirm="Are you sure?" + > + Delete + + + +
    + """ + end + + @impl true + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:page_title, "Listing Members") + |> stream(:members, Ash.read!(Mv.Membership.Member))} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + member = Ash.get!(Mv.Membership.Member, id) + Ash.destroy!(member) + + {:noreply, stream_delete(socket, :members, member)} + end +end diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex new file mode 100644 index 0000000..a7fa16e --- /dev/null +++ b/lib/mv_web/live/member_live/show.ex @@ -0,0 +1,36 @@ +defmodule MvWeb.MemberLive.Show do + use MvWeb, :live_view + + @impl true + def render(assigns) do + ~H""" + + <.header> + Member {@member.id} + <:subtitle>This is a member record from your database. + + <:actions> + <.button navigate={~p"/members"}> + <.icon name="hero-arrow-left" /> + + <.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}> + <.icon name="hero-pencil-square" /> Edit Member + + + + + <.list> + <:item title="Id">{@member.id} + + + """ + end + + @impl true + def mount(%{"id" => id}, _session, socket) do + {:ok, + socket + |> assign(:page_title, "Show Member") + |> assign(:member, Ash.get!(Mv.Membership.Member, id))} + end +end diff --git a/lib/mv_web/live/property_live/form.ex b/lib/mv_web/live/property_live/form.ex new file mode 100644 index 0000000..6df53ef --- /dev/null +++ b/lib/mv_web/live/property_live/form.ex @@ -0,0 +1,87 @@ +defmodule MvWeb.PropertyLive.Form do + use MvWeb, :live_view + + @impl true + def render(assigns) do + ~H""" + + <.header> + {@page_title} + <:subtitle>Use this form to manage property records in your database. + + + <.form for={@form} id="property-form" phx-change="validate" phx-submit="save"> + <.input field={@form[:value]} type="text" label="Value" /><.input + field={@form[:member_id]} + type="text" + label="Member" + /><.input field={@form[:property_type_id]} type="text" label="Property type" /> + + <.button phx-disable-with="Saving..." variant="primary">Save Property + <.button navigate={return_path(@return_to, @property)}>Cancel + + + """ + end + + @impl true + def mount(params, _session, socket) do + property = + case params["id"] do + nil -> nil + id -> Ash.get!(Mv.Membership.Property, id) + end + + action = if is_nil(property), do: "New", else: "Edit" + page_title = action <> " " <> "Property" + + {:ok, + socket + |> assign(:return_to, return_to(params["return_to"])) + |> assign(property: property) + |> assign(:page_title, page_title) + |> assign_form()} + end + + defp return_to("show"), do: "show" + defp return_to(_), do: "index" + + @impl true + def handle_event("validate", %{"property" => property_params}, socket) do + {:noreply, + assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, property_params))} + end + + def handle_event("save", %{"property" => property_params}, socket) do + case AshPhoenix.Form.submit(socket.assigns.form, params: property_params) do + {:ok, property} -> + notify_parent({:saved, property}) + + socket = + socket + |> put_flash(:info, "Property #{socket.assigns.form.source.type}d successfully") + |> push_navigate(to: return_path(socket.assigns.return_to, property)) + + {:noreply, socket} + + {:error, form} -> + {:noreply, assign(socket, form: form)} + end + end + + defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) + + defp assign_form(%{assigns: %{property: property}} = socket) do + form = + if property do + AshPhoenix.Form.for_update(property, :update, as: "property") + else + AshPhoenix.Form.for_create(Mv.Membership.Property, :create, as: "property") + end + + assign(socket, form: to_form(form)) + end + + defp return_path("index", _property), do: ~p"/properties" + defp return_path("show", property), do: ~p"/properties/#{property.id}" +end diff --git a/lib/mv_web/live/property_live/index.ex b/lib/mv_web/live/property_live/index.ex new file mode 100644 index 0000000..7e27344 --- /dev/null +++ b/lib/mv_web/live/property_live/index.ex @@ -0,0 +1,60 @@ +defmodule MvWeb.PropertyLive.Index do + use MvWeb, :live_view + + @impl true + def render(assigns) do + ~H""" + + <.header> + Listing Properties + <:actions> + <.button variant="primary" navigate={~p"/properties/new"}> + <.icon name="hero-plus" /> New Property + + + + + <.table + id="properties" + rows={@streams.properties} + row_click={fn {_id, property} -> JS.navigate(~p"/properties/#{property}") end} + > + <:col :let={{_id, property}} label="Id">{property.id} + + <:action :let={{_id, property}}> +
    + <.link navigate={~p"/properties/#{property}"}>Show +
    + + <.link navigate={~p"/properties/#{property}/edit"}>Edit + + + <:action :let={{id, property}}> + <.link + phx-click={JS.push("delete", value: %{id: property.id}) |> hide("##{id}")} + data-confirm="Are you sure?" + > + Delete + + + +
    + """ + end + + @impl true + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:page_title, "Listing Properties") + |> stream(:properties, Ash.read!(Mv.Membership.Property))} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + property = Ash.get!(Mv.Membership.Property, id) + Ash.destroy!(property) + + {:noreply, stream_delete(socket, :properties, property)} + end +end diff --git a/lib/mv_web/live/property_live/show.ex b/lib/mv_web/live/property_live/show.ex new file mode 100644 index 0000000..bc45ff5 --- /dev/null +++ b/lib/mv_web/live/property_live/show.ex @@ -0,0 +1,36 @@ +defmodule MvWeb.PropertyLive.Show do + use MvWeb, :live_view + + @impl true + def render(assigns) do + ~H""" + + <.header> + Property {@property.id} + <:subtitle>This is a property record from your database. + + <:actions> + <.button navigate={~p"/properties"}> + <.icon name="hero-arrow-left" /> + + <.button variant="primary" navigate={~p"/properties/#{@property}/edit?return_to=show"}> + <.icon name="hero-pencil-square" /> Edit Property + + + + + <.list> + <:item title="Id">{@property.id} + + + """ + end + + @impl true + def mount(%{"id" => id}, _session, socket) do + {:ok, + socket + |> assign(:page_title, "Show Property") + |> assign(:property, Ash.get!(Mv.Membership.Property, id))} + end +end diff --git a/lib/mv_web/live/property_type_live/form.ex b/lib/mv_web/live/property_type_live/form.ex new file mode 100644 index 0000000..6dfe7c9 --- /dev/null +++ b/lib/mv_web/live/property_type_live/form.ex @@ -0,0 +1,95 @@ +defmodule MvWeb.PropertyTypeLive.Form do + use MvWeb, :live_view + + @impl true + def render(assigns) do + ~H""" + + <.header> + {@page_title} + <:subtitle>Use this form to manage property_type records in your database. + + + <.form for={@form} id="property_type-form" phx-change="validate" phx-submit="save"> + <.input field={@form[:name]} type="text" label="Name" /><.input + field={@form[:value_type]} + type="select" + label="Value type" + options={ + Ash.Resource.Info.attribute(Mv.Membership.PropertyType, :value_type).constraints[:one_of] + } + /> + <.input field={@form[:description]} type="text" label="Description" /><.input + field={@form[:immutable]} + type="checkbox" + label="Immutable" + /><.input field={@form[:required]} type="checkbox" label="Required" /> + + <.button phx-disable-with="Saving..." variant="primary">Save Property type + <.button navigate={return_path(@return_to, @property_type)}>Cancel + + + """ + end + + @impl true + def mount(params, _session, socket) do + property_type = + case params["id"] do + nil -> nil + id -> Ash.get!(Mv.Membership.PropertyType, id) + end + + action = if is_nil(property_type), do: "New", else: "Edit" + page_title = action <> " " <> "Property type" + + {:ok, + socket + |> assign(:return_to, return_to(params["return_to"])) + |> assign(property_type: property_type) + |> assign(:page_title, page_title) + |> assign_form()} + end + + defp return_to("show"), do: "show" + defp return_to(_), do: "index" + + @impl true + def handle_event("validate", %{"property_type" => property_type_params}, socket) do + {:noreply, + assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, property_type_params))} + end + + def handle_event("save", %{"property_type" => property_type_params}, socket) do + case AshPhoenix.Form.submit(socket.assigns.form, params: property_type_params) do + {:ok, property_type} -> + notify_parent({:saved, property_type}) + + socket = + socket + |> put_flash(:info, "Property type #{socket.assigns.form.source.type}d successfully") + |> push_navigate(to: return_path(socket.assigns.return_to, property_type)) + + {:noreply, socket} + + {:error, form} -> + {:noreply, assign(socket, form: form)} + end + end + + defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) + + defp assign_form(%{assigns: %{property_type: property_type}} = socket) do + form = + if property_type do + AshPhoenix.Form.for_update(property_type, :update, as: "property_type") + else + AshPhoenix.Form.for_create(Mv.Membership.PropertyType, :create, as: "property_type") + end + + assign(socket, form: to_form(form)) + end + + defp return_path("index", _property_type), do: ~p"/property_types" + defp return_path("show", property_type), do: ~p"/property_types/#{property_type.id}" +end diff --git a/lib/mv_web/live/property_type_live/index.ex b/lib/mv_web/live/property_type_live/index.ex new file mode 100644 index 0000000..ed9ff7d --- /dev/null +++ b/lib/mv_web/live/property_type_live/index.ex @@ -0,0 +1,64 @@ +defmodule MvWeb.PropertyTypeLive.Index do + use MvWeb, :live_view + + @impl true + def render(assigns) do + ~H""" + + <.header> + Listing Property types + <:actions> + <.button variant="primary" navigate={~p"/property_types/new"}> + <.icon name="hero-plus" /> New Property type + + + + + <.table + id="property_types" + rows={@streams.property_types} + row_click={fn {_id, property_type} -> JS.navigate(~p"/property_types/#{property_type}") end} + > + <:col :let={{_id, property_type}} label="Id">{property_type.id} + + <:col :let={{_id, property_type}} label="Name">{property_type.name} + + <:col :let={{_id, property_type}} label="Description">{property_type.description} + + <:action :let={{_id, property_type}}> +
    + <.link navigate={~p"/property_types/#{property_type}"}>Show +
    + + <.link navigate={~p"/property_types/#{property_type}/edit"}>Edit + + + <:action :let={{id, property_type}}> + <.link + phx-click={JS.push("delete", value: %{id: property_type.id}) |> hide("##{id}")} + data-confirm="Are you sure?" + > + Delete + + + +
    + """ + end + + @impl true + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:page_title, "Listing Property types") + |> stream(:property_types, Ash.read!(Mv.Membership.PropertyType))} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + property_type = Ash.get!(Mv.Membership.PropertyType, id) + Ash.destroy!(property_type) + + {:noreply, stream_delete(socket, :property_types, property_type)} + end +end diff --git a/lib/mv_web/live/property_type_live/show.ex b/lib/mv_web/live/property_type_live/show.ex new file mode 100644 index 0000000..027baa6 --- /dev/null +++ b/lib/mv_web/live/property_type_live/show.ex @@ -0,0 +1,43 @@ +defmodule MvWeb.PropertyTypeLive.Show do + use MvWeb, :live_view + + @impl true + def render(assigns) do + ~H""" + + <.header> + Property type {@property_type.id} + <:subtitle>This is a property_type record from your database. + + <:actions> + <.button navigate={~p"/property_types"}> + <.icon name="hero-arrow-left" /> + + <.button + variant="primary" + navigate={~p"/property_types/#{@property_type}/edit?return_to=show"} + > + <.icon name="hero-pencil-square" /> Edit Property type + + + + + <.list> + <:item title="Id">{@property_type.id} + + <:item title="Name">{@property_type.name} + + <:item title="Description">{@property_type.description} + + + """ + end + + @impl true + def mount(%{"id" => id}, _session, socket) do + {:ok, + socket + |> assign(:page_title, "Show Property type") + |> assign(:property_type, Ash.get!(Mv.Membership.PropertyType, id))} + end +end From 658b976898f4695c4b72143b7df21a6241d125c8 Mon Sep 17 00:00:00 2001 From: Moritz Date: Wed, 9 Jul 2025 21:08:11 +0200 Subject: [PATCH 3/6] feat: migration to phoenix 1.8 - merge old live views into new live views --- lib/mv_web/live/member_live/form.ex | 144 ++++++++++---- lib/mv_web/live/member_live/index.ex | 21 ++- lib/mv_web/live/member_live/show.ex | 64 ++++++- lib/mv_web/live/property_live/form.ex | 25 ++- lib/mv_web/live/property_live/show.ex | 14 +- lib/mv_web/live/property_type_live/form.ex | 32 ++-- lib/mv_web/member_live/form_component.ex | 178 ------------------ lib/mv_web/member_live/index.ex | 104 ---------- lib/mv_web/member_live/show.ex | 94 --------- lib/mv_web/property_live/form_component.ex | 77 -------- lib/mv_web/property_live/index.ex | 99 ---------- lib/mv_web/property_live/show.ex | 57 ------ .../property_type_live/form_component.ex | 81 -------- lib/mv_web/property_type_live/index.ex | 103 ---------- lib/mv_web/property_type_live/show.ex | 61 ------ lib/mv_web/router.ex | 12 +- 16 files changed, 234 insertions(+), 932 deletions(-) delete mode 100644 lib/mv_web/member_live/form_component.ex delete mode 100644 lib/mv_web/member_live/index.ex delete mode 100644 lib/mv_web/member_live/show.ex delete mode 100644 lib/mv_web/property_live/form_component.ex delete mode 100644 lib/mv_web/property_live/index.ex delete mode 100644 lib/mv_web/property_live/show.ex delete mode 100644 lib/mv_web/property_type_live/form_component.ex delete mode 100644 lib/mv_web/property_type_live/index.ex delete mode 100644 lib/mv_web/property_type_live/show.ex diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index df5a2c1..a526fc3 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -7,39 +7,49 @@ defmodule MvWeb.MemberLive.Form do <.header> {@page_title} - <:subtitle>Use this form to manage member records in your database. + <:subtitle> + {gettext("Use this form to manage member records and their properties.")} + <.form for={@form} id="member-form" phx-change="validate" phx-submit="save"> - <.input field={@form[:properties]} type="select" multiple label="Properties" options={[]} /> - <.input field={@form[:first_name]} type="text" label="First name" /><.input - field={@form[:last_name]} - type="text" - label="Last name" - /><.input field={@form[:email]} type="text" label="Email" /><.input - field={@form[:birth_date]} - type="date" - label="Birth date" - /><.input field={@form[:paid]} type="checkbox" label="Paid" /><.input - field={@form[:phone_number]} - type="text" - label="Phone number" - /><.input field={@form[:join_date]} type="date" label="Join date" /><.input - field={@form[:exit_date]} - type="date" - label="Exit date" - /><.input field={@form[:notes]} type="text" label="Notes" /><.input - field={@form[:city]} - type="text" - label="City" - /><.input field={@form[:street]} type="text" label="Street" /><.input - field={@form[:house_number]} - type="text" - label="House number" - /><.input field={@form[:postal_code]} type="text" label="Postal code" /> + <.input field={@form[:first_name]} label={gettext("First Name")} required /> + <.input field={@form[:last_name]} label={gettext("Last Name")} required /> + <.input field={@form[:email]} label={gettext("Email")} required type="email" /> + <.input field={@form[:birth_date]} label={gettext("Birth Date")} type="date" /> + <.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" /> + <.input field={@form[:phone_number]} label={gettext("Phone Number")} /> + <.input field={@form[:join_date]} label={gettext("Join Date")} type="date" /> + <.input field={@form[:exit_date]} label={gettext("Exit Date")} type="date" /> + <.input field={@form[:notes]} label={gettext("Notes")} /> + <.input field={@form[:city]} label={gettext("City")} /> + <.input field={@form[:street]} label={gettext("Street")} /> + <.input field={@form[:house_number]} label={gettext("House Number")} /> + <.input field={@form[:postal_code]} label={gettext("Postal Code")} /> - <.button phx-disable-with="Saving..." variant="primary">Save Member - <.button navigate={return_path(@return_to, @member)}>Cancel +

    {gettext("Custom Properties")}

    + <.inputs_for :let={f_property} field={@form[:properties]}> + <% type = Enum.find(@property_types, &(&1.id == f_property[:property_type_id].value)) %> + <.inputs_for :let={value_form} field={f_property[:value]}> + <% input_type = + cond do + type && type.value_type == :boolean -> "checkbox" + type && type.value_type == :date -> :date + true -> :text + end %> + <.input field={value_form[:value]} label={type && type.name} type={input_type} /> + + + + + <.button phx-disable-with={gettext("Saving...")} variant="primary"> + {gettext("Save Member")} + + <.button navigate={return_path(@return_to, @member)}>{gettext("Cancel")}
    """ @@ -47,6 +57,20 @@ defmodule MvWeb.MemberLive.Form do @impl true def mount(params, _session, socket) do + {:ok, property_types} = Mv.Membership.list_property_types() + + initial_properties = + Enum.map(property_types, fn pt -> + %{ + "property_type_id" => pt.id, + "value" => %{ + "type" => pt.value_type, + "value" => nil, + "_union_type" => Atom.to_string(pt.value_type) + } + } + end) + member = case params["id"] do nil -> nil @@ -59,6 +83,8 @@ defmodule MvWeb.MemberLive.Form do {:ok, socket |> assign(:return_to, return_to(params["return_to"])) + |> assign(:property_types, property_types) + |> assign(:initial_properties, initial_properties) |> assign(member: member) |> assign(:page_title, page_title) |> assign_form()} @@ -77,9 +103,16 @@ defmodule MvWeb.MemberLive.Form do {:ok, member} -> notify_parent({:saved, member}) + action = + case socket.assigns.form.source.type do + :create -> gettext("create") + :update -> gettext("update") + other -> to_string(other) + end + socket = socket - |> put_flash(:info, "Member #{socket.assigns.form.source.type}d successfully") + |> put_flash(:info, gettext("Member %{action} successfully", action: action)) |> push_navigate(to: return_path(socket.assigns.return_to, member)) {:noreply, socket} @@ -94,9 +127,56 @@ defmodule MvWeb.MemberLive.Form do defp assign_form(%{assigns: %{member: member}} = socket) do form = if member do - AshPhoenix.Form.for_update(member, :update_member, as: "member") + {:ok, member} = Ash.load(member, properties: [:property_type]) + + existing_properties = + member.properties + |> Enum.map(& &1.property_type_id) + + is_missing_property = fn i -> + not Enum.member?(existing_properties, Map.get(i, "property_type_id")) + end + + params = %{ + "properties" => + Enum.map(member.properties, fn prop -> + %{ + "property_type_id" => prop.property_type_id, + "value" => %{ + "_union_type" => Atom.to_string(prop.value.type), + "type" => prop.value.type, + "value" => prop.value.value + } + } + end) + } + + form = + AshPhoenix.Form.for_update( + member, + :update_member, + api: Mv.Membership, + as: "member", + params: params, + forms: [auto?: true] + ) + + missing_properties = Enum.filter(socket.assigns[:initial_properties], is_missing_property) + + Enum.reduce( + missing_properties, + form, + &AshPhoenix.Form.add_form(&2, [:properties], params: &1) + ) else - AshPhoenix.Form.for_create(Mv.Membership.Member, :create_member, as: "member") + AshPhoenix.Form.for_create( + Mv.Membership.Member, + :create_member, + api: Mv.Membership, + as: "member", + params: %{"properties" => socket.assigns[:initial_properties]}, + forms: [auto?: true] + ) end assign(socket, form: to_form(form)) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 79db12f..1cff898 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -6,10 +6,10 @@ defmodule MvWeb.MemberLive.Index do ~H""" <.header> - Listing Members + {gettext("Listing Members")} <:actions> <.button variant="primary" navigate={~p"/members/new"}> - <.icon name="hero-plus" /> New Member + <.icon name="hero-plus" /> {gettext("New Member")} @@ -19,22 +19,27 @@ defmodule MvWeb.MemberLive.Index do rows={@streams.members} row_click={fn {_id, member} -> JS.navigate(~p"/members/#{member}") end} > - <:col :let={{_id, member}} label="Id">{member.id} + + <:col :let={{_id, member}} label={gettext("First Name")}>{member.first_name} + <:col :let={{_id, member}} label={gettext("Last Name")}>{member.last_name} + <:col :let={{_id, member}} label={gettext("Email")}>{member.email} + <:col :let={{_id, member}} label={gettext("City")}>{member.city} + <:col :let={{_id, member}} label={gettext("Join Date")}>{member.join_date} <:action :let={{_id, member}}>
    - <.link navigate={~p"/members/#{member}"}>Show + <.link navigate={~p"/members/#{member}"}>{gettext("Show")}
    - <.link navigate={~p"/members/#{member}/edit"}>Edit + <.link navigate={~p"/members/#{member}/edit"}>{gettext("Edit")} <:action :let={{id, member}}> <.link phx-click={JS.push("delete", value: %{id: member.id}) |> hide("##{id}")} - data-confirm="Are you sure?" + data-confirm={gettext("Are you sure?")} > - Delete + {gettext("Delete")} @@ -46,7 +51,7 @@ defmodule MvWeb.MemberLive.Index do def mount(_params, _session, socket) do {:ok, socket - |> assign(:page_title, "Listing Members") + |> assign(:page_title, gettext("Listing Members")) |> stream(:members, Ash.read!(Mv.Membership.Member))} end diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index a7fa16e..304709c 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -1,36 +1,82 @@ defmodule MvWeb.MemberLive.Show do use MvWeb, :live_view + import Ash.Query @impl true def render(assigns) do ~H""" <.header> - Member {@member.id} - <:subtitle>This is a member record from your database. + {@member.first_name} {@member.last_name} + <:subtitle>{gettext("This is a member record from your database.")} <:actions> <.button navigate={~p"/members"}> <.icon name="hero-arrow-left" /> <.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}> - <.icon name="hero-pencil-square" /> Edit Member + <.icon name="hero-pencil-square" /> {gettext("Edit Member")} <.list> - <:item title="Id">{@member.id} + <:item title={gettext("Id")}>{@member.id} + <:item title={gettext("First Name")}>{@member.first_name} + <:item title={gettext("Last Name")}>{@member.last_name} + <:item title={gettext("Email")}>{@member.email} + <:item title={gettext("Birth Date")}>{@member.birth_date} + <:item title={gettext("Paid")}> + {if @member.paid, do: gettext("Yes"), else: gettext("No")} + + <:item title={gettext("Phone Number")}>{@member.phone_number} + <:item title={gettext("Join Date")}>{@member.join_date} + <:item title={gettext("Exit Date")}>{@member.exit_date} + <:item title={gettext("Notes")}>{@member.notes} + <:item title={gettext("City")}>{@member.city} + <:item title={gettext("Street")}>{@member.street} + <:item title={gettext("House Number")}>{@member.house_number} + <:item title={gettext("Postal Code")}>{@member.postal_code} + +

    {gettext("Custom Properties")}

    + <.generic_list items={ + Enum.map(@member.properties, fn p -> + { + # name + p.property_type && p.property_type.name, + # value + case p.value do + %{value: v} -> v + v -> v + end + } + end) + } />
    """ end @impl true - def mount(%{"id" => id}, _session, socket) do - {:ok, - socket - |> assign(:page_title, "Show Member") - |> assign(:member, Ash.get!(Mv.Membership.Member, id))} + def mount(_params, _session, socket) do + {:ok, socket} end + + @impl true + def handle_params(%{"id" => id}, _, socket) do + query = + Mv.Membership.Member + |> filter(id == ^id) + |> load(properties: [:property_type]) + + member = Ash.read_one!(query) + + {:noreply, + socket + |> assign(:page_title, page_title(socket.assigns.live_action)) + |> assign(:member, member)} + end + + defp page_title(:show), do: gettext("Show Member") + defp page_title(:edit), do: gettext("Edit Member") end diff --git a/lib/mv_web/live/property_live/form.ex b/lib/mv_web/live/property_live/form.ex index 6df53ef..2987b3e 100644 --- a/lib/mv_web/live/property_live/form.ex +++ b/lib/mv_web/live/property_live/form.ex @@ -7,18 +7,18 @@ defmodule MvWeb.PropertyLive.Form do <.header> {@page_title} - <:subtitle>Use this form to manage property records in your database. + <:subtitle>{gettext("Use this form to manage property records in your database.")} <.form for={@form} id="property-form" phx-change="validate" phx-submit="save"> - <.input field={@form[:value]} type="text" label="Value" /><.input - field={@form[:member_id]} - type="text" - label="Member" - /><.input field={@form[:property_type_id]} type="text" label="Property type" /> + <.input field={@form[:value]} type="text" label={gettext("Value")} /> + <.input field={@form[:member_id]} type="text" label={gettext("Member")} /> + <.input field={@form[:property_type_id]} type="text" label={gettext("Property type")} /> - <.button phx-disable-with="Saving..." variant="primary">Save Property - <.button navigate={return_path(@return_to, @property)}>Cancel + <.button phx-disable-with={gettext("Saving...")} variant="primary"> + {gettext("Save Property")} + + <.button navigate={return_path(@return_to, @property)}>{gettext("Cancel")} """ @@ -57,9 +57,16 @@ defmodule MvWeb.PropertyLive.Form do {:ok, property} -> notify_parent({:saved, property}) + action = + case socket.assigns.form.source.type do + :create -> gettext("create") + :update -> gettext("update") + other -> to_string(other) + end + socket = socket - |> put_flash(:info, "Property #{socket.assigns.form.source.type}d successfully") + |> put_flash(:info, gettext("Property %{action} successfully", action: action)) |> push_navigate(to: return_path(socket.assigns.return_to, property)) {:noreply, socket} diff --git a/lib/mv_web/live/property_live/show.ex b/lib/mv_web/live/property_live/show.ex index bc45ff5..6d9bb1a 100644 --- a/lib/mv_web/live/property_live/show.ex +++ b/lib/mv_web/live/property_live/show.ex @@ -27,10 +27,18 @@ defmodule MvWeb.PropertyLive.Show do end @impl true - def mount(%{"id" => id}, _session, socket) do - {:ok, + def mount(_params, _session, socket) do + {:ok, socket} + end + + @impl true + def handle_params(%{"id" => id}, _, socket) do + {:noreply, socket - |> assign(:page_title, "Show Property") + |> assign(:page_title, page_title(socket.assigns.live_action)) |> assign(:property, Ash.get!(Mv.Membership.Property, id))} end + + defp page_title(:show), do: "Show Property" + defp page_title(:edit), do: "Edit Property" end diff --git a/lib/mv_web/live/property_type_live/form.ex b/lib/mv_web/live/property_type_live/form.ex index 6dfe7c9..87cac94 100644 --- a/lib/mv_web/live/property_type_live/form.ex +++ b/lib/mv_web/live/property_type_live/form.ex @@ -7,26 +7,29 @@ defmodule MvWeb.PropertyTypeLive.Form do <.header> {@page_title} - <:subtitle>Use this form to manage property_type records in your database. + <:subtitle> + {gettext("Use this form to manage property_type records in your database.")} + <.form for={@form} id="property_type-form" phx-change="validate" phx-submit="save"> - <.input field={@form[:name]} type="text" label="Name" /><.input + <.input field={@form[:name]} type="text" label={gettext("Name")} /> + <.input field={@form[:value_type]} type="select" - label="Value type" + label={gettext("Value type")} options={ Ash.Resource.Info.attribute(Mv.Membership.PropertyType, :value_type).constraints[:one_of] } /> - <.input field={@form[:description]} type="text" label="Description" /><.input - field={@form[:immutable]} - type="checkbox" - label="Immutable" - /><.input field={@form[:required]} type="checkbox" label="Required" /> + <.input field={@form[:description]} type="text" label={gettext("Description")} /> + <.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} /> + <.input field={@form[:required]} type="checkbox" label={gettext("Required")} /> - <.button phx-disable-with="Saving..." variant="primary">Save Property type - <.button navigate={return_path(@return_to, @property_type)}>Cancel + <.button phx-disable-with={gettext("Saving...")} variant="primary"> + {gettext("Save Property type")} + + <.button navigate={return_path(@return_to, @property_type)}>{gettext("Cancel")} """ @@ -65,9 +68,16 @@ defmodule MvWeb.PropertyTypeLive.Form do {:ok, property_type} -> notify_parent({:saved, property_type}) + action = + case socket.assigns.form.source.type do + :create -> gettext("create") + :update -> gettext("update") + other -> to_string(other) + end + socket = socket - |> put_flash(:info, "Property type #{socket.assigns.form.source.type}d successfully") + |> put_flash(:info, gettext("Property type %{action} successfully", action: action)) |> push_navigate(to: return_path(socket.assigns.return_to, property_type)) {:noreply, socket} diff --git a/lib/mv_web/member_live/form_component.ex b/lib/mv_web/member_live/form_component.ex deleted file mode 100644 index 5535d1a..0000000 --- a/lib/mv_web/member_live/form_component.ex +++ /dev/null @@ -1,178 +0,0 @@ -defmodule MvWeb.MemberLive.FormComponent do - use MvWeb, :live_component - - @impl true - def mount(socket) do - {:ok, property_types} = Mv.Membership.list_property_types() - - initial_properties = - Enum.map(property_types, fn pt -> - %{ - "property_type_id" => pt.id, - "value" => %{ - "type" => pt.value_type, - "value" => nil, - "_union_type" => Atom.to_string(pt.value_type) - } - } - end) - - {:ok, assign(socket, property_types: property_types, initial_properties: initial_properties)} - end - - @impl true - def render(assigns) do - ~H""" -
    - <.header> - {@title} - <:subtitle> - {gettext("Use this form to manage member records and their properties.")} - - - - <.simple_form - for={@form} - id="member-form" - phx-target={@myself} - phx-change="validate" - phx-submit="save" - > - <.input field={@form[:first_name]} label={gettext("First Name")} required /> - <.input field={@form[:last_name]} label={gettext("Last Name")} required /> - <.input field={@form[:email]} label={gettext("Email")} required type="email" /> - <.input field={@form[:birth_date]} label={gettext("Birth Date")} type="date" /> - <.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" /> - <.input field={@form[:phone_number]} label={gettext("Phone Number")} /> - <.input field={@form[:join_date]} label={gettext("Join Date")} type="date" /> - <.input field={@form[:exit_date]} label={gettext("Exit Date")} type="date" /> - <.input field={@form[:notes]} label={gettext("Notes")} /> - <.input field={@form[:city]} label={gettext("City")} /> - <.input field={@form[:street]} label={gettext("Street")} /> - <.input field={@form[:house_number]} label={gettext("House Number")} /> - <.input field={@form[:postal_code]} label={gettext("Postal Code")} /> - -

    {gettext("Custom Properties")}

    - <.inputs_for :let={f_property} field={@form[:properties]}> - <% type = Enum.find(@property_types, &(&1.id == f_property[:property_type_id].value)) %> - <.inputs_for :let={value_form} field={f_property[:value]}> - <% input_type = - cond do - type && type.value_type == :boolean -> "checkbox" - type && type.value_type == :date -> :date - true -> :text - end %> - <.input field={value_form[:value]} label={type && type.name} type={input_type} /> - - - - - <:actions> - <.button phx-disable-with={gettext("Saving...")}>{gettext("Save Member")} - - -
    - """ - end - - @impl true - def update(assigns, socket) do - {:ok, - socket - |> assign(assigns) - |> assign_form()} - end - - @impl true - def handle_event("validate", %{"member" => member_params}, socket) do - {:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, member_params))} - end - - def handle_event("save", %{"member" => member_params}, socket) do - case AshPhoenix.Form.submit(socket.assigns.form, params: member_params) do - {:ok, member} -> - notify_parent({:saved, member}) - - action = - case socket.assigns.form.source.type do - :create -> gettext("create") - :update -> gettext("update") - other -> to_string(other) - end - - socket = - socket - |> put_flash(:info, gettext("Member %{action} successfully", action: action)) - |> push_patch(to: socket.assigns.patch) - - {:noreply, socket} - - {:error, form} -> - {:noreply, assign(socket, form: form)} - end - end - - defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) - - defp assign_form(%{assigns: %{member: member}} = socket) do - form = - if member do - {:ok, member} = Ash.load(member, properties: [:property_type]) - - existing_properties = - member.properties - |> Enum.map(& &1.property_type_id) - - is_missing_property = fn i -> - not Enum.member?(existing_properties, Map.get(i, "property_type_id")) - end - - params = %{ - "properties" => - Enum.map(member.properties, fn prop -> - %{ - "property_type_id" => prop.property_type_id, - "value" => %{ - "_union_type" => Atom.to_string(prop.value.type), - "type" => prop.value.type, - "value" => prop.value.value - } - } - end) - } - - form = - AshPhoenix.Form.for_update( - member, - :update_member, - api: Mv.Membership, - as: "member", - params: params, - forms: [auto?: true] - ) - - missing_properties = Enum.filter(socket.assigns[:initial_properties], is_missing_property) - - Enum.reduce( - missing_properties, - form, - &AshPhoenix.Form.add_form(&2, [:properties], params: &1) - ) - else - AshPhoenix.Form.for_create( - Mv.Membership.Member, - :create_member, - api: Mv.Membership, - as: "member", - params: %{"properties" => socket.assigns[:initial_properties]}, - forms: [auto?: true] - ) - end - - assign(socket, form: to_form(form)) - end -end diff --git a/lib/mv_web/member_live/index.ex b/lib/mv_web/member_live/index.ex deleted file mode 100644 index 452ebab..0000000 --- a/lib/mv_web/member_live/index.ex +++ /dev/null @@ -1,104 +0,0 @@ -defmodule MvWeb.MemberLive.Index do - use MvWeb, :live_view - - @impl true - def render(assigns) do - ~H""" - <.header> - {gettext("Listing Members")} - <:actions> - <.link patch={~p"/members/new"}> - <.button>{gettext("New Member")} - - - - - <.table - id="members" - rows={@streams.members} - row_click={fn {_id, member} -> JS.navigate(~p"/members/#{member}") end} - > - - <:col :let={{_id, member}} label={gettext("First Name")}>{member.first_name} - <:col :let={{_id, member}} label={gettext("Last Name")}>{member.last_name} - <:col :let={{_id, member}} label={gettext("Email")}>{member.email} - <:col :let={{_id, member}} label={gettext("City")}>{member.city} - <:col :let={{_id, member}} label={gettext("Join Date")}>{member.join_date} - - <:action :let={{_id, member}}> -
    - <.link navigate={~p"/members/#{member}"}>{gettext("Show")} -
    - - <.link patch={~p"/members/#{member}/edit"}>{gettext("Edit")} - - - <:action :let={{id, member}}> - <.link - phx-click={JS.push("delete", value: %{id: member.id}) |> hide("##{id}")} - data-confirm={gettext("Are you sure?")} - > - {gettext("Delete")} - - - - - <.modal - :if={@live_action in [:new, :edit]} - id="member-modal" - show - on_cancel={JS.patch(~p"/members")} - > - <.live_component - module={MvWeb.MemberLive.FormComponent} - id={(@member && @member.id) || :new} - title={@page_title} - action={@live_action} - member={@member} - patch={~p"/members"} - /> - - """ - end - - @impl true - def mount(_params, _session, socket) do - {:ok, stream(socket, :members, Ash.read!(Mv.Membership.Member))} - end - - @impl true - def handle_params(params, _url, socket) do - {:noreply, apply_action(socket, socket.assigns.live_action, params)} - end - - defp apply_action(socket, :edit, %{"id" => id}) do - socket - |> assign(:page_title, gettext("Edit Member")) - |> assign(:member, Ash.get!(Mv.Membership.Member, id)) - end - - defp apply_action(socket, :new, _params) do - socket - |> assign(:page_title, gettext("New Member")) - |> assign(:member, nil) - end - - defp apply_action(socket, :index, _params) do - socket - |> assign(:page_title, gettext("Listing Members")) - |> assign(:member, nil) - end - - @impl true - def handle_info({MvWeb.MemberLive.FormComponent, {:saved, member}}, socket) do - {:noreply, stream_insert(socket, :members, member)} - end - - @impl true - def handle_event("delete", %{"id" => id}, socket) do - member = Ash.get!(Mv.Membership.Member, id) - Ash.destroy!(member) - - {:noreply, stream_delete(socket, :members, member)} - end -end diff --git a/lib/mv_web/member_live/show.ex b/lib/mv_web/member_live/show.ex deleted file mode 100644 index 612abd6..0000000 --- a/lib/mv_web/member_live/show.ex +++ /dev/null @@ -1,94 +0,0 @@ -defmodule MvWeb.MemberLive.Show do - use MvWeb, :live_view - import Ash.Query - - @impl true - def render(assigns) do - ~H""" - <.header> - {@member.first_name} {@member.last_name} - <:subtitle>{gettext("This is a member record from your database.")} - - <:actions> - <.link patch={~p"/members/#{@member}/show/edit"} phx-click={JS.push_focus()}> - <.button>{gettext("Edit member")} - - - - - <.list> - <:item title={gettext("Id")}>{@member.id} - <:item title={gettext("First Name")}>{@member.first_name} - <:item title={gettext("Last Name")}>{@member.last_name} - <:item title={gettext("Email")}>{@member.email} - <:item title={gettext("Birth Date")}>{@member.birth_date} - <:item title={gettext("Paid")}> - {if @member.paid, do: gettext("Yes"), else: gettext("No")} - - <:item title={gettext("Phone Number")}>{@member.phone_number} - <:item title={gettext("Join Date")}>{@member.join_date} - <:item title={gettext("Exit Date")}>{@member.exit_date} - <:item title={gettext("Notes")}>{@member.notes} - <:item title={gettext("City")}>{@member.city} - <:item title={gettext("Street")}>{@member.street} - <:item title={gettext("House Number")}>{@member.house_number} - <:item title={gettext("Postal Code")}>{@member.postal_code} - - -

    {gettext("Custom Properties")}

    - <.generic_list items={ - Enum.map(@member.properties, fn p -> - { - # name - p.property_type && p.property_type.name, - # value - case p.value do - %{value: v} -> v - v -> v - end - } - end) - } /> - <.back navigate={~p"/members"}>{gettext("Back to members")} - - <.modal - :if={@live_action == :edit} - id="member-modal" - show - on_cancel={JS.patch(~p"/members/#{@member}")} - > - <.live_component - module={MvWeb.MemberLive.FormComponent} - id={@member.id} - title={@page_title} - action={@live_action} - member={@member} - patch={~p"/members/#{@member}"} - /> - - """ - end - - @impl true - def mount(_params, _session, socket) do - {:ok, socket} - end - - @impl true - def handle_params(%{"id" => id}, _, socket) do - query = - Mv.Membership.Member - |> filter(id == ^id) - |> load(properties: [:property_type]) - - member = Ash.read_one!(query) - - {:noreply, - socket - |> assign(:page_title, page_title(socket.assigns.live_action)) - |> assign(:member, member)} - end - - defp page_title(:show), do: gettext("Show Member") - defp page_title(:edit), do: gettext("Edit Member") -end diff --git a/lib/mv_web/property_live/form_component.ex b/lib/mv_web/property_live/form_component.ex deleted file mode 100644 index 73461be..0000000 --- a/lib/mv_web/property_live/form_component.ex +++ /dev/null @@ -1,77 +0,0 @@ -defmodule MvWeb.PropertyLive.FormComponent do - use MvWeb, :live_component - - @impl true - def render(assigns) do - ~H""" -
    - <.header> - {@title} - <:subtitle>Use this form to manage property records in your database. - - - <.simple_form - for={@form} - id="property-form" - phx-target={@myself} - phx-change="validate" - phx-submit="save" - > - <.input field={@form[:value]} type="text" label="Value" /><.input - field={@form[:member_id]} - type="text" - label="Member" - /><.input field={@form[:property_type_id]} type="text" label="Property type" /> - - <:actions> - <.button phx-disable-with="Saving...">Save Property - - -
    - """ - end - - @impl true - def update(assigns, socket) do - {:ok, - socket - |> assign(assigns) - |> assign_form()} - end - - @impl true - def handle_event("validate", %{"property" => property_params}, socket) do - {:noreply, - assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, property_params))} - end - - def handle_event("save", %{"property" => property_params}, socket) do - case AshPhoenix.Form.submit(socket.assigns.form, params: property_params) do - {:ok, property} -> - notify_parent({:saved, property}) - - socket = - socket - |> put_flash(:info, "Property #{socket.assigns.form.source.type}d successfully") - |> push_patch(to: socket.assigns.patch) - - {:noreply, socket} - - {:error, form} -> - {:noreply, assign(socket, form: form)} - end - end - - defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) - - defp assign_form(%{assigns: %{property: property}} = socket) do - form = - if property do - AshPhoenix.Form.for_update(property, :update, as: "property") - else - AshPhoenix.Form.for_create(Mv.Membership.Property, :create, as: "property") - end - - assign(socket, form: to_form(form)) - end -end diff --git a/lib/mv_web/property_live/index.ex b/lib/mv_web/property_live/index.ex deleted file mode 100644 index 4e43aee..0000000 --- a/lib/mv_web/property_live/index.ex +++ /dev/null @@ -1,99 +0,0 @@ -defmodule MvWeb.PropertyLive.Index do - use MvWeb, :live_view - - @impl true - def render(assigns) do - ~H""" - <.header> - Listing Properties - <:actions> - <.link patch={~p"/properties/new"}> - <.button>New Property - - - - - <.table - id="properties" - rows={@streams.properties} - row_click={fn {_id, property} -> JS.navigate(~p"/properties/#{property}") end} - > - <:col :let={{_id, property}} label="Id">{property.id} - - <:action :let={{_id, property}}> -
    - <.link navigate={~p"/properties/#{property}"}>Show -
    - - <.link patch={~p"/properties/#{property}/edit"}>Edit - - - <:action :let={{id, property}}> - <.link - phx-click={JS.push("delete", value: %{id: property.id}) |> hide("##{id}")} - data-confirm="Are you sure?" - > - Delete - - - - - <.modal - :if={@live_action in [:new, :edit]} - id="property-modal" - show - on_cancel={JS.patch(~p"/properties")} - > - <.live_component - module={MvWeb.PropertyLive.FormComponent} - id={(@property && @property.id) || :new} - title={@page_title} - action={@live_action} - property={@property} - patch={~p"/properties"} - /> - - """ - end - - @impl true - def mount(_params, _session, socket) do - {:ok, stream(socket, :properties, Ash.read!(Mv.Membership.Property))} - end - - @impl true - def handle_params(params, _url, socket) do - {:noreply, apply_action(socket, socket.assigns.live_action, params)} - end - - defp apply_action(socket, :edit, %{"id" => id}) do - socket - |> assign(:page_title, "Edit Property") - |> assign(:property, Ash.get!(Mv.Membership.Property, id)) - end - - defp apply_action(socket, :new, _params) do - socket - |> assign(:page_title, "New Property") - |> assign(:property, nil) - end - - defp apply_action(socket, :index, _params) do - socket - |> assign(:page_title, "Listing Properties") - |> assign(:property, nil) - end - - @impl true - def handle_info({MvWeb.PropertyLive.FormComponent, {:saved, property}}, socket) do - {:noreply, stream_insert(socket, :properties, property)} - end - - @impl true - def handle_event("delete", %{"id" => id}, socket) do - property = Ash.get!(Mv.Membership.Property, id) - Ash.destroy!(property) - - {:noreply, stream_delete(socket, :properties, property)} - end -end diff --git a/lib/mv_web/property_live/show.ex b/lib/mv_web/property_live/show.ex deleted file mode 100644 index e1a5a65..0000000 --- a/lib/mv_web/property_live/show.ex +++ /dev/null @@ -1,57 +0,0 @@ -defmodule MvWeb.PropertyLive.Show do - use MvWeb, :live_view - - @impl true - def render(assigns) do - ~H""" - <.header> - Property {@property.id} - <:subtitle>This is a property record from your database. - - <:actions> - <.link patch={~p"/properties/#{@property}/show/edit"} phx-click={JS.push_focus()}> - <.button>Edit property - - - - - <.list> - <:item title="Id">{@property.id} - - - <.back navigate={~p"/properties"}>Back to properties - - <.modal - :if={@live_action == :edit} - id="property-modal" - show - on_cancel={JS.patch(~p"/properties/#{@property}")} - > - <.live_component - module={MvWeb.PropertyLive.FormComponent} - id={@property.id} - title={@page_title} - action={@live_action} - property={@property} - patch={~p"/properties/#{@property}"} - /> - - """ - end - - @impl true - def mount(_params, _session, socket) do - {:ok, socket} - end - - @impl true - def handle_params(%{"id" => id}, _, socket) do - {:noreply, - socket - |> assign(:page_title, page_title(socket.assigns.live_action)) - |> assign(:property, Ash.get!(Mv.Membership.Property, id))} - end - - defp page_title(:show), do: "Show Property" - defp page_title(:edit), do: "Edit Property" -end diff --git a/lib/mv_web/property_type_live/form_component.ex b/lib/mv_web/property_type_live/form_component.ex deleted file mode 100644 index 1504f2a..0000000 --- a/lib/mv_web/property_type_live/form_component.ex +++ /dev/null @@ -1,81 +0,0 @@ -defmodule MvWeb.PropertyTypeLive.FormComponent do - use MvWeb, :live_component - - @impl true - def render(assigns) do - ~H""" -
    - <.header> - {@title} - <:subtitle>Use this form to manage property_type records in your database. - - - <.simple_form - for={@form} - id="property_type-form" - phx-target={@myself} - phx-change="validate" - phx-submit="save" - > - <.input field={@form[:name]} type="text" label="Name" /><.input - field={@form[:type]} - type="text" - label="Type" - /><.input field={@form[:description]} type="text" label="Description" /><.input - field={@form[:immutable]} - type="checkbox" - label="Immutable" - /><.input field={@form[:required]} type="checkbox" label="Required" /> - - <:actions> - <.button phx-disable-with="Saving...">Save Property type - - -
    - """ - end - - @impl true - def update(assigns, socket) do - {:ok, - socket - |> assign(assigns) - |> assign_form()} - end - - @impl true - def handle_event("validate", %{"property_type" => property_type_params}, socket) do - {:noreply, - assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, property_type_params))} - end - - def handle_event("save", %{"property_type" => property_type_params}, socket) do - case AshPhoenix.Form.submit(socket.assigns.form, params: property_type_params) do - {:ok, property_type} -> - notify_parent({:saved, property_type}) - - socket = - socket - |> put_flash(:info, "Property type #{socket.assigns.form.source.type}d successfully") - |> push_patch(to: socket.assigns.patch) - - {:noreply, socket} - - {:error, form} -> - {:noreply, assign(socket, form: form)} - end - end - - defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) - - defp assign_form(%{assigns: %{property_type: property_type}} = socket) do - form = - if property_type do - AshPhoenix.Form.for_update(property_type, :update, as: "property_type") - else - AshPhoenix.Form.for_create(Mv.Membership.PropertyType, :create, as: "property_type") - end - - assign(socket, form: to_form(form)) - end -end diff --git a/lib/mv_web/property_type_live/index.ex b/lib/mv_web/property_type_live/index.ex deleted file mode 100644 index f100fae..0000000 --- a/lib/mv_web/property_type_live/index.ex +++ /dev/null @@ -1,103 +0,0 @@ -defmodule MvWeb.PropertyTypeLive.Index do - use MvWeb, :live_view - - @impl true - def render(assigns) do - ~H""" - <.header> - Listing Property types - <:actions> - <.link patch={~p"/property_types/new"}> - <.button>New Property type - - - - - <.table - id="property_types" - rows={@streams.property_types} - row_click={fn {_id, property_type} -> JS.navigate(~p"/property_types/#{property_type}") end} - > - <:col :let={{_id, property_type}} label="Id">{property_type.id} - - <:col :let={{_id, property_type}} label="Name">{property_type.name} - - <:col :let={{_id, property_type}} label="Description">{property_type.description} - - <:action :let={{_id, property_type}}> -
    - <.link navigate={~p"/property_types/#{property_type}"}>Show -
    - - <.link patch={~p"/property_types/#{property_type}/edit"}>Edit - - - <:action :let={{id, property_type}}> - <.link - phx-click={JS.push("delete", value: %{id: property_type.id}) |> hide("##{id}")} - data-confirm="Are you sure?" - > - Delete - - - - - <.modal - :if={@live_action in [:new, :edit]} - id="property_type-modal" - show - on_cancel={JS.patch(~p"/property_types")} - > - <.live_component - module={MvWeb.PropertyTypeLive.FormComponent} - id={(@property_type && @property_type.id) || :new} - title={@page_title} - action={@live_action} - property_type={@property_type} - patch={~p"/property_types"} - /> - - """ - end - - @impl true - def mount(_params, _session, socket) do - {:ok, stream(socket, :property_types, Ash.read!(Mv.Membership.PropertyType))} - end - - @impl true - def handle_params(params, _url, socket) do - {:noreply, apply_action(socket, socket.assigns.live_action, params)} - end - - defp apply_action(socket, :edit, %{"id" => id}) do - socket - |> assign(:page_title, "Edit Property type") - |> assign(:property_type, Ash.get!(Mv.Membership.PropertyType, id)) - end - - defp apply_action(socket, :new, _params) do - socket - |> assign(:page_title, "New Property type") - |> assign(:property_type, nil) - end - - defp apply_action(socket, :index, _params) do - socket - |> assign(:page_title, "Listing Property types") - |> assign(:property_type, nil) - end - - @impl true - def handle_info({MvWeb.PropertyTypeLive.FormComponent, {:saved, property_type}}, socket) do - {:noreply, stream_insert(socket, :property_types, property_type)} - end - - @impl true - def handle_event("delete", %{"id" => id}, socket) do - property_type = Ash.get!(Mv.Membership.PropertyType, id) - Ash.destroy!(property_type) - - {:noreply, stream_delete(socket, :property_types, property_type)} - end -end diff --git a/lib/mv_web/property_type_live/show.ex b/lib/mv_web/property_type_live/show.ex deleted file mode 100644 index b39021c..0000000 --- a/lib/mv_web/property_type_live/show.ex +++ /dev/null @@ -1,61 +0,0 @@ -defmodule MvWeb.PropertyTypeLive.Show do - use MvWeb, :live_view - - @impl true - def render(assigns) do - ~H""" - <.header> - Property type {@property_type.id} - <:subtitle>This is a property_type record from your database. - - <:actions> - <.link patch={~p"/property_types/#{@property_type}/show/edit"} phx-click={JS.push_focus()}> - <.button>Edit property_type - - - - - <.list> - <:item title="Id">{@property_type.id} - - <:item title="Name">{@property_type.name} - - <:item title="Description">{@property_type.description} - - - <.back navigate={~p"/property_types"}>Back to property_types - - <.modal - :if={@live_action == :edit} - id="property_type-modal" - show - on_cancel={JS.patch(~p"/property_types/#{@property_type}")} - > - <.live_component - module={MvWeb.PropertyTypeLive.FormComponent} - id={@property_type.id} - title={@page_title} - action={@live_action} - property_type={@property_type} - patch={~p"/property_types/#{@property_type}"} - /> - - """ - end - - @impl true - def mount(_params, _session, socket) do - {:ok, socket} - end - - @impl true - def handle_params(%{"id" => id}, _, socket) do - {:noreply, - socket - |> assign(:page_title, page_title(socket.assigns.live_action)) - |> assign(:property_type, Ash.get!(Mv.Membership.PropertyType, id))} - end - - defp page_title(:show), do: "Show Property type" - defp page_title(:edit), do: "Edit Property type" -end diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index 595a2da..75210b0 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -50,20 +50,20 @@ defmodule MvWeb.Router do get "/", PageController, :home live "/members", MemberLive.Index, :index - live "/members/new", MemberLive.Index, :new - live "/members/:id/edit", MemberLive.Index, :edit + live "/members/new", MemberLive.Form, :new + live "/members/:id/edit", MemberLive.Form, :edit live "/members/:id", MemberLive.Show, :show live "/members/:id/show/edit", MemberLive.Show, :edit live "/property_types", PropertyTypeLive.Index, :index - live "/property_types/new", PropertyTypeLive.Index, :new - live "/property_types/:id/edit", PropertyTypeLive.Index, :edit + live "/property_types/new", PropertyTypeLive.Form, :new + live "/property_types/:id/edit", PropertyTypeLive.Form, :edit live "/property_types/:id", PropertyTypeLive.Show, :show live "/property_types/:id/show/edit", PropertyTypeLive.Show, :edit live "/properties", PropertyLive.Index, :index - live "/properties/new", PropertyLive.Index, :new - live "/properties/:id/edit", PropertyLive.Index, :edit + live "/properties/new", PropertyLive.Form, :new + live "/properties/:id/edit", PropertyLive.Form, :edit live "/properties/:id", PropertyLive.Show, :show live "/properties/:id/show/edit", PropertyLive.Show, :edit From 5761e5ee1f98a60988eff5063c699b891f27db6d Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 17 Jul 2025 16:11:49 +0200 Subject: [PATCH 4/6] feat: migration to phoenix 1.8 - fix PropertyLive.Form --- lib/mv_web/live/property_live/form.ex | 171 ++++++++++++++++++++++++-- 1 file changed, 164 insertions(+), 7 deletions(-) diff --git a/lib/mv_web/live/property_live/form.ex b/lib/mv_web/live/property_live/form.ex index 2987b3e..3535987 100644 --- a/lib/mv_web/live/property_live/form.ex +++ b/lib/mv_web/live/property_live/form.ex @@ -11,9 +11,35 @@ defmodule MvWeb.PropertyLive.Form do <.form for={@form} id="property-form" phx-change="validate" phx-submit="save"> - <.input field={@form[:value]} type="text" label={gettext("Value")} /> - <.input field={@form[:member_id]} type="text" label={gettext("Member")} /> - <.input field={@form[:property_type_id]} type="text" label={gettext("Property type")} /> + + <.input + field={@form[:property_type_id]} + type="select" + label={gettext("Property type")} + options={property_type_options(@property_types)} + prompt={gettext("Choose a property type")} + /> + + + <.input + field={@form[:member_id]} + type="select" + label={gettext("Member")} + options={member_options(@members)} + prompt={gettext("Choose a member")} + /> + + + <%= if @selected_property_type do %> + <.union_value_input + form={@form} + property_type={@selected_property_type} + /> + <% else %> +
    + {gettext("Please select a property type first")} +
    + <% end %> <.button phx-disable-with={gettext("Saving...")} variant="primary"> {gettext("Save Property")} @@ -24,22 +50,106 @@ defmodule MvWeb.PropertyLive.Form do """ end + # Helper function for Union-Value Input + defp union_value_input(assigns) do + # Extract the current value from the Property + current_value = extract_current_value(assigns.form.data, assigns.property_type.value_type) + assigns = assign(assigns, :current_value, current_value) + + ~H""" +
    + + + <%= case @property_type.value_type do %> + <% :string -> %> + <.inputs_for :let={value_form} field={@form[:value]}> + <.input field={value_form[:value]} type="text" label="" value={@current_value} /> + + + + <% :integer -> %> + <.inputs_for :let={value_form} field={@form[:value]}> + <.input field={value_form[:value]} type="number" label="" value={@current_value} /> + + + + <% :boolean -> %> + <.inputs_for :let={value_form} field={@form[:value]}> + <.input field={value_form[:value]} type="checkbox" label="" checked={@current_value} /> + + + + <% :date -> %> + <.inputs_for :let={value_form} field={@form[:value]}> + <.input field={value_form[:value]} type="date" label="" value={format_date_value(@current_value)} /> + + + + <% :email -> %> + <.inputs_for :let={value_form} field={@form[:value]}> + <.input field={value_form[:value]} type="email" label="" value={@current_value} /> + + + + <% _ -> %> +
    + {gettext("Unsupported value type: %{type}", type: @property_type.value_type)} +
    + <% end %> +
    + """ + end + + # Helper function to extract the current value from the Property + defp extract_current_value(%Mv.Membership.Property{value: %Ash.Union{value: value}}, _value_type) do + value + end + + defp extract_current_value(_data, _value_type) do + nil + end + + # Helper function to format Date values for HTML input + defp format_date_value(%Date{} = date) do + Date.to_iso8601(date) + end + + defp format_date_value(nil), do: "" + + defp format_date_value(date) when is_binary(date) do + case Date.from_iso8601(date) do + {:ok, parsed_date} -> Date.to_iso8601(parsed_date) + _ -> "" + end + end + + defp format_date_value(_), do: "" + @impl true def mount(params, _session, socket) do property = case params["id"] do nil -> nil - id -> Ash.get!(Mv.Membership.Property, id) + id -> Ash.get!(Mv.Membership.Property, id) |> Ash.load!([:property_type]) end action = if is_nil(property), do: "New", else: "Edit" page_title = action <> " " <> "Property" + # Load all PropertyTypes and Members for the selection fields + property_types = Ash.read!(Mv.Membership.PropertyType) + members = Ash.read!(Mv.Membership.Member) + {:ok, socket |> assign(:return_to, return_to(params["return_to"])) |> assign(property: property) |> assign(:page_title, page_title) + |> assign(:property_types, property_types) + |> assign(:members, members) + |> assign(:selected_property_type, property && property.property_type) |> assign_form()} end @@ -48,12 +158,40 @@ defmodule MvWeb.PropertyLive.Form do @impl true def handle_event("validate", %{"property" => property_params}, socket) do + # Find the selected PropertyType + selected_property_type = + case property_params["property_type_id"] do + "" -> nil + nil -> nil + id -> Enum.find(socket.assigns.property_types, &(&1.id == id)) + end + + # Set the Union type based on the selected PropertyType + updated_params = + if selected_property_type do + union_type = to_string(selected_property_type.value_type) + put_in(property_params, ["value", "_union_type"], union_type) + else + property_params + end + {:noreply, - assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, property_params))} + socket + |> assign(:selected_property_type, selected_property_type) + |> assign(form: AshPhoenix.Form.validate(socket.assigns.form, updated_params))} end def handle_event("save", %{"property" => property_params}, socket) do - case AshPhoenix.Form.submit(socket.assigns.form, params: property_params) do + # Set the Union type based on the selected PropertyType + updated_params = + if socket.assigns.selected_property_type do + union_type = to_string(socket.assigns.selected_property_type.value_type) + put_in(property_params, ["value", "_union_type"], union_type) + else + property_params + end + + case AshPhoenix.Form.submit(socket.assigns.form, params: updated_params) do {:ok, property} -> notify_parent({:saved, property}) @@ -81,7 +219,17 @@ defmodule MvWeb.PropertyLive.Form do defp assign_form(%{assigns: %{property: property}} = socket) do form = if property do - AshPhoenix.Form.for_update(property, :update, as: "property") + # Determine the Union type based on the property_type + union_type = property.property_type && property.property_type.value_type + + params = + if union_type do + %{"value" => %{"_union_type" => to_string(union_type)}} + else + %{} + end + + AshPhoenix.Form.for_update(property, :update, as: "property", params: params) else AshPhoenix.Form.for_create(Mv.Membership.Property, :create, as: "property") end @@ -91,4 +239,13 @@ defmodule MvWeb.PropertyLive.Form do defp return_path("index", _property), do: ~p"/properties" defp return_path("show", property), do: ~p"/properties/#{property.id}" + + # Helper functions for selection options + defp property_type_options(property_types) do + Enum.map(property_types, &{&1.name, &1.id}) + end + + defp member_options(members) do + Enum.map(members, &{"#{&1.first_name} #{&1.last_name}", &1.id}) + end end From 51a41e17ba51462d283bde1d2fbac6950fd2aaeb Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 17 Jul 2025 17:41:28 +0200 Subject: [PATCH 5/6] feat: migration to phoenix 1.8 - fix tests broken by redirects --- test/mv_web/member_live/index_test.exs | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index ce47a43..75d7efb 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -35,8 +35,7 @@ defmodule MvWeb.MemberLive.IndexTest do test "shows translated flash message after creating a member in German", %{conn: conn} do conn = conn_with_oidc_user(conn) conn = Plug.Test.init_test_session(conn, locale: "de") - {:ok, view, _html} = live(conn, "/members") - view |> element("a", "Neues Mitglied") |> render_click() + {:ok, form_view, _html} = live(conn, "/members/new") form_data = %{ "member[first_name]" => "Max", @@ -44,15 +43,20 @@ defmodule MvWeb.MemberLive.IndexTest do "member[email]" => "max@example.com" } - view |> form("#member-form", form_data) |> render_submit() - assert has_element?(view, "#flash-group", "Mitglied erstellt erfolgreich") + # Submit form and follow the redirect to get the flash message + {:ok, index_view, _html} = + form_view + |> form("#member-form", form_data) + |> render_submit() + |> follow_redirect(conn, "/members") + + assert has_element?(index_view, "#flash-group", "Mitglied erstellt erfolgreich") end test "shows translated flash message after creating a member in English", %{conn: conn} do conn = conn_with_oidc_user(conn) conn = Plug.Test.init_test_session(conn, locale: "en") - {:ok, view, _html} = live(conn, "/members") - view |> element("a", "New Member") |> render_click() + {:ok, form_view, _html} = live(conn, "/members/new") form_data = %{ "member[first_name]" => "Max", @@ -60,7 +64,13 @@ defmodule MvWeb.MemberLive.IndexTest do "member[email]" => "max@example.com" } - view |> form("#member-form", form_data) |> render_submit() - assert has_element?(view, "#flash-group", "Member create successfully") + # Submit form and follow the redirect to get the flash message + {:ok, index_view, _html} = + form_view + |> form("#member-form", form_data) + |> render_submit() + |> follow_redirect(conn, "/members") + + assert has_element?(index_view, "#flash-group", "Member create successfully") end end From 72bb6ae2fe42f2ce35a2dffec71bf56a2b2f62bc Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 17 Jul 2025 17:43:07 +0200 Subject: [PATCH 6/6] feat: migration to phoenix 1.8 - fix formatting --- lib/mv_web.ex | 2 +- lib/mv_web/live/property_live/form.ex | 64 +++++++++++++------------- test/mv_web/member_live/index_test.exs | 8 ++-- 3 files changed, 37 insertions(+), 37 deletions(-) diff --git a/lib/mv_web.ex b/lib/mv_web.ex index f743377..46e4e8b 100644 --- a/lib/mv_web.ex +++ b/lib/mv_web.ex @@ -51,7 +51,7 @@ defmodule MvWeb do def live_view do quote do use Phoenix.LiveView - + on_mount MvWeb.LiveHelpers unquote(html_helpers()) diff --git a/lib/mv_web/live/property_live/form.ex b/lib/mv_web/live/property_live/form.ex index 3535987..42814a3 100644 --- a/lib/mv_web/live/property_live/form.ex +++ b/lib/mv_web/live/property_live/form.ex @@ -12,29 +12,26 @@ defmodule MvWeb.PropertyLive.Form do <.form for={@form} id="property-form" phx-change="validate" phx-submit="save"> - <.input - field={@form[:property_type_id]} - type="select" + <.input + field={@form[:property_type_id]} + type="select" label={gettext("Property type")} options={property_type_options(@property_types)} prompt={gettext("Choose a property type")} /> - - <.input - field={@form[:member_id]} - type="select" + + <.input + field={@form[:member_id]} + type="select" label={gettext("Member")} options={member_options(@members)} prompt={gettext("Choose a member")} /> - - + + <%= if @selected_property_type do %> - <.union_value_input - form={@form} - property_type={@selected_property_type} - /> + <.union_value_input form={@form} property_type={@selected_property_type} /> <% else %>
    {gettext("Please select a property type first")} @@ -55,44 +52,44 @@ defmodule MvWeb.PropertyLive.Form do # Extract the current value from the Property current_value = extract_current_value(assigns.form.data, assigns.property_type.value_type) assigns = assign(assigns, :current_value, current_value) - + ~H"""
    - + <%= case @property_type.value_type do %> <% :string -> %> <.inputs_for :let={value_form} field={@form[:value]}> <.input field={value_form[:value]} type="text" label="" value={@current_value} /> - <% :integer -> %> <.inputs_for :let={value_form} field={@form[:value]}> <.input field={value_form[:value]} type="number" label="" value={@current_value} /> - <% :boolean -> %> <.inputs_for :let={value_form} field={@form[:value]}> <.input field={value_form[:value]} type="checkbox" label="" checked={@current_value} /> - <% :date -> %> <.inputs_for :let={value_form} field={@form[:value]}> - <.input field={value_form[:value]} type="date" label="" value={format_date_value(@current_value)} /> + <.input + field={value_form[:value]} + type="date" + label="" + value={format_date_value(@current_value)} + /> - <% :email -> %> <.inputs_for :let={value_form} field={@form[:value]}> <.input field={value_form[:value]} type="email" label="" value={@current_value} /> - <% _ -> %>
    {gettext("Unsupported value type: %{type}", type: @property_type.value_type)} @@ -103,10 +100,13 @@ defmodule MvWeb.PropertyLive.Form do end # Helper function to extract the current value from the Property - defp extract_current_value(%Mv.Membership.Property{value: %Ash.Union{value: value}}, _value_type) do + defp extract_current_value( + %Mv.Membership.Property{value: %Ash.Union{value: value}}, + _value_type + ) do value end - + defp extract_current_value(_data, _value_type) do nil end @@ -115,16 +115,16 @@ defmodule MvWeb.PropertyLive.Form do defp format_date_value(%Date{} = date) do Date.to_iso8601(date) end - + defp format_date_value(nil), do: "" - + defp format_date_value(date) when is_binary(date) do case Date.from_iso8601(date) do {:ok, parsed_date} -> Date.to_iso8601(parsed_date) _ -> "" end end - + defp format_date_value(_), do: "" @impl true @@ -159,7 +159,7 @@ defmodule MvWeb.PropertyLive.Form do @impl true def handle_event("validate", %{"property" => property_params}, socket) do # Find the selected PropertyType - selected_property_type = + selected_property_type = case property_params["property_type_id"] do "" -> nil nil -> nil @@ -167,7 +167,7 @@ defmodule MvWeb.PropertyLive.Form do end # Set the Union type based on the selected PropertyType - updated_params = + updated_params = if selected_property_type do union_type = to_string(selected_property_type.value_type) put_in(property_params, ["value", "_union_type"], union_type) @@ -183,7 +183,7 @@ defmodule MvWeb.PropertyLive.Form do def handle_event("save", %{"property" => property_params}, socket) do # Set the Union type based on the selected PropertyType - updated_params = + updated_params = if socket.assigns.selected_property_type do union_type = to_string(socket.assigns.selected_property_type.value_type) put_in(property_params, ["value", "_union_type"], union_type) @@ -221,14 +221,14 @@ defmodule MvWeb.PropertyLive.Form do if property do # Determine the Union type based on the property_type union_type = property.property_type && property.property_type.value_type - - params = + + params = if union_type do %{"value" => %{"_union_type" => to_string(union_type)}} else %{} end - + AshPhoenix.Form.for_update(property, :update, as: "property", params: params) else AshPhoenix.Form.for_create(Mv.Membership.Property, :create, as: "property") diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 75d7efb..e3e77dc 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -44,12 +44,12 @@ defmodule MvWeb.MemberLive.IndexTest do } # Submit form and follow the redirect to get the flash message - {:ok, index_view, _html} = + {:ok, index_view, _html} = form_view |> form("#member-form", form_data) |> render_submit() |> follow_redirect(conn, "/members") - + assert has_element?(index_view, "#flash-group", "Mitglied erstellt erfolgreich") end @@ -65,12 +65,12 @@ defmodule MvWeb.MemberLive.IndexTest do } # Submit form and follow the redirect to get the flash message - {:ok, index_view, _html} = + {:ok, index_view, _html} = form_view |> form("#member-form", form_data) |> render_submit() |> follow_redirect(conn, "/members") - + assert has_element?(index_view, "#flash-group", "Member create successfully") end end