From 2d15c177f5cf39b54acd0876d2bed7ed5b522ec2 Mon Sep 17 00:00:00 2001 From: karimsemmoud Date: Fri, 27 Feb 2026 22:04:43 +0700 Subject: [PATCH 01/21] add generators --- .gitignore | 6 + e2e/.gitignore | 1 + e2e/mix.exs | 2 +- guides/installation.md | 24 + lib/{ => corex}/content.ex | 0 lib/{ => corex}/flash.ex | 0 lib/{ => corex}/form.ex | 0 lib/{ => corex}/gettext.ex | 0 lib/{ => corex}/helpers.ex | 0 lib/corex/igniter.ex | 704 ++++++++++++++++++ lib/{ => corex}/json.ex | 0 lib/{ => corex}/list.ex | 0 lib/{ => corex}/positoning.ex | 0 lib/{ => corex}/tree.ex | 0 lib/corex_web/gettext.ex | 4 + lib/mix/tasks/corex.gen.auth.ex | 38 + lib/mix/tasks/corex.gen.html.ex | 38 + lib/mix/tasks/corex.gen.live.ex | 38 + lib/mix/tasks/corex.install.ex | 150 ++++ mix.exs | 10 +- mix.lock | 21 +- priv/gettext/en/LC_MESSAGES/errors.po | 3 + priv/gettext/errors.pot | 4 + priv/templates/phx.gen.auth/AGENTS.md | 57 ++ priv/templates/phx.gen.auth/auth.ex | 321 ++++++++ priv/templates/phx.gen.auth/auth_test.exs | 442 +++++++++++ .../phx.gen.auth/confirmation_live.ex | 94 +++ .../phx.gen.auth/confirmation_live_test.exs | 118 +++ priv/templates/phx.gen.auth/conn_case.exs | 41 + .../context_fixtures_functions.ex | 82 ++ .../phx.gen.auth/context_functions.ex | 288 +++++++ priv/templates/phx.gen.auth/login_live.ex | 134 ++++ .../phx.gen.auth/login_live_test.exs | 109 +++ priv/templates/phx.gen.auth/migration.ex | 32 + priv/templates/phx.gen.auth/notifier.ex | 84 +++ .../phx.gen.auth/registration_controller.ex | 32 + .../registration_controller_test.exs | 50 ++ .../phx.gen.auth/registration_html.ex | 5 + .../phx.gen.auth/registration_live.ex | 89 +++ .../phx.gen.auth/registration_live_test.exs | 82 ++ .../phx.gen.auth/registration_new.html.heex | 32 + priv/templates/phx.gen.auth/routes.ex | 42 ++ priv/templates/phx.gen.auth/schema.ex | 137 ++++ priv/templates/phx.gen.auth/schema_token.ex | 158 ++++ priv/templates/phx.gen.auth/scope.ex | 33 + .../phx.gen.auth/session_confirm.html.heex | 59 ++ .../phx.gen.auth/session_controller.ex | 143 ++++ .../phx.gen.auth/session_controller_test.exs | 224 ++++++ priv/templates/phx.gen.auth/session_html.ex | 9 + .../phx.gen.auth/session_new.html.heex | 73 ++ .../phx.gen.auth/settings_controller.ex | 77 ++ .../phx.gen.auth/settings_controller_test.exs | 148 ++++ .../phx.gen.auth/settings_edit.html.heex | 49 ++ priv/templates/phx.gen.auth/settings_html.ex | 5 + priv/templates/phx.gen.auth/settings_live.ex | 160 ++++ .../phx.gen.auth/settings_live_test.exs | 212 ++++++ priv/templates/phx.gen.auth/test_cases.exs | 391 ++++++++++ priv/templates/phx.gen.html/controller.ex | 67 ++ .../phx.gen.html/controller_test.exs | 90 +++ priv/templates/phx.gen.html/edit.html.heex | 8 + priv/templates/phx.gen.html/html.ex | 17 + priv/templates/phx.gen.html/index.html.heex | 25 + priv/templates/phx.gen.html/new.html.heex | 8 + .../phx.gen.html/resource_form.html.heex | 7 + priv/templates/phx.gen.html/show.html.heex | 18 + .../templates/phx.gen.live/core_components.ex | 513 +++++++++++++ priv/templates/phx.gen.live/form.ex | 98 +++ priv/templates/phx.gen.live/index.ex | 74 ++ priv/templates/phx.gen.live/live_test.exs | 128 ++++ priv/templates/phx.gen.live/show.ex | 64 ++ test/mix/tasks/corex.install_test.exs | 28 + 71 files changed, 6195 insertions(+), 5 deletions(-) rename lib/{ => corex}/content.ex (100%) rename lib/{ => corex}/flash.ex (100%) rename lib/{ => corex}/form.ex (100%) rename lib/{ => corex}/gettext.ex (100%) rename lib/{ => corex}/helpers.ex (100%) create mode 100644 lib/corex/igniter.ex rename lib/{ => corex}/json.ex (100%) rename lib/{ => corex}/list.ex (100%) rename lib/{ => corex}/positoning.ex (100%) rename lib/{ => corex}/tree.ex (100%) create mode 100644 lib/corex_web/gettext.ex create mode 100644 lib/mix/tasks/corex.gen.auth.ex create mode 100644 lib/mix/tasks/corex.gen.html.ex create mode 100644 lib/mix/tasks/corex.gen.live.ex create mode 100644 lib/mix/tasks/corex.install.ex create mode 100644 priv/gettext/en/LC_MESSAGES/errors.po create mode 100644 priv/gettext/errors.pot create mode 100644 priv/templates/phx.gen.auth/AGENTS.md create mode 100644 priv/templates/phx.gen.auth/auth.ex create mode 100644 priv/templates/phx.gen.auth/auth_test.exs create mode 100644 priv/templates/phx.gen.auth/confirmation_live.ex create mode 100644 priv/templates/phx.gen.auth/confirmation_live_test.exs create mode 100644 priv/templates/phx.gen.auth/conn_case.exs create mode 100644 priv/templates/phx.gen.auth/context_fixtures_functions.ex create mode 100644 priv/templates/phx.gen.auth/context_functions.ex create mode 100644 priv/templates/phx.gen.auth/login_live.ex create mode 100644 priv/templates/phx.gen.auth/login_live_test.exs create mode 100644 priv/templates/phx.gen.auth/migration.ex create mode 100644 priv/templates/phx.gen.auth/notifier.ex create mode 100644 priv/templates/phx.gen.auth/registration_controller.ex create mode 100644 priv/templates/phx.gen.auth/registration_controller_test.exs create mode 100644 priv/templates/phx.gen.auth/registration_html.ex create mode 100644 priv/templates/phx.gen.auth/registration_live.ex create mode 100644 priv/templates/phx.gen.auth/registration_live_test.exs create mode 100644 priv/templates/phx.gen.auth/registration_new.html.heex create mode 100644 priv/templates/phx.gen.auth/routes.ex create mode 100644 priv/templates/phx.gen.auth/schema.ex create mode 100644 priv/templates/phx.gen.auth/schema_token.ex create mode 100644 priv/templates/phx.gen.auth/scope.ex create mode 100644 priv/templates/phx.gen.auth/session_confirm.html.heex create mode 100644 priv/templates/phx.gen.auth/session_controller.ex create mode 100644 priv/templates/phx.gen.auth/session_controller_test.exs create mode 100644 priv/templates/phx.gen.auth/session_html.ex create mode 100644 priv/templates/phx.gen.auth/session_new.html.heex create mode 100644 priv/templates/phx.gen.auth/settings_controller.ex create mode 100644 priv/templates/phx.gen.auth/settings_controller_test.exs create mode 100644 priv/templates/phx.gen.auth/settings_edit.html.heex create mode 100644 priv/templates/phx.gen.auth/settings_html.ex create mode 100644 priv/templates/phx.gen.auth/settings_live.ex create mode 100644 priv/templates/phx.gen.auth/settings_live_test.exs create mode 100644 priv/templates/phx.gen.auth/test_cases.exs create mode 100644 priv/templates/phx.gen.html/controller.ex create mode 100644 priv/templates/phx.gen.html/controller_test.exs create mode 100644 priv/templates/phx.gen.html/edit.html.heex create mode 100644 priv/templates/phx.gen.html/html.ex create mode 100644 priv/templates/phx.gen.html/index.html.heex create mode 100644 priv/templates/phx.gen.html/new.html.heex create mode 100644 priv/templates/phx.gen.html/resource_form.html.heex create mode 100644 priv/templates/phx.gen.html/show.html.heex create mode 100644 priv/templates/phx.gen.live/core_components.ex create mode 100644 priv/templates/phx.gen.live/form.ex create mode 100644 priv/templates/phx.gen.live/index.ex create mode 100644 priv/templates/phx.gen.live/live_test.exs create mode 100644 priv/templates/phx.gen.live/show.ex create mode 100644 test/mix/tasks/corex.install_test.exs diff --git a/.gitignore b/.gitignore index 463d17e..e85d27d 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,9 @@ corex-*.tar .cursor +/.expert/ + +/installer/ + +/integration_test/ +/my_app/ diff --git a/e2e/.gitignore b/e2e/.gitignore index 9b8002b..557f6cb 100644 --- a/e2e/.gitignore +++ b/e2e/.gitignore @@ -35,3 +35,4 @@ e2e-*.tar npm-debug.log /assets/node_modules/ .env +/.expert/ \ No newline at end of file diff --git a/e2e/mix.exs b/e2e/mix.exs index 4a72f2c..6debd2e 100644 --- a/e2e/mix.exs +++ b/e2e/mix.exs @@ -96,7 +96,7 @@ defmodule E2e.MixProject do test: [ &clean_static_assets/1, ©_static_images/1, - "assets.deploy", + "assets.build", "ecto.drop --quiet", "ecto.create --quiet", "ecto.migrate", diff --git a/guides/installation.md b/guides/installation.md index e564589..2d8ce47 100644 --- a/guides/installation.md +++ b/guides/installation.md @@ -63,6 +63,30 @@ mix phx.new my_app ## Dependencies +You can add Corex via Igniter (recommended) or manually. + +### Via Igniter + +From your Phoenix app: + +```bash +mix igniter.install corex +``` + +The installer shows a diff and prompts for confirmation. Add `--yes` to apply changes without prompting: + +```bash +mix igniter.install corex --yes +``` + +To use a local Corex source (e.g. when developing Corex or testing before a Hex release): + +```bash +mix igniter.install corex@path:../corex +``` + +### Manual + Add `corex` to your `mix.exs` dependencies: ```elixir diff --git a/lib/content.ex b/lib/corex/content.ex similarity index 100% rename from lib/content.ex rename to lib/corex/content.ex diff --git a/lib/flash.ex b/lib/corex/flash.ex similarity index 100% rename from lib/flash.ex rename to lib/corex/flash.ex diff --git a/lib/form.ex b/lib/corex/form.ex similarity index 100% rename from lib/form.ex rename to lib/corex/form.ex diff --git a/lib/gettext.ex b/lib/corex/gettext.ex similarity index 100% rename from lib/gettext.ex rename to lib/corex/gettext.ex diff --git a/lib/helpers.ex b/lib/corex/helpers.ex similarity index 100% rename from lib/helpers.ex rename to lib/corex/helpers.ex diff --git a/lib/corex/igniter.ex b/lib/corex/igniter.ex new file mode 100644 index 0000000..dfe0a2b --- /dev/null +++ b/lib/corex/igniter.ex @@ -0,0 +1,704 @@ +if Code.ensure_loaded?(Igniter) do + defmodule Corex.Igniter do + @moduledoc false + + def validate_opts!(opts) do + if theme = Keyword.get(opts, :theme) do + themes = String.split(theme, ":", trim: true) + + if length(themes) < 2 do + Mix.raise("--theme requires at least 2 values (e.g. neo:uno), got: #{inspect(theme)}") + end + end + + if languages = Keyword.get(opts, :languages) do + list = String.split(languages, ":", trim: true) + + if length(list) < 2 do + Mix.raise( + "--languages requires at least 2 values (e.g. en:fr:ar), got: #{inspect(languages)}" + ) + end + end + end + + def install(igniter, opts) do + ensure_phoenix_project!() + {project_path, web_path, _web_app, web_namespace, web_app_str} = project_paths!() + add_gettext_if_missing(igniter, project_path, web_path, web_app_str, web_namespace) + design? = Keyword.get(opts, :design, true) + designex? = Keyword.get(opts, :designex, false) + if design?, do: run_corex_design(igniter, project_path, web_path, designex?) + copy_generator_templates(igniter, web_path) + copy_plugs_and_hooks(igniter, web_path, web_namespace, web_app_str, opts) + + app_js_path = Path.relative_to(Path.join(web_path, "assets/js/app.js"), project_path) + config_path = Path.join("config", "config.exs") + + root_layout_path = + Path.relative_to( + Path.join(web_path, "lib/#{web_app_str}/components/layouts/root.html.heex"), + project_path + ) + + web_ex_path = Path.relative_to(Path.join(web_path, "lib/#{web_app_str}.ex"), project_path) + app_css_path = Path.relative_to(Path.join(web_path, "assets/css/app.css"), project_path) + + igniter + |> add_corex_config(web_namespace) + |> add_rtl_config(opts) + |> patch_app_js(app_js_path) + |> patch_esbuild_config(config_path) + |> patch_root_layout(root_layout_path, web_app_str, opts) + |> patch_html_helpers(web_ex_path) + |> patch_app_css(app_css_path, design?, opts) + |> remove_daisy_vendor(web_path, opts) + end + + def add_corex_config(igniter, web_namespace) do + gettext_backend = Module.concat([web_namespace, Gettext]) + + failure_message = """ + Could not add Corex config to config/config.exs. Add manually: + + config :corex, + gettext_backend: #{inspect(gettext_backend)}, + json_library: Jason + """ + + if Igniter.Project.Config.configures_root_key?(igniter, "config.exs", :corex) do + igniter + else + igniter + |> Igniter.Project.Config.configure_new( + "config.exs", + :corex, + [:gettext_backend], + gettext_backend, + failure_message: failure_message + ) + |> Igniter.Project.Config.configure_new( + "config.exs", + :corex, + [:json_library], + Jason, + failure_message: failure_message + ) + end + end + + def add_rtl_config(igniter, opts) do + case Keyword.get(opts, :rtl) do + nil -> + igniter + + rtl -> + rtl_list = String.split(rtl, ~r/[:,]/, trim: true) + + failure_message = + "Could not add rtl_locales to config :corex. Add manually: rtl_locales: #{inspect(rtl_list)}" + + Igniter.Project.Config.configure( + igniter, + "config.exs", + :corex, + [:rtl_locales], + rtl_list, + failure_message: failure_message + ) + end + end + + def patch_app_js(igniter, app_js_path) do + if Igniter.exists?(igniter, app_js_path) do + igniter + |> Igniter.include_existing_file(app_js_path, required?: false) + |> Igniter.update_file(app_js_path, &patch_app_js_content(&1, app_js_path)) + else + igniter + end + end + + defp patch_app_js_content(source, app_js_path) do + if source.content =~ ~r/from "corex"/ do + source + else + new_content = + source.content + |> String.replace(~r/(import topbar from)/, ~s|import corex from "corex"\n\\1|) + |> String.replace( + ~r/hooks: \{\.\.\.colocatedHooks\}/, + "hooks: {...colocatedHooks, ...corex}" + ) + + maybe_update_app_js(source, source.content, new_content, app_js_path) + end + end + + defp maybe_update_app_js(source, content, new_content, app_js_path) do + if new_content == content do + {:warning, + Igniter.Util.Warning.formatted_warning( + "Could not patch #{app_js_path} (structure may differ). Add manually:", + ~s|import corex from "corex"\nhooks: {...colocatedHooks, ...corex}| + )} + else + Rewrite.Source.update(source, :content, new_content) + end + end + + def patch_esbuild_config(igniter, config_path) do + igniter + |> Igniter.include_existing_file(config_path, required?: true) + |> Igniter.update_file(config_path, &patch_esbuild_content(&1, config_path)) + end + + defp patch_esbuild_content(source, config_path) do + if source.content =~ ~r/--format=esm/ do + source + else + new_content = + String.replace(source.content, ~r/(--bundle )/, "\\1--format=esm --splitting ") + + maybe_update_esbuild(source, source.content, new_content, config_path) + end + end + + defp maybe_update_esbuild(source, content, new_content, config_path) do + if new_content == content do + {:warning, + Igniter.Util.Warning.formatted_warning( + "Could not patch esbuild config in #{config_path}. Add manually to esbuild args:", + ~s|# --format=esm --splitting --bundle| + )} + else + Rewrite.Source.update(source, :content, new_content) + end + end + + def patch_root_layout(igniter, root_layout_path, _web_app_str, opts) do + if Igniter.exists?(igniter, root_layout_path) do + remove_theme_script? = + opts[:daisy] == false || Keyword.get(opts, :mode) || Keyword.get(opts, :theme) + + igniter + |> Igniter.include_existing_file(root_layout_path, required?: false) + |> Igniter.update_file( + root_layout_path, + &patch_root_layout_content(&1, root_layout_path, remove_theme_script?), + required?: false + ) + else + igniter + end + end + + defp patch_root_layout_content(source, root_layout_path, remove_theme_script?) do + content = source.content + + new_content = + content + |> replace_type_script() + |> replace_html_attrs() + |> maybe_theme_script(remove_theme_script?) + + maybe_update_root_layout(source, content, new_content, root_layout_path) + end + + defp maybe_theme_script(content, true), do: remove_theme_script(content) + defp maybe_theme_script(content, _), do: patch_theme_script_to_data_mode(content) + + defp maybe_update_root_layout(source, content, new_content, root_layout_path) do + if new_content == content do + {:warning, + Igniter.Util.Warning.formatted_warning( + "Could not patch #{root_layout_path}. Apply manually: set type=\"module\" on script, add data-theme=\"neo\" data-mode=\"light\" to ", + ~s|# | + )} + else + Rewrite.Source.update(source, :content, new_content) + end + end + + defp replace_type_script(content) do + if content =~ ~r/type="text\/javascript"/ and not (content =~ ~r/type="module"/) do + String.replace(content, ~r/type="text\/javascript"/, ~s|type="module"|) + else + content + end + end + + defp replace_html_attrs(content) do + if content =~ ~r/ String.replace(~r/removeAttribute\("data-theme"\)/, ~s|removeAttribute("data-mode")|) + |> String.replace( + ~r/setAttribute\("data-theme", theme\)/, + ~s|setAttribute("data-mode", theme)| + ) + |> String.replace(~r/hasAttribute\("data-theme"\)/, ~s|hasAttribute("data-mode")|) + |> String.replace(~r/phx:theme/, "phx:mode") + |> String.replace(~r/phxTheme/, "phxMode") + else + content + end + end + + defp remove_theme_script(content) do + regex = ~r/\s*", + language: :unknown + ) + + assert result =~ "<script>" + end + end +end diff --git a/test/components/collapsible_test.exs b/test/components/collapsible_test.exs index a5466b0..16fd5c8 100644 --- a/test/components/collapsible_test.exs +++ b/test/components/collapsible_test.exs @@ -1,9 +1,17 @@ defmodule Corex.CollapsibleTest do - use ExUnit.Case, async: true + use CorexTest.ComponentCase, async: true alias Corex.Collapsible alias Corex.Collapsible.Connect + describe "collapsible/1" do + test "renders" do + html = render_component(&CorexTest.ComponentHelpers.render_collapsible/1, []) + assert html =~ ~r/data-scope="collapsible"/ + assert html =~ ~r/data-part="root"/ + end + end + describe "set_open/2" do test "returns JS command when open is true" do js = Collapsible.set_open("my-collapsible", true) diff --git a/test/components/color_picker_test.exs b/test/components/color_picker_test.exs index da2e40b..0720956 100644 --- a/test/components/color_picker_test.exs +++ b/test/components/color_picker_test.exs @@ -1,9 +1,18 @@ defmodule Corex.ColorPickerTest do - use ExUnit.Case, async: true + use CorexTest.ComponentCase, async: true + alias Corex.ColorPicker alias Corex.ColorPicker.Connect alias Corex.ColorPicker.Initial + describe "color_picker/1" do + test "renders" do + html = render_component(&ColorPicker.color_picker/1, []) + assert html =~ ~r/data-scope="color-picker"/ + assert html =~ ~r/data-part="root"/ + end + end + describe "Initial.parse/1" do test "returns empty map for nil" do result = Initial.parse(nil) diff --git a/test/components/combobox_test.exs b/test/components/combobox_test.exs index 75412d8..ff4a12a 100644 --- a/test/components/combobox_test.exs +++ b/test/components/combobox_test.exs @@ -1,8 +1,16 @@ defmodule Corex.ComboboxTest do - use ExUnit.Case, async: true + use CorexTest.ComponentCase, async: true alias Corex.Combobox.Connect + describe "combobox/1" do + test "renders" do + html = render_component(&CorexTest.ComponentHelpers.render_combobox/1, []) + assert html =~ ~r/data-scope="combobox"/ + assert html =~ ~r/data-part="root"/ + end + end + describe "Connect.root/1" do test "returns root attributes" do assigns = %{id: "test-combobox", invalid: false, read_only: false} @@ -36,4 +44,90 @@ defmodule Corex.ComboboxTest do assert result["id"] == "combobox:test-combobox:control" end end + + describe "Connect.input/1" do + test "returns input attributes" do + assigns = %{ + id: "test-combobox", + dir: "ltr", + disabled: false, + invalid: false, + placeholder: nil, + auto_focus: false + } + + result = Connect.input(assigns) + assert result["data-part"] == "input" + end + end + + describe "Connect.positioner/1" do + test "returns positioner attributes" do + assigns = %{id: "test-combobox", dir: "ltr"} + result = Connect.positioner(assigns) + assert result["data-part"] == "positioner" + end + end + + describe "Connect.content/1" do + test "returns content attributes" do + assigns = %{id: "test-combobox", dir: "ltr"} + result = Connect.content(assigns) + assert result["data-part"] == "content" + end + end + + describe "Connect.props/1" do + test "returns props when uncontrolled" do + assigns = %{ + id: "test-combobox", + collection: [%{id: "a", label: "A"}], + controlled: false, + value: [], + dir: "ltr" + } + + result = Connect.props(Map.merge(default_combobox_props(), assigns)) + assert result["id"] == "test-combobox" + end + + test "returns props when controlled" do + assigns = %{ + id: "test-combobox", + collection: [%{id: "a", label: "A"}], + controlled: true, + value: ["a"], + dir: "ltr" + } + + result = Connect.props(Map.merge(default_combobox_props(), assigns)) + assert result["data-value"] == "a" + end + end + + defp default_combobox_props do + %{ + invalid: false, + read_only: false, + disabled: false, + name: nil, + open: false, + placeholder: nil, + always_submit_on_enter: false, + auto_focus: false, + close_on_select: false, + input_behavior: "autohighlight", + loop_focus: false, + multiple: false, + form: nil, + required: false, + positioning: nil, + on_open_change: nil, + on_open_change_client: nil, + on_input_value_change: nil, + on_value_change: nil, + bubble: false, + filter: true + } + end end diff --git a/test/components/date_picker_test.exs b/test/components/date_picker_test.exs index 3edaefb..f4ec393 100644 --- a/test/components/date_picker_test.exs +++ b/test/components/date_picker_test.exs @@ -1,7 +1,16 @@ defmodule Corex.DatePickerTest do - use ExUnit.Case, async: true + use CorexTest.ComponentCase, async: true alias Corex.DatePicker + alias Corex.DatePicker.Connect + + describe "date_picker/1" do + test "renders" do + html = render_component(&CorexTest.ComponentHelpers.render_date_picker/1, []) + assert html =~ ~r/data-scope="date-picker"/ + assert html =~ ~r/data-part="root"/ + end + end describe "set_value/2" do test "returns JS command" do @@ -17,4 +26,105 @@ defmodule Corex.DatePickerTest do assert %Phoenix.LiveView.Socket{} = result end end + + describe "Connect" do + test "root/1 returns root attributes" do + result = Connect.root(%{id: "test-dp", dir: "ltr"}) + assert result["id"] == "date-picker:test-dp" + assert result["data-part"] == "root" + end + + test "label/1 returns label attributes" do + result = Connect.label(%{id: "test-dp", dir: "ltr"}) + assert result["data-part"] == "label" + assert result["htmlFor"] == "date-picker:test-dp:input:0" + end + + test "control/1 returns control attributes" do + result = Connect.control(%{id: "test-dp", dir: "ltr"}) + assert result["data-part"] == "control" + end + + test "input/1 returns input attributes" do + result = Connect.input(%{id: "test-dp", dir: "ltr"}) + assert result["data-part"] == "input" + end + + test "trigger/1 returns trigger attributes" do + result = Connect.trigger(%{id: "test-dp", dir: "ltr"}) + assert result["data-part"] == "trigger" + end + + test "positioner/1 returns positioner attributes" do + result = Connect.positioner(%{id: "test-dp", dir: "ltr"}) + assert result["data-part"] == "positioner" + end + + test "content/1 returns content attributes" do + result = Connect.content(%{id: "test-dp", dir: "ltr"}) + assert result["data-part"] == "content" + assert result["hidden"] == true + end + + test "props/1 returns props when uncontrolled" do + assigns = %{ + id: "test-dp", + controlled: false, + value: "2025-02-22", + locale: "en", + time_zone: "UTC", + dir: "ltr" + } + + result = Connect.props(Map.merge(default_props(), assigns)) + assert result["data-default-value"] == "2025-02-22" + assert result["data-value"] == nil + end + + test "props/1 returns props when controlled" do + assigns = %{ + id: "test-dp", + controlled: true, + value: "2025-02-22", + locale: "en", + time_zone: "UTC", + dir: "ltr" + } + + result = Connect.props(Map.merge(default_props(), assigns)) + assert result["data-default-value"] == nil + assert result["data-value"] == "2025-02-22" + end + end + + defp default_props do + %{ + name: nil, + disabled: false, + read_only: false, + required: false, + invalid: false, + outside_day_selectable: false, + close_on_select: nil, + min: nil, + max: nil, + focused_value: nil, + num_of_months: nil, + start_of_week: nil, + fixed_weeks: nil, + selection_mode: nil, + placeholder: nil, + default_view: nil, + min_view: nil, + max_view: nil, + positioning: nil, + on_value_change: nil, + on_focus_change: nil, + on_view_change: nil, + on_visible_range_change: nil, + on_open_change: nil, + trigger_aria_label: nil, + input_aria_label: nil + } + end end diff --git a/test/components/dialog_test.exs b/test/components/dialog_test.exs index ce1c27d..e92df31 100644 --- a/test/components/dialog_test.exs +++ b/test/components/dialog_test.exs @@ -1,9 +1,19 @@ defmodule Corex.DialogTest do - use ExUnit.Case, async: true + use CorexTest.ComponentCase, async: true alias Corex.Dialog alias Corex.Dialog.Connect + describe "dialog/1" do + test "renders" do + html = render_component(&CorexTest.ComponentHelpers.render_dialog/1, []) + assert html =~ ~r/data-scope="dialog"/ + assert html =~ ~r/data-part="content"/ + assert html =~ ~r/Open/ + assert html =~ ~r/Dialog content/ + end + end + describe "set_open/2" do test "returns JS command when open is true" do js = Dialog.set_open("my-dialog", true) diff --git a/test/components/editable_test.exs b/test/components/editable_test.exs index 0bae112..2bd7a82 100644 --- a/test/components/editable_test.exs +++ b/test/components/editable_test.exs @@ -1,8 +1,16 @@ defmodule Corex.EditableTest do - use ExUnit.Case, async: true + use CorexTest.ComponentCase, async: true alias Corex.Editable.Connect + describe "editable/1" do + test "renders" do + html = render_component(&CorexTest.ComponentHelpers.render_editable/1, []) + assert html =~ ~r/data-scope="editable"/ + assert html =~ ~r/data-part="root"/ + end + end + describe "Connect.root/1" do test "returns root attributes" do assigns = %{id: "test-editable", dir: "ltr"} diff --git a/test/components/floating_panel_test.exs b/test/components/floating_panel_test.exs index 7192a63..2a219d2 100644 --- a/test/components/floating_panel_test.exs +++ b/test/components/floating_panel_test.exs @@ -1,8 +1,16 @@ defmodule Corex.FloatingPanelTest do - use ExUnit.Case, async: true + use CorexTest.ComponentCase, async: true alias Corex.FloatingPanel.Connect + describe "floating_panel/1" do + test "renders" do + html = render_component(&CorexTest.ComponentHelpers.render_floating_panel/1, []) + assert html =~ ~r/data-scope="floating-panel"/ + assert html =~ ~r/data-part="root"/ + end + end + describe "Connect.root/1" do test "returns root attributes" do assigns = %{id: "test-panel", dir: "ltr"} diff --git a/test/components/hidden_input_test.exs b/test/components/hidden_input_test.exs new file mode 100644 index 0000000..a2c2448 --- /dev/null +++ b/test/components/hidden_input_test.exs @@ -0,0 +1,35 @@ +defmodule Corex.HiddenInputTest do + use CorexTest.ComponentCase, async: true + + describe "hidden_input/1" do + test "renders hidden input with id, name, value" do + result = + render_component(&Corex.HiddenInput.hidden_input/1, + id: "user-id", + name: "user[id]", + value: "123" + ) + + elements = find_in_html(result, "input[type=hidden]#user-id") + assert [_] = elements + assert Floki.attribute(elements, "name") == ["user[id]"] + assert Floki.attribute(elements, "value") == ["123"] + end + + test "renders hidden input without field generates unique id" do + result = + render_component(&Corex.HiddenInput.hidden_input/1, name: "user[token]", value: "abc") + + elements = find_in_html(result, "input[type=hidden][name='user[token]']") + assert [_] = elements + assert Floki.attribute(elements, "value") == ["abc"] + end + + test "renders hidden input with form field" do + form = Phoenix.Component.to_form(%{"id" => "42"}, as: :user) + field = form[:id] + result = render_component(&Corex.HiddenInput.hidden_input/1, field: field) + assert [_] = find_in_html(result, ~s(input[type=hidden][name="user[id]"])) + end + end +end diff --git a/test/components/listbox_test.exs b/test/components/listbox_test.exs index 9ce4875..c749145 100644 --- a/test/components/listbox_test.exs +++ b/test/components/listbox_test.exs @@ -1,8 +1,16 @@ defmodule Corex.ListboxTest do - use ExUnit.Case, async: true + use CorexTest.ComponentCase, async: true alias Corex.Listbox.Connect + describe "listbox/1" do + test "renders" do + html = render_component(&CorexTest.ComponentHelpers.render_listbox/1, []) + assert html =~ ~r/data-scope="listbox"/ + assert html =~ ~r/data-part="root"/ + end + end + describe "Connect.root/1" do test "returns root attributes" do assigns = %{id: "test-listbox", dir: "ltr"} diff --git a/test/components/marquee_test.exs b/test/components/marquee_test.exs index 383df98..9840ba1 100644 --- a/test/components/marquee_test.exs +++ b/test/components/marquee_test.exs @@ -1,9 +1,17 @@ defmodule Corex.MarqueeTest do - use ExUnit.Case, async: true + use CorexTest.ComponentCase, async: true alias Corex.Marquee alias Corex.Marquee.Connect + describe "marquee/1" do + test "renders" do + html = render_component(&CorexTest.ComponentHelpers.render_marquee/1, []) + assert html =~ ~r/data-scope="marquee"/ + assert html =~ ~r/data-part="root"/ + end + end + describe "pause/1" do test "returns JS command" do js = Marquee.pause("my-marquee") diff --git a/test/components/menu_test.exs b/test/components/menu_test.exs index ee73161..7f2cfd5 100644 --- a/test/components/menu_test.exs +++ b/test/components/menu_test.exs @@ -1,9 +1,17 @@ defmodule Corex.MenuTest do - use ExUnit.Case, async: true + use CorexTest.ComponentCase, async: true alias Corex.Menu alias Corex.Menu.Connect + describe "menu/1" do + test "renders" do + html = render_component(&CorexTest.ComponentHelpers.render_menu/1, []) + assert html =~ ~r/data-scope="menu"/ + assert html =~ ~r/menu:/ + end + end + describe "set_open/2" do test "returns JS command when open is true" do js = Menu.set_open("my-menu", true) @@ -118,4 +126,77 @@ defmodule Corex.MenuTest do assert result["role"] == "separator" end end + + describe "Connect.props/1" do + test "returns props when uncontrolled" do + assigns = %{ + id: "test-menu", + controlled: false, + open: false, + dir: "ltr", + close_on_select: true, + loop_focus: false, + typeahead: true, + composite: false, + value: nil, + aria_label: nil, + on_select: nil, + on_select_client: nil, + redirect: false, + on_open_change: nil, + on_open_change_client: nil + } + + result = Connect.props(assigns) + assert result["id"] == "test-menu" + end + + test "returns props when controlled" do + assigns = %{ + id: "test-menu", + controlled: true, + open: true, + dir: "ltr", + close_on_select: true, + loop_focus: false, + typeahead: true, + composite: false, + value: nil, + aria_label: nil, + on_select: nil, + on_select_client: nil, + redirect: false, + on_open_change: nil, + on_open_change_client: nil + } + + result = Connect.props(assigns) + assert result["data-open"] == "" + end + end + + describe "Connect.item_group_label/1" do + test "returns item group label attributes" do + assigns = %{id: "test-menu", group_id: "group-1", dir: "ltr"} + result = Connect.item_group_label(assigns) + assert result["data-part"] == "item-group-label" + end + end + + describe "Connect.item_group/1" do + test "returns item group attributes" do + assigns = %{id: "test-menu", group_id: "group-1", dir: "ltr"} + result = Connect.item_group(assigns) + assert result["data-part"] == "item-group" + end + end + + describe "Connect.nested_menu/1" do + test "returns nested menu attributes" do + assigns = %{id: "test-menu", dir: "ltr"} + result = Connect.nested_menu(assigns) + assert result["data-nested"] == "menu" + assert result["data-scope"] == "menu" + end + end end diff --git a/test/components/native_input_test.exs b/test/components/native_input_test.exs new file mode 100644 index 0000000..9693fc3 --- /dev/null +++ b/test/components/native_input_test.exs @@ -0,0 +1,67 @@ +defmodule Corex.NativeInputTest do + use CorexTest.ComponentCase, async: true + + describe "native_input/1" do + test "renders text input" do + result = + render_component(&Corex.NativeInput.native_input/1, + type: "text", + id: "name", + name: "user[name]", + value: "John" + ) + + elements = + find_in_html(result, ~s([data-scope="native-input"] input[type=text][name="user[name]"])) + + assert [_] = elements + assert Floki.attribute(elements, "value") == ["John"] + end + + test "renders textarea" do + result = + render_component(&Corex.NativeInput.native_input/1, + type: "textarea", + name: "user[bio]", + value: "Hello" + ) + + assert [_] = find_in_html(result, ~s(textarea[name="user[bio]"])) + end + + test "renders checkbox" do + result = + render_component(&Corex.NativeInput.native_input/1, + type: "checkbox", + name: "user[agree]", + value: true + ) + + assert [_] = find_in_html(result, ~s(input[type=checkbox][name="user[agree]"])) + end + + test "renders select with options" do + result = + render_component(&Corex.NativeInput.native_input/1, + type: "select", + name: "user[role]", + options: [Admin: "admin", User: "user"], + prompt: "Choose..." + ) + + assert [_] = find_in_html(result, ~s(select[name="user[role]"])) + end + + test "renders radio with options" do + result = + render_component(&Corex.NativeInput.native_input/1, + type: "radio", + name: "user[size]", + options: [Small: "s", Medium: "m", Large: "l"], + value: "m" + ) + + assert find_in_html(result, ~s(input[type=radio][name="user[size]"])) != [] + end + end +end diff --git a/test/components/navigate_test.exs b/test/components/navigate_test.exs new file mode 100644 index 0000000..c169c9c --- /dev/null +++ b/test/components/navigate_test.exs @@ -0,0 +1,71 @@ +defmodule Corex.NavigateTest do + use CorexTest.ComponentCase, async: true + + describe "navigate/1" do + test "renders href link by default" do + result = + render_component(&CorexTest.ComponentHelpers.render_navigate/1, + to: "/about", + type: "href", + external: false, + download: nil, + aria_label: nil + ) + + assert [_] = find_in_html(result, ~s(a[href="/about"])) + assert text_in_html(result) =~ "Link text" + end + + test "renders navigate link" do + result = + render_component(&CorexTest.ComponentHelpers.render_navigate/1, + to: "/dashboard", + type: "navigate", + external: false, + download: nil, + aria_label: nil + ) + + assert [_] = find_in_html(result, "[data-phx-link]") + end + + test "renders external link with target and rel" do + result = + render_component(&CorexTest.ComponentHelpers.render_navigate/1, + to: "https://example.com", + type: "href", + external: true, + download: nil, + aria_label: nil + ) + + assert [_] = find_in_html(result, ~s(a[target="_blank"][rel="noopener noreferrer"])) + end + + test "renders link with download attribute" do + result = + render_component(&CorexTest.ComponentHelpers.render_navigate/1, + to: "/file.pdf", + type: "href", + external: false, + download: "report.pdf", + aria_label: nil + ) + + assert [_] = find_in_html(result, ~s(a[download="report.pdf"])) + end + + test "renders link with aria_label" do + result = + render_component(&CorexTest.ComponentHelpers.render_navigate/1, + to: "/profile", + type: "href", + external: false, + download: nil, + aria_label: "View profile" + ) + + assert [_] = find_in_html(result, ~s(a[aria-label="View profile"])) + end + end +end diff --git a/test/components/number_input_test.exs b/test/components/number_input_test.exs index 3962195..56df216 100644 --- a/test/components/number_input_test.exs +++ b/test/components/number_input_test.exs @@ -1,8 +1,17 @@ defmodule Corex.NumberInputTest do - use ExUnit.Case, async: true + use CorexTest.ComponentCase, async: true + alias Corex.NumberInput alias Corex.NumberInput.Connect + describe "number_input/1" do + test "renders" do + html = render_component(&NumberInput.number_input/1, []) + assert html =~ ~r/data-scope="number-input"/ + assert html =~ ~r/data-part="root"/ + end + end + describe "Connect.root/1" do test "returns root attributes" do assigns = %{id: "test-number"} diff --git a/test/components/password_input_test.exs b/test/components/password_input_test.exs index 5267dff..09e889b 100644 --- a/test/components/password_input_test.exs +++ b/test/components/password_input_test.exs @@ -1,8 +1,17 @@ defmodule Corex.PasswordInputTest do - use ExUnit.Case, async: true + use CorexTest.ComponentCase, async: true + alias Corex.PasswordInput alias Corex.PasswordInput.Connect + describe "password_input/1" do + test "renders" do + html = render_component(&PasswordInput.password_input/1, name: "pass") + assert html =~ ~r/data-scope="password-input"/ + assert html =~ ~r/data-part="root"/ + end + end + describe "Connect.root/1" do test "returns root attributes" do assigns = %{id: "test-password", dir: "ltr"} diff --git a/test/components/pin_input_test.exs b/test/components/pin_input_test.exs index d9162f9..36b714e 100644 --- a/test/components/pin_input_test.exs +++ b/test/components/pin_input_test.exs @@ -1,8 +1,17 @@ defmodule Corex.PinInputTest do - use ExUnit.Case, async: true + use CorexTest.ComponentCase, async: true + alias Corex.PinInput alias Corex.PinInput.Connect + describe "pin_input/1" do + test "renders" do + html = render_component(&PinInput.pin_input/1, name: "pin", length: 4) + assert html =~ ~r/data-scope="pin-input"/ + assert html =~ ~r/data-part="root"/ + end + end + describe "Connect.root/1" do test "returns root attributes" do assigns = %{id: "test-pin", dir: "ltr"} diff --git a/test/components/radio_group_test.exs b/test/components/radio_group_test.exs index d15590c..6e55f3a 100644 --- a/test/components/radio_group_test.exs +++ b/test/components/radio_group_test.exs @@ -1,8 +1,17 @@ defmodule Corex.RadioGroupTest do - use ExUnit.Case, async: true + use CorexTest.ComponentCase, async: true + alias Corex.RadioGroup alias Corex.RadioGroup.Connect + describe "radio_group/1" do + test "renders" do + html = render_component(&RadioGroup.radio_group/1, items: [["a", "Option A"]]) + assert html =~ ~r/data-scope="radio-group"/ + assert html =~ ~r/data-part="root"/ + end + end + describe "Connect.root/1" do test "returns root attributes without label" do assigns = %{id: "test-radio", dir: "ltr", orientation: "vertical", has_label: false} diff --git a/test/components/select_test.exs b/test/components/select_test.exs index 17b64c7..b58a5d6 100644 --- a/test/components/select_test.exs +++ b/test/components/select_test.exs @@ -1,8 +1,16 @@ defmodule Corex.SelectTest do - use ExUnit.Case, async: true + use CorexTest.ComponentCase, async: true alias Corex.Select.Connect + describe "select/1" do + test "renders" do + html = render_component(&CorexTest.ComponentHelpers.render_select/1, []) + assert html =~ ~r/data-scope="select"/ + assert html =~ ~r/data-part="root"/ + end + end + describe "Connect.root/1" do test "returns root attributes" do assigns = %{id: "test-select", invalid: false, read_only: false} @@ -38,4 +46,68 @@ defmodule Corex.SelectTest do assert result["data-scope"] == "select" end end + + describe "Connect.positioner/1" do + test "returns positioner attributes" do + assigns = %{id: "test-select", dir: "ltr"} + result = Connect.positioner(assigns) + assert result["data-part"] == "positioner" + end + end + + describe "Connect.content/1" do + test "returns content attributes" do + assigns = %{id: "test-select", dir: "ltr"} + result = Connect.content(assigns) + assert result["data-part"] == "content" + end + end + + describe "Connect.props/1" do + test "returns props when uncontrolled" do + assigns = %{ + id: "test-select", + collection: [%{id: "a", label: "A"}], + controlled: false, + value: [], + dir: "ltr" + } + + result = Connect.props(Map.merge(default_select_props(), assigns)) + assert result["id"] == "test-select" + end + + test "returns props when controlled" do + assigns = %{ + id: "test-select", + collection: [%{id: "a", label: "A"}], + controlled: true, + value: ["a"], + dir: "ltr" + } + + result = Connect.props(Map.merge(default_select_props(), assigns)) + assert result["data-value"] == "a" + end + end + + defp default_select_props do + %{ + invalid: false, + read_only: false, + disabled: false, + name: nil, + placeholder: nil, + close_on_select: true, + loop_focus: false, + multiple: false, + form: nil, + required: false, + positioning: nil, + on_value_change: nil, + on_value_change_client: nil, + redirect: false, + redirect_new_tab: false + } + end end diff --git a/test/components/signature_pad_test.exs b/test/components/signature_pad_test.exs index f109a70..f048100 100644 --- a/test/components/signature_pad_test.exs +++ b/test/components/signature_pad_test.exs @@ -1,8 +1,16 @@ defmodule Corex.SignaturePadTest do - use ExUnit.Case, async: true + use CorexTest.ComponentCase, async: true alias Corex.SignaturePad.Connect + describe "signature_pad/1" do + test "renders" do + html = render_component(&CorexTest.ComponentHelpers.render_signature_pad/1, []) + assert html =~ ~r/data-scope="signature-pad"/ + assert html =~ ~r/data-part="root"/ + end + end + describe "Connect.root/1" do test "returns root attributes" do assigns = %{id: "test-signature", dir: "ltr"} diff --git a/test/components/switch_test.exs b/test/components/switch_test.exs index afcb53f..f3fa4df 100644 --- a/test/components/switch_test.exs +++ b/test/components/switch_test.exs @@ -1,9 +1,17 @@ defmodule Corex.SwitchTest do - use ExUnit.Case, async: true + use CorexTest.ComponentCase, async: true alias Corex.Switch alias Corex.Switch.Connect + describe "switch/1" do + test "renders" do + html = render_component(&Switch.switch/1, checked: false, name: "sw") + assert html =~ ~r/data-scope="switch"/ + assert html =~ ~r/data-part="root"/ + end + end + describe "set_checked/2" do test "returns JS command when checked is true" do js = Switch.set_checked("my-switch", true) diff --git a/test/components/tabs_test.exs b/test/components/tabs_test.exs index f8021ba..bf35f58 100644 --- a/test/components/tabs_test.exs +++ b/test/components/tabs_test.exs @@ -1,9 +1,64 @@ defmodule Corex.TabsTest do - use ExUnit.Case, async: true + use CorexTest.ComponentCase, async: true alias Corex.Tabs alias Corex.Tabs.Connect + describe "tabs/1" do + test "renders with items" do + html = render_component(&CorexTest.ComponentHelpers.render_tabs/1, []) + assert html =~ ~r/data-scope="tabs"/ + assert html =~ ~r/data-part="root"/ + assert html =~ ~r/Tab1/ + end + + test "renders with horizontal orientation" do + html = + render_component(&CorexTest.ComponentHelpers.render_tabs/1, orientation: "horizontal") + + assert html =~ ~r/data-scope="tabs"/ + assert html =~ ~r/data-orientation="horizontal"/ + end + + test "renders with custom slots only" do + html = render_component(&CorexTest.ComponentHelpers.render_tabs_custom_slots_only/1, []) + assert html =~ ~r/data-scope="tabs"/ + assert html =~ ~r/Tab 1/ + assert html =~ ~r/Tab 2/ + assert html =~ ~r/Content 1/ + assert html =~ ~r/Content 2/ + end + + test "renders with items and custom trigger/content slots" do + html = + render_component(&CorexTest.ComponentHelpers.render_tabs_items_with_custom_slots/1, []) + + assert html =~ ~r/data-scope="tabs"/ + assert html =~ ~r/A/ + assert html =~ ~r/B/ + assert html =~ ~r/A content/ + assert html =~ ~r/B content/ + end + end + + describe "tabs_trigger/1" do + test "renders trigger" do + html = render_component(&CorexTest.ComponentHelpers.render_tabs_trigger/1, []) + assert html =~ ~r/data-scope="tabs"/ + assert html =~ ~r/data-part="trigger"/ + assert html =~ ~r/Tab 1/ + end + end + + describe "tabs_content/1" do + test "renders content" do + html = render_component(&CorexTest.ComponentHelpers.render_tabs_content/1, []) + assert html =~ ~r/data-scope="tabs"/ + assert html =~ ~r/data-part="content"/ + assert html =~ ~r/Content 1/ + end + end + describe "set_value/2" do test "returns JS command" do js = Tabs.set_value("my-tabs", "tab-1") @@ -139,4 +194,42 @@ defmodule Corex.TabsTest do assert result["data-state"] == "closed" end end + + describe "Connect.props/1" do + test "returns props when uncontrolled" do + assigns = %{ + id: "test-tabs", + controlled: false, + value: "tab-1", + orientation: "vertical", + dir: "ltr", + on_value_change: nil, + on_value_change_client: nil, + on_focus_change: nil, + on_focus_change_client: nil + } + + result = Connect.props(assigns) + assert result["data-default-value"] == "tab-1" + assert result["data-value"] == nil + end + + test "returns props when controlled" do + assigns = %{ + id: "test-tabs", + controlled: true, + value: "tab-1", + orientation: "vertical", + dir: "ltr", + on_value_change: nil, + on_value_change_client: nil, + on_focus_change: nil, + on_focus_change_client: nil + } + + result = Connect.props(assigns) + assert result["data-default-value"] == nil + assert result["data-value"] == "tab-1" + end + end end diff --git a/test/components/timer_test.exs b/test/components/timer_test.exs index 82076f4..301e59d 100644 --- a/test/components/timer_test.exs +++ b/test/components/timer_test.exs @@ -1,8 +1,16 @@ defmodule Corex.TimerTest do - use ExUnit.Case, async: true + use CorexTest.ComponentCase, async: true alias Corex.Timer.Connect + describe "timer/1" do + test "renders" do + html = render_component(&CorexTest.ComponentHelpers.render_timer/1, []) + assert html =~ ~r/data-scope="timer"/ + assert html =~ ~r/data-part="root"/ + end + end + describe "Connect.root/1" do test "returns root attributes" do assigns = %{id: "test-timer"} diff --git a/test/components/toast_test.exs b/test/components/toast_test.exs new file mode 100644 index 0000000..6675c96 --- /dev/null +++ b/test/components/toast_test.exs @@ -0,0 +1,115 @@ +defmodule Corex.ToastTest do + use CorexTest.ComponentCase, async: true + + describe "create_toast/5" do + test "returns JS command for info type" do + js = Corex.Toast.create_toast("layout-toast", "Title", "Description", :info, []) + assert %Phoenix.LiveView.JS{} = js + end + + test "returns JS command for success type" do + js = Corex.Toast.create_toast("layout-toast", "Saved!", "Done", :success, []) + assert %Phoenix.LiveView.JS{} = js + end + + test "returns JS command for error type" do + js = Corex.Toast.create_toast("layout-toast", "Error", "Failed", :error, []) + assert %Phoenix.LiveView.JS{} = js + end + + test "returns JS command for loading type with infinite duration" do + js = + Corex.Toast.create_toast("layout-toast", "Loading", nil, :loading, duration: :infinity) + + assert %Phoenix.LiveView.JS{} = js + end + + test "returns JS command with custom duration" do + js = Corex.Toast.create_toast("layout-toast", "Title", nil, :info, duration: 3000) + assert %Phoenix.LiveView.JS{} = js + end + end + + describe "push_toast/6" do + test "returns modified socket" do + socket = %Phoenix.LiveView.Socket{} + result = Corex.Toast.push_toast(socket, "layout-toast", "Title", "Desc", :success, 5000) + assert %Phoenix.LiveView.Socket{} = result + end + + test "accepts infinite duration" do + socket = %Phoenix.LiveView.Socket{} + result = Corex.Toast.push_toast(socket, "layout-toast", "Loading", nil, :loading, :infinity) + assert %Phoenix.LiveView.Socket{} = result + end + end + + describe "toast_group/1" do + test "renders toast group" do + result = render_component(&Corex.Toast.toast_group/1, id: "layout-toast") + assert [_] = find_in_html(result, ~s([data-scope="toast"][data-part="group"])) + end + + test "renders toast group with flash" do + result = + render_component(&Corex.Toast.toast_group/1, + id: "layout-toast", + flash: %{info: "Hello", error: "Oops"} + ) + + assert [_] = find_in_html(result, ~s([data-scope="toast"])) + end + end + + describe "toast_client_error/1" do + test "renders with phx-disconnected" do + result = + render_component(&Corex.Toast.toast_client_error/1, + toast_group_id: "layout-toast", + title: "Client Error", + description: "Reconnecting" + ) + + assert [_] = find_in_html(result, "[phx-disconnected]") + end + end + + describe "toast_server_error/1" do + test "renders with phx-disconnected" do + result = + render_component(&Corex.Toast.toast_server_error/1, + toast_group_id: "layout-toast", + title: "Server Error", + description: "Retrying" + ) + + assert [_] = find_in_html(result, "[phx-disconnected]") + end + end + + describe "toast_connected/1" do + test "renders with phx-connected" do + result = + render_component(&Corex.Toast.toast_connected/1, + toast_group_id: "layout-toast", + title: "Connected", + description: "Back online" + ) + + assert [_] = find_in_html(result, "[phx-connected]") + end + end + + describe "toast_disconnected/1" do + test "renders with phx-disconnected" do + result = + render_component(&Corex.Toast.toast_disconnected/1, + toast_group_id: "layout-toast", + title: "Disconnected", + description: "Lost connection" + ) + + assert [_] = find_in_html(result, "[phx-disconnected]") + end + end +end diff --git a/test/components/toggle_group_test.exs b/test/components/toggle_group_test.exs index ff3fb38..a09a42f 100644 --- a/test/components/toggle_group_test.exs +++ b/test/components/toggle_group_test.exs @@ -1,9 +1,17 @@ defmodule Corex.ToggleGroupTest do - use ExUnit.Case, async: true + use CorexTest.ComponentCase, async: true alias Corex.ToggleGroup alias Corex.ToggleGroup.Connect + describe "toggle_group/1" do + test "renders" do + html = render_component(&CorexTest.ComponentHelpers.render_toggle_group/1, []) + assert html =~ ~r/data-scope="toggle-group"/ + assert html =~ ~r/data-part="root"/ + end + end + describe "set_value/2" do test "returns JS command with single value" do js = ToggleGroup.set_value("my-toggle-group", ["item-1"]) diff --git a/test/components/tree_view_test.exs b/test/components/tree_view_test.exs index 8565383..d0cfb98 100644 --- a/test/components/tree_view_test.exs +++ b/test/components/tree_view_test.exs @@ -1,9 +1,18 @@ defmodule Corex.TreeViewTest do - use ExUnit.Case, async: true + use CorexTest.ComponentCase, async: true alias Corex.TreeView alias Corex.TreeView.Connect + describe "tree_view/1" do + test "renders" do + html = render_component(&CorexTest.ComponentHelpers.render_tree_view/1, []) + assert html =~ ~r/data-scope="tree-view"/ + assert html =~ ~r/data-part="root"/ + assert html =~ ~r/Item/ + end + end + describe "set_expanded_value/2" do test "returns JS command with list" do js = TreeView.set_expanded_value("my-tree", ["node-1"]) @@ -72,4 +81,312 @@ defmodule Corex.TreeViewTest do assert result["data-part"] == "tree" end end + + describe "Connect.item/1" do + test "returns item attributes" do + assigns = %{ + id: "test-tree", + value: "node-1", + index_path: [0], + disabled: false, + redirect: true, + new_tab: false, + dir: "ltr" + } + + result = Connect.item(assigns) + assert result["id"] == "tree-view:test-tree:item:node-1" + assert result["data-value"] == "node-1" + assert result["style"] == "--depth: 1" + end + + test "adds data-name when name present" do + assigns = %{ + id: "test-tree", + value: "node-1", + index_path: [], + disabled: false, + redirect: true, + new_tab: false, + dir: "ltr", + name: "my-link" + } + + result = Connect.item(assigns) + assert result["data-name"] == "my-link" + end + + test "adds data-redirect when redirect false" do + assigns = %{ + id: "test-tree", + value: "node-1", + index_path: [], + disabled: false, + redirect: false, + new_tab: false, + dir: "ltr" + } + + result = Connect.item(assigns) + assert result["data-redirect"] == "false" + end + + test "adds data-new-tab when new_tab true" do + assigns = %{ + id: "test-tree", + value: "node-1", + index_path: [], + disabled: false, + redirect: true, + new_tab: true, + dir: "ltr" + } + + result = Connect.item(assigns) + assert Map.has_key?(result, "data-new-tab") + end + + test "adds data-selected when selected true" do + assigns = %{ + id: "test-tree", + value: "node-1", + index_path: [], + disabled: false, + redirect: true, + new_tab: false, + dir: "ltr", + selected: true + } + + result = Connect.item(assigns) + assert Map.has_key?(result, "data-selected") + end + + test "adds data-focus when focused true" do + assigns = %{ + id: "test-tree", + value: "node-1", + index_path: [], + disabled: false, + redirect: true, + new_tab: false, + dir: "ltr", + focused: true + } + + result = Connect.item(assigns) + assert Map.has_key?(result, "data-focus") + end + + test "uses depth 0 when index_path is not list" do + assigns = %{ + id: "test-tree", + value: "node-1", + index_path: nil, + disabled: false, + redirect: true, + new_tab: false, + dir: "ltr" + } + + result = Connect.item(assigns) + assert result["style"] == "--depth: 0" + end + end + + describe "Connect.branch/1" do + test "returns branch attributes when expanded" do + assigns = %{ + id: "test-tree", + value: "node-1", + index_path: [0], + expanded: true, + disabled: false, + dir: "ltr" + } + + result = Connect.branch(assigns) + assert result["data-state"] == "open" + assert result["data-part"] == "branch" + end + + test "returns branch attributes when collapsed" do + assigns = %{ + id: "test-tree", + value: "node-1", + index_path: [], + expanded: false, + disabled: false, + dir: "ltr" + } + + result = Connect.branch(assigns) + assert result["data-state"] == "closed" + end + + test "adds data-name when name present" do + assigns = %{ + id: "test-tree", + value: "node-1", + index_path: [], + expanded: false, + disabled: false, + dir: "ltr", + name: "branch-link" + } + + result = Connect.branch(assigns) + assert result["data-name"] == "branch-link" + end + + test "adds data-selected when selected true" do + assigns = %{ + id: "test-tree", + value: "node-1", + index_path: [], + expanded: false, + disabled: false, + dir: "ltr", + selected: true + } + + result = Connect.branch(assigns) + assert Map.has_key?(result, "data-selected") + end + + test "adds data-focus when focused true" do + assigns = %{ + id: "test-tree", + value: "node-1", + index_path: [], + expanded: false, + disabled: false, + dir: "ltr", + focused: true + } + + result = Connect.branch(assigns) + assert Map.has_key?(result, "data-focus") + end + end + + describe "Connect.branch_trigger/1" do + test "returns branch trigger attributes" do + assigns = %{ + id: "test-tree", + value: "node-1", + index_path: [0], + expanded: true, + disabled: false, + dir: "ltr" + } + + result = Connect.branch_trigger(assigns) + assert result["data-part"] == "branch-control" + assert result["data-state"] == "open" + end + end + + describe "Connect.branch_content/1" do + test "returns branch content when expanded" do + assigns = %{ + id: "test-tree", + value: "node-1", + index_path: [], + expanded: true, + dir: "ltr" + } + + result = Connect.branch_content(assigns) + refute Map.has_key?(result, "hidden") + end + + test "adds hidden when collapsed" do + assigns = %{ + id: "test-tree", + value: "node-1", + index_path: [], + expanded: false, + dir: "ltr" + } + + result = Connect.branch_content(assigns) + assert Map.has_key?(result, "hidden") + end + end + + describe "Connect.branch_indicator/1" do + test "returns branch indicator attributes" do + assigns = %{ + id: "test-tree", + value: "node-1", + index_path: [], + expanded: false, + disabled: false, + dir: "ltr" + } + + result = Connect.branch_indicator(assigns) + assert result["data-part"] == "branch-indicator" + assert result["data-state"] == "closed" + end + end + + describe "Connect.branch_text/1" do + test "returns branch text attributes" do + assigns = %{id: "test-tree", value: "node-1", index_path: [], dir: "ltr"} + result = Connect.branch_text(assigns) + assert result["data-part"] == "branch-text" + end + end + + describe "Connect.branch_indent_guide/1" do + test "returns branch indent guide attributes" do + assigns = %{id: "test-tree", value: "node-1", index_path: [0], dir: "ltr"} + result = Connect.branch_indent_guide(assigns) + assert result["data-part"] == "branch-indent-guide" + end + end + + describe "Connect.props/1" do + test "returns props when uncontrolled" do + assigns = %{ + id: "test-tree", + controlled: false, + expanded_value: ["node-1"], + value: ["node-2"], + selection_mode: "single", + dir: "ltr", + on_selection_change: nil, + on_expanded_change: nil, + redirect: true + } + + result = Connect.props(assigns) + assert result["data-default-expanded-value"] == "node-1" + assert result["data-default-selected-value"] == "node-2" + assert result["data-expanded-value"] == nil + assert result["data-selected-value"] == nil + end + + test "returns props when controlled" do + assigns = %{ + id: "test-tree", + controlled: true, + expanded_value: ["node-1"], + value: ["node-2"], + selection_mode: "single", + dir: "ltr", + on_selection_change: nil, + on_expanded_change: nil, + redirect: true + } + + result = Connect.props(assigns) + assert result["data-default-expanded-value"] == nil + assert result["data-default-selected-value"] == nil + assert result["data-expanded-value"] == "node-1" + assert result["data-selected-value"] == "node-2" + end + end end diff --git a/test/content_test.exs b/test/content_test.exs new file mode 100644 index 0000000..6a4fbc8 --- /dev/null +++ b/test/content_test.exs @@ -0,0 +1,121 @@ +defmodule Corex.ContentTest do + use ExUnit.Case, async: true + + alias Corex.Content + alias Corex.Content.Item + + describe "Content.new/1" do + test "returns empty list for empty input" do + assert Content.new([]) == [] + end + + test "creates list of items from keyword lists" do + items = + Content.new([ + [trigger: "T1", content: "C1"], + [trigger: "T2", content: "C2"] + ]) + + assert length(items) == 2 + assert Enum.all?(items, &is_struct(&1, Item)) + assert Enum.at(items, 0).trigger == "T1" + assert Enum.at(items, 0).content == "C1" + assert Enum.at(items, 1).trigger == "T2" + assert Enum.at(items, 1).content == "C2" + end + + test "creates list of items from maps" do + items = + Content.new([ + %{trigger: "T1", content: "C1"}, + %{trigger: "T2", content: "C2"} + ]) + + assert length(items) == 2 + assert Enum.at(items, 0).trigger == "T1" + assert Enum.at(items, 1).trigger == "T2" + end + + test "accepts id, disabled, meta on items" do + items = + Content.new([ + [id: "custom-id", trigger: "T1", content: "C1", disabled: true, meta: %{x: 1}] + ]) + + assert length(items) == 1 + assert Enum.at(items, 0).id == "custom-id" + assert Enum.at(items, 0).disabled == true + assert Enum.at(items, 0).meta == %{x: 1} + end + + test "raises for invalid list format" do + assert_raise ArgumentError, ~r/invalid item format/, fn -> + Content.new(["not", "keyword"]) + end + end + + test "raises for non-list input" do + assert_raise ArgumentError, ~r/Expected a list/, fn -> + Content.new("not a list") + end + + assert_raise ArgumentError, ~r/Expected a list/, fn -> + Content.new(%{}) + end + end + end + + describe "Content.Item.new/1" do + test "creates item with required fields" do + item = Item.new(trigger: "Lorem", content: "Consectetur") + assert item.trigger == "Lorem" + assert item.content == "Consectetur" + assert is_binary(item.id) + assert String.starts_with?(item.id, "content-") + assert item.disabled == false + end + + test "creates item from map" do + item = Item.new(%{trigger: "T", content: "C"}) + assert item.trigger == "T" + assert item.content == "C" + end + + test "accepts explicit id" do + item = Item.new(id: "my-id", trigger: "T", content: "C") + assert item.id == "my-id" + end + + test "raises when trigger missing" do + assert_raise ArgumentError, ~r/Required fields/, fn -> + Item.new(content: "C only") + end + end + + test "raises when content missing" do + assert_raise ArgumentError, ~r/Required fields/, fn -> + Item.new(trigger: "T only") + end + end + + test "raises for non-keyword non-map input" do + assert_raise ArgumentError, ~r/Expected a keyword list or map/, fn -> + Item.new("string") + end + + assert_raise ArgumentError, ~r/Expected a keyword list or map/, fn -> + Item.new(123) + end + end + end + + describe "Content.generate_id/0" do + test "returns unique id string" do + id1 = Content.generate_id() + id2 = Content.generate_id() + assert is_binary(id1) + assert String.starts_with?(id1, "content-") + refute id1 == id2 + end + end +end diff --git a/test/corex/gettext_test.exs b/test/corex/gettext_test.exs new file mode 100644 index 0000000..0a01c3e --- /dev/null +++ b/test/corex/gettext_test.exs @@ -0,0 +1,45 @@ +defmodule Corex.GettextTest do + use ExUnit.Case, async: false + + describe "backend/1" do + test "returns nil when not configured" do + Application.delete_env(:corex, :gettext_backend) + assert Corex.Gettext.backend() == nil + end + end + + describe "gettext/2" do + test "returns msg when backend is nil" do + Application.delete_env(:corex, :gettext_backend) + assert Corex.Gettext.gettext("Hello") == "Hello" + assert Corex.Gettext.gettext("World", []) == "World" + end + + test "translates when backend is configured" do + Application.put_env(:corex, :gettext_backend, CorexTest.Gettext) + assert Corex.Gettext.gettext("Hello") == "Hello" + Application.delete_env(:corex, :gettext_backend) + end + end + + describe "translate_error/1" do + test "returns msg when backend is nil" do + Application.delete_env(:corex, :gettext_backend) + assert Corex.Gettext.translate_error({"Error", []}) == "Error" + end + + test "uses dngettext when count present and backend configured" do + Application.put_env(:corex, :gettext_backend, CorexTest.Gettext) + result = Corex.Gettext.translate_error({"1 file", [count: 2]}) + assert is_binary(result) + Application.delete_env(:corex, :gettext_backend) + end + + test "uses dgettext when count absent and backend configured" do + Application.put_env(:corex, :gettext_backend, CorexTest.Gettext) + result = Corex.Gettext.translate_error({"Invalid", []}) + assert result == "Invalid" + Application.delete_env(:corex, :gettext_backend) + end + end +end diff --git a/test/corex/igniter_test.exs b/test/corex/igniter_test.exs new file mode 100644 index 0000000..3e5c9ca --- /dev/null +++ b/test/corex/igniter_test.exs @@ -0,0 +1,36 @@ +defmodule Corex.IgniterTest do + use ExUnit.Case, async: true + + @moduletag :requires_igniter + + describe "validate_opts!/1" do + @tag :requires_igniter + test "accepts valid theme" do + Corex.Igniter.validate_opts!(theme: "neo:uno") + end + + test "raises when theme has fewer than 2 values" do + assert_raise Mix.Error, ~r/--theme requires at least 2 values/, fn -> + Corex.Igniter.validate_opts!(theme: "neo") + end + end + + @tag :requires_igniter + @tag :requires_igniter + test "accepts valid languages" do + Corex.Igniter.validate_opts!(languages: "en:fr:ar") + end + + test "raises when languages has fewer than 2 values" do + assert_raise Mix.Error, ~r/--languages requires at least 2 values/, fn -> + Corex.Igniter.validate_opts!(languages: "en") + end + end + + @tag :requires_igniter + @tag :requires_igniter + test "accepts empty opts" do + Corex.Igniter.validate_opts!([]) + end + end +end diff --git a/test/corex_test.exs b/test/corex_test.exs new file mode 100644 index 0000000..6d50a46 --- /dev/null +++ b/test/corex_test.exs @@ -0,0 +1,61 @@ +defmodule CorexTest do + use CorexTest.ComponentCase, async: true + + describe "__using__/1" do + test "with default opts imports all components" do + defmodule AllComponents do + use Corex + + def try_code(assigns), do: code(assigns) + end + + result = render_component(&AllComponents.try_code/1, code: "def x, do: 1") + assert is_binary(to_string(result)) + end + + test "with only: [code] imports only specified component" do + defmodule OnlyCode do + use Corex, only: [:code] + + def try_code(assigns), do: code(assigns) + end + + result = render_component(&OnlyCode.try_code/1, code: "x") + assert is_binary(to_string(result)) + + assert_raise UndefinedFunctionError, fn -> + apply(OnlyCode, :action, [%{}]) + end + end + + test "with except: [code] excludes specified component" do + defmodule ExceptCode do + use Corex, except: [:code] + + def try_hidden_input(assigns), do: hidden_input(assigns) + end + + result = render_component(&ExceptCode.try_hidden_input/1, id: "x", name: "x", value: "x") + assert is_binary(to_string(result)) + + assert_raise UndefinedFunctionError, fn -> + apply(ExceptCode, :code, [%{code: "x"}]) + end + end + + test "with prefix generates prefixed functions" do + defmodule PrefixedComponents do + use Corex, prefix: "corex", only: [:code] + + def try_code(assigns), do: corex_code(assigns) + end + + result = render_component(&PrefixedComponents.try_code/1, code: "x") + assert is_binary(to_string(result)) + + assert_raise UndefinedFunctionError, fn -> + apply(PrefixedComponents, :code, [%{code: "x"}]) + end + end + end +end diff --git a/test/form_test.exs b/test/form_test.exs new file mode 100644 index 0000000..8b40adc --- /dev/null +++ b/test/form_test.exs @@ -0,0 +1,24 @@ +defmodule Corex.FormTest do + use ExUnit.Case, async: true + + alias Corex.Form + import Phoenix.Component + + describe "get_form_id/1" do + test "returns id from Phoenix.HTML.Form" do + form = to_form(%{}, as: :user) + assert Form.get_form_id(form) == form.id + assert is_binary(Form.get_form_id(form)) + end + + test "raises for invalid input" do + assert_raise ArgumentError, ~r/expected Ecto.Changeset or Phoenix.HTML.Form/, fn -> + Form.get_form_id(%{}) + end + + assert_raise ArgumentError, ~r/expected Ecto.Changeset or Phoenix.HTML.Form/, fn -> + Form.get_form_id("not a form") + end + end + end +end diff --git a/test/list_test.exs b/test/list_test.exs new file mode 100644 index 0000000..1b41dab --- /dev/null +++ b/test/list_test.exs @@ -0,0 +1,76 @@ +defmodule Corex.ListTest do + use ExUnit.Case, async: true + + alias Corex.List + alias Corex.List.Item + + describe "List.new/1" do + test "returns empty list for empty input" do + assert List.new([]) == [] + end + + test "creates list of items from keyword lists" do + items = + List.new([ + [label: "A", id: "a"], + [label: "B"] + ]) + + assert length(items) == 2 + assert Enum.all?(items, &is_struct(&1, Item)) + assert Enum.at(items, 0).label == "A" + assert Enum.at(items, 0).id == "a" + assert Enum.at(items, 1).label == "B" + assert is_binary(Enum.at(items, 1).id) + end + + test "creates list from maps" do + items = List.new([%{label: "X"}, %{label: "Y"}]) + assert length(items) == 2 + assert Enum.at(items, 0).label == "X" + end + + test "raises for invalid list format" do + assert_raise ArgumentError, ~r/invalid item format/, fn -> + List.new(["a", "b"]) + end + end + + test "raises for non-list input" do + assert_raise ArgumentError, ~r/Expected a list/, fn -> + List.new("not a list") + end + end + end + + describe "List.Item.new/1" do + test "creates item with required label" do + item = Item.new(label: "Foo") + assert item.label == "Foo" + assert is_binary(item.id) + assert String.starts_with?(item.id, "list-") + end + + test "raises when label missing" do + assert_raise ArgumentError, ~r/Required fields/, fn -> + Item.new(id: "x") + end + end + + test "raises for non-keyword non-map input" do + assert_raise ArgumentError, ~r/Expected a keyword list or map/, fn -> + Item.new("string") + end + end + end + + describe "List.generate_id/0" do + test "returns unique id" do + id1 = List.generate_id() + id2 = List.generate_id() + assert is_binary(id1) + assert String.starts_with?(id1, "list-") + refute id1 == id2 + end + end +end diff --git a/test/mix/tasks/corex.code_test.exs b/test/mix/tasks/corex.code_test.exs new file mode 100644 index 0000000..561f52c --- /dev/null +++ b/test/mix/tasks/corex.code_test.exs @@ -0,0 +1,48 @@ +defmodule Mix.Tasks.Corex.CodeTest do + use ExUnit.Case, async: false + + @tag :tmp_dir + test "generates stylesheet at given path", %{tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "assets/css/code_highlight.css") + Mix.Task.reenable("corex.code") + Mix.Task.run("corex.code", [path]) + assert File.exists?(path) + content = File.read!(path) + assert content =~ ~r/\.highlight|\.token|pre/ + end + + @tag :tmp_dir + test "generates stylesheet at custom path", %{tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "custom/syntax.css") + Mix.Task.reenable("corex.code") + Mix.Task.run("corex.code", [path]) + assert File.exists?(path) + content = File.read!(path) + assert is_binary(content) + assert byte_size(content) > 0 + end + + @tag :tmp_dir + test "overwrites with --force", %{tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "code_highlight.css") + File.mkdir_p!(tmp_dir) + File.write!(path, "old content") + Mix.Task.reenable("corex.code") + Mix.Task.run("corex.code", [path, "--force"]) + content = File.read!(path) + refute content == "old content" + assert byte_size(content) > 0 + end + + @tag :tmp_dir + test "raises when file exists without --force", %{tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "existing.css") + File.mkdir_p!(tmp_dir) + File.write!(path, "existing") + Mix.Task.reenable("corex.code") + + assert_raise Mix.Error, ~r/already exists/, fn -> + Mix.Task.run("corex.code", [path]) + end + end +end diff --git a/test/mix/tasks/corex.design_test.exs b/test/mix/tasks/corex.design_test.exs new file mode 100644 index 0000000..dd4ea24 --- /dev/null +++ b/test/mix/tasks/corex.design_test.exs @@ -0,0 +1,64 @@ +defmodule Mix.Tasks.Corex.DesignTest do + use ExUnit.Case, async: false + + @tag :tmp_dir + test "copies design files to given path", %{tmp_dir: tmp_dir} do + target = Path.join(tmp_dir, "assets/corex") + Mix.Task.reenable("corex.design") + Mix.Task.run("corex.design", [target]) + assert File.exists?(target) + assert File.exists?(Path.join(target, "components")) + assert File.exists?(Path.join(target, "main.css")) + end + + @tag :tmp_dir + test "copies design files to custom path", %{tmp_dir: tmp_dir} do + target = Path.join(tmp_dir, "custom/design") + Mix.Task.reenable("corex.design") + Mix.Task.run("corex.design", [target]) + assert File.exists?(target) + assert File.exists?(Path.join(target, "main.css")) + end + + @tag :tmp_dir + test "overwrites existing target with --force", %{tmp_dir: tmp_dir} do + target = Path.join(tmp_dir, "design") + File.mkdir_p!(target) + File.write!(Path.join(target, "marker"), "before") + Mix.Task.reenable("corex.design") + Mix.Task.run("corex.design", [target, "--force"]) + assert File.exists?(target) + assert File.exists?(Path.join(target, "main.css")) + end + + @tag :tmp_dir + test "raises when target exists without --force", %{tmp_dir: tmp_dir} do + target = Path.join(tmp_dir, "existing") + File.mkdir_p!(target) + Mix.Task.reenable("corex.design") + + assert_raise Mix.Error, ~r/already exists/, fn -> + Mix.Task.run("corex.design", [target]) + end + end + + @tag :tmp_dir + test "accepts --designex option", %{tmp_dir: tmp_dir} do + target = Path.join(tmp_dir, "design_with_tokens") + Mix.Task.reenable("corex.design") + Mix.Task.run("corex.design", [target, "--designex"]) + assert File.exists?(target) + assert File.exists?(Path.join(target, "design")) + end + + @tag :tmp_dir + test "excludes design tokens when run without --designex", %{tmp_dir: tmp_dir} do + target = Path.join(tmp_dir, "design_no_tokens") + Mix.Task.reenable("corex.design") + Mix.Task.run("corex.design", [target]) + assert File.exists?(target) + assert File.exists?(Path.join(target, "main.css")) + design_path = Path.join(target, "design") + refute File.dir?(design_path) + end +end diff --git a/test/mix/tasks/corex.gen.auth_test.exs b/test/mix/tasks/corex.gen.auth_test.exs new file mode 100644 index 0000000..37228ac --- /dev/null +++ b/test/mix/tasks/corex.gen.auth_test.exs @@ -0,0 +1,22 @@ +defmodule Mix.Tasks.Corex.Gen.AuthTest do + use ExUnit.Case, async: false + + @tag :tmp_dir + test "raises when Corex templates not found", %{tmp_dir: tmp_dir} do + File.cd!(tmp_dir, fn -> + Mix.Task.reenable("corex.gen.auth") + + assert_raise Mix.Error, ~r/Corex templates not found/, fn -> + Mix.Task.run("corex.gen.auth", []) + end + end) + end + + test "runs phx.gen.auth when templates exist" do + Mix.Task.reenable("corex.gen.auth") + + assert_raise Mix.Error, fn -> + Mix.Task.run("corex.gen.auth", []) + end + end +end diff --git a/test/mix/tasks/corex.gen.html_test.exs b/test/mix/tasks/corex.gen.html_test.exs new file mode 100644 index 0000000..f9736bc --- /dev/null +++ b/test/mix/tasks/corex.gen.html_test.exs @@ -0,0 +1,22 @@ +defmodule Mix.Tasks.Corex.Gen.HtmlTest do + use ExUnit.Case, async: false + + @tag :tmp_dir + test "raises when Corex templates not found", %{tmp_dir: tmp_dir} do + File.cd!(tmp_dir, fn -> + Mix.Task.reenable("corex.gen.html") + + assert_raise Mix.Error, ~r/Corex templates not found/, fn -> + Mix.Task.run("corex.gen.html", []) + end + end) + end + + test "runs phx.gen.html when templates exist" do + Mix.Task.reenable("corex.gen.html") + + assert_raise Mix.Error, fn -> + Mix.Task.run("corex.gen.html", []) + end + end +end diff --git a/test/mix/tasks/corex.gen.live_test.exs b/test/mix/tasks/corex.gen.live_test.exs new file mode 100644 index 0000000..4435272 --- /dev/null +++ b/test/mix/tasks/corex.gen.live_test.exs @@ -0,0 +1,22 @@ +defmodule Mix.Tasks.Corex.Gen.LiveTest do + use ExUnit.Case, async: false + + @tag :tmp_dir + test "raises when Corex templates not found", %{tmp_dir: tmp_dir} do + File.cd!(tmp_dir, fn -> + Mix.Task.reenable("corex.gen.live") + + assert_raise Mix.Error, ~r/Corex templates not found/, fn -> + Mix.Task.run("corex.gen.live", []) + end + end) + end + + test "runs phx.gen.live when templates exist" do + Mix.Task.reenable("corex.gen.live") + + assert_raise Mix.Error, fn -> + Mix.Task.run("corex.gen.live", []) + end + end +end diff --git a/test/mix/tasks/corex.install_test.exs b/test/mix/tasks/corex.install_test.exs index 156410a..4c51845 100644 --- a/test/mix/tasks/corex.install_test.exs +++ b/test/mix/tasks/corex.install_test.exs @@ -25,4 +25,12 @@ defmodule Mix.Tasks.Corex.InstallTest do +| ~w(js/app.js --bundle --format=esm --splitting --target= """) end + + test "adds rtl_locales to config with --rtl" do + phx_test_project(app_name: :corex) + |> Igniter.compose_task("corex.install", ["--no-design", "--yes", "--rtl", "ar"]) + |> assert_has_patch("config/config.exs", """ + +| rtl_locales: ["ar"] + """) + end end diff --git a/test/support/component_case.ex b/test/support/component_case.ex new file mode 100644 index 0000000..db26051 --- /dev/null +++ b/test/support/component_case.ex @@ -0,0 +1,26 @@ +defmodule CorexTest.ComponentCase do + @moduledoc false + + use ExUnit.CaseTemplate + + using do + quote do + @endpoint CorexTest.Endpoint + import Phoenix.LiveViewTest + + defp find_in_html(html, selector) do + {:ok, doc} = Floki.parse_fragment(to_string(html)) + Floki.find(doc, selector) + end + + defp text_in_html(html) do + {:ok, doc} = Floki.parse_fragment(to_string(html)) + Floki.text(doc) + end + end + end + + setup _tags do + :ok + end +end diff --git a/test/support/component_helpers.ex b/test/support/component_helpers.ex new file mode 100644 index 0000000..56bd0fe --- /dev/null +++ b/test/support/component_helpers.ex @@ -0,0 +1,302 @@ +defmodule CorexTest.ComponentHelpers do + @moduledoc false + + use Phoenix.Component + + import Corex.Accordion + import Corex.Action + import Corex.Avatar + import Corex.Carousel + import Corex.Clipboard + import Corex.Collapsible + import Corex.Combobox + import Corex.DatePicker + import Corex.Dialog + import Corex.Editable + import Corex.FloatingPanel + import Corex.Listbox + import Corex.Marquee + import Corex.Menu + import Corex.Navigate + import Corex.Select + import Corex.SignaturePad + import Corex.Tabs + import Corex.Timer + import Corex.ToggleGroup + import Corex.TreeView + + def render_accordion(assigns) do + assigns = + assigns + |> assign_new(:items, fn -> Corex.Content.new([[trigger: "T1", content: "C1"]]) end) + |> assign_new(:orientation, fn -> "vertical" end) + |> assign_new(:collapsible, fn -> true end) + |> assign_new(:multiple, fn -> true end) + |> assign_new(:dir, fn -> nil end) + + ~H""" + <.accordion items={@items} orientation={@orientation} collapsible={@collapsible} multiple={@multiple} dir={@dir} /> + """ + end + + def render_accordion_with_indicator(assigns) do + assigns = + assign_new(assigns, :items, fn -> Corex.Content.new([[trigger: "T1", content: "C1"]]) end) + + ~H""" + <.accordion items={@items}> + <:indicator :let={_item}>! + + """ + end + + def render_accordion_with_custom_slots(assigns) do + assigns = + assign_new(assigns, :items, fn -> Corex.Content.new([[trigger: "T1", content: "C1"]]) end) + + ~H""" + <.accordion items={@items}> + <:trigger :let={_item}>Custom + <:content :let={_item}>Custom content + + """ + end + + def render_avatar(assigns) do + ~H""" + <.avatar><:fallback>JD + """ + end + + def render_carousel(assigns) do + ~H""" + <.carousel items={["/img1.jpg"]}> + <:prev_trigger>Prev + <:next_trigger>Next + + """ + end + + def render_clipboard(assigns) do + ~H""" + <.clipboard value={Map.get(assigns, :value, "text")}> + <:label>Copy + <:trigger>Copy + + """ + end + + def render_collapsible(assigns) do + ~H""" + <.collapsible open={true}> + <:trigger>Toggle + <:content>Content + + """ + end + + def render_combobox(assigns) do + ~H""" + <.combobox collection={[]}> + <:empty>No items + <:trigger>Select + + """ + end + + def render_date_picker(assigns) do + ~H""" + <.date_picker> + <:label>Date + <:trigger>Pick date + + """ + end + + def render_dialog(assigns) do + ~H""" + <.dialog id="test-dialog"> + <:trigger>Open + <:content>Dialog content + + """ + end + + def render_editable(assigns) do + ~H""" + <.editable value="text"> + <:label>Label + <:edit_trigger>Edit + <:submit_trigger>Save + <:cancel_trigger>Cancel + + """ + end + + def render_floating_panel(assigns) do + ~H""" + <.floating_panel> + <:open_trigger>Open + <:closed_trigger>Closed + <:minimize_trigger>Min + <:maximize_trigger>Max + <:default_trigger>Default + <:close_trigger>Close + <:content>Content + + """ + end + + def render_listbox(assigns) do + ~H""" + <.listbox collection={[%{label: "A", id: "a"}]} /> + """ + end + + def render_marquee(assigns) do + ~H""" + <.marquee items={[%{id: "1"}]} duration={10}> + <:item :let={_item}>Item + + """ + end + + def render_menu(assigns) do + ~H""" + <.menu items={Corex.Tree.new([ [label: "Item", id: "1"] ])}> + <:trigger>Menu + + """ + end + + def render_select(assigns) do + ~H""" + <.select collection={[%{label: "A", id: "a"}]}> + <:trigger>Select + + """ + end + + def render_tabs(assigns) do + assigns = assign_new(assigns, :orientation, fn -> "vertical" end) + + ~H""" + <.tabs items={Corex.Content.new([ [trigger: "Tab1", content: "C1"] ])} orientation={@orientation} /> + """ + end + + def render_tabs_trigger(assigns) do + assigns = + assign(assigns, :item, %{ + id: "test-tabs", + value: "tab-1", + values: ["tab-1"], + disabled: false, + orientation: "vertical", + dir: "ltr" + }) + + ~H""" + <.tabs_trigger item={@item}>Tab 1 + """ + end + + def render_tabs_content(assigns) do + assigns = + assign(assigns, :item, %{ + id: "test-tabs", + value: "tab-1", + values: ["tab-1"], + disabled: false, + orientation: "vertical", + dir: "ltr" + }) + + ~H""" + <.tabs_content item={@item}>Content 1 + """ + end + + def render_tabs_custom_slots_only(assigns) do + ~H""" + <.tabs id="custom-tabs" value="tab-2"> + <:trigger value="tab-1">Tab 1 + <:trigger value="tab-2">Tab 2 + <:content value="tab-1">Content 1 + <:content value="tab-2">Content 2 + + """ + end + + def render_tabs_items_with_custom_slots(assigns) do + assigns = + assign_new(assigns, :items, fn -> + Corex.Content.new([ + [trigger: "A", content: "A content"], + [trigger: "B", content: "B content"] + ]) + end) + + ~H""" + <.tabs id="items-custom-tabs" items={@items} value="item-0"> + <:trigger :let={item}>{item.data.trigger} + <:content :let={item}>{item.data.content} + + """ + end + + def render_timer(assigns) do + ~H""" + <.timer start_ms={60_000}> + <:start_trigger>Start + <:pause_trigger>Pause + <:resume_trigger>Resume + <:reset_trigger>Reset + + """ + end + + def render_signature_pad(assigns) do + ~H""" + <.signature_pad name="sig"> + <:label>Sign + <:clear_trigger>Clear + + """ + end + + def render_toggle_group(assigns) do + ~H""" + <.toggle_group> + <:item value="a">A + <:item value="b">B + + """ + end + + def render_tree_view(assigns) do + ~H""" + <.tree_view id="tree-test" items={Corex.Tree.new([ [label: "Item", id: "1"] ])} /> + """ + end + + def render_action(assigns) do + ~H""" + <.action>Click + """ + end + + def render_action_with_opts(assigns) do + ~H""" + <.action type={@type} aria_label={@aria_label} disabled={@disabled}>Save + """ + end + + def render_navigate(assigns) do + ~H""" + <.navigate to={@to} type={@type} external={@external} download={@download} aria_label={@aria_label}> + Link text + + """ + end +end diff --git a/test/support/endpoint.ex b/test/support/endpoint.ex new file mode 100644 index 0000000..c6edb72 --- /dev/null +++ b/test/support/endpoint.ex @@ -0,0 +1,3 @@ +defmodule CorexTest.Endpoint do + use Phoenix.Endpoint, otp_app: :corex +end diff --git a/test/support/gettext.ex b/test/support/gettext.ex new file mode 100644 index 0000000..1cb4c70 --- /dev/null +++ b/test/support/gettext.ex @@ -0,0 +1,4 @@ +defmodule CorexTest.Gettext do + @moduledoc false + use Gettext.Backend, otp_app: :corex +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..409695c 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,3 @@ +Application.ensure_all_started(:phoenix_live_view) + ExUnit.start() diff --git a/test/tree_test.exs b/test/tree_test.exs new file mode 100644 index 0000000..2f3bb7a --- /dev/null +++ b/test/tree_test.exs @@ -0,0 +1,90 @@ +defmodule Corex.TreeTest do + use ExUnit.Case, async: true + + alias Corex.Tree + alias Corex.Tree.Item + + describe "Tree.new/1" do + test "returns empty list for empty input" do + assert Tree.new([]) == [] + end + + test "creates list of items from keyword lists" do + items = + Tree.new([ + [label: "File", id: "file"], + [label: "Edit"] + ]) + + assert length(items) == 2 + assert Enum.all?(items, &is_struct(&1, Item)) + assert Enum.at(items, 0).label == "File" + assert Enum.at(items, 0).id == "file" + assert Enum.at(items, 1).label == "Edit" + end + + test "creates nested items with children" do + items = + Tree.new([ + [label: "File", children: [[label: "New"], [label: "Open"]]] + ]) + + assert length(items) == 1 + assert Enum.at(items, 0).label == "File" + assert length(Enum.at(items, 0).children) == 2 + assert Enum.at(Enum.at(items, 0).children, 0).label == "New" + assert Enum.at(Enum.at(items, 0).children, 1).label == "Open" + end + + test "raises for invalid list format" do + assert_raise ArgumentError, ~r/invalid item format/, fn -> + Tree.new(["a", "b"]) + end + end + + test "raises for non-list input" do + assert_raise ArgumentError, ~r/Expected a list/, fn -> + Tree.new("not a list") + end + end + end + + describe "Tree.Item.new/1" do + test "creates item with required label" do + item = Item.new(label: "Foo") + assert item.label == "Foo" + assert is_binary(item.id) + assert String.starts_with?(item.id, "tree-") + assert item.children == [] + end + + test "creates item with children" do + item = Item.new(label: "Parent", children: [[label: "Child"]]) + assert item.label == "Parent" + assert length(item.children) == 1 + assert Enum.at(item.children, 0).label == "Child" + end + + test "raises when label missing" do + assert_raise ArgumentError, ~r/Required fields/, fn -> + Item.new(id: "x") + end + end + + test "raises for non-keyword non-map input" do + assert_raise ArgumentError, ~r/Expected a keyword list or map/, fn -> + Item.new("string") + end + end + end + + describe "Tree.generate_id/0" do + test "returns unique id" do + id1 = Tree.generate_id() + id2 = Tree.generate_id() + assert is_binary(id1) + assert String.starts_with?(id1, "tree-") + refute id1 == id2 + end + end +end From 904bf466bf007ac4cd304b105a4f3f8be26c4389 Mon Sep 17 00:00:00 2001 From: karimsemmoud Date: Sat, 28 Feb 2026 10:34:13 +0700 Subject: [PATCH 03/21] Update elixir.yml --- .github/workflows/elixir.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 8534035..762db01 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -19,7 +19,9 @@ jobs: steps: - uses: actions/checkout@v4 - + with: + fetch-depth: 0 + - name: Set up Elixir uses: erlef/setup-beam@v1 with: @@ -35,7 +37,10 @@ jobs: - name: Install dependencies run: mix deps.get - + + - name: Install Phoenix archive + run: mix archive.install hex phx_new --force + - name: Run tests and post coverage to Coveralls run: mix coveralls.github From 19e225a2bb8e4df58315eae3473a54b88f2d89a7 Mon Sep 17 00:00:00 2001 From: karimsemmoud Date: Sat, 28 Feb 2026 16:27:20 +0700 Subject: [PATCH 04/21] update test --- coveralls.json | 3 +- lib/corex/igniter.ex | 133 ++++++-- mix.exs | 6 +- mix.lock | 1 + test/components/color_picker_test.exs | 65 ++++ test/components/combobox_test.exs | 65 ++++ test/components/dialog_test.exs | 17 + test/components/listbox_test.exs | 139 ++++++++ test/components/menu_test.exs | 19 ++ test/components/navigate_test.exs | 68 ++++ test/components/password_input_test.exs | 98 ++++++ test/components/radio_group_test.exs | 96 ++++++ test/components/select_test.exs | 69 ++++ test/components/signature_pad_test.exs | 79 +++++ test/components/tabs_test.exs | 11 + test/components/tree_view_test.exs | 62 ++++ test/corex/helpers_test.exs | 88 +++++ test/corex/igniter_test.exs | 127 ++++++++ test/corex/json_test.exs | 34 ++ test/form_test.exs | 16 + .../tasks/corex.install_integration_test.exs | 36 ++ test/mix/tasks/corex.install_test.exs | 106 +++++- test/support/component_helpers.ex | 307 ++++++++++++++++++ test/tree_test.exs | 32 ++ 24 files changed, 1633 insertions(+), 44 deletions(-) create mode 100644 test/corex/helpers_test.exs create mode 100644 test/corex/json_test.exs create mode 100644 test/mix/tasks/corex.install_integration_test.exs diff --git a/coveralls.json b/coveralls.json index aa10181..224f4a1 100644 --- a/coveralls.json +++ b/coveralls.json @@ -3,6 +3,7 @@ "anatomy\\.ex", "lib/corex/flash\\.ex", "lib/corex/positoning\\.ex", - "test/support/endpoint\\.ex" + "test/support/endpoint\\.ex", + "test/support/gettext\\.ex" ] } diff --git a/lib/corex/igniter.ex b/lib/corex/igniter.ex index 328ff60..60abe32 100644 --- a/lib/corex/igniter.ex +++ b/lib/corex/igniter.ex @@ -24,7 +24,10 @@ if Code.ensure_loaded?(Igniter) do def install(igniter, opts) do ensure_phoenix_project!() - {project_path, web_path, otp_app, web_namespace, web_app_str} = project_paths!() + + {project_path, web_path, otp_app, web_namespace, web_app_str} = + project_paths!(igniter) + design? = Keyword.get(opts, :design, true) designex? = Keyword.get(opts, :designex, false) @@ -35,7 +38,7 @@ if Code.ensure_loaded?(Igniter) do do: run_corex_design(igniter, project_path, web_path, designex?), else: igniter end) - |> copy_generator_templates(otp_app) + |> copy_generator_templates(project_path, web_path, otp_app) |> copy_plugs_and_hooks(web_path, web_namespace, web_app_str, opts) app_js_path = Path.relative_to(Path.join(web_path, "assets/js/app.js"), project_path) @@ -144,10 +147,12 @@ if Code.ensure_loaded?(Igniter) do defp maybe_update_app_js(source, content, new_content, app_js_path) do if new_content == content do {:warning, - Igniter.Util.Warning.formatted_warning( - "Could not patch #{app_js_path} (structure may differ). Add manually:", - ~s|import corex from "corex"\nhooks: {...colocatedHooks, ...corex}| - )} + """ + Could not patch #{app_js_path} (structure may differ). Add manually: + + import corex from "corex" + hooks: {...colocatedHooks, ...corex} + """} else Rewrite.Source.update(source, :content, new_content) end @@ -216,16 +221,25 @@ if Code.ensure_loaded?(Igniter) do defp maybe_update_root_layout(source, content, new_content, root_layout_path) do if new_content == content do - {:warning, - Igniter.Util.Warning.formatted_warning( - "Could not patch #{root_layout_path}. Apply manually: set type=\"module\" on script, add data-theme=\"neo\" data-mode=\"light\" to ", - ~s|# | - )} + if already_patched_root_layout?(content) do + Rewrite.Source.update(source, :content, content) + else + {:warning, + Igniter.Util.Warning.formatted_warning( + "Could not patch #{root_layout_path}. Apply manually: set type=\"module\" on script, add data-theme=\"neo\" data-mode=\"light\" to ", + ~s|# | + )} + end else Rewrite.Source.update(source, :content, new_content) end end + defp already_patched_root_layout?(content) do + (content =~ ~r/type="module"/ or not (content =~ ~r/type="text\/javascript"/)) and + (content =~ ~r/data-theme=/ or content =~ ~r/data-mode=/) + end + defp replace_type_script(content) do if content =~ ~r/type="text\/javascript"/ and not (content =~ ~r/type="module"/) do String.replace(content, ~r/type="text\/javascript"/, ~s|type="module"|) @@ -282,8 +296,8 @@ if Code.ensure_loaded?(Igniter) do defp patch_html_helpers_zipper(zipper, web_ex_path) do use_corex? = Igniter.Code.Common.move_to(zipper, fn z -> - Igniter.Code.Function.function_call?(z, :use, 2) and - Igniter.Code.Function.argument_equals?(z, 1, Corex) + Igniter.Code.Function.function_call?(z, :use, [1, 2]) and + Igniter.Code.Function.argument_equals?(z, 0, Corex) end) != :error if use_corex? do @@ -343,7 +357,7 @@ if Code.ensure_loaded?(Igniter) do content -> updated = content - |> patch_data_mode(edit_theme_script?) + |> patch_data_mode(edit_theme_script? or remove_daisy?) |> remove_daisy_css(remove_daisy?) Rewrite.Source.update(source, :content, updated) @@ -365,10 +379,11 @@ if Code.ensure_loaded?(Igniter) do if new_content == content do {:warning, - Igniter.Util.Warning.formatted_warning( - "Could not patch app.css. Add manually after @source:", - String.trim(imports) - )} + """ + Could not patch app.css. Add manually after @source: + + #{String.trim(imports)} + """} else new_content end @@ -394,12 +409,12 @@ if Code.ensure_loaded?(Igniter) do daisyui_theme_block = ~r/\n\s*\/\* daisyUI theme plugin[\s\S]*?@plugin "\.\.\/vendor\/daisyui-theme" \{[^}]*\}\s*/ - daisyui_theme_light = ~r/\n\s*@plugin "\.\.\/vendor\/daisyui-theme" \{[^}]*\}\s*/ + daisyui_theme_any = ~r/@plugin "\.\.\/vendor\/daisyui-theme" \{[^}]*\}\s*/ content |> String.replace(daisyui_plugin, "") |> String.replace(daisyui_theme_block, "") - |> String.replace(daisyui_theme_light, "") + |> String.replace(daisyui_theme_any, "", global: true) end defp remove_daisy_css(content, _), do: content @@ -423,12 +438,12 @@ if Code.ensure_loaded?(Igniter) do def run_setup_phase(igniter, opts) do ensure_phoenix_project!() - {project_path, web_path, otp_app, web_namespace, web_app_str} = project_paths!() + {project_path, web_path, otp_app, web_namespace, web_app_str} = project_paths!(igniter) add_gettext_if_missing(igniter, project_path, web_path, otp_app, web_app_str, web_namespace) design? = Keyword.get(opts, :design, true) designex? = Keyword.get(opts, :designex, false) if design?, do: run_corex_design(igniter, project_path, web_path, designex?) - copy_generator_templates(igniter, otp_app) + copy_generator_templates(igniter, project_path, web_path, otp_app) copy_plugs_and_hooks(igniter, web_path, web_namespace, web_app_str, opts) assigns = @@ -502,7 +517,36 @@ if Code.ensure_loaded?(Igniter) do end end - defp project_paths! do + defp project_paths!(igniter) do + if igniter && Map.get(igniter.assigns || %{}, :test_mode?) do + project_paths_from_igniter(igniter) + else + project_paths_from_mix!() + end + end + + defp project_paths_from_igniter(igniter) do + web_ex_path = + igniter.rewrite.sources + |> Map.keys() + |> Enum.find(fn path -> + path =~ ~r/lib\/[^\/]+_web\.ex\z/ and not String.contains?(path, "/components/") + end) + + if web_ex_path do + web_app_str = Path.basename(web_ex_path, ".ex") + app_str = String.replace_suffix(web_app_str, "_web", "") + otp_app = String.to_atom(app_str) + web_namespace_str = Macro.camelize(app_str) <> "Web" + web_namespace = Module.concat([web_namespace_str]) + project_root = "." + {project_root, project_root, otp_app, web_namespace, web_app_str} + else + project_paths_from_mix!() + end + end + + defp project_paths_from_mix! do project_root = File.cwd!() if Mix.Project.umbrella?() do @@ -618,18 +662,37 @@ if Code.ensure_loaded?(Igniter) do defp add_gettext_config(igniter, _project_path, _web_namespace), do: igniter defp run_corex_design(igniter, project_path, web_path, designex?) do - target = Path.relative_to(Path.join([web_path, "assets", "corex"]), project_path) - args = [target, "--force"] - args = if designex?, do: ["--designex" | args], else: args - suffix = if designex?, do: " --designex", else: "" - Mix.Task.run("corex.design", args) - Igniter.add_notice(igniter, "* running mix corex.design#{suffix}") + if Map.get(igniter.assigns || %{}, :test_mode?) do + igniter + else + target = Path.relative_to(Path.join([web_path, "assets", "corex"]), project_path) + args = [target, "--force"] + args = if designex?, do: ["--designex" | args], else: args + suffix = if designex?, do: " --designex", else: "" + Mix.Task.run("corex.design", args) + Igniter.add_notice(igniter, "* running mix corex.design#{suffix}") + end + end + + defp copy_generator_templates(igniter, _project_path, web_path, otp_app) do + if Map.get(igniter.assigns || %{}, :test_mode?) do + igniter + else + copy_generator_templates_impl(igniter, web_path, otp_app) + end + end + + defp priv_templates_path(web_path, otp_app) do + case :code.priv_dir(otp_app) do + path when is_binary(path) or is_list(path) -> Path.join(path, "templates") + {:error, _} -> Path.join(web_path, "priv/templates") + end end - defp copy_generator_templates(igniter, otp_app) do + defp copy_generator_templates_impl(igniter, web_path, otp_app) do corex_priv = Path.join(:code.priv_dir(:corex), "templates") phoenix_priv = Path.join(:code.priv_dir(:phoenix), "templates") - templates_root = Path.join(:code.priv_dir(otp_app), "templates") + templates_root = priv_templates_path(web_path, otp_app) Enum.reduce( [ @@ -656,6 +719,14 @@ if Code.ensure_loaded?(Igniter) do end defp copy_plugs_and_hooks(igniter, web_path, web_namespace, web_app_str, opts) do + if Map.get(igniter.assigns || %{}, :test_mode?) do + igniter + else + copy_plugs_and_hooks_impl(igniter, web_path, web_namespace, web_app_str, opts) + end + end + + defp copy_plugs_and_hooks_impl(igniter, web_path, web_namespace, web_app_str, opts) do corex_root = :code.lib_dir(:corex) |> Path.dirname() installer_templates = Path.join([corex_root, "installer", "templates"]) diff --git a/mix.exs b/mix.exs index 620fcd4..c36f81f 100644 --- a/mix.exs +++ b/mix.exs @@ -22,10 +22,7 @@ defmodule Corex.MixProject do docs: &docs/0, test_coverage: [ tool: ExCoveralls, - threshold: 90, - ignore_modules: [ - CorexTest.Endpoint - ] + threshold: 85 ] ] end @@ -54,6 +51,7 @@ defmodule Corex.MixProject do {:makeup_syntect, "~> 0.1.0", only: [:dev, :test]}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:floki, "~> 0.38.0", only: :test}, + {:phoenix_ecto, "~> 4.0", only: :test}, {:excoveralls, "~> 0.18", only: :test}, {:igniter, "~> 0.6", optional: true}, {:tidewave, "~> 0.5.5", only: :dev}, diff --git a/mix.lock b/mix.lock index 2ce31a9..f9302a9 100644 --- a/mix.lock +++ b/mix.lock @@ -31,6 +31,7 @@ "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, "phoenix": {:hex, :phoenix, "1.8.4", "0387f84f00071cba8d71d930b9121b2fb3645197a9206c31b908d2e7902a4851", [: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", "c988b1cd3b084eebb13e6676d572597d387fa607dab258526637b4e6c4c08543"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [: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", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.25", "abc1bdf7f148d7f9a003f149834cc858b24290c433b10ef6d1cbb1d6e9a211ca", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, 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.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b8946e474799da1f874eab7e9ce107502c96ca318ed46d19f811f847df270865"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, diff --git a/test/components/color_picker_test.exs b/test/components/color_picker_test.exs index 0720956..c6a5e7e 100644 --- a/test/components/color_picker_test.exs +++ b/test/components/color_picker_test.exs @@ -13,6 +13,71 @@ defmodule Corex.ColorPickerTest do end end + describe "set_open/2" do + test "returns JS command when open is true" do + js = ColorPicker.set_open("my-color-picker", true) + assert %Phoenix.LiveView.JS{} = js + end + + test "returns JS command when open is false" do + js = ColorPicker.set_open("my-color-picker", false) + assert %Phoenix.LiveView.JS{} = js + end + end + + describe "set_open/3" do + test "pushes event to socket" do + socket = %Phoenix.LiveView.Socket{} + result = ColorPicker.set_open(socket, "my-color-picker", true) + assert %Phoenix.LiveView.Socket{} = result + end + end + + describe "set_value/2" do + test "returns JS command for hex string" do + js = ColorPicker.set_value("my-color-picker", "#ff0000") + assert %Phoenix.LiveView.JS{} = js + end + + test "returns JS command for rgba string" do + js = ColorPicker.set_value("my-color-picker", "rgba(255, 0, 0, 1)") + assert %Phoenix.LiveView.JS{} = js + end + end + + describe "set_value/3" do + test "pushes event to socket" do + socket = %Phoenix.LiveView.Socket{} + result = ColorPicker.set_value(socket, "my-color-picker", "#00ff00") + assert %Phoenix.LiveView.Socket{} = result + end + end + + describe "set_format/2" do + test "returns JS command for rgba format" do + js = ColorPicker.set_format("my-color-picker", "rgba") + assert %Phoenix.LiveView.JS{} = js + end + + test "returns JS command for hex format" do + js = ColorPicker.set_format("my-color-picker", "hex") + assert %Phoenix.LiveView.JS{} = js + end + + test "returns JS command for hsla and hsba formats" do + assert %Phoenix.LiveView.JS{} = ColorPicker.set_format("my-color-picker", "hsla") + assert %Phoenix.LiveView.JS{} = ColorPicker.set_format("my-color-picker", "hsba") + end + end + + describe "set_format/3" do + test "pushes event to socket" do + socket = %Phoenix.LiveView.Socket{} + result = ColorPicker.set_format(socket, "my-color-picker", "hex") + assert %Phoenix.LiveView.Socket{} = result + end + end + describe "Initial.parse/1" do test "returns empty map for nil" do result = Initial.parse(nil) diff --git a/test/components/combobox_test.exs b/test/components/combobox_test.exs index ff4a12a..be3e78c 100644 --- a/test/components/combobox_test.exs +++ b/test/components/combobox_test.exs @@ -9,6 +9,71 @@ defmodule Corex.ComboboxTest do assert html =~ ~r/data-scope="combobox"/ assert html =~ ~r/data-part="root"/ end + + test "renders with collection and empty slot" do + html = render_component(&CorexTest.ComponentHelpers.render_combobox_with_items/1, []) + assert html =~ ~r/data-part="empty"/ + assert html =~ ~r/data-part="item".*data-value="a"/ + assert html =~ ~r/A/ + end + + test "renders with custom item slot" do + html = render_component(&CorexTest.ComponentHelpers.render_combobox_with_item_slot/1, []) + assert html =~ ~r/data-part="item-text"/ + assert html =~ ~r/X!/ + end + + test "renders with clear_trigger and item_indicator slots" do + html = + render_component( + &CorexTest.ComponentHelpers.render_combobox_with_clear_and_indicator/1, + [] + ) + + assert html =~ ~r/data-part="clear-trigger"/ + assert html =~ ~r/data-part="item-indicator"/ + end + + test "renders with grouped collection" do + html = render_component(&CorexTest.ComponentHelpers.render_combobox_grouped/1, []) + assert html =~ ~r/data-part="item-group"/ + assert html =~ ~r/item-group-label/ + end + + test "renders with filter false" do + html = render_component(&CorexTest.ComponentHelpers.render_combobox_filter_false/1, []) + assert html =~ ~r/data-scope="combobox"/ + assert html =~ ~r/data-value="c"/ + end + + test "renders with multiple and controlled" do + result = + render_component(&CorexTest.ComponentHelpers.render_combobox_controlled_multiple/1, []) + + assert result =~ ~r/data-value/ + assert result =~ ~r/data-multiple/ + end + + test "renders with errors" do + html = render_component(&CorexTest.ComponentHelpers.render_combobox_with_errors/1, []) + assert html =~ ~r/data-part="error"/ + assert html =~ ~r/Required/ + end + + test "Connect.props with filter false sets data-filter to nil" do + assigns = + Map.merge(default_combobox_props(), %{ + id: "test", + collection: [%{id: "a", label: "A"}], + controlled: false, + value: [], + dir: "ltr", + filter: false + }) + + result = Connect.props(assigns) + assert result["data-filter"] == nil + end end describe "Connect.root/1" do diff --git a/test/components/dialog_test.exs b/test/components/dialog_test.exs index e92df31..01fe392 100644 --- a/test/components/dialog_test.exs +++ b/test/components/dialog_test.exs @@ -124,4 +124,21 @@ defmodule Corex.DialogTest do assert result["aria-label"] == "Close" end end + + describe "dialog/1 with options" do + test "renders with nested dialog_title, dialog_description, dialog_close_trigger" do + html = render_component(&CorexTest.ComponentHelpers.render_dialog_nested_slots/1, []) + assert html =~ ~r/Nested Title/ + assert html =~ ~r/Nested desc/ + assert html =~ ~r/Body/ + end + + test "renders with controlled and title/description slots" do + html = render_component(&CorexTest.ComponentHelpers.render_dialog_controlled/1, []) + assert html =~ ~r/data-scope="dialog"/ + assert html =~ ~r/Title/ + assert html =~ ~r/Description/ + assert html =~ ~r/Content/ + end + end end diff --git a/test/components/listbox_test.exs b/test/components/listbox_test.exs index c749145..6f48948 100644 --- a/test/components/listbox_test.exs +++ b/test/components/listbox_test.exs @@ -36,4 +36,143 @@ defmodule Corex.ListboxTest do assert result["id"] == "listbox:test-listbox:content" end end + + describe "Connect.value_text/1" do + test "returns value text attributes" do + assigns = %{id: "test-listbox"} + result = Connect.value_text(assigns) + assert result["id"] == "listbox:test-listbox:value-text" + assert result["data-part"] == "value-text" + end + end + + describe "Connect.input/1" do + test "returns input attributes" do + assigns = %{id: "test-listbox"} + result = Connect.input(assigns) + assert result["id"] == "listbox:test-listbox:input" + assert result["data-part"] == "input" + end + end + + describe "Connect.item_group/1" do + test "returns item group attributes" do + assigns = %{id: "test-listbox", group_id: "g1"} + result = Connect.item_group(assigns) + assert result["id"] == "listbox:test-listbox:item-group:g1" + assert result["data-part"] == "item-group" + assert result["data-id"] == "g1" + end + end + + describe "Connect.item_group_label/1" do + test "returns item group label attributes" do + assigns = %{id: "test-listbox", html_for: "g1"} + result = Connect.item_group_label(assigns) + assert result["id"] == "listbox:test-listbox:item-group-label:g1" + assert result["data-part"] == "item-group-label" + end + end + + describe "Connect.item/1" do + test "returns item attributes" do + assigns = %{id: "test-listbox", value: "opt1"} + result = Connect.item(assigns) + assert result["id"] == "listbox:test-listbox:item:opt1" + assert result["data-part"] == "item" + assert result["data-value"] == "opt1" + end + end + + describe "Connect.item_text/1" do + test "returns item text attributes" do + assigns = %{id: "test-listbox", item: %{id: "x", label: "X"}} + result = Connect.item_text(assigns) + assert result["id"] == "listbox:test-listbox:item-text:x" + assert result["data-part"] == "item-text" + end + + test "uses value when item has value key" do + assigns = %{id: "test-listbox", item: %{value: "v1", label: "L"}} + result = Connect.item_text(assigns) + assert result["id"] == "listbox:test-listbox:item-text:v1" + end + end + + describe "Connect.item_indicator/1" do + test "returns item indicator attributes" do + assigns = %{id: "test-listbox", item: %{id: "y", label: "Y"}} + result = Connect.item_indicator(assigns) + assert result["id"] == "listbox:test-listbox:item-indicator:y" + assert result["data-part"] == "item-indicator" + end + end + + describe "Connect.props/1" do + test "returns props when controlled" do + assigns = %{ + id: "test-listbox", + collection: [%{id: "a", label: "A"}], + controlled: true, + value: ["a"], + dir: "ltr", + orientation: "vertical", + loop_focus: false, + selection_mode: "single", + select_on_highlight: false, + deselectable: false, + typeahead: false, + disabled: false, + on_value_change: nil, + on_value_change_client: nil + } + + result = Connect.props(assigns) + assert result["data-value"] == "a" + assert result["data-controlled"] == "" + end + + test "returns props when uncontrolled" do + assigns = %{ + id: "test-listbox", + collection: [%{id: "a", label: "A"}], + controlled: false, + value: ["a"], + dir: "ltr", + orientation: "vertical", + loop_focus: false, + selection_mode: "single", + select_on_highlight: false, + deselectable: false, + typeahead: false, + disabled: false, + on_value_change: nil, + on_value_change_client: nil + } + + result = Connect.props(assigns) + assert result["data-default-value"] == "a" + assert result["data-value"] == nil + end + end + + describe "listbox/1 with options" do + test "renders with grouped collection" do + html = render_component(&CorexTest.ComponentHelpers.render_listbox_grouped/1, []) + assert html =~ ~r/data-scope="listbox"/ + end + + test "renders with controlled" do + html = render_component(&CorexTest.ComponentHelpers.render_listbox_controlled/1, []) + assert html =~ ~r/data-scope="listbox"/ + assert html =~ ~r/data-controlled/ + end + + test "renders with Corex.List.Item collection" do + html = render_component(&CorexTest.ComponentHelpers.render_listbox_list_items/1, []) + assert html =~ ~r/data-scope="listbox"/ + assert html =~ ~r/Item 1/ + assert html =~ ~r/Item 2/ + end + end end diff --git a/test/components/menu_test.exs b/test/components/menu_test.exs index 7f2cfd5..22e7b0d 100644 --- a/test/components/menu_test.exs +++ b/test/components/menu_test.exs @@ -10,6 +10,25 @@ defmodule Corex.MenuTest do assert html =~ ~r/data-scope="menu"/ assert html =~ ~r/menu:/ end + + test "renders with grouped items" do + html = render_component(&CorexTest.ComponentHelpers.render_menu_grouped/1, []) + assert html =~ ~r/data-part="item-group"/ + assert html =~ ~r/data-part="item-group-label"/ + end + + test "renders with nested items and custom nested_indicator" do + html = render_component(&CorexTest.ComponentHelpers.render_menu_nested/1, []) + assert html =~ ~r/data-scope="menu"/ + assert html =~ ~r/Share/ + assert html =~ ~r/Messages/ + end + + test "renders with controlled" do + html = render_component(&CorexTest.ComponentHelpers.render_menu_controlled/1, []) + assert html =~ ~r/data-scope="menu"/ + assert html =~ ~r/data-controlled/ + end end describe "set_open/2" do diff --git a/test/components/navigate_test.exs b/test/components/navigate_test.exs index c169c9c..ea21328 100644 --- a/test/components/navigate_test.exs +++ b/test/components/navigate_test.exs @@ -1,6 +1,21 @@ defmodule Corex.NavigateTest do use CorexTest.ComponentCase, async: true + defp render_with_captured_stderr(fun) do + parent = self() + ref = make_ref() + + _ = ExUnit.CaptureIO.capture_io(:stderr, fn -> + send(parent, {ref, fun.()}) + end) + + receive do + {^ref, result} -> result + after + 1000 -> flunk("timeout") + end + end + describe "navigate/1" do test "renders href link by default" do result = @@ -67,5 +82,58 @@ defmodule Corex.NavigateTest do assert [_] = find_in_html(result, ~s(a[aria-label="View profile"])) end + + test "renders patch link" do + result = + render_component(&CorexTest.ComponentHelpers.render_navigate/1, + to: "/items", + type: "patch", + external: false, + download: nil, + aria_label: nil + ) + + assert [_] = find_in_html(result, "[data-phx-link]") + end + + test "renders link with download boolean" do + result = + render_component(&CorexTest.ComponentHelpers.render_navigate/1, + to: "/file.pdf", + type: "href", + external: false, + download: true, + aria_label: nil + ) + + assert [_] = find_in_html(result, "a[download]") + end + + test "drops replace when type href" do + result = + render_with_captured_stderr(fn -> + render_component(&CorexTest.ComponentHelpers.render_navigate_replace/1, to: "/") + end) + + assert [_] = find_in_html(result, "a[href='/']") + end + + test "drops method when type navigate" do + result = + render_with_captured_stderr(fn -> + render_component(&CorexTest.ComponentHelpers.render_navigate_method/1, to: "/") + end) + + assert [_] = find_in_html(result, "[data-phx-link]") + end + + test "drops external when type patch" do + result = + render_with_captured_stderr(fn -> + render_component(&CorexTest.ComponentHelpers.render_navigate_external_patch/1, to: "/") + end) + + assert [_] = find_in_html(result, "[data-phx-link]") + end end end diff --git a/test/components/password_input_test.exs b/test/components/password_input_test.exs index 09e889b..703666b 100644 --- a/test/components/password_input_test.exs +++ b/test/components/password_input_test.exs @@ -10,6 +10,19 @@ defmodule Corex.PasswordInputTest do assert html =~ ~r/data-scope="password-input"/ assert html =~ ~r/data-part="root"/ end + + test "renders with all slots" do + html = render_component(&CorexTest.ComponentHelpers.render_password_input_full/1, []) + assert html =~ ~r/data-scope="password-input"/ + assert html =~ ~r/Password/ + assert html =~ ~r/Show|Hide/ + end + + test "renders with field" do + html = render_component(&CorexTest.ComponentHelpers.render_password_input_with_field/1, []) + assert html =~ ~r/data-scope="password-input"/ + assert html =~ ~r/name="user\[password\]"/ + end end describe "Connect.root/1" do @@ -31,4 +44,89 @@ defmodule Corex.PasswordInputTest do assert result["for"] == "p-input-test-password-input" end end + + describe "Connect.control/1" do + test "returns control attributes" do + assigns = %{id: "test-password", dir: "ltr"} + result = Connect.control(assigns) + assert result["id"] == "password-input:test-password:control" + assert result["data-part"] == "control" + end + end + + describe "Connect.input/1" do + test "returns input attributes with name" do + assigns = %{id: "test-password", dir: "ltr", name: "pass", disabled: false} + result = Connect.input(assigns) + assert result["id"] == "p-input-test-password-input" + assert result["name"] == "pass" + assert result["data-part"] == "input" + end + end + + describe "Connect.visibility_trigger/1" do + test "returns visibility trigger attributes" do + assigns = %{id: "test-password", dir: "ltr"} + result = Connect.visibility_trigger(assigns) + assert result["data-part"] == "visibility-trigger" + assert result["aria-label"] == "Toggle password visibility" + end + end + + describe "Connect.indicator/1" do + test "returns indicator attributes" do + assigns = %{id: "test-password", dir: "ltr"} + result = Connect.indicator(assigns) + assert result["data-part"] == "indicator" + assert result["aria-hidden"] == "true" + end + end + + describe "Connect.props/1" do + test "returns props when controlled visible" do + assigns = %{ + id: "test-password", + controlled_visible: true, + visible: true, + disabled: false, + invalid: false, + read_only: false, + required: false, + ignore_password_managers: false, + name: "pass", + form: nil, + dir: "ltr", + auto_complete: nil, + on_visibility_change: nil, + on_visibility_change_client: nil + } + + result = Connect.props(assigns) + assert result["data-visible"] == "" + assert result["data-default-visible"] == nil + end + + test "returns props when uncontrolled visible" do + assigns = %{ + id: "test-password", + controlled_visible: false, + visible: true, + disabled: false, + invalid: false, + read_only: false, + required: false, + ignore_password_managers: false, + name: nil, + form: nil, + dir: "ltr", + auto_complete: nil, + on_visibility_change: nil, + on_visibility_change_client: nil + } + + result = Connect.props(assigns) + assert result["data-default-visible"] != nil + assert result["data-visible"] == nil + end + end end diff --git a/test/components/radio_group_test.exs b/test/components/radio_group_test.exs index 6e55f3a..81ab491 100644 --- a/test/components/radio_group_test.exs +++ b/test/components/radio_group_test.exs @@ -10,6 +10,42 @@ defmodule Corex.RadioGroupTest do assert html =~ ~r/data-scope="radio-group"/ assert html =~ ~r/data-part="root"/ end + + test "renders with items as maps" do + html = + render_component(&RadioGroup.radio_group/1, + items: [%{value: "a", label: "A"}, %{value: "b", label: "B", disabled: true}] + ) + + assert html =~ ~r/data-scope="radio-group"/ + assert html =~ ~r/Option A|A/ + end + + test "renders with item_control slot" do + html = render_component(&CorexTest.ComponentHelpers.render_radio_group_with_indicator/1, []) + assert html =~ ~r/data-scope="radio-group"/ + assert html =~ ~r/Option A/ + assert html =~ ~r/Option B/ + end + + test "renders with controlled" do + html = render_component(&CorexTest.ComponentHelpers.render_radio_group_controlled/1, []) + assert html =~ ~r/data-scope="radio-group"/ + assert html =~ ~r/data-controlled/ + end + + test "renders with custom item slot" do + html = render_component(&CorexTest.ComponentHelpers.render_radio_group_with_item_slot/1, []) + assert html =~ ~r/data-scope="radio-group"/ + assert html =~ ~r/data-value="x"/ + assert html =~ ~r/X/ + end + + test "renders with form" do + html = render_component(&CorexTest.ComponentHelpers.render_radio_group_with_form/1, []) + assert html =~ ~r/data-scope="radio-group"/ + assert html =~ ~r/data-form/ + end end describe "Connect.root/1" do @@ -102,6 +138,22 @@ defmodule Corex.RadioGroupTest do end end + describe "Connect.item_text/1" do + test "returns item text attributes" do + assigns = %{ + id: "test-radio", + value: "opt-1", + disabled: false, + invalid: false + } + + result = Connect.item_text(assigns) + assert result["id"] == "radio-group:test-radio:item-text:opt-1" + assert result["data-part"] == "item-text" + assert result["data-value"] == "opt-1" + end + end + describe "Connect.item_hidden_input/1" do test "returns item hidden input attributes" do assigns = %{ @@ -121,4 +173,48 @@ defmodule Corex.RadioGroupTest do assert result["name"] == "choice" end end + + describe "Connect.props/1" do + test "returns props when controlled" do + assigns = %{ + id: "test-radio", + value: "opt-1", + controlled: true, + dir: "ltr", + orientation: "vertical", + disabled: false, + invalid: false, + read_only: false, + name: nil, + form: nil, + required: false, + on_value_change: nil, + on_value_change_client: nil + } + + result = Connect.props(assigns) + assert result["data-value"] == "opt-1" + end + + test "returns props when uncontrolled" do + assigns = %{ + id: "test-radio", + value: "opt-1", + controlled: false, + dir: "ltr", + orientation: "vertical", + disabled: false, + invalid: false, + read_only: false, + name: nil, + form: nil, + required: false, + on_value_change: nil, + on_value_change_client: nil + } + + result = Connect.props(assigns) + assert result["data-default-value"] == "opt-1" + end + end end diff --git a/test/components/select_test.exs b/test/components/select_test.exs index b58a5d6..0b282b8 100644 --- a/test/components/select_test.exs +++ b/test/components/select_test.exs @@ -89,6 +89,75 @@ defmodule Corex.SelectTest do result = Connect.props(Map.merge(default_select_props(), assigns)) assert result["data-value"] == "a" end + + test "returns props with redirect" do + assigns = %{ + id: "test-select", + collection: [%{id: "a", label: "A"}], + controlled: false, + value: [], + dir: "ltr", + redirect: true + } + + result = Connect.props(Map.merge(default_select_props(), assigns)) + assert result["data-redirect"] != nil + end + + test "returns props with positioning" do + assigns = %{ + id: "test-select", + collection: [%{id: "a", label: "A"}], + controlled: false, + value: [], + dir: "ltr", + positioning: %{placement: "bottom"} + } + + result = Connect.props(Map.merge(default_select_props(), assigns)) + assert result["data-positioning"] =~ "placement" + end + + test "returns props with on_value_change" do + assigns = %{ + id: "test-select", + collection: [%{id: "a", label: "A"}], + controlled: false, + value: [], + dir: "ltr", + on_value_change: "phx-value-change" + } + + result = Connect.props(Map.merge(default_select_props(), assigns)) + assert result["data-on-value-change"] == "phx-value-change" + end + + test "returns props with on_value_change_client" do + assigns = %{ + id: "test-select", + collection: [%{id: "a", label: "A"}], + controlled: false, + value: [], + dir: "ltr", + on_value_change_client: "on-change-client" + } + + result = Connect.props(Map.merge(default_select_props(), assigns)) + assert result["data-on-value-change-client"] == "on-change-client" + end + end + + describe "select/1 with options" do + test "renders with controlled and multiple" do + html = render_component(&CorexTest.ComponentHelpers.render_select_controlled_multiple/1, []) + assert html =~ ~r/data-scope="select"/ + assert html =~ ~r/data-part="root"/ + end + + test "renders with grouped collection" do + html = render_component(&CorexTest.ComponentHelpers.render_select_grouped/1, []) + assert html =~ ~r/data-scope="select"/ + end end defp default_select_props do diff --git a/test/components/signature_pad_test.exs b/test/components/signature_pad_test.exs index f048100..706d776 100644 --- a/test/components/signature_pad_test.exs +++ b/test/components/signature_pad_test.exs @@ -28,4 +28,83 @@ defmodule Corex.SignaturePadTest do assert result["id"] == "signature-pad:test-signature:control" end end + + describe "Connect.clear_trigger/1" do + test "returns clear trigger attributes without hidden when has_paths" do + assigns = %{id: "test-signature", dir: "ltr", has_paths: true, aria_label: nil} + result = Connect.clear_trigger(assigns) + assert result["data-part"] == "clear-trigger" + refute Map.has_key?(result, "hidden") + end + + test "returns clear trigger with hidden when no paths" do + assigns = %{id: "test-signature", dir: "ltr", has_paths: false, aria_label: nil} + result = Connect.clear_trigger(assigns) + assert result["hidden"] == "true" + end + end + + describe "signature_pad/1 with options" do + test "renders with controlled" do + html = render_component(&CorexTest.ComponentHelpers.render_signature_pad_controlled/1, []) + assert html =~ ~r/data-controlled/ + end + + test "renders with drawing options" do + html = render_component(&CorexTest.ComponentHelpers.render_signature_pad_drawing_opts/1, []) + assert html =~ ~r/data-scope="signature-pad"/ + end + + test "renders with on_draw_end" do + html = render_component(&CorexTest.ComponentHelpers.render_signature_pad_on_draw_end/1, []) + assert html =~ ~r/data-on-draw-end/ + end + + test "renders with field" do + html = render_component(&CorexTest.ComponentHelpers.render_signature_pad_with_field/1, []) + assert html =~ ~r/data-scope="signature-pad"/ + assert html =~ ~r/name="user\[signature\]"/ + end + + test "renders with field value as list" do + html = + render_component(&CorexTest.ComponentHelpers.render_signature_pad_with_field/1, + params: %{"signature" => ["M0 0 L5 5"]} + ) + + assert html =~ ~r/data-scope="signature-pad"/ + end + + test "renders with paths as list" do + html = + render_component(&CorexTest.ComponentHelpers.render_signature_pad_with_paths/1, + paths: ["M0 0 L10 10"] + ) + + assert html =~ ~r/data-scope="signature-pad"/ + assert html =~ ~r/M0 0 L10 10/ + end + + test "renders with paths as JSON string" do + html = + render_component(&CorexTest.ComponentHelpers.render_signature_pad_with_paths/1, + paths: ~s(["M0 0 L10 10"]) + ) + + assert html =~ ~r/data-scope="signature-pad"/ + end + + test "renders with errors slot" do + html = render_component(&CorexTest.ComponentHelpers.render_signature_pad_with_errors/1, []) + assert html =~ ~r/data-scope="signature-pad"/ + assert html =~ ~r/Required/ + end + end + + describe "clear/1" do + test "returns JS command for client-side clear" do + js = Corex.SignaturePad.clear("my-pad") + assert %Phoenix.LiveView.JS{} = js + end + end end diff --git a/test/components/tabs_test.exs b/test/components/tabs_test.exs index bf35f58..d075cf0 100644 --- a/test/components/tabs_test.exs +++ b/test/components/tabs_test.exs @@ -64,6 +64,11 @@ defmodule Corex.TabsTest do js = Tabs.set_value("my-tabs", "tab-1") assert %Phoenix.LiveView.JS{} = js end + + test "returns JS command for nil to close all tabs" do + js = Tabs.set_value("my-tabs", nil) + assert %Phoenix.LiveView.JS{} = js + end end describe "set_value/3" do @@ -72,6 +77,12 @@ defmodule Corex.TabsTest do result = Tabs.set_value(socket, "my-tabs", "tab-1") assert %Phoenix.LiveView.Socket{} = result end + + test "pushes event to socket with nil to close all tabs" do + socket = %Phoenix.LiveView.Socket{} + result = Tabs.set_value(socket, "my-tabs", nil) + assert %Phoenix.LiveView.Socket{} = result + end end describe "Connect.root/1" do diff --git a/test/components/tree_view_test.exs b/test/components/tree_view_test.exs index d0cfb98..36bd186 100644 --- a/test/components/tree_view_test.exs +++ b/test/components/tree_view_test.exs @@ -285,6 +285,36 @@ defmodule Corex.TreeViewTest do assert result["data-part"] == "branch-control" assert result["data-state"] == "open" end + + test "adds data-selected when selected true" do + assigns = %{ + id: "test-tree", + value: "node-1", + index_path: [], + expanded: false, + disabled: false, + dir: "ltr", + selected: true + } + + result = Connect.branch_trigger(assigns) + assert Map.has_key?(result, "data-selected") + end + + test "adds data-focus when focused true" do + assigns = %{ + id: "test-tree", + value: "node-1", + index_path: [], + expanded: false, + disabled: false, + dir: "ltr", + focused: true + } + + result = Connect.branch_trigger(assigns) + assert Map.has_key?(result, "data-focus") + end end describe "Connect.branch_content/1" do @@ -388,5 +418,37 @@ defmodule Corex.TreeViewTest do assert result["data-expanded-value"] == "node-1" assert result["data-selected-value"] == "node-2" end + + test "returns props with redirect false" do + assigns = %{ + id: "test-tree", + controlled: false, + expanded_value: [], + value: [], + selection_mode: "single", + dir: "ltr", + on_selection_change: nil, + on_expanded_change: nil, + redirect: false + } + + result = Connect.props(assigns) + assert result["data-redirect"] == nil + end + end + + describe "tree_view/1 with options" do + test "renders with branch" do + html = render_component(&CorexTest.ComponentHelpers.render_tree_view_with_branch/1, []) + assert html =~ ~r/data-scope="tree-view"/ + assert html =~ ~r/Parent/ + assert html =~ ~r/Child/ + end + + test "renders with controlled" do + html = render_component(&CorexTest.ComponentHelpers.render_tree_view_controlled/1, []) + assert html =~ ~r/data-scope="tree-view"/ + assert html =~ ~r/data-controlled/ + end end end diff --git a/test/corex/helpers_test.exs b/test/corex/helpers_test.exs new file mode 100644 index 0000000..247aad9 --- /dev/null +++ b/test/corex/helpers_test.exs @@ -0,0 +1,88 @@ +defmodule Corex.HelpersTest do + use ExUnit.Case, async: true + + alias Corex.Helpers + + describe "get_boolean/1" do + test "returns empty string for true" do + assert Helpers.get_boolean(true) == "" + end + + test "returns nil for false" do + assert Helpers.get_boolean(false) == nil + end + + test "returns nil for nil" do + assert Helpers.get_boolean(nil) == nil + end + end + + describe "get_boolean/2" do + test "returns empty string when controlled and value is truthy" do + assert Helpers.get_boolean(true, true) == "" + assert Helpers.get_boolean(true, "x") == "" + end + + test "returns nil when controlled and value is falsy" do + assert Helpers.get_boolean(true, false) == nil + assert Helpers.get_boolean(true, nil) == nil + end + + test "returns nil when uncontrolled" do + assert Helpers.get_boolean(false, true) == nil + assert Helpers.get_boolean(false, false) == nil + assert Helpers.get_boolean(nil, true) == nil + end + end + + describe "get_default_boolean/2" do + test "returns empty string when uncontrolled and value is truthy" do + assert Helpers.get_default_boolean(false, true) == "" + end + + test "returns nil when uncontrolled and value is falsy" do + assert Helpers.get_default_boolean(false, false) == nil + assert Helpers.get_default_boolean(false, nil) == nil + end + + test "returns nil when controlled" do + assert Helpers.get_default_boolean(true, true) == nil + assert Helpers.get_default_boolean(true, false) == nil + end + end + + describe "validate_value!/1" do + test "returns empty list for empty input" do + assert Helpers.validate_value!([]) == [] + end + + test "returns list when all elements are strings" do + assert Helpers.validate_value!(["a", "b"]) == ["a", "b"] + assert Helpers.validate_value!(["x"]) == ["x"] + end + + test "raises when list contains non-strings" do + assert_raise ArgumentError, ~r/value must be a list of strings/, fn -> + Helpers.validate_value!([1, 2, 3]) + end + + assert_raise ArgumentError, ~r/value must be a list of strings/, fn -> + Helpers.validate_value!(["a", :atom]) + end + end + + test "raises for non-list input" do + assert_raise ArgumentError, ~r/value must be a list of strings/, fn -> + Helpers.validate_value!("string") + end + + assert_raise ArgumentError, ~r/value must be a list of strings/, fn -> + Helpers.validate_value!(%{}) + end + + assert_raise ArgumentError, ~r/value must be a list of strings/, fn -> + Helpers.validate_value!(123) + end + end + end +end diff --git a/test/corex/igniter_test.exs b/test/corex/igniter_test.exs index 3e5c9ca..af4a5c8 100644 --- a/test/corex/igniter_test.exs +++ b/test/corex/igniter_test.exs @@ -1,8 +1,135 @@ defmodule Corex.IgniterTest do use ExUnit.Case, async: true + import Igniter.Test + @moduletag :requires_igniter + describe "run_setup_phase/2" do + @tag :requires_igniter + test "sets corex_project_paths in assigns" do + igniter = phx_test_project(app_name: :phx_setup) + result = Corex.Igniter.run_setup_phase(igniter, design: false) + + assert {_project_path, _web_path, _web_namespace, _web_app_str} = + result.assigns[:corex_project_paths] + end + end + + describe "run_config_phase/2" do + @tag :requires_igniter + test "adds corex config" do + igniter = + phx_test_project(app_name: :phx_config) + |> Corex.Igniter.run_setup_phase(design: false) + + result = Corex.Igniter.run_config_phase(igniter, []) + + source = Rewrite.source!(result.rewrite, "config/config.exs") + content = Rewrite.Source.get(source, :content) + assert content =~ ~r/config :corex/ + end + + @tag :requires_igniter + test "adds corex config only once when run twice" do + igniter = + phx_test_project(app_name: :phx_config_twice) + |> Corex.Igniter.run_setup_phase(design: false) + + result1 = Corex.Igniter.run_config_phase(igniter, []) + result2 = Corex.Igniter.run_config_phase(result1, []) + + source = Rewrite.source!(result2.rewrite, "config/config.exs") + content = Rewrite.Source.get(source, :content) + config_count = content |> String.split("config :corex") |> length() |> Kernel.-(1) + assert config_count == 1 + end + + @tag :requires_igniter + test "adds rtl config when rtl opts provided" do + igniter = + phx_test_project(app_name: :phx_rtl_config) + |> Corex.Igniter.run_setup_phase(design: false) + + result = Corex.Igniter.run_config_phase(igniter, rtl: "ar:he") + + source = Rewrite.source!(result.rewrite, "config/config.exs") + content = Rewrite.Source.get(source, :content) + assert content =~ ~r/rtl_locales/ + end + end + + describe "run_assets_phase/2" do + @tag :requires_igniter + test "patches app.js and esbuild config" do + igniter = + phx_test_project(app_name: :phx_assets) + |> Corex.Igniter.run_setup_phase(design: false) + + result = Corex.Igniter.run_assets_phase(igniter, []) + + js_source = Rewrite.source!(result.rewrite, "assets/js/app.js") + js_content = Rewrite.Source.get(js_source, :content) + assert js_content =~ ~r/from "corex"/ + + config_source = Rewrite.source!(result.rewrite, "config/config.exs") + config_content = Rewrite.Source.get(config_source, :content) + assert config_content =~ ~r/--format=esm --splitting/ + end + end + + describe "run_layout_phase/2" do + @tag :requires_igniter + test "patches root layout and web module" do + igniter = + phx_test_project(app_name: :phx_layout) + |> Corex.Igniter.run_setup_phase(design: false) + + result = Corex.Igniter.run_layout_phase(igniter, []) + + layout_source = + Rewrite.source!(result.rewrite, "lib/phx_layout_web/components/layouts/root.html.heex") + + layout_content = Rewrite.Source.get(layout_source, :content) + assert layout_content =~ ~r/type="module"/ + assert layout_content =~ ~r/data-theme=/ + + web_source = Rewrite.source!(result.rewrite, "lib/phx_layout_web.ex") + web_content = Rewrite.Source.get(web_source, :content) + assert web_content =~ ~r/use Corex/ + end + end + + describe "run_css_phase/2" do + @tag :requires_igniter + test "patches app.css and removes daisy with --no-daisy" do + igniter = + phx_test_project(app_name: :phx_css_no_daisy) + |> Corex.Igniter.run_setup_phase(design: false) + + result = Corex.Igniter.run_css_phase(igniter, daisy: false) + + source = Rewrite.source!(result.rewrite, "assets/css/app.css") + content = Rewrite.Source.get(source, :content) + refute content =~ ~r/@plugin "\.\.\/vendor\/daisyui/ + refute content =~ ~r/@plugin "\.\.\/vendor\/daisyui-theme/ + assert content =~ ~r/\[data-mode=dark\]/ + end + + @tag :requires_igniter + test "patches app.css with design when daisy not false" do + igniter = + phx_test_project(app_name: :phx_css_design) + |> Corex.Igniter.run_setup_phase(design: true) + + result = Corex.Igniter.run_css_phase(igniter, daisy: true) + + source = Rewrite.source!(result.rewrite, "assets/css/app.css") + content = Rewrite.Source.get(source, :content) + assert content =~ ~r/@import "\.\.\/corex/ + end + end + describe "validate_opts!/1" do @tag :requires_igniter test "accepts valid theme" do diff --git a/test/corex/json_test.exs b/test/corex/json_test.exs new file mode 100644 index 0000000..4f25c93 --- /dev/null +++ b/test/corex/json_test.exs @@ -0,0 +1,34 @@ +defmodule Corex.JsonTest do + use ExUnit.Case, async: true + + alias Corex.Json + + describe "encoder/0" do + test "returns default Jason when not configured" do + Application.delete_env(:corex, :json_library) + assert Json.encoder() == Jason + end + + test "returns configured library when set" do + Application.put_env(:corex, :json_library, Jason) + assert Json.encoder() == Jason + Application.delete_env(:corex, :json_library) + end + end + + describe "encode!/1" do + test "encodes map to JSON string" do + encoded = Json.encode!(%{a: 1, b: 2}) + assert is_binary(encoded) + assert Jason.decode!(encoded) == %{"a" => 1, "b" => 2} + end + + test "encodes list to JSON string" do + assert Json.encode!([1, 2, 3]) == "[1,2,3]" + end + + test "encodes string" do + assert Json.encode!("hello") == "\"hello\"" + end + end +end diff --git a/test/form_test.exs b/test/form_test.exs index 8b40adc..46c9524 100644 --- a/test/form_test.exs +++ b/test/form_test.exs @@ -1,10 +1,26 @@ +defmodule Corex.FormTest.Schema do + use Ecto.Schema + + embedded_schema do + field(:name, :string) + end +end + defmodule Corex.FormTest do use ExUnit.Case, async: true alias Corex.Form + alias Corex.FormTest.Schema import Phoenix.Component describe "get_form_id/1" do + test "returns id from Ecto.Changeset" do + changeset = Ecto.Changeset.change(%Schema{}) + id = Form.get_form_id(changeset) + assert is_binary(id) + assert id == Phoenix.Component.to_form(changeset).id + end + test "returns id from Phoenix.HTML.Form" do form = to_form(%{}, as: :user) assert Form.get_form_id(form) == form.id diff --git a/test/mix/tasks/corex.install_integration_test.exs b/test/mix/tasks/corex.install_integration_test.exs new file mode 100644 index 0000000..4e968e2 --- /dev/null +++ b/test/mix/tasks/corex.install_integration_test.exs @@ -0,0 +1,36 @@ +defmodule Mix.Tasks.Corex.InstallIntegrationTest do + use ExUnit.Case, async: false + + @moduletag :integration + @tag :tmp_dir + @tag timeout: 180_000 + + test "runs full install in real Phoenix project (covers non-test-mode igniter paths)", %{ + tmp_dir: tmp_dir + } do + project_dir = Path.join(tmp_dir, "demo") + corex_path = Path.expand("../../..", __DIR__) + + {_, 0} = + System.cmd("mix", [ + "phx.new", + project_dir, + "--no-ecto", + "--no-mailer", + "--no-install" + ], cd: File.cwd!()) + + {_, 0} = System.cmd("mix", ["deps.get"], cd: project_dir) + + {_output, exit_code} = + System.cmd("mix", ["igniter.install", "corex@path:#{corex_path}", "--yes", "--no-design"], + cd: project_dir + ) + + templates_root = Path.join(project_dir, "priv/templates") + assert File.dir?(Path.join(templates_root, "phx.gen.html")), + "copy_generator_templates should succeed (exit: #{exit_code})" + + assert File.dir?(Path.join(templates_root, "phx.gen.live")) + end +end diff --git a/test/mix/tasks/corex.install_test.exs b/test/mix/tasks/corex.install_test.exs index 4c51845..eae604b 100644 --- a/test/mix/tasks/corex.install_test.exs +++ b/test/mix/tasks/corex.install_test.exs @@ -3,7 +3,7 @@ defmodule Mix.Tasks.Corex.InstallTest do import Igniter.Test test "patches app.js with corex import" do - phx_test_project(app_name: :corex) + phx_test_project(app_name: :phx_patch_js) |> Igniter.compose_task("corex.install", ["--no-design", "--yes"]) |> assert_has_patch("assets/js/app.js", """ +|import corex from "corex" @@ -11,7 +11,7 @@ defmodule Mix.Tasks.Corex.InstallTest do end test "patches app.js with corex hooks" do - phx_test_project(app_name: :corex) + phx_test_project(app_name: :phx_patch_hooks) |> Igniter.compose_task("corex.install", ["--no-design", "--yes"]) |> assert_has_patch("assets/js/app.js", """ +| hooks: {...colocatedHooks, ...corex}, @@ -19,7 +19,7 @@ defmodule Mix.Tasks.Corex.InstallTest do end test "patches esbuild config with format=esm and splitting" do - phx_test_project(app_name: :corex) + phx_test_project(app_name: :phx_esbuild) |> Igniter.compose_task("corex.install", ["--no-design", "--yes"]) |> assert_has_patch("config/config.exs", """ +| ~w(js/app.js --bundle --format=esm --splitting --target= @@ -27,10 +27,100 @@ defmodule Mix.Tasks.Corex.InstallTest do end test "adds rtl_locales to config with --rtl" do - phx_test_project(app_name: :corex) - |> Igniter.compose_task("corex.install", ["--no-design", "--yes", "--rtl", "ar"]) - |> assert_has_patch("config/config.exs", """ - +| rtl_locales: ["ar"] - """) + igniter = + phx_test_project(app_name: :phx_rtl) + |> Igniter.compose_task("corex.install", ["--no-design", "--yes", "--rtl", "ar"]) + + source = Rewrite.source!(igniter.rewrite, "config/config.exs") + content = Rewrite.Source.get(source, :content) + assert content =~ ~r/rtl_locales: \["ar"\]/ + end + + test "does not add use Corex twice when running install twice" do + igniter = + phx_test_project(app_name: :phx_idempotent) + |> Igniter.compose_task("corex.install", ["--no-design", "--yes"]) + |> Igniter.Test.apply_igniter!() + |> Igniter.compose_task("corex.install", ["--no-design", "--yes"]) + + source = Rewrite.source!(igniter.rewrite, "lib/phx_idempotent_web.ex") + content = Rewrite.Source.get(source, :content) + use_corex_matches = Regex.scan(~r/\buse Corex\b/, content) + + assert length(use_corex_matches) == 1, + "Expected exactly one 'use Corex', got #{length(use_corex_matches)}" + end + + test "does not warn when root layout already patched on second run" do + igniter = + phx_test_project(app_name: :phx_idempotent) + |> Igniter.compose_task("corex.install", ["--no-design", "--yes"]) + |> Igniter.Test.apply_igniter!() + |> Igniter.compose_task("corex.install", ["--no-design", "--yes"]) + + source = + Rewrite.source!(igniter.rewrite, "lib/phx_idempotent_web/components/layouts/root.html.heex") + + content = Rewrite.Source.get(source, :content) + assert content =~ ~r/type="module"/, "Expected type=\"module\" on script" + + assert content =~ ~r/data-theme=/ or content =~ ~r/data-mode=/, + "Expected data-theme or data-mode on html" + + root_layout_warning? = + Enum.any?(igniter.warnings, fn w -> + to_string(w) =~ ~r/root\.html\.heex/ and to_string(w) =~ ~r/Could not patch/ + end) + + refute root_layout_warning?, "Should not warn about root layout when already patched" + end + + test "install with --mode adds mode script" do + igniter = + phx_test_project(app_name: :phx_mode) + |> Igniter.compose_task("corex.install", ["--no-design", "--yes", "--mode"]) + + source = Rewrite.source!(igniter.rewrite, "lib/phx_mode_web/components/layouts/root.html.heex") + content = Rewrite.Source.get(source, :content) + assert content =~ ~r/data-mode=/ + end + + test "install with --theme adds theme config" do + igniter = + phx_test_project(app_name: :phx_theme) + |> Igniter.compose_task("corex.install", ["--no-design", "--yes", "--theme", "neo:uno"]) + + source = Rewrite.source!(igniter.rewrite, "lib/phx_theme_web/components/layouts/root.html.heex") + content = Rewrite.Source.get(source, :content) + assert content =~ ~r/data-theme=|data-mode=/ + end + + test "install with design adds corex CSS imports" do + igniter = + phx_test_project(app_name: :phx_design) + |> Igniter.compose_task("corex.install", ["--yes"]) + + source = Rewrite.source!(igniter.rewrite, "assets/css/app.css") + content = Rewrite.Source.get(source, :content) + assert content =~ ~r/@import "\.\.\/corex\/main\.css"/ + end + + test "no-daisy removes daisyui CSS and uses data-mode for dark variant" do + igniter = + phx_test_project(app_name: :phx_no_daisy) + |> Igniter.compose_task("corex.install", ["--no-design", "--no-daisy", "--yes"]) + + source = Rewrite.source!(igniter.rewrite, "assets/css/app.css") + content = Rewrite.Source.get(source, :content) + + refute content =~ ~r/@plugin "\.\.\/vendor\/daisyui/, "Should remove daisyui plugin" + + refute content =~ ~r/@plugin "\.\.\/vendor\/daisyui-theme/, + "Should remove daisyui-theme plugin" + + refute content =~ ~r/\[data-theme=dark\]/, + "Should replace data-theme=dark with data-mode=dark" + + assert content =~ ~r/\[data-mode=dark\]/, "Should use data-mode=dark for dark variant" end end diff --git a/test/support/component_helpers.ex b/test/support/component_helpers.ex index 56bd0fe..d93bd36 100644 --- a/test/support/component_helpers.ex +++ b/test/support/component_helpers.ex @@ -18,6 +18,8 @@ defmodule CorexTest.ComponentHelpers do import Corex.Marquee import Corex.Menu import Corex.Navigate + import Corex.PasswordInput + import Corex.RadioGroup import Corex.Select import Corex.SignaturePad import Corex.Tabs @@ -104,6 +106,73 @@ defmodule CorexTest.ComponentHelpers do """ end + def render_combobox_with_items(assigns) do + ~H""" + <.combobox collection={[%{id: "a", label: "A"}]}> + <:empty>No results + <:trigger>Select + + """ + end + + def render_combobox_with_item_slot(assigns) do + ~H""" + <.combobox collection={[%{id: "x", label: "X"}]}> + <:empty>None + <:item :let={item}>{item.label}! + <:trigger>Open + + """ + end + + def render_combobox_with_clear_and_indicator(assigns) do + ~H""" + <.combobox collection={[%{id: "b", label: "B"}]}> + <:empty>None + <:trigger>Open + <:clear_trigger>Clear + <:item_indicator>Check + + """ + end + + def render_combobox_grouped(assigns) do + ~H""" + <.combobox collection={[%{id: "e1", label: "E1", group: "Europe"}, %{id: "a1", label: "A1", group: "Asia"}]}> + <:empty>None + <:trigger>Open + + """ + end + + def render_combobox_filter_false(assigns) do + ~H""" + <.combobox collection={[%{id: "c", label: "C"}]} filter={false}> + <:empty>None + <:trigger>Open + + """ + end + + def render_combobox_controlled_multiple(assigns) do + ~H""" + <.combobox collection={[%{id: "m1", label: "M1"}, %{id: "m2", label: "M2"}]} controlled value={["m1"]} multiple> + <:empty>None + <:trigger>Open + + """ + end + + def render_combobox_with_errors(assigns) do + ~H""" + <.combobox collection={[]} id="cb" errors={["Required"]}> + <:empty>None + <:trigger>Open + <:error :let={msg}>{msg} + + """ + end + def render_date_picker(assigns) do ~H""" <.date_picker> @@ -122,6 +191,32 @@ defmodule CorexTest.ComponentHelpers do """ end + def render_dialog_nested_slots(assigns) do + ~H""" + <.dialog id="dialog-nested"> + <:trigger>Open + <:content> + <.dialog_title id="dialog-nested">Nested Title + <.dialog_description id="dialog-nested">Nested desc +

Body

+ <.dialog_close_trigger id="dialog-nested">× + + + """ + end + + def render_dialog_controlled(assigns) do + ~H""" + <.dialog id="test-dialog-ctrl" controlled open={false}> + <:trigger>Open + <:title>Title + <:description>Description + <:content>

Content

+ <:close_trigger>Close + + """ + end + def render_editable(assigns) do ~H""" <.editable value="text"> @@ -153,6 +248,24 @@ defmodule CorexTest.ComponentHelpers do """ end + def render_listbox_grouped(assigns) do + ~H""" + <.listbox collection={[%{label: "E1", id: "e1", group: "Europe"}, %{label: "A1", id: "a1", group: "Asia"}]} /> + """ + end + + def render_listbox_list_items(assigns) do + ~H""" + <.listbox collection={[%Corex.List.Item{id: "li-1", label: "Item 1"}, %Corex.List.Item{id: "li-2", label: "Item 2"}]} /> + """ + end + + def render_listbox_controlled(assigns) do + ~H""" + <.listbox collection={[%{label: "A", id: "a"}]} controlled value={["a"]} /> + """ + end + def render_marquee(assigns) do ~H""" <.marquee items={[%{id: "1"}]} duration={10}> @@ -169,6 +282,31 @@ defmodule CorexTest.ComponentHelpers do """ end + def render_menu_grouped(assigns) do + ~H""" + <.menu items={Corex.Tree.new([ [label: "A1", id: "a1", group: "Group A"], [label: "A2", id: "a2", group: "Group A"], [label: "B1", id: "b1", group: "Group B"] ])}> + <:trigger>Menu + + """ + end + + def render_menu_nested(assigns) do + ~H""" + <.menu items={Corex.Tree.new([ [label: "Share", id: "share", children: [ [label: "Messages", id: "messages"] ] ] ])}> + <:trigger>Menu + <:nested_indicator>→ + + """ + end + + def render_menu_controlled(assigns) do + ~H""" + <.menu items={Corex.Tree.new([ [label: "Item", id: "1"] ])} controlled open={false}> + <:trigger>Menu + + """ + end + def render_select(assigns) do ~H""" <.select collection={[%{label: "A", id: "a"}]}> @@ -177,6 +315,22 @@ defmodule CorexTest.ComponentHelpers do """ end + def render_select_controlled_multiple(assigns) do + ~H""" + <.select collection={[%{label: "A", id: "a"}, %{label: "B", id: "b"}]} controlled value={["a"]} multiple> + <:trigger>Select + + """ + end + + def render_select_grouped(assigns) do + ~H""" + <.select collection={[%{label: "E1", id: "e1", group: "Europe"}, %{label: "A1", id: "a1", group: "Asia"}]}> + <:trigger>Select + + """ + end + def render_tabs(assigns) do assigns = assign_new(assigns, :orientation, fn -> "vertical" end) @@ -265,6 +419,67 @@ defmodule CorexTest.ComponentHelpers do """ end + def render_signature_pad_controlled(assigns) do + ~H""" + <.signature_pad id="sig-pad" controlled paths={[]}> + <:label>Sign + <:clear_trigger>Clear + + """ + end + + def render_signature_pad_drawing_opts(assigns) do + ~H""" + <.signature_pad id="sig-pad" drawing_fill="blue" drawing_size={3} drawing_simulate_pressure> + <:label>Sign + <:clear_trigger>Clear + + """ + end + + def render_signature_pad_on_draw_end(assigns) do + ~H""" + <.signature_pad id="sig-pad" on_draw_end="signature_drawn"> + <:label>Sign + <:clear_trigger>Clear + + """ + end + + def render_signature_pad_with_field(assigns) do + params = Map.get(assigns, :params, %{"signature" => nil}) + form = Phoenix.Component.to_form(params, as: :user) + field = form[:signature] + + assigns = assign(assigns, :field, field) + + ~H""" + <.signature_pad field={@field}> + <:label>Sign + <:clear_trigger>Clear + + """ + end + + def render_signature_pad_with_paths(assigns) do + ~H""" + <.signature_pad id="sig-paths" paths={@paths}> + <:label>Sign + <:clear_trigger>Clear + + """ + end + + def render_signature_pad_with_errors(assigns) do + ~H""" + <.signature_pad id="sig-err" errors={["Required"]}> + <:label>Sign + <:clear_trigger>Clear + <:error :let={msg}>{msg} + + """ + end + def render_toggle_group(assigns) do ~H""" <.toggle_group> @@ -280,6 +495,18 @@ defmodule CorexTest.ComponentHelpers do """ end + def render_tree_view_with_branch(assigns) do + ~H""" + <.tree_view id="tree-branch" items={Corex.Tree.new([ [label: "Parent", id: "p", children: [ [label: "Child", id: "c"] ] ] ])} /> + """ + end + + def render_tree_view_controlled(assigns) do + ~H""" + <.tree_view id="tree-ctrl" items={Corex.Tree.new([ [label: "Item", id: "1"] ])} controlled expanded_value={[]} value={[]} /> + """ + end + def render_action(assigns) do ~H""" <.action>Click @@ -299,4 +526,84 @@ defmodule CorexTest.ComponentHelpers do """ end + + def render_navigate_replace(assigns) do + ~H""" + <.navigate to={@to} type="href" replace>Link + """ + end + + def render_navigate_method(assigns) do + ~H""" + <.navigate to={@to} type="navigate" method="post">Link + """ + end + + def render_password_input_full(assigns) do + ~H""" + <.password_input name="pass"> + <:label>Password + <:visible_indicator>Show + <:hidden_indicator>Hide + <:error :let={msg}>{msg} + + """ + end + + def render_password_input_with_field(assigns) do + form = Phoenix.Component.to_form(%{"password" => nil}, as: :user) + field = form[:password] + assigns = assign(assigns, :field, field) + + ~H""" + <.password_input field={@field}> + <:label>Password + <:visible_indicator>Show + <:hidden_indicator>Hide + <:error :let={msg}>{msg} + + """ + end + + def render_radio_group_with_indicator(assigns) do + ~H""" + <.radio_group id="rg-ind" name="choice" items={[["a", "Option A"], ["b", "Option B"]]}> + <:label>Choose + <:item_control>✓ + + """ + end + + def render_radio_group_controlled(assigns) do + ~H""" + <.radio_group id="rg-ctrl" name="choice" items={[["a", "A"]]} controlled value="a"> + <:label>Choose + + """ + end + + def render_radio_group_with_item_slot(assigns) do + ~H""" + <.radio_group id="rg-item" name="choice" items={[["x", "X"], ["y", "Y"]]}> + <:label>Choose + <:item :let={item}> + {item.label} + + + """ + end + + def render_radio_group_with_form(assigns) do + ~H""" + <.radio_group id="rg-form" name="choice" form="my-form" items={[["a", "A"]]}> + <:label>Choose + + """ + end + + def render_navigate_external_patch(assigns) do + ~H""" + <.navigate to={@to} type="patch" external>Link + """ + end end diff --git a/test/tree_test.exs b/test/tree_test.exs index 2f3bb7a..bb802cc 100644 --- a/test/tree_test.exs +++ b/test/tree_test.exs @@ -36,6 +36,26 @@ defmodule Corex.TreeTest do assert Enum.at(Enum.at(items, 0).children, 1).label == "Open" end + test "creates items from maps" do + items = Tree.new([%{label: "Map item", id: "map1"}]) + assert length(items) == 1 + assert Enum.at(items, 0).label == "Map item" + assert Enum.at(items, 0).id == "map1" + end + + test "creates items with group and meta" do + items = + Tree.new([ + [label: "A1", id: "a1", group: "Group A"], + [label: "B1", id: "b1", group: "Group B", meta: %{key: "val"}] + ]) + + assert length(items) == 2 + assert Enum.at(items, 0).group == "Group A" + assert Enum.at(items, 1).group == "Group B" + assert Enum.at(items, 1).meta == %{key: "val"} + end + test "raises for invalid list format" do assert_raise ArgumentError, ~r/invalid item format/, fn -> Tree.new(["a", "b"]) @@ -50,6 +70,12 @@ defmodule Corex.TreeTest do end describe "Tree.Item.new/1" do + test "creates item from map" do + item = Item.new(%{label: "From map"}) + assert item.label == "From map" + assert is_binary(item.id) + end + test "creates item with required label" do item = Item.new(label: "Foo") assert item.label == "Foo" @@ -65,6 +91,12 @@ defmodule Corex.TreeTest do assert Enum.at(item.children, 0).label == "Child" end + test "raises for invalid child type" do + assert_raise ArgumentError, ~r/Invalid child item/, fn -> + Item.new(label: "Parent", children: [[label: "Valid"], 123]) + end + end + test "raises when label missing" do assert_raise ArgumentError, ~r/Required fields/, fn -> Item.new(id: "x") From 7daba3e633f198cac37ed9d490187ff75ddbd617 Mon Sep 17 00:00:00 2001 From: karimsemmoud Date: Sat, 28 Feb 2026 18:31:10 +0700 Subject: [PATCH 05/21] update installer --- .sobelow-conf | 1 + e2e/mix.exs | 4 +- e2e/mix.lock | 7 +- lib/corex/igniter.ex | 300 +++++++++++++----- lib/mix/tasks/corex.install.ex | 6 +- mix.exs | 5 +- mix.lock | 1 + test/components/navigate_test.exs | 7 +- test/corex/igniter_test.exs | 18 +- .../tasks/corex.install_integration_test.exs | 31 +- test/mix/tasks/corex.install_test.exs | 52 ++- 11 files changed, 306 insertions(+), 126 deletions(-) create mode 100644 .sobelow-conf diff --git a/.sobelow-conf b/.sobelow-conf new file mode 100644 index 0000000..6936d78 --- /dev/null +++ b/.sobelow-conf @@ -0,0 +1 @@ +[exit: false, format: "txt", ignore_files: [], ignore: [], out: nil, private: false, router: nil, skip: true, threshold: :low, verbose: false, version: false] \ No newline at end of file diff --git a/e2e/mix.exs b/e2e/mix.exs index 6debd2e..f3b1bf3 100644 --- a/e2e/mix.exs +++ b/e2e/mix.exs @@ -75,9 +75,7 @@ defmodule E2e.MixProject do {:makeup_js, "~> 0.1.0"}, {:wallaby, "~> 0.30", only: :test}, {:a11y_audit, "~> 0.3.1", only: :test}, - {:flagpack, "~> 0.6.0"}, - {:ex_cldr, "~> 2.47"}, - {:ex_cldr_territories, "~> 2.10.0"} + {:flagpack, "~> 0.6.0"} ] end diff --git a/e2e/mix.lock b/e2e/mix.lock index 02f826e..114f44a 100644 --- a/e2e/mix.lock +++ b/e2e/mix.lock @@ -3,7 +3,6 @@ "bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, - "cldr_utils": {:hex, :cldr_utils, "2.29.4", "11437b0bf9a0d57db4eccdf751c49f675a04fa4261c5dae1e23552a0347e25c9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "e72a43e69a3f546979085cbdbeae7e9049998cd21cedfdd796cff9155998114e"}, "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, @@ -11,8 +10,6 @@ "ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [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", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, - "ex_cldr": {:hex, :ex_cldr, "2.47.0", "350cab41e7deac2ab65cedf71e21e055a52927543dc84570abd8c686ac00cb4d", [:mix], [{:cldr_utils, "~> 2.28", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "3e454cbe4354f042795ae0562686e5137d4cfb953f3bc54c87077ac24c17be09"}, - "ex_cldr_territories": {:hex, :ex_cldr_territories, "2.10.0", "2ae852c43b7a6689bcf18f0325f362a71c7ab5496d1c20b5b94867eda7fd95fa", [:mix], [{:ex_cldr, "~> 2.42", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "13f084f9283f8ab1ba5bf3aead936f008341297a8291be6236efaffd1a200e95"}, "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, @@ -45,7 +42,7 @@ "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.24", "1a000a048d5971b61a9efe29a3c4144ca955afd42224998d841c5011a5354838", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, 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.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0c724e6c65f197841cac49d73be4e0f9b93a7711eaa52d2d4d1b9f859c329267"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.25", "abc1bdf7f148d7f9a003f149834cc858b24290c433b10ef6d1cbb1d6e9a211ca", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, 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.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b8946e474799da1f874eab7e9ce107502c96ca318ed46d19f811f847df270865"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "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"}, "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [: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", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, @@ -53,7 +50,7 @@ "postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [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", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, - "swoosh": {:hex, :swoosh, "1.22.0", "0d65a95f89aedb5011af13295742294e309b4b4aaca556858d81e3b372b58abc", [: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]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {: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", "c01ced23d8786d1ee1a03e4c16574290b2ccd6267beb8c81d081c4a34574ef6e"}, + "swoosh": {:hex, :swoosh, "1.22.1", "8450ac62d0a7cb82f0765592037cab2d30cbc7801acd879f77b8f672a9b49f58", [: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]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {: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", "13795cd69e137c7a6b99850b938177fa3713bd6b95e92b3bdcdb29b70e88868e"}, "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, "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"}, diff --git a/lib/corex/igniter.ex b/lib/corex/igniter.ex index 60abe32..3858c93 100644 --- a/lib/corex/igniter.ex +++ b/lib/corex/igniter.ex @@ -2,6 +2,35 @@ if Code.ensure_loaded?(Igniter) do defmodule Corex.Igniter do @moduledoc false + defp assigns_map(igniter) do + case Map.get(igniter, :assigns) do + m when is_map(m) -> m + _ -> %{} + end + end + + defp path_under_root?(path, root) do + abs_path = Path.absname(path) + abs_root = Path.absname(root) |> String.trim_trailing("/") + String.starts_with?(abs_path, abs_root <> "/") or abs_path == abs_root + end + + # sobelow_skip ["DOS.StringToAtom"] + defp safe_to_atom(str) when is_binary(str) do + cond do + byte_size(str) > 128 -> + Mix.raise("App name too long: #{inspect(str)}") + + str =~ ~r/[^a-z0-9_]/ -> + Mix.raise( + "Invalid app name (lowercase letters, digits, underscore only): #{inspect(str)}" + ) + + true -> + String.to_atom(str) + end + end + def validate_opts!(opts) do if theme = Keyword.get(opts, :theme) do themes = String.split(theme, ":", trim: true) @@ -53,15 +82,22 @@ if Code.ensure_loaded?(Igniter) do web_ex_path = Path.relative_to(Path.join(web_path, "lib/#{web_app_str}.ex"), project_path) app_css_path = Path.relative_to(Path.join(web_path, "assets/css/app.css"), project_path) + layouts_path = + Path.relative_to( + Path.join(web_path, "lib/#{web_app_str}/components/layouts.ex"), + project_path + ) + igniter |> add_corex_config(web_namespace) |> add_rtl_config(opts) |> patch_app_js(app_js_path) |> patch_esbuild_config(config_path) - |> patch_root_layout(root_layout_path, web_app_str, opts) + |> patch_root_layout(root_layout_path, web_app_str, design?, opts) + |> patch_layouts(layouts_path, design?, opts) |> patch_html_helpers(web_ex_path) |> patch_app_css(app_css_path, design?, opts) - |> remove_daisy_vendor(web_path, opts) + |> remove_daisy_vendor(web_path, design?) end def add_corex_config(igniter, web_namespace) do @@ -187,16 +223,13 @@ if Code.ensure_loaded?(Igniter) do end end - def patch_root_layout(igniter, root_layout_path, _web_app_str, opts) do + def patch_root_layout(igniter, root_layout_path, _web_app_str, design?, _opts) do if Igniter.exists?(igniter, root_layout_path) do - remove_theme_script? = - opts[:daisy] == false || Keyword.get(opts, :mode) || Keyword.get(opts, :theme) - igniter |> Igniter.include_existing_file(root_layout_path, required?: false) |> Igniter.update_file( root_layout_path, - &patch_root_layout_content(&1, root_layout_path, remove_theme_script?), + &patch_root_layout_content(&1, root_layout_path, design?), required?: false ) else @@ -204,31 +237,34 @@ if Code.ensure_loaded?(Igniter) do end end - defp patch_root_layout_content(source, root_layout_path, remove_theme_script?) do + defp patch_root_layout_content(source, root_layout_path, design?) do content = source.content new_content = content |> replace_type_script() - |> replace_html_attrs() - |> maybe_theme_script(remove_theme_script?) + |> maybe_replace_html_attrs(design?) + |> maybe_theme_script(design?) - maybe_update_root_layout(source, content, new_content, root_layout_path) + maybe_update_root_layout(source, content, new_content, root_layout_path, design?) end + defp maybe_replace_html_attrs(content, true), do: replace_html_attrs(content) + defp maybe_replace_html_attrs(content, _), do: content + defp maybe_theme_script(content, true), do: remove_theme_script(content) - defp maybe_theme_script(content, _), do: patch_theme_script_to_data_mode(content) + defp maybe_theme_script(content, _), do: content - defp maybe_update_root_layout(source, content, new_content, root_layout_path) do + defp maybe_update_root_layout(source, content, new_content, root_layout_path, design?) do if new_content == content do - if already_patched_root_layout?(content) do - Rewrite.Source.update(source, :content, content) - else + if design? and not already_patched_root_layout?(content) do {:warning, Igniter.Util.Warning.formatted_warning( "Could not patch #{root_layout_path}. Apply manually: set type=\"module\" on script, add data-theme=\"neo\" data-mode=\"light\" to ", ~s|# | )} + else + Rewrite.Source.update(source, :content, content) end else Rewrite.Source.update(source, :content, new_content) @@ -260,25 +296,101 @@ if Code.ensure_loaded?(Igniter) do end end - defp patch_theme_script_to_data_mode(content) do - if content =~ ~r/removeAttribute\("data-theme"\)/ do - content - |> String.replace(~r/removeAttribute\("data-theme"\)/, ~s|removeAttribute("data-mode")|) - |> String.replace( - ~r/setAttribute\("data-theme", theme\)/, - ~s|setAttribute("data-mode", theme)| + defp remove_theme_script(content) do + regex = ~r/\s* + #{String.trim(scripts)} + + + {@inner_content} + + + """ + end + + defp mode_script do + """ + + """ + end + + defp theme_script(theme_opts) when is_binary(theme_opts) do + themes = theme_opts |> String.split(":", trim: true) + themes_json = themes |> Enum.map(&("\"" <> &1 <> "\"")) |> Enum.join(", ") + + """ + + """ + end + + def patch_layouts(igniter, layouts_path, design?, opts) do + if Igniter.exists?(igniter, layouts_path) and design? do + igniter + |> Igniter.include_existing_file(layouts_path, required?: false) + |> Igniter.update_file( + layouts_path, + &patch_layouts_content(&1, design?, opts), + required?: false + ) + else + igniter + end + end + + defp patch_layouts_content(source, design?, opts) do + content = source.content + + updated = + content + |> remove_daisy_theme_toggle(design?) + |> add_layout_toggle(Keyword.get(opts, :mode), :mode_toggle) + |> add_layout_toggle(Keyword.get(opts, :theme), :theme_toggle) + + Rewrite.Source.update(source, :content, updated) + end + + defp remove_daisy_theme_toggle(content, true) do + li_regex = ~r/\s*
  • \s*<\.theme_toggle \/>\s*<\/li>/ + + theme_toggle_fn = + ~r/\n @doc """\s*\n Provides dark vs light theme toggle[\s\S]*?def theme_toggle\(assigns\) do\s*\n ~H"""\s*\n[\s\S]*?data-phx-theme[\s\S]*?"""\s*\n end/ + + content + |> String.replace(li_regex, "") + |> String.replace(theme_toggle_fn, "") + end + + defp remove_daisy_theme_toggle(content, _), do: content + + defp add_layout_toggle(content, nil, _), do: content + defp add_layout_toggle(content, false, _), do: content + + defp add_layout_toggle(content, _value, :mode_toggle) do + if content =~ ~r/<\.mode_toggle/ do + content + else + li = """ +
  • + <.mode_toggle /> +
  • + """ + + mode_toggle_fn = + "\n @doc \"\"\"\n Provides dark vs light mode toggle. See root.html.heex script for phx:set-mode.\n \"\"\"\n def mode_toggle(assigns) do\n ~H\"\"\"\n
    \n
    \n \n <.icon name=\"hero-computer-desktop-micro\" class=\"size-4 opacity-75 hover:opacity-100\" />\n \n \n <.icon name=\"hero-sun-micro\" class=\"size-4 opacity-75 hover:opacity-100\" />\n \n \n <.icon name=\"hero-moon-micro\" class=\"size-4 opacity-75 hover:opacity-100\" />\n \n
    \n \"\"\"\n end\n" + + content + |> String.replace( + ~r/(
  • \s* "\n \\1" + ) + |> String.replace(~r/\nend\s*\z/, mode_toggle_fn <> "end\n") + end + end + + defp add_layout_toggle(content, themes, :theme_toggle) when is_binary(themes) do + if content =~ ~r/<\.theme_toggle/ do + content + else + themes_list = String.split(themes, ":", trim: true) + + li = """ +
  • + <.theme_toggle theme={assigns[:theme] || "neo"} /> +
  • + """ + + collection = Enum.map(themes_list, fn t -> %{id: t, label: String.capitalize(t)} end) + + theme_toggle_fn = + "\n attr :theme, :string, default: \"neo\", doc: \"current theme from cookie/session\"\n\n @doc \"\"\"\n Provides theme selection. Requires ThemePlug and phx:set-theme script in root.\n \"\"\"\n def theme_toggle(assigns) do\n ~H\"\"\"\n <.select\n id=\"theme-select\"\n class=\"select select--sm select--micro\"\n collection={" <> + inspect(collection) <> + "}\n value={[@theme]}\n on_value_change_client=\"phx:set-theme\"\n >\n <:label class=\"sr-only\">Theme\n <:item :let={item}>{item.label}\n <:trigger>\n <.icon name=\"hero-swatch\" />\n \n <:item_indicator>\n <.icon name=\"hero-check\" />\n \n \n \"\"\"\n end\n" + + content + |> String.replace( + ~r/(
  • \s* "\n \\1" + ) + |> String.replace(~r/\nend\s*\z/, theme_toggle_fn <> "end\n") + end + end + + def patch_html_helpers(igniter, web_ex_path, opts) do + if Igniter.exists?(igniter, web_ex_path) do + igniter + |> Igniter.include_existing_file(web_ex_path, required?: false) + |> Igniter.update_elixir_file( + web_ex_path, + &patch_html_helpers_zipper(&1, web_ex_path, opts), + required?: false + ) + else + igniter + end + end + + defp patch_html_helpers_zipper(zipper, web_ex_path, opts) do + use_corex? = + Igniter.Code.Common.move_to(zipper, fn z -> + Igniter.Code.Function.function_call?(z, :use, [1, 2]) and + Igniter.Code.Function.argument_equals?(z, 0, Corex) + end) != :error + + if use_corex? do + {:ok, zipper} + else + add_use_corex_or_warn(zipper, web_ex_path, opts) + end + end + + defp add_use_corex_or_warn(zipper, web_ex_path, opts) do + use_corex_code = build_use_corex(opts) + + import_predicate = fn z -> + Igniter.Code.Function.function_call?(z, :import, 1) and + Igniter.Code.Function.argument_matches_predicate?(z, 0, fn arg_z -> + str = Sourceror.to_string(Sourceror.Zipper.node(arg_z)) + is_binary(str) and str =~ ~r/\.CoreComponents/ + end) + end + + case Igniter.Code.Common.move_to(zipper, import_predicate) do + {:ok, import_zipper} -> + {:ok, Igniter.Code.Common.add_code(import_zipper, use_corex_code, placement: :after)} + + :error -> + {:warning, + Igniter.Util.Warning.formatted_warning( + "Could not patch #{web_ex_path}. Add manually in html/0 block:", + use_corex_code + )} + end + end + + defp build_use_corex(opts) do + prefix = Keyword.get(opts, :prefix) + only_str = Keyword.get(opts, :only) + + cond do + prefix != nil and only_str != nil -> + atoms = + only_str + |> String.split(":", trim: true) + |> Enum.map(&String.to_atom/1) + |> Enum.filter(&(&1 in Corex.component_keys())) + + ~s|use Corex, only: #{inspect(atoms)}, prefix: "#{prefix}"| + + only_str != nil -> + atoms = + only_str + |> String.split(":", trim: true) + |> Enum.map(&String.to_atom/1) + |> Enum.filter(&(&1 in Corex.component_keys())) + + ~s|use Corex, only: #{inspect(atoms)}| + + prefix != nil and prefix != "" -> + ~s|use Corex, prefix: "#{prefix}"| + + true -> + "use Corex" + end + end + + def patch_app_css(igniter, app_css_path, design?, preserve?, _opts) do + if Igniter.exists?(igniter, app_css_path) and design? do + igniter + |> Igniter.include_existing_file(app_css_path, required?: false) + |> Igniter.update_file( + app_css_path, + &patch_app_css_content(&1, design?, preserve?), + required?: false + ) + else + igniter + end + end + + defp patch_app_css_content(source, design?, preserve?) do + case add_corex_imports(source.content, design?) do + {:warning, _} = warn -> + warn + + content -> + updated = + content + |> patch_data_mode(design?) + |> remove_daisy_css(preserve?) + + Rewrite.Source.update(source, :content, updated) + end + end + + def remove_daisy_vendor_files(igniter, web_path, project_path, preserve?, design?) do + if design? and not preserve? do + vendor_path = Path.join(web_path, "assets/vendor") + daisyui_js = Path.relative_to(Path.join(vendor_path, "daisyui.js"), project_path) + + daisyui_theme_js = + Path.relative_to(Path.join(vendor_path, "daisyui-theme.js"), project_path) + + igniter + |> maybe_rm(project_path, daisyui_js) + |> maybe_rm(project_path, daisyui_theme_js) + else + igniter + end + end + + defp maybe_rm(igniter, project_path, rel_path) do + abs_path = Path.join(project_path, rel_path) + if File.exists?(abs_path), do: Igniter.rm(igniter, rel_path), else: igniter + end + + defp remove_daisy_css(content, true), do: content + + defp remove_daisy_css(content, false) do + content + |> String.replace(~r/\s*@import "daisyui";\s*\n/, "") + |> String.replace(~r/\s*@import "daisyui\/css\/unstyled";\s*\n/, "") + |> String.replace(~r/\s*@import "daisyui\/css\/styled";\s*\n/, "") + |> remove_daisy_plugin_blocks() + end + + defp remove_daisy_plugin_blocks(content) do + content + |> String.replace( + ~r/\n\s*\/\* daisyUI Tailwind Plugin[\s\S]*?@plugin "\.\.\/vendor\/daisyui" \{\s*themes: false;\s*\}\s*\n/, + "\n" + ) + |> String.replace( + ~r/\n\s*\/\* daisyUI theme plugin[\s\S]*?@plugin "\.\.\/vendor\/daisyui-theme" \{\n[\s\S]*?\n\}\s*\n/, + "\n" + ) + |> String.replace( + ~r/\n\s*@plugin "\.\.\/vendor\/daisyui-theme" \{\n[\s\S]*?\n\}\s*\n/, + "\n", + global: true + ) + end + + defp add_corex_imports(content, true) do + if content =~ ~r/@import "\.\.\/corex\/main\.css"/ do + content + else + imports = """ + @import "../corex/main.css"; + @import "../corex/tokens/themes/neo/light.css"; + @import "../corex/components/typo.css"; + @import "../corex/components/button.css"; + @import "../corex/components/toast.css"; + + """ + + new_content = String.replace(content, ~r/((?:@source "[^"]+";\s*\n)+)/, "\\1\n#{imports}") + + if new_content == content do + {:warning, + """ + Could not patch app.css. Add manually after @source: + + #{String.trim(imports)} + """} + else + new_content + end + end + end + + defp add_corex_imports(content, _), do: content + + defp patch_data_mode(content, true) do + if content =~ ~r/\[data-theme=dark\]/ do + String.replace(content, ~r/\[data-theme=dark\]/, "[data-mode=dark]", global: true) + else + content + end + end + + defp patch_data_mode(content, _), do: content + + def run_setup_phase(igniter, opts) do + ensure_phoenix_project!() + {project_path, web_path, otp_app, web_namespace, web_app_str} = project_paths!(igniter) + add_gettext_if_missing(igniter, project_path, web_path, otp_app, web_app_str, web_namespace) + design? = Keyword.get(opts, :design, true) + designex? = Keyword.get(opts, :designex, false) + if design?, do: run_corex_design(igniter, project_path, web_path, designex?) + copy_generator_templates(igniter, project_path, web_path, otp_app) + copy_plugs_and_hooks(igniter, web_path, web_namespace, web_app_str, opts) + + assigns = + Map.put( + assigns_map(igniter), + :corex_project_paths, + {project_path, web_path, web_namespace, web_app_str} + ) + + %{igniter | assigns: assigns} + end + + def run_config_phase(igniter, opts) do + {_project_path, _web_path, web_namespace, _web_app_str} = + assigns_map(igniter)[:corex_project_paths] + + igniter + |> add_corex_config(web_namespace) + |> add_rtl_config(opts) + end + + def run_assets_phase(igniter, opts) do + {project_path, web_path, _web_namespace, _web_app_str} = + assigns_map(igniter)[:corex_project_paths] + + app_js_path = Path.relative_to(Path.join(web_path, "assets/js/app.js"), project_path) + config_path = Path.join("config", "config.exs") + + igniter + |> patch_app_js(app_js_path, opts) + |> patch_esbuild_config(config_path) + end + + def run_layout_phase(igniter, opts) do + {project_path, web_path, web_namespace, web_app_str} = + assigns_map(igniter)[:corex_project_paths] + + design? = Keyword.get(opts, :design, true) + preserve? = Keyword.get(opts, :preserve, false) + + root_layout_path = + Path.relative_to( + Path.join(web_path, "lib/#{web_app_str}/components/layouts/root.html.heex"), + project_path + ) + + layouts_path = + Path.relative_to( + Path.join(web_path, "lib/#{web_app_str}/components/layouts.ex"), + project_path + ) + + web_ex_path = Path.relative_to(Path.join(web_path, "lib/#{web_app_str}.ex"), project_path) + + igniter + |> patch_root_layout(root_layout_path, web_app_str, design?, preserve?, opts) + |> create_corex_root(web_path, web_app_str, project_path, design?, preserve?, opts) + |> add_corex_app_to_layouts(layouts_path, preserve?, design?) + |> patch_html_helpers(web_ex_path, opts) + |> create_corex_page(web_path, web_app_str, project_path, web_namespace, preserve?, design?) + |> replace_root_route(web_path, web_app_str, project_path, web_namespace, preserve?) + |> remove_daisy_vendor_files(web_path, project_path, preserve?, design?) + end + + def run_css_phase(igniter, opts) do + {project_path, web_path, _web_namespace, _web_app_str} = + assigns_map(igniter)[:corex_project_paths] + + app_css_path = Path.relative_to(Path.join(web_path, "assets/css/app.css"), project_path) + design? = Keyword.get(opts, :design, true) + preserve? = Keyword.get(opts, :preserve, false) + + igniter + |> patch_app_css(app_css_path, design?, preserve?, opts) + end + + defp ensure_phoenix_project! do + unless Code.ensure_loaded?(Phoenix) do + Mix.raise(""" + Corex install requires a Phoenix project. + Create one first with: + + mix phx.new my_app + """) + end + end + + defp project_paths!(igniter) do + if igniter && Map.get(assigns_map(igniter), :test_mode?) do + project_paths_from_igniter(igniter) + else + project_paths_from_mix!() + end + end + + defp project_paths_from_igniter(igniter) do + web_ex_path = + igniter.rewrite.sources + |> Map.keys() + |> Enum.find(fn path -> + path =~ ~r/lib\/[^\/]+_web\.ex\z/ and not String.contains?(path, "/components/") + end) + + if web_ex_path do + web_app_str = Path.basename(web_ex_path, ".ex") + app_str = String.replace_suffix(web_app_str, "_web", "") + otp_app = safe_to_atom(app_str) + web_namespace_str = Macro.camelize(app_str) <> "Web" + web_namespace = Module.concat([web_namespace_str]) + project_root = "." + {project_root, project_root, otp_app, web_namespace, web_app_str} + else + project_paths_from_mix!() + end + end + + defp project_paths_from_mix! do + project_root = File.cwd!() + + if Mix.Project.umbrella?() do + apps_path = Path.join(project_root, "apps") + web_apps = File.ls!(apps_path) |> Enum.filter(&String.ends_with?(&1, "_web")) + web_app_name = List.first(web_apps) || Mix.raise("No *_web app found in apps/") + web_path = Path.join(apps_path, web_app_name) + + web_namespace = + web_app_name + |> String.replace_suffix("_web", "") + |> Macro.camelize() + |> Kernel.<>("Web") + + web_namespace_mod = Module.concat([web_namespace]) + web_app_atom = safe_to_atom(web_app_name) + {project_root, web_path, web_app_atom, web_namespace_mod, web_app_name} + else + app_name = Mix.Project.config()[:app] + web_namespace_str = app_name |> to_string() |> Macro.camelize() |> Kernel.<>("Web") + web_namespace = Module.concat([web_namespace_str]) + web_app_dir = to_string(app_name) <> "_web" + {project_root, project_root, app_name, web_namespace, web_app_dir} + end + end + + defp add_gettext_if_missing( + igniter, + project_path, + web_path, + otp_app, + web_app_str, + web_namespace + ) do + gettext_path = + Path.relative_to(Path.join([web_path, "lib", web_app_str, "gettext.ex"]), project_path) + + if Igniter.exists?(igniter, gettext_path) do + igniter + else + igniter + |> add_gettext_dep(project_path, web_path) + |> create_gettext_module(project_path, web_path, web_app_str, web_namespace) + |> create_gettext_files(project_path, otp_app) + |> add_gettext_config(project_path, web_namespace) + |> then(&Igniter.add_notice(&1, "* adding gettext")) + end + end + + defp add_gettext_dep(igniter, project_path, web_path) do + mix_path = Path.relative_to(Path.join(web_path, "mix.exs"), project_path) + + if Igniter.exists?(igniter, mix_path) do + igniter + |> Igniter.include_existing_file(mix_path, required?: true) + |> Igniter.update_file(mix_path, &patch_mix_gettext_dep/1) + else + igniter + end + end + + defp patch_mix_gettext_dep(source) do + if source.content =~ ~r/:gettext,/ do + source + else + insert = ~s/ {:gettext, "~> 1.0"},/ + + new_content = + String.replace( + source.content, + ~r/\{:telemetry_poller, "~> 1\.0"\}/, + insert <> "\n {:telemetry_poller, \"~> 1.0\"}" + ) + + Rewrite.Source.update(source, :content, new_content) + end + end + + defp create_gettext_module(igniter, project_path, web_path, web_app_str, web_namespace) do + lib_web = Path.relative_to(Path.join([web_path, "lib", web_app_str]), project_path) + gettext_path = Path.join(lib_web, "gettext.ex") + + igniter + |> Igniter.mkdir(lib_web) + |> Igniter.create_new_file(gettext_path, """ + defmodule #{inspect(web_namespace)}.Gettext do + @moduledoc false + use Gettext.Backend, otp_app: #{inspect(safe_to_atom(web_app_str))} + end + """) + end + + defp create_gettext_files(igniter, project_path, otp_app) do + gettext_priv = Path.join(Path.relative_to(:code.priv_dir(otp_app), project_path), "gettext") + errors_pot = Path.join(gettext_priv, "errors.pot") + en_po = Path.join(gettext_priv, "en/LC_MESSAGES/errors.po") + + igniter + |> Igniter.mkdir(Path.join(gettext_priv, "en/LC_MESSAGES")) + |> Igniter.create_new_file(errors_pot, """ + ## This is a PO Template file. + ## Run `mix gettext.extract` to bring this file up to date. + msgid "" + msgstr "" + """) + |> Igniter.create_new_file(en_po, """ + msgid "" + msgstr "" + "Language: en\\n" + """) + end + + defp add_gettext_config(igniter, _project_path, _web_namespace), do: igniter + + defp run_corex_design(igniter, project_path, web_path, designex?) do + if Map.get(assigns_map(igniter), :test_mode?) do + igniter + else + target = Path.relative_to(Path.join([web_path, "assets", "corex"]), project_path) + args = [target, "--force"] + args = if designex?, do: ["--designex" | args], else: args + suffix = if designex?, do: " --designex", else: "" + Mix.Task.run("corex.design", args) + Igniter.add_notice(igniter, "* running mix corex.design#{suffix}") + end + end + + defp copy_generator_templates(igniter, _project_path, web_path, otp_app) do + if Map.get(assigns_map(igniter), :test_mode?) do + igniter + else + copy_generator_templates_impl(igniter, web_path, otp_app) + end + end + + defp priv_templates_path(web_path, otp_app) do + case :code.priv_dir(otp_app) do + path when is_binary(path) or is_list(path) -> Path.join(path, "templates") + {:error, _} -> Path.join(web_path, "priv/templates") + end + end + + # sobelow_skip ["Traversal.FileModule"] + defp copy_generator_templates_impl(igniter, web_path, otp_app) do + corex_priv = Path.join(:code.priv_dir(:corex), "templates") + phoenix_priv = Path.join(:code.priv_dir(:phoenix), "templates") + templates_root = priv_templates_path(web_path, otp_app) + + Enum.reduce( + [ + {"phx.gen.html", "phx.gen.html"}, + {"phx.gen.live", "phx.gen.live"}, + {"phx.gen.auth", "phx.gen.auth"} + ], + igniter, + ©_generator_template(&1, &2, corex_priv, phoenix_priv, templates_root) + ) + end + + defp copy_generator_template( + {gen_name, phoenix_dir}, + igniter, + corex_priv, + phoenix_priv, + templates_root + ) do + corex_src = Path.join(corex_priv, String.replace(gen_name, "phx.", "corex.")) + phoenix_src = Path.join(phoenix_priv, phoenix_dir) + dst = Path.join(templates_root, phoenix_dir) + src = if File.exists?(corex_src), do: corex_src, else: phoenix_src + src_root = if src == phoenix_src, do: phoenix_priv, else: corex_priv + + if File.exists?(src) do + path_under_root?(src, src_root) || Mix.raise("Path traversal blocked: #{inspect(src)}") + + path_under_root?(dst, templates_root) || + Mix.raise("Path traversal blocked: #{inspect(dst)}") + + File.mkdir_p!(dst) + File.cp_r!(src, dst) + Igniter.add_notice(igniter, "* copying #{phoenix_dir} templates") + else + igniter + end + end + + defp copy_plugs_and_hooks(igniter, web_path, web_namespace, web_app_str, opts) do + if Map.get(assigns_map(igniter), :test_mode?) do + igniter + else + copy_plugs_and_hooks_impl(igniter, web_path, web_namespace, web_app_str, opts) + end + end + + defp copy_plugs_and_hooks_impl(igniter, web_path, web_namespace, web_app_str, opts) do + corex_root = :code.lib_dir(:corex) |> Path.dirname() + installer_templates = Path.join([corex_root, "installer", "templates"]) + + if File.exists?(installer_templates) do + binding = [ + web_namespace: web_namespace, + web_app_name: web_app_str + ] + + lib_web = Path.join([web_path, "lib", web_app_str]) + + igniter = + if Keyword.get(opts, :mode) do + copy_eex( + Path.join([installer_templates, "phx_web", "plugs", "mode.ex.eex"]), + Path.join([lib_web, "plugs", "mode.ex"]), + binding, + installer_templates, + web_path + ) + + copy_eex( + Path.join([installer_templates, "phx_web", "live", "hooks", "mode_live.ex.eex"]), + Path.join([lib_web, "live", "hooks", "mode_live.ex"]), + binding, + installer_templates, + web_path + ) + + Igniter.add_notice(igniter, "* adding mode plug and hook") + else + igniter + end + + igniter = + if themes = Keyword.get(opts, :theme) do + themes_list = String.split(themes, ":", trim: true) + + binding = + Keyword.merge(binding, themes: themes_list, default_locale: List.first(themes_list)) + + copy_eex( + Path.join([installer_templates, "phx_web", "plugs", "theme.ex.eex"]), + Path.join([lib_web, "plugs", "theme.ex"]), + binding, + installer_templates, + web_path + ) + + copy_eex( + Path.join([installer_templates, "phx_web", "live", "hooks", "theme_live.ex.eex"]), + Path.join([lib_web, "live", "hooks", "theme_live.ex"]), + binding, + installer_templates, + web_path + ) + + Igniter.add_notice(igniter, "* adding theme plug and hook") + else + igniter + end + + igniter = + if languages = Keyword.get(opts, :languages) do + langs_list = String.split(languages, ":", trim: true) + + binding = + Keyword.merge(binding, + languages: langs_list, + default_locale: List.first(langs_list) + ) + + copy_eex( + Path.join([installer_templates, "phx_web", "plugs", "locale.ex.eex"]), + Path.join([lib_web, "plugs", "locale.ex"]), + binding, + installer_templates, + web_path + ) + + copy_eex( + Path.join([installer_templates, "phx_web", "live", "shared_events.ex.eex"]), + Path.join([lib_web, "live", "shared_events.ex"]), + binding, + installer_templates, + web_path + ) + + Igniter.add_notice(igniter, "* adding locale plug and shared events") + else + igniter + end + + igniter + else + igniter + end + end + + # sobelow_skip ["Traversal.FileModule", "RCE.EEx"] + defp copy_eex(src, dst, binding, src_root, dst_root) do + unless path_under_root?(src, src_root), + do: Mix.raise("Path traversal blocked: #{inspect(src)}") + + unless path_under_root?(dst, dst_root), + do: Mix.raise("Path traversal blocked: #{inspect(dst)}") + + if File.exists?(src) do + content = EEx.eval_file(src, binding: binding) + File.mkdir_p!(Path.dirname(dst)) + File.write!(dst, content) + end + end + end +end diff --git a/mix.exs b/mix.exs index e8b0993..b88d566 100644 --- a/mix.exs +++ b/mix.exs @@ -53,7 +53,7 @@ defmodule Corex.MixProject do {:floki, "~> 0.38.0", only: :test}, {:phoenix_ecto, "~> 4.0", only: :test}, {:excoveralls, "~> 0.18", only: :test}, - {:igniter, ">= 0.6.0 and < 1.0.0-0", optional: true}, + {:igniter, ">= 0.6.0 and < 1.0.0-0", only: [:dev, :test]}, {:tidewave, "~> 0.5.5", only: :dev}, {:bandit, "~> 1.0", only: :dev}, {:sobelow, "~> 0.13", only: [:dev, :test], runtime: false} @@ -74,7 +74,12 @@ defmodule Corex.MixProject do ], "assets.watch": "esbuild module --watch", tidewave: - "run --no-halt -e 'Agent.start(fn -> Bandit.start_link(plug: Tidewave, port: 4004) end)'" + "run --no-halt -e 'Agent.start(fn -> Bandit.start_link(plug: Tidewave, port: 4004) end)'", + "pre.publish": [ + "format --check-formatted", + "credo --strict", + "sobelow --exit" + ] ] end diff --git a/test/corex/igniter_test.exs b/test/corex/igniter_test.exs index ded6412..5128b5c 100644 --- a/test/corex/igniter_test.exs +++ b/test/corex/igniter_test.exs @@ -98,11 +98,44 @@ defmodule Corex.IgniterTest do web_content = Rewrite.Source.get(web_source, :content) assert web_content =~ ~r/use Corex/ end + + @tag :requires_igniter + test "preserve creates corex_root without patching root layout" do + igniter = + phx_test_project(app_name: :phx_layout_preserve) + |> Corex.Igniter.run_setup_phase(design: true) + + result = Corex.Igniter.run_layout_phase(igniter, preserve: true) + + corex_root = + Rewrite.source!( + result.rewrite, + "lib/phx_layout_preserve_web/components/layouts/corex_root.html.heex" + ) + + corex_root_content = Rewrite.Source.get(corex_root, :content) + assert corex_root_content =~ ~r/data-theme=/ + assert corex_root_content =~ ~r/data-mode=/ + + refute corex_root_content =~ ~r/phx:set-theme/, + "corex_root should not have theme script when no --theme" + + root_source = + Rewrite.source!( + result.rewrite, + "lib/phx_layout_preserve_web/components/layouts/root.html.heex" + ) + + root_content = Rewrite.Source.get(root_source, :content) + + refute root_content =~ ~r/data-theme="neo"/, + "root.html.heex should not be patched with data-theme when preserve" + end end describe "run_css_phase/2" do @tag :requires_igniter - test "patches app.css and removes daisy when design" do + test "patches app.css and adds Corex imports when design" do igniter = phx_test_project(app_name: :phx_css_design) |> Corex.Igniter.run_setup_phase(design: true) @@ -111,8 +144,7 @@ defmodule Corex.IgniterTest do source = Rewrite.source!(result.rewrite, "assets/css/app.css") content = Rewrite.Source.get(source, :content) - refute content =~ ~r/@plugin "\.\.\/vendor\/daisyui/ - refute content =~ ~r/@plugin "\.\.\/vendor\/daisyui-theme/ + assert content =~ ~r/@import "\.\.\/corex\/main\.css"/ assert content =~ ~r/\[data-mode=dark\]/ end diff --git a/test/mix/tasks/corex.install_integration_test.exs b/test/mix/tasks/corex.install_integration_test.exs index 767876a..3e5afdc 100644 --- a/test/mix/tasks/corex.install_integration_test.exs +++ b/test/mix/tasks/corex.install_integration_test.exs @@ -1,47 +1,96 @@ defmodule Mix.Tasks.Corex.InstallIntegrationTest do use ExUnit.Case, async: false + import CorexTest.InstallIntegrationCase + @moduletag :integration - @tag :tmp_dir - @tag timeout: 180_000 + @moduletag timeout: 300_000 + + setup context do + tmp_dir = + case Map.get(context, :tmp_dir) do + nil -> + base = System.tmp_dir!() + path = Path.join(base, "corex_install_#{System.unique_integer([:positive])}") + File.mkdir_p!(path) + path + + dir -> + dir + end + + {:ok, tmp_dir: tmp_dir} + end + + defp run_install_and_assert(tmp_dir, app_name, install_args, assertions \\ []) do + project_dir = run_phoenix_project(tmp_dir, app_name) + corex_path = Path.expand("../../..", __DIR__) + + {output, exit_code} = run_corex_install(project_dir, corex_path, install_args) + + assert exit_code == 0, "install failed: #{output}" - test "runs full install in real Phoenix project", %{ + if Keyword.get(assertions, :templates?, true) do + templates_root = Path.join(project_dir, "priv/templates") + + assert File.dir?(Path.join(templates_root, "phx.gen.html")), + "templates not copied. install output: #{output}" + + assert File.dir?(Path.join(templates_root, "phx.gen.live")) + end + + if Keyword.get(assertions, :app_js?, true) do + app_js = File.read!(Path.join(project_dir, "assets/js/app.js")) + assert app_js =~ ~r/from "corex"/ + end + + assert_no_compilation_warnings(project_dir) + assert_tests_pass(project_dir) + format_project(project_dir) + assert_passes_formatter_check(project_dir) + + project_dir + end + + test "preserve: keeps home, adds corex_page, default tests pass", %{tmp_dir: tmp_dir} do + run_install_and_assert(tmp_dir, "demo_preserve", ["--preserve"]) + end + + test "no-preserve: overwrites home, patches PageControllerTest to expect Corex", %{ tmp_dir: tmp_dir } do - project_dir = Path.join(tmp_dir, "demo") - corex_path = Path.expand("../../..", __DIR__) + run_install_and_assert(tmp_dir, "demo_no_preserve", []) + end + + test "no-preserve + mode: overwrites home plus mode script and mode_toggle", %{tmp_dir: tmp_dir} do + run_install_and_assert(tmp_dir, "demo_mode", ["--mode"]) + end + + test "preserve + mode: preserve flow with mode", %{tmp_dir: tmp_dir} do + run_install_and_assert(tmp_dir, "demo_preserve_mode", ["--preserve", "--mode"]) + end + + test "theme: theme config in root and theme_toggle", %{tmp_dir: tmp_dir} do + run_install_and_assert(tmp_dir, "demo_theme", ["--theme", "neo:uno"]) + end + + test "languages: gettext locales wired", %{tmp_dir: tmp_dir} do + run_install_and_assert(tmp_dir, "demo_languages", ["--languages", "en:fr"]) + end + + test "rtl: RTL config in config.exs", %{tmp_dir: tmp_dir} do + run_install_and_assert(tmp_dir, "demo_rtl", ["--rtl", "ar"]) + end + + test "no-design: skips design files, no data-theme/data-mode", %{tmp_dir: tmp_dir} do + project_dir = run_install_and_assert(tmp_dir, "demo_no_design", ["--no-design"]) + + root_source = + Path.join(project_dir, "lib/demo_no_design_web/components/layouts/root.html.heex") + + root_content = File.read!(root_source) - {_, 0} = - System.cmd( - "mix", - [ - "phx.new", - project_dir, - "--no-ecto", - "--no-mailer", - "--no-install" - ], - cd: File.cwd!() - ) - - env = System.get_env() |> Map.put("MIX_ENV", "dev") |> Map.to_list() - {_, 0} = System.cmd("mix", ["deps.get"], cd: project_dir, env: env) - {_, 0} = System.cmd("mix", ["compile"], cd: project_dir, env: env) - - {output, 0} = - System.cmd("mix", ["igniter.install", "corex@path:#{corex_path}", "--yes", "--no-design"], - cd: project_dir, - env: env - ) - - templates_root = Path.join(project_dir, "priv/templates") - - assert File.dir?(Path.join(templates_root, "phx.gen.html")), - "templates not copied. install output: #{output}" - - assert File.dir?(Path.join(templates_root, "phx.gen.live")) - - app_js = File.read!(Path.join(project_dir, "assets/js/app.js")) - assert app_js =~ ~r/from "corex"/ + refute root_content =~ ~r/data-theme="neo"/, "should not add data-theme when no-design" + refute root_content =~ ~r/data-mode="light"/, "should not add data-mode when no-design" end end diff --git a/test/mix/tasks/corex.install_test.exs b/test/mix/tasks/corex.install_test.exs index 19213e4..9e5bb0b 100644 --- a/test/mix/tasks/corex.install_test.exs +++ b/test/mix/tasks/corex.install_test.exs @@ -75,33 +75,44 @@ defmodule Mix.Tasks.Corex.InstallTest do refute root_layout_warning?, "Should not warn about root layout when already patched" end - test "install with --mode adds mode script and mode_toggle to layouts" do + test "install with --mode adds mode script to root and replaces app layout with Corex" do igniter = phx_test_project(app_name: :phx_mode) |> Igniter.compose_task("corex.install", ["--yes", "--mode"]) - source = + root_source = Rewrite.source!(igniter.rewrite, "lib/phx_mode_web/components/layouts/root.html.heex") - content = Rewrite.Source.get(source, :content) - assert content =~ ~r/data-mode=/ + root_content = Rewrite.Source.get(root_source, :content) + assert root_content =~ ~r/data-mode=/ + assert root_content =~ ~r/phx:set-mode/ + refute root_content =~ ~r/phx:set-theme/ layouts_source = Rewrite.source!(igniter.rewrite, "lib/phx_mode_web/components/layouts.ex") layouts_content = Rewrite.Source.get(layouts_source, :content) - assert layouts_content =~ ~r/<\.mode_toggle \/>/ - assert layouts_content =~ ~r/def mode_toggle\(assigns\)/ + + assert layouts_content =~ ~r/class="typo layout"/, + "def app is replaced with Corex layout" + + assert layouts_content =~ ~r/toast_group/, + "layouts use toast_group instead of flash_group" + + refute layouts_content =~ ~r/def flash_group/, + "flash_group should be removed when no-preserve" end - test "install with --theme adds theme config" do + test "install with --theme adds theme script to root" do igniter = phx_test_project(app_name: :phx_theme) |> Igniter.compose_task("corex.install", ["--yes", "--theme", "neo:uno"]) - source = + root_source = Rewrite.source!(igniter.rewrite, "lib/phx_theme_web/components/layouts/root.html.heex") - content = Rewrite.Source.get(source, :content) - assert content =~ ~r/data-theme=|data-mode=/ + root_content = Rewrite.Source.get(root_source, :content) + assert root_content =~ ~r/data-theme=/ + assert root_content =~ ~r/phx:set-theme/ + refute root_content =~ ~r/phx:set-mode/ end test "install with design adds corex CSS imports" do @@ -112,9 +123,20 @@ defmodule Mix.Tasks.Corex.InstallTest do source = Rewrite.source!(igniter.rewrite, "assets/css/app.css") content = Rewrite.Source.get(source, :content) assert content =~ ~r/@import "\.\.\/corex\/main\.css"/ + + root_source = + Rewrite.source!(igniter.rewrite, "lib/phx_design_web/components/layouts/root.html.heex") + + root_content = Rewrite.Source.get(root_source, :content) + + assert root_content =~ ~r/lang="en"\s+data-theme="neo"\s+data-mode="light"/, + "default install uses simple root with static en/neo/light" + + refute root_content =~ ~r/phx:set-mode|phx:set-theme/, + "root should have no mode/theme script when neither --mode nor --theme" end - test "design removes daisyui CSS and uses data-mode for dark variant" do + test "design adds Corex imports and uses data-mode for dark variant" do igniter = phx_test_project(app_name: :phx_design_daisy) |> Igniter.compose_task("corex.install", ["--yes"]) @@ -122,10 +144,11 @@ defmodule Mix.Tasks.Corex.InstallTest do source = Rewrite.source!(igniter.rewrite, "assets/css/app.css") content = Rewrite.Source.get(source, :content) - refute content =~ ~r/@plugin "\.\.\/vendor\/daisyui/, "Should remove daisyui plugin" + assert content =~ ~r/@import "\.\.\/corex\/main\.css"/, + "Should add Corex imports when design" - refute content =~ ~r/@plugin "\.\.\/vendor\/daisyui-theme/, - "Should remove daisyui-theme plugin" + assert content =~ ~r/@import "\.\.\/corex\/components\/toast\.css"/, + "Should add toast.css when design" refute content =~ ~r/\[data-theme=dark\]/, "Should replace data-theme=dark with data-mode=dark" @@ -155,4 +178,141 @@ defmodule Mix.Tasks.Corex.InstallTest do refute root_content =~ ~r/data-mode="light"/, "Should not add data-mode when no-design" end + + test "default keeps root route as home and overwrites home.html.heex" do + igniter = + phx_test_project(app_name: :phx_default_route) + |> Igniter.compose_task("corex.install", ["--yes"]) + + router_source = Rewrite.source!(igniter.rewrite, "lib/phx_default_route_web/router.ex") + router_content = Rewrite.Source.get(router_source, :content) + + assert router_content =~ ~r/get\s*\(\s*"\/"\s*,\s*PageController\s*,\s*:home\s*\)/, + "default no-preserve keeps get \"/\" PageController :home" + + home_source = + Rewrite.source!( + igniter.rewrite, + "lib/phx_default_route_web/controllers/page_html/home.html.heex" + ) + + home_content = Rewrite.Source.get(home_source, :content) + + assert home_content =~ ~r/Build/, + "home.html.heex has Corex landing content" + end + + test "preserve creates corex_root and adds get /corex without modifying root or home" do + igniter = + phx_test_project(app_name: :phx_preserve) + |> Igniter.compose_task("corex.install", ["--yes", "--preserve"]) + + corex_root = + Rewrite.source!( + igniter.rewrite, + "lib/phx_preserve_web/components/layouts/corex_root.html.heex" + ) + + corex_root_content = Rewrite.Source.get(corex_root, :content) + assert corex_root_content =~ ~r/data-theme=/ + assert corex_root_content =~ ~r/data-mode=/ + + refute corex_root_content =~ ~r/phx:set-theme/, + "corex_root should not have theme script when no --theme" + + root_source = + Rewrite.source!(igniter.rewrite, "lib/phx_preserve_web/components/layouts/root.html.heex") + + root_content = Rewrite.Source.get(root_source, :content) + + refute root_content =~ ~r/data-theme="neo"/, + "root.html.heex should not be patched with data-theme when preserve" + + router_source = Rewrite.source!(igniter.rewrite, "lib/phx_preserve_web/router.ex") + router_content = Rewrite.Source.get(router_source, :content) + + assert router_content =~ + ~r/get\s*\(?\s*"\/corex"\s*,\s*PageController\s*,\s*:corex_page\s*\)?/, + "router should have get /corex route" + + assert router_content =~ ~r/get\s*\(?\s*"\/"\s*,\s*PageController\s*,\s*:home\s*\)?/, + "home route should be kept when preserve" + + layouts_source = + Rewrite.source!(igniter.rewrite, "lib/phx_preserve_web/components/layouts.ex") + + layouts_content = Rewrite.Source.get(layouts_source, :content) + + assert layouts_content =~ ~r/toast_group/, + "corex_app uses toast_group" + + css_source = Rewrite.source!(igniter.rewrite, "assets/css/app.css") + css_content = Rewrite.Source.get(css_source, :content) + + assert css_content =~ ~r/@import "\.\.\/corex\/main\.css"/, + "Corex imports should be added" + + assert css_content =~ ~r/@import "\.\.\/corex\/components\/toast\.css"/, + "toast.css should be added when design" + end + + test "install with --only uses hooks import and use Corex only" do + igniter = + phx_test_project(app_name: :phx_only) + |> Igniter.compose_task("corex.install", [ + "--no-design", + "--yes", + "--only", + "accordion:checkbox" + ]) + + app_js = Rewrite.source!(igniter.rewrite, "assets/js/app.js") + app_js_content = Rewrite.Source.get(app_js, :content) + + assert app_js_content =~ ~r/import \{ hooks \} from "corex"/, + "expected hooks import when --only" + + assert app_js_content =~ ~r/hooks\(\["Accordion", "Checkbox"\]\)/, + "expected hooks with PascalCase names" + + web_ex = Rewrite.source!(igniter.rewrite, "lib/phx_only_web.ex") + web_ex_content = Rewrite.Source.get(web_ex, :content) + + assert web_ex_content =~ ~r/use Corex, only: \[:accordion, :checkbox\]/, + "expected use Corex only" + end + + test "install with --prefix uses use Corex prefix" do + igniter = + phx_test_project(app_name: :phx_prefix) + |> Igniter.compose_task("corex.install", ["--no-design", "--yes", "--prefix", "ui"]) + + web_ex = Rewrite.source!(igniter.rewrite, "lib/phx_prefix_web.ex") + web_ex_content = Rewrite.Source.get(web_ex, :content) + + assert web_ex_content =~ ~r/use Corex, prefix: "ui"/, + "expected use Corex prefix" + end + + test "preserve does not add corex route twice when running install twice" do + igniter = + phx_test_project(app_name: :phx_preserve_idempotent) + |> Igniter.compose_task("corex.install", ["--yes", "--preserve"]) + |> Igniter.Test.apply_igniter!() + |> Igniter.compose_task("corex.install", ["--yes", "--preserve"]) + + router_source = + Rewrite.source!(igniter.rewrite, "lib/phx_preserve_idempotent_web/router.ex") + + router_content = Rewrite.Source.get(router_source, :content) + + corex_route_matches = + Regex.scan( + ~r/get\s*\(?\s*"\/corex"\s*,\s*PageController\s*,\s*:corex_page\s*\)?/, + router_content + ) + + assert length(corex_route_matches) == 1, + "Expected exactly one get /corex route when running install twice with preserve, got #{length(corex_route_matches)}" + end end diff --git a/test/support/install_integration_case.ex b/test/support/install_integration_case.ex new file mode 100644 index 0000000..ceedb6c --- /dev/null +++ b/test/support/install_integration_case.ex @@ -0,0 +1,72 @@ +defmodule CorexTest.InstallIntegrationCase do + @moduledoc false + + def run_phoenix_project(tmp_dir, app_name \\ "demo") when is_binary(app_name) do + project_dir = Path.join(tmp_dir, app_name) + cwd = File.cwd!() + + {_, 0} = + System.cmd("mix", ["phx.new", project_dir, "--no-ecto", "--no-mailer", "--no-install"], + cd: cwd + ) + + env = System.get_env() |> Map.put("MIX_ENV", "dev") |> Map.to_list() + {_, 0} = System.cmd("mix", ["deps.get"], cd: project_dir, env: env) + {_, 0} = System.cmd("mix", ["compile"], cd: project_dir, env: env) + + project_dir + end + + def run_corex_install(project_dir, corex_path, args, opts \\ []) do + env = Keyword.get(opts, :env, System.get_env() |> Map.put("MIX_ENV", "dev") |> Map.to_list()) + + System.cmd( + "mix", + ["igniter.install", "corex@path:#{corex_path}", "--yes" | args], + cd: project_dir, + env: env + ) + end + + def mix_run!(args, app_path, opts \\ []) + when is_list(args) and is_binary(app_path) and is_list(opts) do + case mix_run(args, app_path, opts) do + {output, 0} -> + output + + {output, exit_code} -> + raise """ + mix command failed with exit code: #{inspect(exit_code)} + + mix #{Enum.join(args, " ")} + + #{output} + + Options: + cd: #{Path.expand(app_path)} + env: #{Keyword.get(opts, :env, []) |> inspect()} + """ + end + end + + def mix_run(args, app_path, opts \\ []) + when is_list(args) and is_binary(app_path) and is_list(opts) do + System.cmd("mix", args, [stderr_to_stdout: true, cd: Path.expand(app_path)] ++ opts) + end + + def assert_tests_pass(app_path) do + mix_run!(~w(test), app_path) + end + + def assert_passes_formatter_check(app_path) do + mix_run!(~w(format --check-formatted), app_path) + end + + def format_project(app_path) do + mix_run!(~w(format), app_path) + end + + def assert_no_compilation_warnings(app_path) do + mix_run!(["do", "clean", "compile", "--warnings-as-errors"], app_path) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index bd3eacc..9676277 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,4 +1,6 @@ Application.ensure_all_started(:phoenix_live_view) -exclude = if System.get_env("CI"), do: [integration: true], else: [] +exclude = + if System.get_env("CI") && !System.get_env("RUN_INTEGRATION"), do: [integration: true], else: [] + ExUnit.start(exclude: exclude) From c664b64b47242716dea4d87dd0813a2cd6ac7562 Mon Sep 17 00:00:00 2001 From: karimsemmoud Date: Mon, 2 Mar 2026 11:12:59 +0700 Subject: [PATCH 15/21] dev --- lib/mix/tasks/corex.install.ex | 6 +- lib/mix/tasks/corex/igniter.ex | 599 ++++++++++++++++++++------ test/corex/igniter_test.exs | 4 +- test/mix/tasks/corex.install_test.exs | 33 +- 4 files changed, 484 insertions(+), 158 deletions(-) diff --git a/lib/mix/tasks/corex.install.ex b/lib/mix/tasks/corex.install.ex index df70dc5..d0e0674 100644 --- a/lib/mix/tasks/corex.install.ex +++ b/lib/mix/tasks/corex.install.ex @@ -14,10 +14,14 @@ if Code.ensure_loaded?(Igniter) do mix igniter.install corex@path:../corex + By default, Corex overrides root layout, layouts, and DaisyUI (convenient for new projects). + Use `--preserve` to keep your layouts, app.css, app.js, and DaisyUI; Corex adds its own + CorexLayouts, corex_app.css, corex_app.js, and /corex route. + ## Options * `--no-design` - skip copying Corex design files (does not touch daisyUI or add data-theme/data-mode) - * `--preserve` - create corex_root and get "/corex" without modifying root layout, home route, or DaisyUI + * `--preserve` - keep your layouts, app.css, app.js, DaisyUI; create CorexLayouts, corex_app.css/js, get "/corex" * `--designex` - include design tokens and build scripts in design/ * `--mode` - enable light/dark mode (plug, script, toggle) * `--theme THEMES` - colon-separated themes (e.g. neo:uno). At least 2 values. diff --git a/lib/mix/tasks/corex/igniter.ex b/lib/mix/tasks/corex/igniter.ex index b95c0e0..4651c18 100644 --- a/lib/mix/tasks/corex/igniter.ex +++ b/lib/mix/tasks/corex/igniter.ex @@ -112,10 +112,12 @@ if Code.ensure_loaded?(Igniter) do |> add_corex_config(web_namespace) |> add_rtl_config(opts) |> patch_app_js(app_js_path, opts) - |> patch_esbuild_config(config_path) + |> patch_esbuild_config(config_path, otp_app, preserve?) |> patch_root_layout(root_layout_path, web_app_str, design?, preserve?, opts) - |> create_corex_root(web_path, web_app_str, project_path, design?, preserve?, opts) + |> create_corex_root(web_path, web_app_str, project_path, otp_app, design?, preserve?, opts) |> add_corex_app_to_layouts(layouts_path, preserve?, design?) + |> add_esbuild_corex_profile(config_path, otp_app, preserve?, design?) + |> add_corex_mix_alias(otp_app, preserve?, design?) |> patch_html_helpers(web_ex_path, opts) |> patch_app_css(app_css_path, design?, preserve?, opts) |> create_corex_page(web_path, web_app_str, project_path, web_namespace, preserve?, design?) @@ -178,12 +180,16 @@ if Code.ensure_loaded?(Igniter) do end def patch_app_js(igniter, app_js_path, opts) do - if Igniter.exists?(igniter, app_js_path) do + if Keyword.get(opts, :preserve, false) do igniter - |> Igniter.include_existing_file(app_js_path, required?: false) - |> Igniter.update_file(app_js_path, &patch_app_js_content(&1, app_js_path, opts)) else - igniter + if Igniter.exists?(igniter, app_js_path) do + igniter + |> Igniter.include_existing_file(app_js_path, required?: false) + |> Igniter.update_file(app_js_path, &patch_app_js_content(&1, app_js_path, opts)) + else + igniter + end end end @@ -235,10 +241,14 @@ if Code.ensure_loaded?(Igniter) do end end - def patch_esbuild_config(igniter, config_path) do - igniter - |> Igniter.include_existing_file(config_path, required?: true) - |> Igniter.update_file(config_path, &patch_esbuild_content(&1, config_path)) + def patch_esbuild_config(igniter, config_path, _otp_app, preserve?) do + if preserve? do + igniter + else + igniter + |> Igniter.include_existing_file(config_path, required?: true) + |> Igniter.update_file(config_path, &patch_esbuild_content(&1, config_path)) + end end defp patch_esbuild_content(source, config_path) do @@ -279,35 +289,38 @@ if Code.ensure_loaded?(Igniter) do end def add_corex_app_to_layouts(igniter, layouts_path, preserve?, design?) do - if Igniter.exists?(igniter, layouts_path) do + if preserve? do igniter - |> Igniter.include_existing_file(layouts_path, required?: false) - |> Igniter.update_file( - layouts_path, - &update_layouts_content(&1, layouts_path, preserve?, design?), - required?: false - ) else - igniter + if Igniter.exists?(igniter, layouts_path) do + igniter + |> Igniter.include_existing_file(layouts_path, required?: false) + |> Igniter.update_elixir_file( + layouts_path, + &update_layouts_zipper(&1, layouts_path, preserve?, design?), + required?: false + ) + else + igniter + end end end - defp update_layouts_content(source, layouts_path, preserve?, design?) do + defp update_layouts_zipper(zipper, layouts_path, preserve?, design?) do if preserve? do - add_corex_app_function(source, layouts_path, design?) + add_corex_app_zipper(zipper, layouts_path, design?) else - replace_app_and_remove_theme_toggle(source, layouts_path, design?) + replace_app_and_remove_theme_toggle_zipper(zipper, layouts_path, design?) end end - defp add_corex_app_function(source, layouts_path, design?) do - if source.content =~ ~r/def corex_app\(/ do - Rewrite.Source.update(source, :content, source.content) + defp add_corex_app_zipper(zipper, layouts_path, design?) do + if has_corex_app?(zipper) do + {:ok, zipper} else toast_block = toast_group_block(design?) - corex_app_fn = """ - + corex_app_code = """ attr :flash, :map, required: true, doc: "the map of flash messages" def corex_app(assigns) do @@ -324,20 +337,27 @@ if Code.ensure_loaded?(Igniter) do end """ - new_content = String.replace(source.content, ~r/\nend\s*\z/, corex_app_fn <> "\nend\n") - - if new_content == source.content do - {:warning, - Igniter.Util.Warning.formatted_warning( - "Could not add corex_app to #{layouts_path}. Add manually before the final `end`:", - corex_app_fn - )} + with {:ok, zipper} <- Igniter.Code.Module.move_to_defmodule(zipper), + {:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper), + zipper <- Igniter.Code.Common.rightmost(zipper), + zipper <- Igniter.Code.Common.add_code(zipper, corex_app_code, placement: :after) do + {:ok, zipper} else - Rewrite.Source.update(source, :content, new_content) + _ -> + {:warning, + Igniter.Util.Warning.formatted_warning( + "Could not add corex_app to #{layouts_path}. Add manually before the final `end`:", + corex_app_code + )} end end end + defp has_corex_app?(zipper) do + str = Sourceror.Zipper.root(zipper) |> Sourceror.to_string() + str =~ ~r/def corex_app\(/ + end + defp toast_group_block(design?) do toast_class = if design?, do: ~s| class="toast"|, else: "" @@ -364,17 +384,17 @@ if Code.ensure_loaded?(Igniter) do """ end - defp replace_app_and_remove_theme_toggle(source, layouts_path, design?) do - content = source.content + defp replace_app_and_remove_theme_toggle_zipper(zipper, layouts_path, design?) do + content = Sourceror.Zipper.root(zipper) |> Sourceror.to_string() if content =~ ~r/def app\(assigns\) do[\s\S]*?id="layout-toast"/ do - Rewrite.Source.update(source, :content, content) + {:ok, zipper} else - do_replace_app_and_remove_theme_toggle(source, layouts_path, design?, content) + do_replace_app_and_remove_theme_toggle_zipper(zipper, layouts_path, design?) end end - defp do_replace_app_and_remove_theme_toggle(source, layouts_path, design?, content) do + defp do_replace_app_and_remove_theme_toggle_zipper(zipper, layouts_path, design?) do toast_block = toast_group_block(design?) corex_app_body = @@ -387,38 +407,53 @@ if Code.ensure_loaded?(Igniter) do " \n" <> "
  • " - li_regex = ~r/\s*
  • \s*<\.theme_toggle \/>\s*<\/li>/ - - theme_toggle_fn = - ~r/\n @doc """\s*\n Provides dark vs light theme toggle[\s\S]*?def theme_toggle\(assigns\) do\s*\n ~H"""\s*\n[\s\S]*?data-phx-theme[\s\S]*?"""\s*\n end/ - - theme_toggle_fn_alt = - ~r/\n @doc """\s*\n Provides dark vs light theme toggle[\s\S]*?def theme_toggle\(assigns\) do\s*\n ~H"""\s*\n[\s\S]*?phx-click=[^>]*>[\s\S]*?"""\s*\n end/ + content = Sourceror.Zipper.root(zipper) |> Sourceror.to_string() + content_without_flash = remove_flash_group(content) - app_def_regex = ~r/(def app\(assigns\) do\s*\n\s*~H""")[\s\S]*?("""\s*\n\s*end)/ - slot_regex = ~r/slot :inner_block, required: true/ + zipper = + content_without_flash + |> Sourceror.parse_string!() + |> Sourceror.Zipper.zip() + |> Igniter.Code.Common.remove(theme_toggle_predicate()) - content_without_flash_group = remove_flash_group(content) + result = apply_layout_replacements(zipper, corex_app_body) - updated = - content_without_flash_group - |> String.replace(slot_regex, "slot :inner_block") - |> String.replace(li_regex, "") - |> String.replace(theme_toggle_fn, "") - |> String.replace(theme_toggle_fn_alt, "") - |> String.replace(app_def_regex, "\\1\n#{corex_app_body}\n\\2") - - if updated == content_without_flash_group do + if result == content_without_flash do {:warning, Igniter.Util.Warning.formatted_warning( "Could not replace app and remove theme_toggle in #{layouts_path}.", ~s|# Replace def app/1 body with Corex layout| )} else - Rewrite.Source.update(source, :content, updated) + {:ok, result |> Sourceror.parse_string!() |> Sourceror.Zipper.zip()} + end + end + + defp theme_toggle_predicate do + fn z -> + node = Sourceror.Zipper.node(z) + + cond do + match?({:def, _, [{:theme_toggle, _, _} | _]}, node) -> true + match?({:@, _, [{:doc, _, _} | _]}, node) -> next_def_name(z) == :theme_toggle + true -> false + end end end + defp apply_layout_replacements(zipper, corex_app_body) do + slot_regex = ~r/slot :inner_block, required: true/ + li_regex = ~r/\s*
  • \s*<\.theme_toggle \/>\s*<\/li>/ + app_def_regex = ~r/(def app\(assigns\) do\s*\n\s*~H""")[\s\S]*?("""\s*\n\s*end)/ + + zipper + |> Sourceror.Zipper.root() + |> Sourceror.to_string() + |> String.replace(slot_regex, "slot :inner_block") + |> String.replace(li_regex, "") + |> String.replace(app_def_regex, "\\1\n#{corex_app_body}\n\\2") + end + defp remove_flash_group(content) do zipper = Sourceror.parse_string!(content) |> Sourceror.Zipper.zip() pred = flash_group_predicate() @@ -576,26 +611,38 @@ if Code.ensure_loaded?(Igniter) do if Igniter.exists?(igniter, rel_test) do igniter |> Igniter.include_existing_file(rel_test, required?: false) - |> Igniter.update_file(rel_test, &patch_page_controller_test_content/1, required?: false) + |> Igniter.update_elixir_file( + rel_test, + &patch_page_controller_test_zipper/1, + required?: false + ) else igniter end end - defp patch_page_controller_test_content(source) do - content = source.content + defp patch_page_controller_test_zipper(zipper) do + phoenix_assertion_str = + ~s|assert html_response(conn, 200) =~ "Peace of mind from prototype to production"| + + corex_assertions_code = """ + assert html_response(conn, 200) =~ "Build" + assert html_response(conn, 200) =~ "Corex" + """ - phoenix_assertion = - ~r/assert html_response\(conn, 200\) =~ "Peace of mind from prototype to production"/ + pred = fn z -> + str = Sourceror.to_string(z) + str == phoenix_assertion_str or String.trim(str) == phoenix_assertion_str + end - corex_assertions = - "assert html_response(conn, 200) =~ \"Build\"\n assert html_response(conn, 200) =~ \"Corex\"" + fun = fn _z -> + new_ast = Sourceror.parse_string!(corex_assertions_code) + {:code, new_ast} + end - if content =~ phoenix_assertion do - new_content = String.replace(content, phoenix_assertion, corex_assertions) - Rewrite.Source.update(source, :content, new_content) - else - Rewrite.Source.update(source, :content, content) + case Igniter.Code.Common.update_all_matches(zipper, pred, fun) do + {:ok, updated} -> {:ok, updated} + other -> other end end @@ -606,19 +653,23 @@ if Code.ensure_loaded?(Igniter) do design?, preserve? ) do - if Igniter.exists?(igniter, page_controller_path) and design? do - layout_module = Module.concat([web_namespace, Layouts]) - layout_name = if preserve?, do: :corex_app, else: :app - + if preserve? do igniter - |> Igniter.include_existing_file(page_controller_path, required?: false) - |> Igniter.update_elixir_file( - page_controller_path, - &patch_home_action_zipper(&1, layout_module, layout_name), - required?: false - ) else - igniter + if Igniter.exists?(igniter, page_controller_path) and design? do + layout_module = Module.concat([web_namespace, Layouts]) + layout_name = if preserve?, do: :corex_app, else: :app + + igniter + |> Igniter.include_existing_file(page_controller_path, required?: false) + |> Igniter.update_elixir_file( + page_controller_path, + &patch_home_action_zipper(&1, layout_module, layout_name), + required?: false + ) + else + igniter + end end end @@ -710,7 +761,10 @@ if Code.ensure_loaded?(Igniter) do end defp corex_page_action_insert(layout_module, true) do - " def corex_page(conn, _params) do\n conn\n |> put_root_layout(html: {#{inspect(layout_module)}, :corex_root})\n |> put_layout(html: {#{inspect(layout_module)}, :corex_app})\n |> render(:corex_page)\n end\n\n " + [web_ns | _] = Module.split(layout_module) + corex_layouts = Module.concat([web_ns, "CorexLayouts"]) + + " def corex_page(conn, _params) do\n conn\n |> put_root_layout(html: {#{inspect(corex_layouts)}, :corex_root})\n |> put_layout(html: {#{inspect(corex_layouts)}, :root})\n |> render(:corex_page)\n end\n\n " end defp corex_page_action_insert(layout_module, false) do @@ -718,7 +772,10 @@ if Code.ensure_loaded?(Igniter) do end defp corex_page_action_manual(layout_module, true) do - "def corex_page(conn, _params) do\n conn\n |> put_root_layout(html: {#{inspect(layout_module)}, :corex_root})\n |> put_layout(html: {#{inspect(layout_module)}, :corex_app})\n |> render(:corex_page)\nend" + [web_ns | _] = Module.split(layout_module) + corex_layouts = Module.concat([web_ns, "CorexLayouts"]) + + "def corex_page(conn, _params) do\n conn\n |> put_root_layout(html: {#{inspect(corex_layouts)}, :corex_root})\n |> put_layout(html: {#{inspect(corex_layouts)}, :root})\n |> render(:corex_page)\nend" end defp corex_page_action_manual(layout_module, false) do @@ -731,22 +788,18 @@ if Code.ensure_loaded?(Igniter) do web_app_str, project_path, web_namespace, - preserve? + _preserve? ) do - unless preserve? do + router_path = Path.join([web_path, "lib", web_app_str, "router.ex"]) + rel_router = Path.relative_to(router_path, project_path) + router = Module.concat([web_namespace, Router]) + + if Igniter.exists?(igniter, rel_router) do igniter + |> Igniter.include_existing_file(rel_router, required?: false) + |> replace_root_route_with_append(rel_router, router, web_namespace) else - router_path = Path.join([web_path, "lib", web_app_str, "router.ex"]) - rel_router = Path.relative_to(router_path, project_path) - router = Module.concat([web_namespace, Router]) - - if Igniter.exists?(igniter, rel_router) do - igniter - |> Igniter.include_existing_file(rel_router, required?: false) - |> replace_root_route_with_append(rel_router, router, web_namespace) - else - igniter - end + igniter end end @@ -804,28 +857,292 @@ if Code.ensure_loaded?(Igniter) do end end - def create_corex_root(igniter, web_path, web_app_str, project_path, design?, preserve?, opts) do + def create_corex_root( + igniter, + web_path, + web_app_str, + project_path, + otp_app, + design?, + preserve?, + opts + ) do if design? and preserve? do - corex_root_path = - Path.relative_to( - Path.join(web_path, "lib/#{web_app_str}/components/layouts/corex_root.html.heex"), - project_path - ) + web_namespace = Module.concat([Macro.camelize(to_string(otp_app)) <> "Web"]) + + igniter + |> create_corex_layouts_module(web_path, web_app_str, project_path, web_namespace) + |> create_corex_layouts_templates(web_path, web_app_str, project_path, opts) + |> create_corex_app_css(web_path, web_app_str, project_path) + |> create_corex_app_js(web_path, project_path, otp_app) + |> Igniter.add_notice("* creating CorexLayouts, corex_app.css, corex_app.js") + else + igniter + end + end + + defp create_corex_layouts_module(igniter, web_path, web_app_str, project_path, web_namespace) do + components_dir = Path.join(web_path, "lib/#{web_app_str}/components") - template = corex_root_template(opts) + core_layouts_path = + Path.relative_to(Path.join(components_dir, "core_layouts.ex"), project_path) + + content = """ + defmodule #{inspect(Module.concat([web_namespace, CorexLayouts]))} do + @moduledoc false + use #{inspect(web_namespace)}, :html + + embed_templates "core_layouts/*" + end + """ + + Igniter.create_or_update_file( + igniter, + core_layouts_path, + content, + fn source -> Rewrite.Source.update(source, :content, content) end + ) + end + + defp create_corex_layouts_templates(igniter, web_path, web_app_str, project_path, opts) do + core_layouts_dir = Path.join(web_path, "lib/#{web_app_str}/components/core_layouts") + + corex_root_path = + Path.relative_to(Path.join(core_layouts_dir, "corex_root.html.heex"), project_path) + + root_path = Path.relative_to(Path.join(core_layouts_dir, "root.html.heex"), project_path) + + corex_root_content = corex_root_template_for_preserve(opts) + root_content = corex_layout_root_content() + + igniter + |> Igniter.mkdir(core_layouts_dir) + |> Igniter.create_or_update_file(corex_root_path, corex_root_content, fn s -> + Rewrite.Source.update(s, :content, corex_root_content) + end) + |> Igniter.create_or_update_file(root_path, root_content, fn s -> + Rewrite.Source.update(s, :content, root_content) + end) + end + + defp corex_root_template_for_preserve(opts) do + mode_script_block = if Keyword.get(opts, :mode), do: mode_script(), else: "" + theme_script_block = if theme = Keyword.get(opts, :theme), do: theme_script(theme), else: "" + scripts = mode_script_block <> theme_script_block + + """ + + + + + + + <.live_title default="" suffix=""> + {assigns[:page_title]} + + + + #{String.trim(scripts)} + + + {@inner_content} + + + """ + end + + defp corex_layout_root_content do + """ +
    + <.toast_group id="layout-toast" class="toast" flash={@flash}> + <:loading> + <.icon name="hero-arrow-path" /> + + + <.toast_client_error + toast_group_id="layout-toast" + title={gettext("We can't find the internet")} + description={gettext("Attempting to reconnect")} + type={:error} + duration={:infinity} + /> + <.toast_server_error + toast_group_id="layout-toast" + title={gettext("Something went wrong!")} + description={gettext("Attempting to reconnect")} + type={:error} + duration={:infinity} + /> +
    +
    + {assigns[:inner_content] || render_slot(@inner_block)} +
    +
    +
    + """ + end + + defp create_corex_app_css(igniter, web_path, web_app_str, project_path) do + corex_app_css_path = + Path.relative_to(Path.join(web_path, "assets/css/corex_app.css"), project_path) + + content = """ + @import "tailwindcss" source(none); + @source "../css"; + @source "../js"; + @source "../../lib/#{web_app_str}"; + + @import "../corex/main.css"; + @import "../corex/tokens/themes/neo/light.css"; + @import "../corex/components/typo.css"; + @import "../corex/components/button.css"; + @import "../corex/components/toast.css"; + + @plugin "../vendor/heroicons"; + @custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &); + @custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &); + @custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &); + @custom-variant dark (&:where([data-mode=dark], [data-mode=dark] *)); + [data-phx-session], [data-phx-teleported-src] { display: contents } + """ + + Igniter.create_or_update_file( + igniter, + corex_app_css_path, + content, + fn source -> Rewrite.Source.update(source, :content, content) end + ) + end + + defp create_corex_app_js(igniter, web_path, project_path, otp_app) do + corex_app_js_path = + Path.relative_to(Path.join(web_path, "assets/js/corex_app.js"), project_path) + + app_str = to_string(otp_app) + + content = """ + import "phoenix_html" + import {Socket} from "phoenix" + import {LiveSocket} from "phoenix_live_view" + import {hooks as colocatedHooks} from "phoenix-colocated/#{app_str}" + import corex from "corex" + import topbar from "../vendor/topbar" + + const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") + const liveSocket = new LiveSocket("/live", Socket, { + longPollFallbackMs: 2500, + params: {_csrf_token: csrfToken}, + hooks: {...colocatedHooks, ...corex}, + }) + + topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) + window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) + window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) + liveSocket.connect() + window.liveSocket = liveSocket + + if (process.env.NODE_ENV === "development") { + window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => { + reloader.enableServerLogs() + }) + } + """ + + Igniter.create_or_update_file( + igniter, + corex_app_js_path, + content, + fn source -> Rewrite.Source.update(source, :content, content) end + ) + end + + def add_esbuild_corex_profile(igniter, config_path, otp_app, preserve?, design?) do + if design? and preserve? do + profile_name = String.to_atom("#{otp_app}_corex") igniter - |> Igniter.create_or_update_file( - corex_root_path, - template, - fn source -> Rewrite.Source.update(source, :content, template) end - ) - |> Igniter.add_notice("* creating corex_root.html.heex") + |> Igniter.include_existing_file(config_path, required?: true) + |> Igniter.update_elixir_file(config_path, &add_esbuild_profile_zipper(&1, profile_name)) + else + igniter + end + end + + defp add_esbuild_profile_zipper(zipper, profile_name) do + if Igniter.Project.Config.configures_key?(zipper, :esbuild, profile_name) do + {:ok, zipper} + else + profile_value = + Sourceror.parse_string!(""" + [ + args: + ~w(js/corex_app.js --bundle --format=esm --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.), + cd: Path.expand("../assets", __DIR__), + env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]} + ] + """) + + with {:ok, zipper} <- + Igniter.Code.Function.move_to_function_call_in_current_scope( + zipper, + :config, + [2, 3], + &Igniter.Code.Function.argument_equals?(&1, 0, :esbuild) + ), + {:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 1), + true <- Igniter.Code.List.list?(zipper), + {:ok, zipper} <- + Igniter.Code.Keyword.put_in_keyword( + zipper, + [profile_name], + profile_value, + nil + ) do + {:ok, zipper} + else + _ -> {:ok, zipper} + end + end + end + + def add_corex_mix_alias(igniter, otp_app, preserve?, design?) do + if design? and preserve? do + mix_exs_path = "mix.exs" + profile_name = "#{otp_app}_corex" + + igniter + |> Igniter.include_existing_file(mix_exs_path, required?: true) + |> Igniter.update_elixir_file(mix_exs_path, &add_corex_alias_zipper(&1, profile_name)) else igniter end end + defp add_corex_alias_zipper(zipper, profile_name) do + esbuild_build = Sourceror.parse_string!("\"esbuild #{profile_name}\"") + esbuild_deploy = Sourceror.parse_string!("\"esbuild #{profile_name} --minify\"") + + with {:ok, zipper} <- Igniter.Code.Module.move_to_module_using(zipper, Mix.Project), + {:ok, zipper} <- Igniter.Code.Function.move_to_defp(zipper, :aliases, 0), + zipper <- Igniter.Code.Common.maybe_move_to_single_child_block(zipper), + true <- Igniter.Code.List.list?(zipper), + {:ok, _} <- Igniter.Code.Keyword.get_key(zipper, :"assets.build"), + {:ok, zipper} <- + Igniter.Code.Keyword.put_in_keyword(zipper, [:"assets.build"], nil, fn zipper -> + Igniter.Code.List.append_new_to_list(zipper, esbuild_build) + end), + {:ok, _} <- Igniter.Code.Keyword.get_key(zipper, :"assets.deploy"), + {:ok, zipper} <- + Igniter.Code.Keyword.put_in_keyword(zipper, [:"assets.deploy"], nil, fn zipper -> + Igniter.Code.List.append_new_to_list(zipper, esbuild_deploy) + end) do + {:ok, zipper} + else + _ -> {:ok, zipper} + end + end + defp simple_root_attrs?(opts) when is_list(opts) do [ Keyword.get(opts, :mode), @@ -1128,16 +1445,20 @@ if Code.ensure_loaded?(Igniter) do end def patch_app_css(igniter, app_css_path, design?, preserve?, _opts) do - if Igniter.exists?(igniter, app_css_path) and design? do + if preserve? do igniter - |> Igniter.include_existing_file(app_css_path, required?: false) - |> Igniter.update_file( - app_css_path, - &patch_app_css_content(&1, design?, preserve?), - required?: false - ) else - igniter + if Igniter.exists?(igniter, app_css_path) and design? do + igniter + |> Igniter.include_existing_file(app_css_path, required?: false) + |> Igniter.update_file( + app_css_path, + &patch_app_css_content(&1, design?, preserve?), + required?: false + ) + else + igniter + end end end @@ -1258,14 +1579,14 @@ if Code.ensure_loaded?(Igniter) do Map.put( assigns_map(igniter), :corex_project_paths, - {project_path, web_path, web_namespace, web_app_str} + {project_path, web_path, otp_app, web_namespace, web_app_str} ) %{igniter | assigns: assigns} end def run_config_phase(igniter, opts) do - {_project_path, _web_path, web_namespace, _web_app_str} = + {_project_path, _web_path, _otp_app, web_namespace, _web_app_str} = assigns_map(igniter)[:corex_project_paths] igniter @@ -1274,23 +1595,25 @@ if Code.ensure_loaded?(Igniter) do end def run_assets_phase(igniter, opts) do - {project_path, web_path, _web_namespace, _web_app_str} = + {project_path, web_path, otp_app, _web_namespace, _web_app_str} = assigns_map(igniter)[:corex_project_paths] app_js_path = Path.relative_to(Path.join(web_path, "assets/js/app.js"), project_path) config_path = Path.join("config", "config.exs") + preserve? = Keyword.get(opts, :preserve, false) igniter |> patch_app_js(app_js_path, opts) - |> patch_esbuild_config(config_path) + |> patch_esbuild_config(config_path, otp_app, preserve?) end def run_layout_phase(igniter, opts) do - {project_path, web_path, web_namespace, web_app_str} = + {project_path, web_path, otp_app, web_namespace, web_app_str} = assigns_map(igniter)[:corex_project_paths] design? = Keyword.get(opts, :design, true) preserve? = Keyword.get(opts, :preserve, false) + config_path = Path.join("config", "config.exs") root_layout_path = Path.relative_to( @@ -1308,8 +1631,10 @@ if Code.ensure_loaded?(Igniter) do igniter |> patch_root_layout(root_layout_path, web_app_str, design?, preserve?, opts) - |> create_corex_root(web_path, web_app_str, project_path, design?, preserve?, opts) + |> create_corex_root(web_path, web_app_str, project_path, otp_app, design?, preserve?, opts) |> add_corex_app_to_layouts(layouts_path, preserve?, design?) + |> add_esbuild_corex_profile(config_path, otp_app, preserve?, design?) + |> add_corex_mix_alias(otp_app, preserve?, design?) |> patch_html_helpers(web_ex_path, opts) |> create_corex_page(web_path, web_app_str, project_path, web_namespace, preserve?, design?) |> replace_root_route(web_path, web_app_str, project_path, web_namespace, preserve?) @@ -1317,7 +1642,7 @@ if Code.ensure_loaded?(Igniter) do end def run_css_phase(igniter, opts) do - {project_path, web_path, _web_namespace, _web_app_str} = + {project_path, web_path, _otp_app, _web_namespace, _web_app_str} = assigns_map(igniter)[:corex_project_paths] app_css_path = Path.relative_to(Path.join(web_path, "assets/css/app.css"), project_path) @@ -1424,26 +1749,20 @@ if Code.ensure_loaded?(Igniter) do if Igniter.exists?(igniter, mix_path) do igniter |> Igniter.include_existing_file(mix_path, required?: true) - |> Igniter.update_file(mix_path, &patch_mix_gettext_dep/1) + |> maybe_add_gettext_dep() else igniter end end - defp patch_mix_gettext_dep(source) do - if source.content =~ ~r/:gettext,/ do - source + defp maybe_add_gettext_dep(igniter) do + if Igniter.Project.Deps.has_dep?(igniter, :gettext) do + igniter else - insert = ~s/ {:gettext, "~> 1.0"},/ - - new_content = - String.replace( - source.content, - ~r/\{:telemetry_poller, "~> 1\.0"\}/, - insert <> "\n {:telemetry_poller, \"~> 1.0\"}" - ) - - Rewrite.Source.update(source, :content, new_content) + Igniter.Project.Deps.add_dep(igniter, {:gettext, "~> 1.0"}, + append?: true, + on_exists: :skip + ) end end diff --git a/test/corex/igniter_test.exs b/test/corex/igniter_test.exs index 5128b5c..8edae0e 100644 --- a/test/corex/igniter_test.exs +++ b/test/corex/igniter_test.exs @@ -11,7 +11,7 @@ defmodule Corex.IgniterTest do igniter = phx_test_project(app_name: :phx_setup) result = Corex.Igniter.run_setup_phase(igniter, design: false) - assert {_project_path, _web_path, _web_namespace, _web_app_str} = + assert {_project_path, _web_path, _otp_app, _web_namespace, _web_app_str} = result.assigns[:corex_project_paths] end end @@ -110,7 +110,7 @@ defmodule Corex.IgniterTest do corex_root = Rewrite.source!( result.rewrite, - "lib/phx_layout_preserve_web/components/layouts/corex_root.html.heex" + "lib/phx_layout_preserve_web/components/core_layouts/corex_root.html.heex" ) corex_root_content = Rewrite.Source.get(corex_root, :content) diff --git a/test/mix/tasks/corex.install_test.exs b/test/mix/tasks/corex.install_test.exs index 9e5bb0b..8210b68 100644 --- a/test/mix/tasks/corex.install_test.exs +++ b/test/mix/tasks/corex.install_test.exs @@ -202,7 +202,7 @@ defmodule Mix.Tasks.Corex.InstallTest do "home.html.heex has Corex landing content" end - test "preserve creates corex_root and adds get /corex without modifying root or home" do + test "preserve creates CorexLayouts, corex_app.css/js and adds get /corex without modifying root or home" do igniter = phx_test_project(app_name: :phx_preserve) |> Igniter.compose_task("corex.install", ["--yes", "--preserve"]) @@ -210,15 +210,14 @@ defmodule Mix.Tasks.Corex.InstallTest do corex_root = Rewrite.source!( igniter.rewrite, - "lib/phx_preserve_web/components/layouts/corex_root.html.heex" + "lib/phx_preserve_web/components/core_layouts/corex_root.html.heex" ) corex_root_content = Rewrite.Source.get(corex_root, :content) assert corex_root_content =~ ~r/data-theme=/ assert corex_root_content =~ ~r/data-mode=/ - - refute corex_root_content =~ ~r/phx:set-theme/, - "corex_root should not have theme script when no --theme" + assert corex_root_content =~ ~r/corex_app\.css/ + assert corex_root_content =~ ~r/corex_app\.js/ root_source = Rewrite.source!(igniter.rewrite, "lib/phx_preserve_web/components/layouts/root.html.heex") @@ -238,22 +237,26 @@ defmodule Mix.Tasks.Corex.InstallTest do assert router_content =~ ~r/get\s*\(?\s*"\/"\s*,\s*PageController\s*,\s*:home\s*\)?/, "home route should be kept when preserve" - layouts_source = + layouts_content = Rewrite.source!(igniter.rewrite, "lib/phx_preserve_web/components/layouts.ex") + |> Rewrite.Source.get(:content) - layouts_content = Rewrite.Source.get(layouts_source, :content) + refute layouts_content =~ ~r/corex_app/, + "layouts.ex should not have corex_app when preserve" - assert layouts_content =~ ~r/toast_group/, - "corex_app uses toast_group" + corex_app_css = + Rewrite.source!(igniter.rewrite, "assets/css/corex_app.css") + |> Rewrite.Source.get(:content) - css_source = Rewrite.source!(igniter.rewrite, "assets/css/app.css") - css_content = Rewrite.Source.get(css_source, :content) + assert corex_app_css =~ ~r/@import "\.\.\/corex\/main\.css"/, + "corex_app.css should have Corex imports" - assert css_content =~ ~r/@import "\.\.\/corex\/main\.css"/, - "Corex imports should be added" + core_layouts = + Rewrite.source!(igniter.rewrite, "lib/phx_preserve_web/components/core_layouts.ex") + |> Rewrite.Source.get(:content) - assert css_content =~ ~r/@import "\.\.\/corex\/components\/toast\.css"/, - "toast.css should be added when design" + assert core_layouts =~ ~r/CorexLayouts/ + assert core_layouts =~ ~r/embed_templates "core_layouts\/\*"/ end test "install with --only uses hooks import and use Corex only" do From bc62c2d4bfe284ebbe2f2d8d00d9e16e5ae928ea Mon Sep 17 00:00:00 2001 From: karimsemmoud Date: Mon, 2 Mar 2026 17:13:55 +0700 Subject: [PATCH 16/21] remove igniter --- design/components/layout.css | 5 +- e2e/assets/corex/components/layout.css | 5 +- lib/mix/tasks/corex.gen.auth.ex | 38 - lib/mix/tasks/corex.gen.html.ex | 38 - lib/mix/tasks/corex.gen.live.ex | 38 - lib/mix/tasks/corex.install.ex | 134 -- lib/mix/tasks/corex/igniter.ex | 2000 ----------------- mix.exs | 1 - mix.lock | 7 - priv/design/components/layout.css | 5 +- test/corex/igniter_test.exs | 195 -- test/mix/tasks/corex.gen.auth_test.exs | 22 - test/mix/tasks/corex.gen.html_test.exs | 22 - test/mix/tasks/corex.gen.live_test.exs | 22 - .../tasks/corex.install_integration_test.exs | 96 - test/mix/tasks/corex.install_test.exs | 321 --- test/support/install_integration_case.ex | 72 - 17 files changed, 6 insertions(+), 3015 deletions(-) delete mode 100644 lib/mix/tasks/corex.gen.auth.ex delete mode 100644 lib/mix/tasks/corex.gen.html.ex delete mode 100644 lib/mix/tasks/corex.gen.live.ex delete mode 100644 lib/mix/tasks/corex.install.ex delete mode 100644 lib/mix/tasks/corex/igniter.ex delete mode 100644 test/corex/igniter_test.exs delete mode 100644 test/mix/tasks/corex.gen.auth_test.exs delete mode 100644 test/mix/tasks/corex.gen.html_test.exs delete mode 100644 test/mix/tasks/corex.gen.live_test.exs delete mode 100644 test/mix/tasks/corex.install_integration_test.exs delete mode 100644 test/mix/tasks/corex.install_test.exs delete mode 100644 test/support/install_integration_case.ex diff --git a/design/components/layout.css b/design/components/layout.css index 06c5394..4a15e95 100644 --- a/design/components/layout.css +++ b/design/components/layout.css @@ -12,7 +12,7 @@ background: var(--color-root); overflow-y: scroll; - scrollbar-gutter: stable both-edges; + scrollbar-gutter: stable; } .layout__header { @@ -80,7 +80,7 @@ width: auto; height: calc(100vh - var(--spacing-ui-lg)); overflow-y: scroll; - scrollbar-gutter: stable both-edges; + scrollbar-gutter: stable; } @@ -95,7 +95,6 @@ flex-direction: column; flex: 1; min-width: 0; - min-height: calc(100vh - var(--spacing-ui-lg)); width: 100%; height: 100%; margin-inline: auto; diff --git a/e2e/assets/corex/components/layout.css b/e2e/assets/corex/components/layout.css index 06c5394..4a15e95 100644 --- a/e2e/assets/corex/components/layout.css +++ b/e2e/assets/corex/components/layout.css @@ -12,7 +12,7 @@ background: var(--color-root); overflow-y: scroll; - scrollbar-gutter: stable both-edges; + scrollbar-gutter: stable; } .layout__header { @@ -80,7 +80,7 @@ width: auto; height: calc(100vh - var(--spacing-ui-lg)); overflow-y: scroll; - scrollbar-gutter: stable both-edges; + scrollbar-gutter: stable; } @@ -95,7 +95,6 @@ flex-direction: column; flex: 1; min-width: 0; - min-height: calc(100vh - var(--spacing-ui-lg)); width: 100%; height: 100%; margin-inline: auto; diff --git a/lib/mix/tasks/corex.gen.auth.ex b/lib/mix/tasks/corex.gen.auth.ex deleted file mode 100644 index ab50207..0000000 --- a/lib/mix/tasks/corex.gen.auth.ex +++ /dev/null @@ -1,38 +0,0 @@ -defmodule Mix.Tasks.Corex.Gen.Auth do - @shortdoc "Generates authentication (Corex components)" - - @moduledoc """ - Generates authentication for a Phoenix application. - - Same as `mix phx.gen.auth` but uses Corex components when templates are installed. - - Corex templates must be installed first. Run `mix corex.install` if you see: - - Corex templates not found. Run mix corex.install first. - - See `mix help phx.gen.auth` for options and examples. - """ - use Mix.Task - - @impl Mix.Task - def run(args) do - unless templates_exist?("phx.gen.auth", "auth.ex") do - Mix.raise(""" - Corex templates not found. Run mix corex.install first. - """) - end - - Mix.Task.reenable("phx.gen.auth") - Mix.Task.run("phx.gen.auth", args) - end - - defp templates_exist?(template_dir, check_file) do - base = templates_base_path() - path = Path.join([base, "priv", "templates", template_dir, check_file]) - File.exists?(path) - end - - defp templates_base_path do - File.cwd!() - end -end diff --git a/lib/mix/tasks/corex.gen.html.ex b/lib/mix/tasks/corex.gen.html.ex deleted file mode 100644 index bb16117..0000000 --- a/lib/mix/tasks/corex.gen.html.ex +++ /dev/null @@ -1,38 +0,0 @@ -defmodule Mix.Tasks.Corex.Gen.Html do - @shortdoc "Generates context and controller for an HTML resource (Corex components)" - - @moduledoc """ - Generates controller with view, templates, schema and context for an HTML resource. - - Same as `mix phx.gen.html` but uses Corex components when templates are installed. - - Corex templates must be installed first. Run `mix corex.install` if you see: - - Corex templates not found. Run mix corex.install first. - - See `mix help phx.gen.html` for options and examples. - """ - use Mix.Task - - @impl Mix.Task - def run(args) do - unless templates_exist?("phx.gen.html", "controller.ex") do - Mix.raise(""" - Corex templates not found. Run mix corex.install first. - """) - end - - Mix.Task.reenable("phx.gen.html") - Mix.Task.run("phx.gen.html", args) - end - - defp templates_exist?(template_dir, check_file) do - base = templates_base_path() - path = Path.join([base, "priv", "templates", template_dir, check_file]) - File.exists?(path) - end - - defp templates_base_path do - File.cwd!() - end -end diff --git a/lib/mix/tasks/corex.gen.live.ex b/lib/mix/tasks/corex.gen.live.ex deleted file mode 100644 index 4251e87..0000000 --- a/lib/mix/tasks/corex.gen.live.ex +++ /dev/null @@ -1,38 +0,0 @@ -defmodule Mix.Tasks.Corex.Gen.Live do - @shortdoc "Generates LiveView, templates, and context for a resource (Corex components)" - - @moduledoc """ - Generates LiveView, templates, and context for a resource. - - Same as `mix phx.gen.live` but uses Corex components when templates are installed. - - Corex templates must be installed first. Run `mix corex.install` if you see: - - Corex templates not found. Run mix corex.install first. - - See `mix help phx.gen.live` for options and examples. - """ - use Mix.Task - - @impl Mix.Task - def run(args) do - unless templates_exist?("phx.gen.live", "form.ex") do - Mix.raise(""" - Corex templates not found. Run mix corex.install first. - """) - end - - Mix.Task.reenable("phx.gen.live") - Mix.Task.run("phx.gen.live", args) - end - - defp templates_exist?(template_dir, check_file) do - base = templates_base_path() - path = Path.join([base, "priv", "templates", template_dir, check_file]) - File.exists?(path) - end - - defp templates_base_path do - File.cwd!() - end -end diff --git a/lib/mix/tasks/corex.install.ex b/lib/mix/tasks/corex.install.ex deleted file mode 100644 index d0e0674..0000000 --- a/lib/mix/tasks/corex.install.ex +++ /dev/null @@ -1,134 +0,0 @@ -if Code.ensure_loaded?(Igniter) do - defmodule Mix.Tasks.Corex.Install do - @shortdoc "Installs Corex into a Phoenix project" - @moduledoc """ - Installs Corex into an existing Phoenix project. - - Run this after `mix phx.new` or use Igniter: - - mix corex.install - mix igniter.install corex - - Add `--yes` to apply changes without prompting. To test with a local Corex source - (e.g. before publishing to Hex): - - mix igniter.install corex@path:../corex - - By default, Corex overrides root layout, layouts, and DaisyUI (convenient for new projects). - Use `--preserve` to keep your layouts, app.css, app.js, and DaisyUI; Corex adds its own - CorexLayouts, corex_app.css, corex_app.js, and /corex route. - - ## Options - - * `--no-design` - skip copying Corex design files (does not touch daisyUI or add data-theme/data-mode) - * `--preserve` - keep your layouts, app.css, app.js, DaisyUI; create CorexLayouts, corex_app.css/js, get "/corex" - * `--designex` - include design tokens and build scripts in design/ - * `--mode` - enable light/dark mode (plug, script, toggle) - * `--theme THEMES` - colon-separated themes (e.g. neo:uno). At least 2 values. - * `--languages LANGUAGES` - colon-separated locales (e.g. en:fr:ar). At least 2 values. - * `--rtl RTL` - RTL locale codes (e.g. ar) - * `--prefix PREFIX` - prefix for Corex components (e.g. ui for <.ui_accordion>) - * `--only COMPONENTS` - colon-separated component names (e.g. accordion:checkbox:dialog) - """ - use Igniter.Mix.Task - - @impl Igniter.Mix.Task - def supports_umbrella?, do: true - - @impl Igniter.Mix.Task - def info(_argv, _parent) do - %Igniter.Mix.Task.Info{ - group: :corex, - composes: [], - schema: [ - design: :boolean, - designex: :boolean, - preserve: :boolean, - mode: :boolean, - theme: :string, - languages: :string, - rtl: :string, - prefix: :string, - only: :string - ], - defaults: [design: true, preserve: false] - } - end - - @impl Igniter.Mix.Task - def parse_argv(argv) do - args = super(argv) - validate_opts!(args.options) - args - end - - defp validate_opts!(opts) do - if theme = Keyword.get(opts, :theme) do - themes = String.split(theme, ":", trim: true) - - if length(themes) < 2 do - Mix.raise("--theme requires at least 2 values (e.g. neo:uno), got: #{inspect(theme)}") - end - end - - if languages = Keyword.get(opts, :languages) do - list = String.split(languages, ":", trim: true) - - if length(list) < 2 do - Mix.raise( - "--languages requires at least 2 values (e.g. en:fr:ar), got: #{inspect(languages)}" - ) - end - end - - if prefix = Keyword.get(opts, :prefix) do - if prefix == "" or (is_binary(prefix) and String.trim(prefix) == "") do - Mix.raise("--prefix must be non-empty, got: #{inspect(prefix)}") - end - end - - if only_str = Keyword.get(opts, :only) do - parts = String.split(only_str, ":", trim: true) - valid = Corex.component_keys() |> Enum.map(&to_string/1) - invalid = Enum.reject(parts, &(&1 in valid)) - - if invalid != [] do - Mix.raise( - "--only contains invalid components: #{inspect(invalid)}. Valid: #{inspect(valid)}" - ) - end - end - end - - @impl Igniter.Mix.Task - def igniter(igniter) do - Corex.Igniter.install(igniter, igniter.args.options) - end - - @impl Mix.Task - def run(argv) do - super(argv) - Mix.Task.reenable("format") - Mix.Task.run("format") - end - end -else - defmodule Mix.Tasks.Corex.Install do - @moduledoc "Installs Corex into a Phoenix project. Run with: mix igniter.install corex" - @shortdoc @moduledoc - - use Mix.Task - - def run(_argv) do - Mix.shell().error(""" - The task 'corex.install' requires igniter. - - Run: mix igniter.install corex - - See https://hexdocs.pm/igniter - """) - - Mix.raise("corex.install requires igniter") - end - end -end diff --git a/lib/mix/tasks/corex/igniter.ex b/lib/mix/tasks/corex/igniter.ex deleted file mode 100644 index 4651c18..0000000 --- a/lib/mix/tasks/corex/igniter.ex +++ /dev/null @@ -1,2000 +0,0 @@ -if Code.ensure_loaded?(Igniter) do - defmodule Corex.Igniter do - @moduledoc false - - defp assigns_map(igniter) do - case Map.get(igniter, :assigns) do - m when is_map(m) -> m - _ -> %{} - end - end - - defp path_under_root?(path, root) do - abs_path = Path.absname(path) - abs_root = Path.absname(root) |> String.trim_trailing("/") - String.starts_with?(abs_path, abs_root <> "/") or abs_path == abs_root - end - - # sobelow_skip ["DOS.StringToAtom"] - defp safe_to_atom(str) when is_binary(str) do - cond do - byte_size(str) > 128 -> - Mix.raise("App name too long: #{inspect(str)}") - - str =~ ~r/[^a-z0-9_]/ -> - Mix.raise( - "Invalid app name (lowercase letters, digits, underscore only): #{inspect(str)}" - ) - - true -> - String.to_atom(str) - end - end - - def validate_opts!(opts) do - if theme = Keyword.get(opts, :theme) do - themes = String.split(theme, ":", trim: true) - - if length(themes) < 2 do - Mix.raise("--theme requires at least 2 values (e.g. neo:uno), got: #{inspect(theme)}") - end - end - - if languages = Keyword.get(opts, :languages) do - list = String.split(languages, ":", trim: true) - - if length(list) < 2 do - Mix.raise( - "--languages requires at least 2 values (e.g. en:fr:ar), got: #{inspect(languages)}" - ) - end - end - - if prefix = Keyword.get(opts, :prefix) do - if prefix == "" or (is_binary(prefix) and String.trim(prefix) == "") do - Mix.raise("--prefix must be non-empty, got: #{inspect(prefix)}") - end - end - - if only_str = Keyword.get(opts, :only) do - parts = String.split(only_str, ":", trim: true) - valid = Corex.component_keys() |> Enum.map(&to_string/1) - invalid = Enum.reject(parts, &(&1 in valid)) - - if invalid != [] do - Mix.raise( - "--only contains invalid components: #{inspect(invalid)}. Valid: #{inspect(valid)}" - ) - end - end - end - - def install(igniter, opts) do - ensure_phoenix_project!() - - {project_path, web_path, otp_app, web_namespace, web_app_str} = - project_paths!(igniter) - - design? = Keyword.get(opts, :design, true) - designex? = Keyword.get(opts, :designex, false) - preserve? = Keyword.get(opts, :preserve, false) - - igniter - |> add_gettext_if_missing(project_path, web_path, otp_app, web_app_str, web_namespace) - |> then(fn igniter -> - if design?, - do: run_corex_design(igniter, project_path, web_path, designex?), - else: igniter - end) - |> copy_generator_templates(project_path, web_path, otp_app) - |> copy_plugs_and_hooks(web_path, web_namespace, web_app_str, opts) - - app_js_path = Path.relative_to(Path.join(web_path, "assets/js/app.js"), project_path) - config_path = Path.join("config", "config.exs") - - root_layout_path = - Path.relative_to( - Path.join(web_path, "lib/#{web_app_str}/components/layouts/root.html.heex"), - project_path - ) - - web_ex_path = Path.relative_to(Path.join(web_path, "lib/#{web_app_str}.ex"), project_path) - app_css_path = Path.relative_to(Path.join(web_path, "assets/css/app.css"), project_path) - - layouts_path = - Path.relative_to( - Path.join(web_path, "lib/#{web_app_str}/components/layouts.ex"), - project_path - ) - - igniter - |> ensure_layouts_exist!(layouts_path, project_path) - |> add_corex_config(web_namespace) - |> add_rtl_config(opts) - |> patch_app_js(app_js_path, opts) - |> patch_esbuild_config(config_path, otp_app, preserve?) - |> patch_root_layout(root_layout_path, web_app_str, design?, preserve?, opts) - |> create_corex_root(web_path, web_app_str, project_path, otp_app, design?, preserve?, opts) - |> add_corex_app_to_layouts(layouts_path, preserve?, design?) - |> add_esbuild_corex_profile(config_path, otp_app, preserve?, design?) - |> add_corex_mix_alias(otp_app, preserve?, design?) - |> patch_html_helpers(web_ex_path, opts) - |> patch_app_css(app_css_path, design?, preserve?, opts) - |> create_corex_page(web_path, web_app_str, project_path, web_namespace, preserve?, design?) - |> replace_root_route(web_path, web_app_str, project_path, web_namespace, preserve?) - |> remove_daisy_vendor_files(web_path, project_path, preserve?, design?) - end - - def add_corex_config(igniter, web_namespace) do - gettext_backend = Module.concat([web_namespace, Gettext]) - - failure_message = """ - Could not add Corex config to config/config.exs. Add manually: - - config :corex, - gettext_backend: #{inspect(gettext_backend)}, - json_library: Jason - """ - - if Igniter.Project.Config.configures_root_key?(igniter, "config.exs", :corex) do - igniter - else - igniter - |> Igniter.Project.Config.configure_new( - "config.exs", - :corex, - [:gettext_backend], - gettext_backend, - failure_message: failure_message - ) - |> Igniter.Project.Config.configure_new( - "config.exs", - :corex, - [:json_library], - Jason, - failure_message: failure_message - ) - end - end - - def add_rtl_config(igniter, opts) do - case Keyword.get(opts, :rtl) do - nil -> - igniter - - rtl -> - rtl_list = String.split(rtl, ~r/[:,]/, trim: true) - - failure_message = - "Could not add rtl_locales to config :corex. Add manually: rtl_locales: #{inspect(rtl_list)}" - - Igniter.Project.Config.configure( - igniter, - "config.exs", - :corex, - [:rtl_locales], - rtl_list, - failure_message: failure_message - ) - end - end - - def patch_app_js(igniter, app_js_path, opts) do - if Keyword.get(opts, :preserve, false) do - igniter - else - if Igniter.exists?(igniter, app_js_path) do - igniter - |> Igniter.include_existing_file(app_js_path, required?: false) - |> Igniter.update_file(app_js_path, &patch_app_js_content(&1, app_js_path, opts)) - else - igniter - end - end - end - - defp patch_app_js_content(source, app_js_path, opts) do - if source.content =~ ~r/from "corex"/ do - source - else - only = Keyword.get(opts, :only) - {import_line, hooks_line} = app_js_corex_lines(only) - - new_content = - source.content - |> String.replace(~r/(import topbar from)/, import_line <> "\n\\1") - |> String.replace(~r/hooks: \{\.\.\.colocatedHooks\}/, hooks_line) - - update_app_js_or_warn(source, source.content, new_content, app_js_path, only) - end - end - - defp app_js_corex_lines(nil) do - {~s|import corex from "corex"|, "hooks: {...colocatedHooks, ...corex}"} - end - - defp app_js_corex_lines(only_str) do - atoms = - only_str - |> String.split(":", trim: true) - |> Enum.map(&String.to_atom/1) - |> Enum.filter(&(&1 in Corex.component_keys())) - - pascal = Enum.map(atoms, &Phoenix.Naming.camelize(to_string(&1))) - hooks_arg = inspect(pascal) - {~s|import { hooks } from "corex"|, "hooks: {...colocatedHooks, ...hooks(#{hooks_arg})}"} - end - - defp update_app_js_or_warn(source, content, new_content, app_js_path, only) do - if new_content == content do - {import_line, hooks_line} = app_js_corex_lines(only) - - {:warning, - """ - Could not patch #{app_js_path} (structure may differ). Add manually: - - #{import_line} - #{hooks_line} - """} - else - Rewrite.Source.update(source, :content, new_content) - end - end - - def patch_esbuild_config(igniter, config_path, _otp_app, preserve?) do - if preserve? do - igniter - else - igniter - |> Igniter.include_existing_file(config_path, required?: true) - |> Igniter.update_file(config_path, &patch_esbuild_content(&1, config_path)) - end - end - - defp patch_esbuild_content(source, config_path) do - if source.content =~ ~r/--format=esm/ do - source - else - new_content = - String.replace(source.content, ~r/(--bundle )/, "\\1--format=esm --splitting ") - - update_esbuild_or_warn(source, source.content, new_content, config_path) - end - end - - defp update_esbuild_or_warn(source, content, new_content, config_path) do - if new_content == content do - {:warning, - Igniter.Util.Warning.formatted_warning( - "Could not patch esbuild config in #{config_path}. Add manually to esbuild args:", - ~s|# --format=esm --splitting --bundle| - )} - else - Rewrite.Source.update(source, :content, new_content) - end - end - - defp ensure_layouts_exist!(igniter, layouts_path, project_path) do - abs_layouts = Path.join(project_path, layouts_path) - - if Igniter.exists?(igniter, layouts_path) or File.exists?(abs_layouts) do - igniter - else - Mix.raise(""" - Layouts module not found. Create lib//components/layouts.ex first, then run: - - mix corex.install - """) - end - end - - def add_corex_app_to_layouts(igniter, layouts_path, preserve?, design?) do - if preserve? do - igniter - else - if Igniter.exists?(igniter, layouts_path) do - igniter - |> Igniter.include_existing_file(layouts_path, required?: false) - |> Igniter.update_elixir_file( - layouts_path, - &update_layouts_zipper(&1, layouts_path, preserve?, design?), - required?: false - ) - else - igniter - end - end - end - - defp update_layouts_zipper(zipper, layouts_path, preserve?, design?) do - if preserve? do - add_corex_app_zipper(zipper, layouts_path, design?) - else - replace_app_and_remove_theme_toggle_zipper(zipper, layouts_path, design?) - end - end - - defp add_corex_app_zipper(zipper, layouts_path, design?) do - if has_corex_app?(zipper) do - {:ok, zipper} - else - toast_block = toast_group_block(design?) - - corex_app_code = """ - attr :flash, :map, required: true, doc: "the map of flash messages" - - def corex_app(assigns) do - ~H\"\"\" -
    - #{toast_block} -
    -
    - {@inner_content} -
    -
    -
    - \"\"\" - end - """ - - with {:ok, zipper} <- Igniter.Code.Module.move_to_defmodule(zipper), - {:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper), - zipper <- Igniter.Code.Common.rightmost(zipper), - zipper <- Igniter.Code.Common.add_code(zipper, corex_app_code, placement: :after) do - {:ok, zipper} - else - _ -> - {:warning, - Igniter.Util.Warning.formatted_warning( - "Could not add corex_app to #{layouts_path}. Add manually before the final `end`:", - corex_app_code - )} - end - end - end - - defp has_corex_app?(zipper) do - str = Sourceror.Zipper.root(zipper) |> Sourceror.to_string() - str =~ ~r/def corex_app\(/ - end - - defp toast_group_block(design?) do - toast_class = if design?, do: ~s| class="toast"|, else: "" - - """ - <.toast_group id="layout-toast"#{toast_class} flash={@flash}> - <:loading> - <.icon name="hero-arrow-path" /> - - - <.toast_client_error - toast_group_id="layout-toast" - title={gettext("We can't find the internet")} - description={gettext("Attempting to reconnect")} - type={:error} - duration={:infinity} - /> - <.toast_server_error - toast_group_id="layout-toast" - title={gettext("Something went wrong!")} - description={gettext("Attempting to reconnect")} - type={:error} - duration={:infinity} - /> - """ - end - - defp replace_app_and_remove_theme_toggle_zipper(zipper, layouts_path, design?) do - content = Sourceror.Zipper.root(zipper) |> Sourceror.to_string() - - if content =~ ~r/def app\(assigns\) do[\s\S]*?id="layout-toast"/ do - {:ok, zipper} - else - do_replace_app_and_remove_theme_toggle_zipper(zipper, layouts_path, design?) - end - end - - defp do_replace_app_and_remove_theme_toggle_zipper(zipper, layouts_path, design?) do - toast_block = toast_group_block(design?) - - corex_app_body = - "
    \n" <> - toast_block <> - "\n
    \n" <> - "
    \n" <> - " {assigns[:inner_content] || render_slot(@inner_block)}\n" <> - "
    \n" <> - "
    \n" <> - "
    " - - content = Sourceror.Zipper.root(zipper) |> Sourceror.to_string() - content_without_flash = remove_flash_group(content) - - zipper = - content_without_flash - |> Sourceror.parse_string!() - |> Sourceror.Zipper.zip() - |> Igniter.Code.Common.remove(theme_toggle_predicate()) - - result = apply_layout_replacements(zipper, corex_app_body) - - if result == content_without_flash do - {:warning, - Igniter.Util.Warning.formatted_warning( - "Could not replace app and remove theme_toggle in #{layouts_path}.", - ~s|# Replace def app/1 body with Corex layout| - )} - else - {:ok, result |> Sourceror.parse_string!() |> Sourceror.Zipper.zip()} - end - end - - defp theme_toggle_predicate do - fn z -> - node = Sourceror.Zipper.node(z) - - cond do - match?({:def, _, [{:theme_toggle, _, _} | _]}, node) -> true - match?({:@, _, [{:doc, _, _} | _]}, node) -> next_def_name(z) == :theme_toggle - true -> false - end - end - end - - defp apply_layout_replacements(zipper, corex_app_body) do - slot_regex = ~r/slot :inner_block, required: true/ - li_regex = ~r/\s*
  • \s*<\.theme_toggle \/>\s*<\/li>/ - app_def_regex = ~r/(def app\(assigns\) do\s*\n\s*~H""")[\s\S]*?("""\s*\n\s*end)/ - - zipper - |> Sourceror.Zipper.root() - |> Sourceror.to_string() - |> String.replace(slot_regex, "slot :inner_block") - |> String.replace(li_regex, "") - |> String.replace(app_def_regex, "\\1\n#{corex_app_body}\n\\2") - end - - defp remove_flash_group(content) do - zipper = Sourceror.parse_string!(content) |> Sourceror.Zipper.zip() - pred = flash_group_predicate() - updated_zipper = Igniter.Code.Common.remove(zipper, pred) - Sourceror.Zipper.root(updated_zipper) |> Sourceror.to_string() - end - - defp flash_group_predicate do - fn z -> - node = Sourceror.Zipper.node(z) - - cond do - match?({:def, _, [{:flash_group, _, _} | _]}, node) -> true - match?({:@, _, [{:doc, _, _} | _]}, node) -> next_def_name(z) == :flash_group - match?({:attr, _, _}, node) -> next_def_name(z) == :flash_group - true -> false - end - end - end - - defp next_def_name(zipper) do - case Sourceror.Zipper.right(zipper) do - nil -> - nil - - right_z -> - case Sourceror.Zipper.node(right_z) do - {:def, _, [{name, _, _} | _]} -> name - _ -> next_def_name(right_z) - end - end - end - - defp create_corex_page( - igniter, - web_path, - web_app_str, - project_path, - web_namespace, - preserve?, - design? - ) do - create_corex_page_impl( - igniter, - web_path, - web_app_str, - project_path, - web_namespace, - preserve?, - design? - ) - end - - defp corex_landing_content(design?) do - navigate_attrs = - if design? do - " external\n class=\"button button--brand button--sm md:button--md w-full md:w-auto\"" - else - "external" - end - - """ -
    -

    - Build fast, - accessible - and interactive - websites - - with Corex - -

    - -

    - From design tokens to accessible, interactive components, Corex provides a complete foundation for creating beautiful, scalable, and performant websites. -

    -
    - <.navigate - to="https://hexdocs.pm/corex" - #{navigate_attrs} - > - Hex Documentation <.icon name="hero-arrow-top-right-on-square" /> - -
    -
    - """ - end - - defp create_corex_page_impl( - igniter, - web_path, - web_app_str, - project_path, - web_namespace, - preserve?, - design? - ) do - page_html_dir = Path.join([web_path, "lib", web_app_str, "controllers", "page_html"]) - corex_page_path = Path.join(page_html_dir, "corex_page.html.heex") - rel_corex_page = Path.relative_to(corex_page_path, project_path) - home_page_path = Path.join(page_html_dir, "home.html.heex") - rel_home_page = Path.relative_to(home_page_path, project_path) - - page_controller_path = - Path.join([web_path, "lib", web_app_str, "controllers", "page_controller.ex"]) - - rel_page_controller = Path.relative_to(page_controller_path, project_path) - content = corex_landing_content(design?) - - if preserve? do - igniter - |> Igniter.mkdir(page_html_dir) - |> Igniter.create_or_update_file( - rel_corex_page, - content, - fn source -> Rewrite.Source.update(source, :content, content) end - ) - |> Igniter.add_notice("* creating corex_page.html.heex") - |> add_corex_page_action(rel_page_controller, web_namespace, preserve?) - else - igniter - |> Igniter.mkdir(page_html_dir) - |> Igniter.create_or_update_file( - rel_home_page, - content, - fn source -> Rewrite.Source.update(source, :content, content) end - ) - |> maybe_add_home_overwrite_notice(project_path, rel_home_page, content) - |> patch_home_action_for_corex_layout( - rel_page_controller, - web_namespace, - design?, - preserve? - ) - |> patch_page_controller_test(web_path, web_app_str, project_path) - end - end - - defp maybe_add_home_overwrite_notice(igniter, project_path, rel_home_page, content) do - abs_path = Path.join(project_path, rel_home_page) - - if not File.exists?(abs_path) or File.read!(abs_path) != content do - Igniter.add_notice(igniter, "* overwriting home.html.heex with Corex landing") - else - igniter - end - end - - defp patch_page_controller_test(igniter, _web_path, web_app_str, project_path) do - page_controller_test_path = - Path.join([project_path, "test", web_app_str, "controllers", "page_controller_test.exs"]) - - rel_test = Path.relative_to(page_controller_test_path, project_path) - - if Igniter.exists?(igniter, rel_test) do - igniter - |> Igniter.include_existing_file(rel_test, required?: false) - |> Igniter.update_elixir_file( - rel_test, - &patch_page_controller_test_zipper/1, - required?: false - ) - else - igniter - end - end - - defp patch_page_controller_test_zipper(zipper) do - phoenix_assertion_str = - ~s|assert html_response(conn, 200) =~ "Peace of mind from prototype to production"| - - corex_assertions_code = """ - assert html_response(conn, 200) =~ "Build" - assert html_response(conn, 200) =~ "Corex" - """ - - pred = fn z -> - str = Sourceror.to_string(z) - str == phoenix_assertion_str or String.trim(str) == phoenix_assertion_str - end - - fun = fn _z -> - new_ast = Sourceror.parse_string!(corex_assertions_code) - {:code, new_ast} - end - - case Igniter.Code.Common.update_all_matches(zipper, pred, fun) do - {:ok, updated} -> {:ok, updated} - other -> other - end - end - - defp patch_home_action_for_corex_layout( - igniter, - page_controller_path, - web_namespace, - design?, - preserve? - ) do - if preserve? do - igniter - else - if Igniter.exists?(igniter, page_controller_path) and design? do - layout_module = Module.concat([web_namespace, Layouts]) - layout_name = if preserve?, do: :corex_app, else: :app - - igniter - |> Igniter.include_existing_file(page_controller_path, required?: false) - |> Igniter.update_elixir_file( - page_controller_path, - &patch_home_action_zipper(&1, layout_module, layout_name), - required?: false - ) - else - igniter - end - end - end - - defp patch_home_action_zipper(zipper, layout_module, layout_name) do - content = Sourceror.Zipper.root(zipper) |> Sourceror.to_string() - layout_pattern = ~r/put_layout\(html: \{[^}]+, :#{layout_name}\}\)/ - - if content =~ layout_pattern do - {:ok, zipper} - else - insert = - "conn\n |> put_layout(html: {#{inspect(layout_module)}, #{inspect(layout_name)}})\n |> render(:home)" - - new_content = - content - |> String.replace( - ~r/(def home\(conn, _params\) do)\s*\n\s*conn\s*\n\s*\|>\s*render\(:home\)/s, - "\\1\n " <> insert - ) - |> String.replace( - ~r/(def home\(conn, _params\) do)\s*\n\s*render\(conn,\s*:home\)/s, - "\\1\n " <> insert - ) - |> String.replace( - ~r/(def home\(conn, _params\) do)\s*\n\s*render\(conn, :home\)/s, - "\\1\n " <> insert - ) - - if new_content != content do - {:ok, Sourceror.parse_string!(new_content) |> Sourceror.Zipper.zip()} - else - {:ok, zipper} - end - end - end - - defp add_corex_page_action(igniter, page_controller_path, web_namespace, preserve?) do - if Igniter.exists?(igniter, page_controller_path) do - igniter - |> Igniter.include_existing_file(page_controller_path, required?: false) - |> Igniter.update_elixir_file( - page_controller_path, - &add_corex_page_action_zipper(&1, page_controller_path, web_namespace, preserve?), - required?: false - ) - else - igniter - end - end - - defp add_corex_page_action_zipper(zipper, page_controller_path, web_namespace, preserve?) do - layout_module = Module.concat([web_namespace, Layouts]) - - has_corex_page? = - case Igniter.Code.Function.move_to_def(zipper, :corex_page, 2) do - {:ok, _} -> true - :error -> false - end - - if has_corex_page? do - {:ok, zipper} - else - content = Sourceror.Zipper.root(zipper) |> Sourceror.to_string() - insert = corex_page_action_insert(layout_module, preserve?) - manual_example = corex_page_action_manual(layout_module, preserve?) - - new_content = - if content =~ ~r/def home\(conn, _params\) do/ do - String.replace( - content, - ~r/(def home\(conn, _params\) do)/, - insert <> "\\1" - ) - else - content - end - - if new_content != content do - new_ast = Sourceror.parse_string!(new_content) - {:ok, Sourceror.Zipper.zip(new_ast)} - else - {:warning, - Igniter.Util.Warning.formatted_warning( - "Could not add corex_page to #{page_controller_path}. Add manually:", - manual_example - )} - end - end - end - - defp corex_page_action_insert(layout_module, true) do - [web_ns | _] = Module.split(layout_module) - corex_layouts = Module.concat([web_ns, "CorexLayouts"]) - - " def corex_page(conn, _params) do\n conn\n |> put_root_layout(html: {#{inspect(corex_layouts)}, :corex_root})\n |> put_layout(html: {#{inspect(corex_layouts)}, :root})\n |> render(:corex_page)\n end\n\n " - end - - defp corex_page_action_insert(layout_module, false) do - " def corex_page(conn, _params) do\n conn\n |> put_layout(html: {#{inspect(layout_module)}, :corex_app})\n |> render(:corex_page)\n end\n\n " - end - - defp corex_page_action_manual(layout_module, true) do - [web_ns | _] = Module.split(layout_module) - corex_layouts = Module.concat([web_ns, "CorexLayouts"]) - - "def corex_page(conn, _params) do\n conn\n |> put_root_layout(html: {#{inspect(corex_layouts)}, :corex_root})\n |> put_layout(html: {#{inspect(corex_layouts)}, :root})\n |> render(:corex_page)\nend" - end - - defp corex_page_action_manual(layout_module, false) do - "def corex_page(conn, _params) do\n conn\n |> put_layout(html: {#{inspect(layout_module)}, :corex_app})\n |> render(:corex_page)\nend" - end - - defp replace_root_route( - igniter, - web_path, - web_app_str, - project_path, - web_namespace, - _preserve? - ) do - router_path = Path.join([web_path, "lib", web_app_str, "router.ex"]) - rel_router = Path.relative_to(router_path, project_path) - router = Module.concat([web_namespace, Router]) - - if Igniter.exists?(igniter, rel_router) do - igniter - |> Igniter.include_existing_file(rel_router, required?: false) - |> replace_root_route_with_append(rel_router, router, web_namespace) - else - igniter - end - end - - defp replace_root_route_with_append(igniter, _rel_router, router, web_namespace) do - {igniter, has_route?} = has_corex_route?(igniter, router) - - if has_route? do - igniter - else - Igniter.Libs.Phoenix.append_to_scope( - igniter, - "/", - ~s|get "/corex", PageController, :corex_page|, - router: router, - arg2: web_namespace, - with_pipelines: [:browser] - ) - end - end - - defp has_corex_route?(igniter, router) do - case Igniter.Project.Module.find_module(igniter, router) do - {:ok, {igniter, _source, zipper}} -> - case Igniter.Code.Function.move_to_function_call(zipper, :get, 3, fn zipper -> - Igniter.Code.Function.argument_equals?(zipper, 0, "/corex") - end) do - {:ok, _} -> {igniter, true} - :error -> {igniter, false} - end - - {:error, igniter} -> - {igniter, false} - end - end - - def patch_root_layout(igniter, root_layout_path, _web_app_str, design?, preserve?, opts) do - if design? and not preserve? do - replace_root_with_corex_template(igniter, root_layout_path, opts) - else - igniter - end - end - - defp replace_root_with_corex_template(igniter, root_layout_path, opts) do - if Igniter.exists?(igniter, root_layout_path) do - igniter - |> Igniter.include_existing_file(root_layout_path, required?: false) - |> Igniter.update_file( - root_layout_path, - fn source -> Rewrite.Source.update(source, :content, corex_root_template(opts)) end, - required?: false - ) - else - igniter - end - end - - def create_corex_root( - igniter, - web_path, - web_app_str, - project_path, - otp_app, - design?, - preserve?, - opts - ) do - if design? and preserve? do - web_namespace = Module.concat([Macro.camelize(to_string(otp_app)) <> "Web"]) - - igniter - |> create_corex_layouts_module(web_path, web_app_str, project_path, web_namespace) - |> create_corex_layouts_templates(web_path, web_app_str, project_path, opts) - |> create_corex_app_css(web_path, web_app_str, project_path) - |> create_corex_app_js(web_path, project_path, otp_app) - |> Igniter.add_notice("* creating CorexLayouts, corex_app.css, corex_app.js") - else - igniter - end - end - - defp create_corex_layouts_module(igniter, web_path, web_app_str, project_path, web_namespace) do - components_dir = Path.join(web_path, "lib/#{web_app_str}/components") - - core_layouts_path = - Path.relative_to(Path.join(components_dir, "core_layouts.ex"), project_path) - - content = """ - defmodule #{inspect(Module.concat([web_namespace, CorexLayouts]))} do - @moduledoc false - use #{inspect(web_namespace)}, :html - - embed_templates "core_layouts/*" - end - """ - - Igniter.create_or_update_file( - igniter, - core_layouts_path, - content, - fn source -> Rewrite.Source.update(source, :content, content) end - ) - end - - defp create_corex_layouts_templates(igniter, web_path, web_app_str, project_path, opts) do - core_layouts_dir = Path.join(web_path, "lib/#{web_app_str}/components/core_layouts") - - corex_root_path = - Path.relative_to(Path.join(core_layouts_dir, "corex_root.html.heex"), project_path) - - root_path = Path.relative_to(Path.join(core_layouts_dir, "root.html.heex"), project_path) - - corex_root_content = corex_root_template_for_preserve(opts) - root_content = corex_layout_root_content() - - igniter - |> Igniter.mkdir(core_layouts_dir) - |> Igniter.create_or_update_file(corex_root_path, corex_root_content, fn s -> - Rewrite.Source.update(s, :content, corex_root_content) - end) - |> Igniter.create_or_update_file(root_path, root_content, fn s -> - Rewrite.Source.update(s, :content, root_content) - end) - end - - defp corex_root_template_for_preserve(opts) do - mode_script_block = if Keyword.get(opts, :mode), do: mode_script(), else: "" - theme_script_block = if theme = Keyword.get(opts, :theme), do: theme_script(theme), else: "" - scripts = mode_script_block <> theme_script_block - - """ - - - - - - - <.live_title default="" suffix=""> - {assigns[:page_title]} - - - - #{String.trim(scripts)} - - - {@inner_content} - - - """ - end - - defp corex_layout_root_content do - """ -
    - <.toast_group id="layout-toast" class="toast" flash={@flash}> - <:loading> - <.icon name="hero-arrow-path" /> - - - <.toast_client_error - toast_group_id="layout-toast" - title={gettext("We can't find the internet")} - description={gettext("Attempting to reconnect")} - type={:error} - duration={:infinity} - /> - <.toast_server_error - toast_group_id="layout-toast" - title={gettext("Something went wrong!")} - description={gettext("Attempting to reconnect")} - type={:error} - duration={:infinity} - /> -
    -
    - {assigns[:inner_content] || render_slot(@inner_block)} -
    -
    -
    - """ - end - - defp create_corex_app_css(igniter, web_path, web_app_str, project_path) do - corex_app_css_path = - Path.relative_to(Path.join(web_path, "assets/css/corex_app.css"), project_path) - - content = """ - @import "tailwindcss" source(none); - @source "../css"; - @source "../js"; - @source "../../lib/#{web_app_str}"; - - @import "../corex/main.css"; - @import "../corex/tokens/themes/neo/light.css"; - @import "../corex/components/typo.css"; - @import "../corex/components/button.css"; - @import "../corex/components/toast.css"; - - @plugin "../vendor/heroicons"; - @custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &); - @custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &); - @custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &); - @custom-variant dark (&:where([data-mode=dark], [data-mode=dark] *)); - [data-phx-session], [data-phx-teleported-src] { display: contents } - """ - - Igniter.create_or_update_file( - igniter, - corex_app_css_path, - content, - fn source -> Rewrite.Source.update(source, :content, content) end - ) - end - - defp create_corex_app_js(igniter, web_path, project_path, otp_app) do - corex_app_js_path = - Path.relative_to(Path.join(web_path, "assets/js/corex_app.js"), project_path) - - app_str = to_string(otp_app) - - content = """ - import "phoenix_html" - import {Socket} from "phoenix" - import {LiveSocket} from "phoenix_live_view" - import {hooks as colocatedHooks} from "phoenix-colocated/#{app_str}" - import corex from "corex" - import topbar from "../vendor/topbar" - - const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") - const liveSocket = new LiveSocket("/live", Socket, { - longPollFallbackMs: 2500, - params: {_csrf_token: csrfToken}, - hooks: {...colocatedHooks, ...corex}, - }) - - topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) - window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) - window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) - liveSocket.connect() - window.liveSocket = liveSocket - - if (process.env.NODE_ENV === "development") { - window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => { - reloader.enableServerLogs() - }) - } - """ - - Igniter.create_or_update_file( - igniter, - corex_app_js_path, - content, - fn source -> Rewrite.Source.update(source, :content, content) end - ) - end - - def add_esbuild_corex_profile(igniter, config_path, otp_app, preserve?, design?) do - if design? and preserve? do - profile_name = String.to_atom("#{otp_app}_corex") - - igniter - |> Igniter.include_existing_file(config_path, required?: true) - |> Igniter.update_elixir_file(config_path, &add_esbuild_profile_zipper(&1, profile_name)) - else - igniter - end - end - - defp add_esbuild_profile_zipper(zipper, profile_name) do - if Igniter.Project.Config.configures_key?(zipper, :esbuild, profile_name) do - {:ok, zipper} - else - profile_value = - Sourceror.parse_string!(""" - [ - args: - ~w(js/corex_app.js --bundle --format=esm --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.), - cd: Path.expand("../assets", __DIR__), - env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]} - ] - """) - - with {:ok, zipper} <- - Igniter.Code.Function.move_to_function_call_in_current_scope( - zipper, - :config, - [2, 3], - &Igniter.Code.Function.argument_equals?(&1, 0, :esbuild) - ), - {:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 1), - true <- Igniter.Code.List.list?(zipper), - {:ok, zipper} <- - Igniter.Code.Keyword.put_in_keyword( - zipper, - [profile_name], - profile_value, - nil - ) do - {:ok, zipper} - else - _ -> {:ok, zipper} - end - end - end - - def add_corex_mix_alias(igniter, otp_app, preserve?, design?) do - if design? and preserve? do - mix_exs_path = "mix.exs" - profile_name = "#{otp_app}_corex" - - igniter - |> Igniter.include_existing_file(mix_exs_path, required?: true) - |> Igniter.update_elixir_file(mix_exs_path, &add_corex_alias_zipper(&1, profile_name)) - else - igniter - end - end - - defp add_corex_alias_zipper(zipper, profile_name) do - esbuild_build = Sourceror.parse_string!("\"esbuild #{profile_name}\"") - esbuild_deploy = Sourceror.parse_string!("\"esbuild #{profile_name} --minify\"") - - with {:ok, zipper} <- Igniter.Code.Module.move_to_module_using(zipper, Mix.Project), - {:ok, zipper} <- Igniter.Code.Function.move_to_defp(zipper, :aliases, 0), - zipper <- Igniter.Code.Common.maybe_move_to_single_child_block(zipper), - true <- Igniter.Code.List.list?(zipper), - {:ok, _} <- Igniter.Code.Keyword.get_key(zipper, :"assets.build"), - {:ok, zipper} <- - Igniter.Code.Keyword.put_in_keyword(zipper, [:"assets.build"], nil, fn zipper -> - Igniter.Code.List.append_new_to_list(zipper, esbuild_build) - end), - {:ok, _} <- Igniter.Code.Keyword.get_key(zipper, :"assets.deploy"), - {:ok, zipper} <- - Igniter.Code.Keyword.put_in_keyword(zipper, [:"assets.deploy"], nil, fn zipper -> - Igniter.Code.List.append_new_to_list(zipper, esbuild_deploy) - end) do - {:ok, zipper} - else - _ -> {:ok, zipper} - end - end - - defp simple_root_attrs?(opts) when is_list(opts) do - [ - Keyword.get(opts, :mode), - Keyword.get(opts, :theme), - Keyword.get(opts, :languages), - Keyword.get(opts, :rtl) - ] - |> Enum.all?(&is_nil/1) - end - - defp simple_root_attrs?(_), do: true - - defp corex_root_html_attrs_dynamic do - """ - lang={assigns[:locale] || "en"} - dir={ - assigns[:dir] || - if(assigns[:locale] in Application.get_env(:corex, :rtl_locales, []), - do: "rtl", - else: "ltr" - ) - } - data-theme={assigns[:theme] || "neo"} - data-mode={assigns[:mode] || "light"} - """ - end - - defp corex_root_template(opts) do - mode_script_block = if Keyword.get(opts, :mode), do: mode_script(), else: "" - theme_script_block = if theme = Keyword.get(opts, :theme), do: theme_script(theme), else: "" - scripts = mode_script_block <> theme_script_block - - html_open = - if simple_root_attrs?(opts) do - "" - else - html_attrs = corex_root_html_attrs_dynamic() - - """ - - """ - |> String.trim() - end - - """ - - #{html_open} - - - - - <.live_title default="" suffix=""> - {assigns[:page_title]} - - - - #{String.trim(scripts)} - - - {@inner_content} - - - """ - end - - defp mode_script do - """ - - """ - end - - defp theme_script(theme_opts) when is_binary(theme_opts) do - themes = theme_opts |> String.split(":", trim: true) - themes_json = themes |> Enum.map(&("\"" <> &1 <> "\"")) |> Enum.join(", ") - - """ - - """ - end - - def patch_layouts(igniter, layouts_path, design?, opts) do - if Igniter.exists?(igniter, layouts_path) and design? do - igniter - |> Igniter.include_existing_file(layouts_path, required?: false) - |> Igniter.update_file( - layouts_path, - &patch_layouts_content(&1, design?, opts), - required?: false - ) - else - igniter - end - end - - defp patch_layouts_content(source, design?, opts) do - content = source.content - - updated = - content - |> remove_daisy_theme_toggle(design?) - |> add_layout_toggle(Keyword.get(opts, :mode), :mode_toggle) - |> add_layout_toggle(Keyword.get(opts, :theme), :theme_toggle) - - Rewrite.Source.update(source, :content, updated) - end - - defp remove_daisy_theme_toggle(content, true) do - li_regex = ~r/\s*
  • \s*<\.theme_toggle \/>\s*<\/li>/ - - theme_toggle_fn = - ~r/\n @doc """\s*\n Provides dark vs light theme toggle[\s\S]*?def theme_toggle\(assigns\) do\s*\n ~H"""\s*\n[\s\S]*?data-phx-theme[\s\S]*?"""\s*\n end/ - - content - |> String.replace(li_regex, "") - |> String.replace(theme_toggle_fn, "") - end - - defp remove_daisy_theme_toggle(content, _), do: content - - defp add_layout_toggle(content, nil, _), do: content - defp add_layout_toggle(content, false, _), do: content - - defp add_layout_toggle(content, _value, :mode_toggle) do - if content =~ ~r/<\.mode_toggle/ do - content - else - li = """ -
  • - <.mode_toggle /> -
  • - """ - - mode_toggle_fn = - "\n @doc \"\"\"\n Provides dark vs light mode toggle. See root.html.heex script for phx:set-mode.\n \"\"\"\n def mode_toggle(assigns) do\n ~H\"\"\"\n
    \n
    \n \n <.icon name=\"hero-computer-desktop-micro\" class=\"size-4 opacity-75 hover:opacity-100\" />\n \n \n <.icon name=\"hero-sun-micro\" class=\"size-4 opacity-75 hover:opacity-100\" />\n \n \n <.icon name=\"hero-moon-micro\" class=\"size-4 opacity-75 hover:opacity-100\" />\n \n
    \n \"\"\"\n end\n" - - content - |> String.replace( - ~r/(
  • \s* "\n \\1" - ) - |> String.replace(~r/\nend\s*\z/, mode_toggle_fn <> "end\n") - end - end - - defp add_layout_toggle(content, themes, :theme_toggle) when is_binary(themes) do - if content =~ ~r/<\.theme_toggle/ do - content - else - themes_list = String.split(themes, ":", trim: true) - - li = """ -
  • - <.theme_toggle theme={assigns[:theme] || "neo"} /> -
  • - """ - - collection = Enum.map(themes_list, fn t -> %{id: t, label: String.capitalize(t)} end) - - theme_toggle_fn = - "\n attr :theme, :string, default: \"neo\", doc: \"current theme from cookie/session\"\n\n @doc \"\"\"\n Provides theme selection. Requires ThemePlug and phx:set-theme script in root.\n \"\"\"\n def theme_toggle(assigns) do\n ~H\"\"\"\n <.select\n id=\"theme-select\"\n class=\"select select--sm select--micro\"\n collection={" <> - inspect(collection) <> - "}\n value={[@theme]}\n on_value_change_client=\"phx:set-theme\"\n >\n <:label class=\"sr-only\">Theme\n <:item :let={item}>{item.label}\n <:trigger>\n <.icon name=\"hero-swatch\" />\n \n <:item_indicator>\n <.icon name=\"hero-check\" />\n \n \n \"\"\"\n end\n" - - content - |> String.replace( - ~r/(
  • \s* "\n \\1" - ) - |> String.replace(~r/\nend\s*\z/, theme_toggle_fn <> "end\n") - end - end - - def patch_html_helpers(igniter, web_ex_path, opts) do - if Igniter.exists?(igniter, web_ex_path) do - igniter - |> Igniter.include_existing_file(web_ex_path, required?: false) - |> Igniter.update_elixir_file( - web_ex_path, - &patch_html_helpers_zipper(&1, web_ex_path, opts), - required?: false - ) - else - igniter - end - end - - defp patch_html_helpers_zipper(zipper, web_ex_path, opts) do - use_corex? = - Igniter.Code.Common.move_to(zipper, fn z -> - Igniter.Code.Function.function_call?(z, :use, [1, 2]) and - Igniter.Code.Function.argument_equals?(z, 0, Corex) - end) != :error - - if use_corex? do - {:ok, zipper} - else - add_use_corex_or_warn(zipper, web_ex_path, opts) - end - end - - defp add_use_corex_or_warn(zipper, web_ex_path, opts) do - use_corex_code = build_use_corex(opts) - - import_predicate = fn z -> - Igniter.Code.Function.function_call?(z, :import, 1) and - Igniter.Code.Function.argument_matches_predicate?(z, 0, fn arg_z -> - str = Sourceror.to_string(Sourceror.Zipper.node(arg_z)) - is_binary(str) and str =~ ~r/\.CoreComponents/ - end) - end - - case Igniter.Code.Common.move_to(zipper, import_predicate) do - {:ok, import_zipper} -> - {:ok, Igniter.Code.Common.add_code(import_zipper, use_corex_code, placement: :after)} - - :error -> - {:warning, - Igniter.Util.Warning.formatted_warning( - "Could not patch #{web_ex_path}. Add manually in html/0 block:", - use_corex_code - )} - end - end - - defp build_use_corex(opts) do - prefix = Keyword.get(opts, :prefix) - only_str = Keyword.get(opts, :only) - - cond do - prefix != nil and only_str != nil -> - atoms = - only_str - |> String.split(":", trim: true) - |> Enum.map(&String.to_atom/1) - |> Enum.filter(&(&1 in Corex.component_keys())) - - ~s|use Corex, only: #{inspect(atoms)}, prefix: "#{prefix}"| - - only_str != nil -> - atoms = - only_str - |> String.split(":", trim: true) - |> Enum.map(&String.to_atom/1) - |> Enum.filter(&(&1 in Corex.component_keys())) - - ~s|use Corex, only: #{inspect(atoms)}| - - prefix != nil and prefix != "" -> - ~s|use Corex, prefix: "#{prefix}"| - - true -> - "use Corex" - end - end - - def patch_app_css(igniter, app_css_path, design?, preserve?, _opts) do - if preserve? do - igniter - else - if Igniter.exists?(igniter, app_css_path) and design? do - igniter - |> Igniter.include_existing_file(app_css_path, required?: false) - |> Igniter.update_file( - app_css_path, - &patch_app_css_content(&1, design?, preserve?), - required?: false - ) - else - igniter - end - end - end - - defp patch_app_css_content(source, design?, preserve?) do - case add_corex_imports(source.content, design?) do - {:warning, _} = warn -> - warn - - content -> - updated = - content - |> patch_data_mode(design?) - |> remove_daisy_css(preserve?) - - Rewrite.Source.update(source, :content, updated) - end - end - - def remove_daisy_vendor_files(igniter, web_path, project_path, preserve?, design?) do - if design? and not preserve? do - vendor_path = Path.join(web_path, "assets/vendor") - daisyui_js = Path.relative_to(Path.join(vendor_path, "daisyui.js"), project_path) - - daisyui_theme_js = - Path.relative_to(Path.join(vendor_path, "daisyui-theme.js"), project_path) - - igniter - |> maybe_rm(project_path, daisyui_js) - |> maybe_rm(project_path, daisyui_theme_js) - else - igniter - end - end - - defp maybe_rm(igniter, project_path, rel_path) do - abs_path = Path.join(project_path, rel_path) - if File.exists?(abs_path), do: Igniter.rm(igniter, rel_path), else: igniter - end - - defp remove_daisy_css(content, true), do: content - - defp remove_daisy_css(content, false) do - content - |> String.replace(~r/\s*@import "daisyui";\s*\n/, "") - |> String.replace(~r/\s*@import "daisyui\/css\/unstyled";\s*\n/, "") - |> String.replace(~r/\s*@import "daisyui\/css\/styled";\s*\n/, "") - |> remove_daisy_plugin_blocks() - end - - defp remove_daisy_plugin_blocks(content) do - content - |> String.replace( - ~r/\n\s*\/\* daisyUI Tailwind Plugin[\s\S]*?@plugin "\.\.\/vendor\/daisyui" \{\s*themes: false;\s*\}\s*\n/, - "\n" - ) - |> String.replace( - ~r/\n\s*\/\* daisyUI theme plugin[\s\S]*?@plugin "\.\.\/vendor\/daisyui-theme" \{\n[\s\S]*?\n\}\s*\n/, - "\n" - ) - |> String.replace( - ~r/\n\s*@plugin "\.\.\/vendor\/daisyui-theme" \{\n[\s\S]*?\n\}\s*\n/, - "\n", - global: true - ) - end - - defp add_corex_imports(content, true) do - if content =~ ~r/@import "\.\.\/corex\/main\.css"/ do - content - else - imports = """ - @import "../corex/main.css"; - @import "../corex/tokens/themes/neo/light.css"; - @import "../corex/components/typo.css"; - @import "../corex/components/button.css"; - @import "../corex/components/toast.css"; - - """ - - new_content = String.replace(content, ~r/((?:@source "[^"]+";\s*\n)+)/, "\\1\n#{imports}") - - if new_content == content do - {:warning, - """ - Could not patch app.css. Add manually after @source: - - #{String.trim(imports)} - """} - else - new_content - end - end - end - - defp add_corex_imports(content, _), do: content - - defp patch_data_mode(content, true) do - if content =~ ~r/\[data-theme=dark\]/ do - String.replace(content, ~r/\[data-theme=dark\]/, "[data-mode=dark]", global: true) - else - content - end - end - - defp patch_data_mode(content, _), do: content - - def run_setup_phase(igniter, opts) do - ensure_phoenix_project!() - {project_path, web_path, otp_app, web_namespace, web_app_str} = project_paths!(igniter) - add_gettext_if_missing(igniter, project_path, web_path, otp_app, web_app_str, web_namespace) - design? = Keyword.get(opts, :design, true) - designex? = Keyword.get(opts, :designex, false) - if design?, do: run_corex_design(igniter, project_path, web_path, designex?) - copy_generator_templates(igniter, project_path, web_path, otp_app) - copy_plugs_and_hooks(igniter, web_path, web_namespace, web_app_str, opts) - - assigns = - Map.put( - assigns_map(igniter), - :corex_project_paths, - {project_path, web_path, otp_app, web_namespace, web_app_str} - ) - - %{igniter | assigns: assigns} - end - - def run_config_phase(igniter, opts) do - {_project_path, _web_path, _otp_app, web_namespace, _web_app_str} = - assigns_map(igniter)[:corex_project_paths] - - igniter - |> add_corex_config(web_namespace) - |> add_rtl_config(opts) - end - - def run_assets_phase(igniter, opts) do - {project_path, web_path, otp_app, _web_namespace, _web_app_str} = - assigns_map(igniter)[:corex_project_paths] - - app_js_path = Path.relative_to(Path.join(web_path, "assets/js/app.js"), project_path) - config_path = Path.join("config", "config.exs") - preserve? = Keyword.get(opts, :preserve, false) - - igniter - |> patch_app_js(app_js_path, opts) - |> patch_esbuild_config(config_path, otp_app, preserve?) - end - - def run_layout_phase(igniter, opts) do - {project_path, web_path, otp_app, web_namespace, web_app_str} = - assigns_map(igniter)[:corex_project_paths] - - design? = Keyword.get(opts, :design, true) - preserve? = Keyword.get(opts, :preserve, false) - config_path = Path.join("config", "config.exs") - - root_layout_path = - Path.relative_to( - Path.join(web_path, "lib/#{web_app_str}/components/layouts/root.html.heex"), - project_path - ) - - layouts_path = - Path.relative_to( - Path.join(web_path, "lib/#{web_app_str}/components/layouts.ex"), - project_path - ) - - web_ex_path = Path.relative_to(Path.join(web_path, "lib/#{web_app_str}.ex"), project_path) - - igniter - |> patch_root_layout(root_layout_path, web_app_str, design?, preserve?, opts) - |> create_corex_root(web_path, web_app_str, project_path, otp_app, design?, preserve?, opts) - |> add_corex_app_to_layouts(layouts_path, preserve?, design?) - |> add_esbuild_corex_profile(config_path, otp_app, preserve?, design?) - |> add_corex_mix_alias(otp_app, preserve?, design?) - |> patch_html_helpers(web_ex_path, opts) - |> create_corex_page(web_path, web_app_str, project_path, web_namespace, preserve?, design?) - |> replace_root_route(web_path, web_app_str, project_path, web_namespace, preserve?) - |> remove_daisy_vendor_files(web_path, project_path, preserve?, design?) - end - - def run_css_phase(igniter, opts) do - {project_path, web_path, _otp_app, _web_namespace, _web_app_str} = - assigns_map(igniter)[:corex_project_paths] - - app_css_path = Path.relative_to(Path.join(web_path, "assets/css/app.css"), project_path) - design? = Keyword.get(opts, :design, true) - preserve? = Keyword.get(opts, :preserve, false) - - igniter - |> patch_app_css(app_css_path, design?, preserve?, opts) - end - - defp ensure_phoenix_project! do - unless Code.ensure_loaded?(Phoenix) do - Mix.raise(""" - Corex install requires a Phoenix project. - Create one first with: - - mix phx.new my_app - """) - end - end - - defp project_paths!(igniter) do - if igniter && Map.get(assigns_map(igniter), :test_mode?) do - project_paths_from_igniter(igniter) - else - project_paths_from_mix!() - end - end - - defp project_paths_from_igniter(igniter) do - web_ex_path = - igniter.rewrite.sources - |> Map.keys() - |> Enum.find(fn path -> - path =~ ~r/lib\/[^\/]+_web\.ex\z/ and not String.contains?(path, "/components/") - end) - - if web_ex_path do - web_app_str = Path.basename(web_ex_path, ".ex") - app_str = String.replace_suffix(web_app_str, "_web", "") - otp_app = safe_to_atom(app_str) - web_namespace_str = Macro.camelize(app_str) <> "Web" - web_namespace = Module.concat([web_namespace_str]) - project_root = "." - {project_root, project_root, otp_app, web_namespace, web_app_str} - else - project_paths_from_mix!() - end - end - - defp project_paths_from_mix! do - project_root = File.cwd!() - - if Mix.Project.umbrella?() do - apps_path = Path.join(project_root, "apps") - web_apps = File.ls!(apps_path) |> Enum.filter(&String.ends_with?(&1, "_web")) - web_app_name = List.first(web_apps) || Mix.raise("No *_web app found in apps/") - web_path = Path.join(apps_path, web_app_name) - - web_namespace = - web_app_name - |> String.replace_suffix("_web", "") - |> Macro.camelize() - |> Kernel.<>("Web") - - web_namespace_mod = Module.concat([web_namespace]) - web_app_atom = safe_to_atom(web_app_name) - {project_root, web_path, web_app_atom, web_namespace_mod, web_app_name} - else - app_name = Mix.Project.config()[:app] - web_namespace_str = app_name |> to_string() |> Macro.camelize() |> Kernel.<>("Web") - web_namespace = Module.concat([web_namespace_str]) - web_app_dir = to_string(app_name) <> "_web" - {project_root, project_root, app_name, web_namespace, web_app_dir} - end - end - - defp add_gettext_if_missing( - igniter, - project_path, - web_path, - otp_app, - web_app_str, - web_namespace - ) do - gettext_path = - Path.relative_to(Path.join([web_path, "lib", web_app_str, "gettext.ex"]), project_path) - - if Igniter.exists?(igniter, gettext_path) do - igniter - else - igniter - |> add_gettext_dep(project_path, web_path) - |> create_gettext_module(project_path, web_path, web_app_str, web_namespace) - |> create_gettext_files(project_path, otp_app) - |> add_gettext_config(project_path, web_namespace) - |> then(&Igniter.add_notice(&1, "* adding gettext")) - end - end - - defp add_gettext_dep(igniter, project_path, web_path) do - mix_path = Path.relative_to(Path.join(web_path, "mix.exs"), project_path) - - if Igniter.exists?(igniter, mix_path) do - igniter - |> Igniter.include_existing_file(mix_path, required?: true) - |> maybe_add_gettext_dep() - else - igniter - end - end - - defp maybe_add_gettext_dep(igniter) do - if Igniter.Project.Deps.has_dep?(igniter, :gettext) do - igniter - else - Igniter.Project.Deps.add_dep(igniter, {:gettext, "~> 1.0"}, - append?: true, - on_exists: :skip - ) - end - end - - defp create_gettext_module(igniter, project_path, web_path, web_app_str, web_namespace) do - lib_web = Path.relative_to(Path.join([web_path, "lib", web_app_str]), project_path) - gettext_path = Path.join(lib_web, "gettext.ex") - - igniter - |> Igniter.mkdir(lib_web) - |> Igniter.create_new_file(gettext_path, """ - defmodule #{inspect(web_namespace)}.Gettext do - @moduledoc false - use Gettext.Backend, otp_app: #{inspect(safe_to_atom(web_app_str))} - end - """) - end - - defp create_gettext_files(igniter, project_path, otp_app) do - gettext_priv = Path.join(Path.relative_to(:code.priv_dir(otp_app), project_path), "gettext") - errors_pot = Path.join(gettext_priv, "errors.pot") - en_po = Path.join(gettext_priv, "en/LC_MESSAGES/errors.po") - - igniter - |> Igniter.mkdir(Path.join(gettext_priv, "en/LC_MESSAGES")) - |> Igniter.create_new_file(errors_pot, """ - ## This is a PO Template file. - ## Run `mix gettext.extract` to bring this file up to date. - msgid "" - msgstr "" - """) - |> Igniter.create_new_file(en_po, """ - msgid "" - msgstr "" - "Language: en\\n" - """) - end - - defp add_gettext_config(igniter, _project_path, _web_namespace), do: igniter - - defp run_corex_design(igniter, project_path, web_path, designex?) do - if Map.get(assigns_map(igniter), :test_mode?) do - igniter - else - target = Path.relative_to(Path.join([web_path, "assets", "corex"]), project_path) - args = [target, "--force"] - args = if designex?, do: ["--designex" | args], else: args - suffix = if designex?, do: " --designex", else: "" - Mix.Task.run("corex.design", args) - Igniter.add_notice(igniter, "* running mix corex.design#{suffix}") - end - end - - defp copy_generator_templates(igniter, _project_path, web_path, otp_app) do - if Map.get(assigns_map(igniter), :test_mode?) do - igniter - else - copy_generator_templates_impl(igniter, web_path, otp_app) - end - end - - defp priv_templates_path(web_path, otp_app) do - case :code.priv_dir(otp_app) do - path when is_binary(path) or is_list(path) -> Path.join(path, "templates") - {:error, _} -> Path.join(web_path, "priv/templates") - end - end - - # sobelow_skip ["Traversal.FileModule"] - defp copy_generator_templates_impl(igniter, web_path, otp_app) do - corex_priv = Path.join(:code.priv_dir(:corex), "templates") - phoenix_priv = Path.join(:code.priv_dir(:phoenix), "templates") - templates_root = priv_templates_path(web_path, otp_app) - - Enum.reduce( - [ - {"phx.gen.html", "phx.gen.html"}, - {"phx.gen.live", "phx.gen.live"}, - {"phx.gen.auth", "phx.gen.auth"} - ], - igniter, - ©_generator_template(&1, &2, corex_priv, phoenix_priv, templates_root) - ) - end - - defp copy_generator_template( - {gen_name, phoenix_dir}, - igniter, - corex_priv, - phoenix_priv, - templates_root - ) do - corex_src = Path.join(corex_priv, String.replace(gen_name, "phx.", "corex.")) - phoenix_src = Path.join(phoenix_priv, phoenix_dir) - dst = Path.join(templates_root, phoenix_dir) - src = if File.exists?(corex_src), do: corex_src, else: phoenix_src - src_root = if src == phoenix_src, do: phoenix_priv, else: corex_priv - - if File.exists?(src) do - path_under_root?(src, src_root) || Mix.raise("Path traversal blocked: #{inspect(src)}") - - path_under_root?(dst, templates_root) || - Mix.raise("Path traversal blocked: #{inspect(dst)}") - - File.mkdir_p!(dst) - File.cp_r!(src, dst) - Igniter.add_notice(igniter, "* copying #{phoenix_dir} templates") - else - igniter - end - end - - defp copy_plugs_and_hooks(igniter, web_path, web_namespace, web_app_str, opts) do - if Map.get(assigns_map(igniter), :test_mode?) do - igniter - else - copy_plugs_and_hooks_impl(igniter, web_path, web_namespace, web_app_str, opts) - end - end - - defp copy_plugs_and_hooks_impl(igniter, web_path, web_namespace, web_app_str, opts) do - corex_root = :code.lib_dir(:corex) |> Path.dirname() - installer_templates = Path.join([corex_root, "installer", "templates"]) - - if File.exists?(installer_templates) do - binding = [ - web_namespace: web_namespace, - web_app_name: web_app_str - ] - - lib_web = Path.join([web_path, "lib", web_app_str]) - - igniter = - if Keyword.get(opts, :mode) do - copy_eex( - Path.join([installer_templates, "phx_web", "plugs", "mode.ex.eex"]), - Path.join([lib_web, "plugs", "mode.ex"]), - binding, - installer_templates, - web_path - ) - - copy_eex( - Path.join([installer_templates, "phx_web", "live", "hooks", "mode_live.ex.eex"]), - Path.join([lib_web, "live", "hooks", "mode_live.ex"]), - binding, - installer_templates, - web_path - ) - - Igniter.add_notice(igniter, "* adding mode plug and hook") - else - igniter - end - - igniter = - if themes = Keyword.get(opts, :theme) do - themes_list = String.split(themes, ":", trim: true) - - binding = - Keyword.merge(binding, themes: themes_list, default_locale: List.first(themes_list)) - - copy_eex( - Path.join([installer_templates, "phx_web", "plugs", "theme.ex.eex"]), - Path.join([lib_web, "plugs", "theme.ex"]), - binding, - installer_templates, - web_path - ) - - copy_eex( - Path.join([installer_templates, "phx_web", "live", "hooks", "theme_live.ex.eex"]), - Path.join([lib_web, "live", "hooks", "theme_live.ex"]), - binding, - installer_templates, - web_path - ) - - Igniter.add_notice(igniter, "* adding theme plug and hook") - else - igniter - end - - igniter = - if languages = Keyword.get(opts, :languages) do - langs_list = String.split(languages, ":", trim: true) - - binding = - Keyword.merge(binding, - languages: langs_list, - default_locale: List.first(langs_list) - ) - - copy_eex( - Path.join([installer_templates, "phx_web", "plugs", "locale.ex.eex"]), - Path.join([lib_web, "plugs", "locale.ex"]), - binding, - installer_templates, - web_path - ) - - copy_eex( - Path.join([installer_templates, "phx_web", "live", "shared_events.ex.eex"]), - Path.join([lib_web, "live", "shared_events.ex"]), - binding, - installer_templates, - web_path - ) - - Igniter.add_notice(igniter, "* adding locale plug and shared events") - else - igniter - end - - igniter - else - igniter - end - end - - # sobelow_skip ["Traversal.FileModule", "RCE.EEx"] - defp copy_eex(src, dst, binding, src_root, dst_root) do - unless path_under_root?(src, src_root), - do: Mix.raise("Path traversal blocked: #{inspect(src)}") - - unless path_under_root?(dst, dst_root), - do: Mix.raise("Path traversal blocked: #{inspect(dst)}") - - if File.exists?(src) do - content = EEx.eval_file(src, binding: binding) - File.mkdir_p!(Path.dirname(dst)) - File.write!(dst, content) - end - end - end -end diff --git a/mix.exs b/mix.exs index b88d566..f06bbf7 100644 --- a/mix.exs +++ b/mix.exs @@ -53,7 +53,6 @@ defmodule Corex.MixProject do {:floki, "~> 0.38.0", only: :test}, {:phoenix_ecto, "~> 4.0", only: :test}, {:excoveralls, "~> 0.18", only: :test}, - {:igniter, ">= 0.6.0 and < 1.0.0-0", only: [:dev, :test]}, {:tidewave, "~> 0.5.5", only: :dev}, {:bandit, "~> 1.0", only: :dev}, {:sobelow, "~> 0.13", only: [:dev, :test], runtime: false} diff --git a/mix.lock b/mix.lock index 6d65e7a..ef6731f 100644 --- a/mix.lock +++ b/mix.lock @@ -15,9 +15,7 @@ "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, "floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"}, "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, - "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, - "igniter": {:hex, :igniter, "0.7.2", "81c132c0df95963c7a228f74a32d3348773743ed9651f24183bfce0fe6ff16d1", [: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", "f4cab73ec31f4fb452de1a17037f8a08826105265aa2d76486fcb848189bef9b"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_eex": {:hex, :makeup_eex, "2.0.2", "88983b72aadb2e8408b06f7c9413804ce7eae2ca2a5a35cb738c6a9cb393c155", [:mix], [{:makeup, "~> 1.2.1 or ~> 1.3", [hex: :makeup, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_html, "~> 0.2.0 or ~> 1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "30ac121dda580298ff3378324ffaec94aad5a5b67e0cc6af177c67d5f45629b9"}, @@ -29,7 +27,6 @@ "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, - "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, "phoenix": {:hex, :phoenix, "1.8.4", "0387f84f00071cba8d71d930b9121b2fb3645197a9206c31b908d2e7902a4851", [: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", "c988b1cd3b084eebb13e6676d572597d387fa607dab258526637b4e6c4c08543"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [: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", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, @@ -39,13 +36,9 @@ "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [: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", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, - "rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, - "sourceror": {:hex, :sourceror, "1.11.0", "df2cdaffdc323e804009ff50b50bb31e6f2d6e116d936ccf22981f592594d624", [:mix], [], "hexpm", "6e26f572bdfc21d7ad397f596b4cfbbf31d7112126fe3e902c120947073231a8"}, - "spitfire": {:hex, :spitfire, "0.3.7", "d6051f94f554d33d038ab3c1d7e017293ae30429cc6b267b08cb6ad69e35e9a3", [:mix], [], "hexpm", "798ff97db02477b05fa3db8e2810cebda6ed5d90c6de6b21aa65abd577599744"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, - "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, "tidewave": {:hex, :tidewave, "0.5.5", "a125dfc87f99daf0e2280b3a9719b874c616ead5926cdf9cdfe4fcc19a020eff", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "825ebb4fa20de005785efa21e5a88c04d81c3f57552638d12ff3def2f203dbf7"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, diff --git a/priv/design/components/layout.css b/priv/design/components/layout.css index 06c5394..4a15e95 100644 --- a/priv/design/components/layout.css +++ b/priv/design/components/layout.css @@ -12,7 +12,7 @@ background: var(--color-root); overflow-y: scroll; - scrollbar-gutter: stable both-edges; + scrollbar-gutter: stable; } .layout__header { @@ -80,7 +80,7 @@ width: auto; height: calc(100vh - var(--spacing-ui-lg)); overflow-y: scroll; - scrollbar-gutter: stable both-edges; + scrollbar-gutter: stable; } @@ -95,7 +95,6 @@ flex-direction: column; flex: 1; min-width: 0; - min-height: calc(100vh - var(--spacing-ui-lg)); width: 100%; height: 100%; margin-inline: auto; diff --git a/test/corex/igniter_test.exs b/test/corex/igniter_test.exs deleted file mode 100644 index 8edae0e..0000000 --- a/test/corex/igniter_test.exs +++ /dev/null @@ -1,195 +0,0 @@ -defmodule Corex.IgniterTest do - use ExUnit.Case, async: true - - import Igniter.Test - - @moduletag :requires_igniter - - describe "run_setup_phase/2" do - @tag :requires_igniter - test "sets corex_project_paths in assigns" do - igniter = phx_test_project(app_name: :phx_setup) - result = Corex.Igniter.run_setup_phase(igniter, design: false) - - assert {_project_path, _web_path, _otp_app, _web_namespace, _web_app_str} = - result.assigns[:corex_project_paths] - end - end - - describe "run_config_phase/2" do - @tag :requires_igniter - test "adds corex config" do - igniter = - phx_test_project(app_name: :phx_config) - |> Corex.Igniter.run_setup_phase(design: false) - - result = Corex.Igniter.run_config_phase(igniter, []) - - source = Rewrite.source!(result.rewrite, "config/config.exs") - content = Rewrite.Source.get(source, :content) - assert content =~ ~r/config :corex/ - end - - @tag :requires_igniter - test "adds corex config only once when run twice" do - igniter = - phx_test_project(app_name: :phx_config_twice) - |> Corex.Igniter.run_setup_phase(design: false) - - result1 = Corex.Igniter.run_config_phase(igniter, []) - result2 = Corex.Igniter.run_config_phase(result1, []) - - source = Rewrite.source!(result2.rewrite, "config/config.exs") - content = Rewrite.Source.get(source, :content) - config_count = content |> String.split("config :corex") |> length() |> Kernel.-(1) - assert config_count == 1 - end - - @tag :requires_igniter - test "adds rtl config when rtl opts provided" do - igniter = - phx_test_project(app_name: :phx_rtl_config) - |> Corex.Igniter.run_setup_phase(design: false) - - result = Corex.Igniter.run_config_phase(igniter, rtl: "ar:he") - - source = Rewrite.source!(result.rewrite, "config/config.exs") - content = Rewrite.Source.get(source, :content) - assert content =~ ~r/rtl_locales/ - end - end - - describe "run_assets_phase/2" do - @tag :requires_igniter - test "patches app.js and esbuild config" do - igniter = - phx_test_project(app_name: :phx_assets) - |> Corex.Igniter.run_setup_phase(design: false) - - result = Corex.Igniter.run_assets_phase(igniter, []) - - js_source = Rewrite.source!(result.rewrite, "assets/js/app.js") - js_content = Rewrite.Source.get(js_source, :content) - assert js_content =~ ~r/from "corex"/ - - config_source = Rewrite.source!(result.rewrite, "config/config.exs") - config_content = Rewrite.Source.get(config_source, :content) - assert config_content =~ ~r/--format=esm --splitting/ - end - end - - describe "run_layout_phase/2" do - @tag :requires_igniter - test "patches root layout and web module" do - igniter = - phx_test_project(app_name: :phx_layout) - |> Corex.Igniter.run_setup_phase(design: false) - - result = Corex.Igniter.run_layout_phase(igniter, []) - - layout_source = - Rewrite.source!(result.rewrite, "lib/phx_layout_web/components/layouts/root.html.heex") - - layout_content = Rewrite.Source.get(layout_source, :content) - assert layout_content =~ ~r/type="module"/ - assert layout_content =~ ~r/data-theme=/ - - web_source = Rewrite.source!(result.rewrite, "lib/phx_layout_web.ex") - web_content = Rewrite.Source.get(web_source, :content) - assert web_content =~ ~r/use Corex/ - end - - @tag :requires_igniter - test "preserve creates corex_root without patching root layout" do - igniter = - phx_test_project(app_name: :phx_layout_preserve) - |> Corex.Igniter.run_setup_phase(design: true) - - result = Corex.Igniter.run_layout_phase(igniter, preserve: true) - - corex_root = - Rewrite.source!( - result.rewrite, - "lib/phx_layout_preserve_web/components/core_layouts/corex_root.html.heex" - ) - - corex_root_content = Rewrite.Source.get(corex_root, :content) - assert corex_root_content =~ ~r/data-theme=/ - assert corex_root_content =~ ~r/data-mode=/ - - refute corex_root_content =~ ~r/phx:set-theme/, - "corex_root should not have theme script when no --theme" - - root_source = - Rewrite.source!( - result.rewrite, - "lib/phx_layout_preserve_web/components/layouts/root.html.heex" - ) - - root_content = Rewrite.Source.get(root_source, :content) - - refute root_content =~ ~r/data-theme="neo"/, - "root.html.heex should not be patched with data-theme when preserve" - end - end - - describe "run_css_phase/2" do - @tag :requires_igniter - test "patches app.css and adds Corex imports when design" do - igniter = - phx_test_project(app_name: :phx_css_design) - |> Corex.Igniter.run_setup_phase(design: true) - - result = Corex.Igniter.run_css_phase(igniter, design: true) - - source = Rewrite.source!(result.rewrite, "assets/css/app.css") - content = Rewrite.Source.get(source, :content) - assert content =~ ~r/@import "\.\.\/corex\/main\.css"/ - assert content =~ ~r/\[data-mode=dark\]/ - end - - @tag :requires_igniter - test "does not touch app.css daisy when no-design" do - igniter = - phx_test_project(app_name: :phx_css_no_design) - |> Corex.Igniter.run_setup_phase(design: false) - - result = Corex.Igniter.run_css_phase(igniter, design: false) - - source = Rewrite.source!(result.rewrite, "assets/css/app.css") - content = Rewrite.Source.get(source, :content) - refute content =~ ~r/@import "\.\.\/corex/ - end - end - - describe "validate_opts!/1" do - @tag :requires_igniter - test "accepts valid theme" do - Corex.Igniter.validate_opts!(theme: "neo:uno") - end - - test "raises when theme has fewer than 2 values" do - assert_raise Mix.Error, ~r/--theme requires at least 2 values/, fn -> - Corex.Igniter.validate_opts!(theme: "neo") - end - end - - @tag :requires_igniter - @tag :requires_igniter - test "accepts valid languages" do - Corex.Igniter.validate_opts!(languages: "en:fr:ar") - end - - test "raises when languages has fewer than 2 values" do - assert_raise Mix.Error, ~r/--languages requires at least 2 values/, fn -> - Corex.Igniter.validate_opts!(languages: "en") - end - end - - @tag :requires_igniter - @tag :requires_igniter - test "accepts empty opts" do - Corex.Igniter.validate_opts!([]) - end - end -end diff --git a/test/mix/tasks/corex.gen.auth_test.exs b/test/mix/tasks/corex.gen.auth_test.exs deleted file mode 100644 index 37228ac..0000000 --- a/test/mix/tasks/corex.gen.auth_test.exs +++ /dev/null @@ -1,22 +0,0 @@ -defmodule Mix.Tasks.Corex.Gen.AuthTest do - use ExUnit.Case, async: false - - @tag :tmp_dir - test "raises when Corex templates not found", %{tmp_dir: tmp_dir} do - File.cd!(tmp_dir, fn -> - Mix.Task.reenable("corex.gen.auth") - - assert_raise Mix.Error, ~r/Corex templates not found/, fn -> - Mix.Task.run("corex.gen.auth", []) - end - end) - end - - test "runs phx.gen.auth when templates exist" do - Mix.Task.reenable("corex.gen.auth") - - assert_raise Mix.Error, fn -> - Mix.Task.run("corex.gen.auth", []) - end - end -end diff --git a/test/mix/tasks/corex.gen.html_test.exs b/test/mix/tasks/corex.gen.html_test.exs deleted file mode 100644 index f9736bc..0000000 --- a/test/mix/tasks/corex.gen.html_test.exs +++ /dev/null @@ -1,22 +0,0 @@ -defmodule Mix.Tasks.Corex.Gen.HtmlTest do - use ExUnit.Case, async: false - - @tag :tmp_dir - test "raises when Corex templates not found", %{tmp_dir: tmp_dir} do - File.cd!(tmp_dir, fn -> - Mix.Task.reenable("corex.gen.html") - - assert_raise Mix.Error, ~r/Corex templates not found/, fn -> - Mix.Task.run("corex.gen.html", []) - end - end) - end - - test "runs phx.gen.html when templates exist" do - Mix.Task.reenable("corex.gen.html") - - assert_raise Mix.Error, fn -> - Mix.Task.run("corex.gen.html", []) - end - end -end diff --git a/test/mix/tasks/corex.gen.live_test.exs b/test/mix/tasks/corex.gen.live_test.exs deleted file mode 100644 index 4435272..0000000 --- a/test/mix/tasks/corex.gen.live_test.exs +++ /dev/null @@ -1,22 +0,0 @@ -defmodule Mix.Tasks.Corex.Gen.LiveTest do - use ExUnit.Case, async: false - - @tag :tmp_dir - test "raises when Corex templates not found", %{tmp_dir: tmp_dir} do - File.cd!(tmp_dir, fn -> - Mix.Task.reenable("corex.gen.live") - - assert_raise Mix.Error, ~r/Corex templates not found/, fn -> - Mix.Task.run("corex.gen.live", []) - end - end) - end - - test "runs phx.gen.live when templates exist" do - Mix.Task.reenable("corex.gen.live") - - assert_raise Mix.Error, fn -> - Mix.Task.run("corex.gen.live", []) - end - end -end diff --git a/test/mix/tasks/corex.install_integration_test.exs b/test/mix/tasks/corex.install_integration_test.exs deleted file mode 100644 index 3e5afdc..0000000 --- a/test/mix/tasks/corex.install_integration_test.exs +++ /dev/null @@ -1,96 +0,0 @@ -defmodule Mix.Tasks.Corex.InstallIntegrationTest do - use ExUnit.Case, async: false - - import CorexTest.InstallIntegrationCase - - @moduletag :integration - @moduletag timeout: 300_000 - - setup context do - tmp_dir = - case Map.get(context, :tmp_dir) do - nil -> - base = System.tmp_dir!() - path = Path.join(base, "corex_install_#{System.unique_integer([:positive])}") - File.mkdir_p!(path) - path - - dir -> - dir - end - - {:ok, tmp_dir: tmp_dir} - end - - defp run_install_and_assert(tmp_dir, app_name, install_args, assertions \\ []) do - project_dir = run_phoenix_project(tmp_dir, app_name) - corex_path = Path.expand("../../..", __DIR__) - - {output, exit_code} = run_corex_install(project_dir, corex_path, install_args) - - assert exit_code == 0, "install failed: #{output}" - - if Keyword.get(assertions, :templates?, true) do - templates_root = Path.join(project_dir, "priv/templates") - - assert File.dir?(Path.join(templates_root, "phx.gen.html")), - "templates not copied. install output: #{output}" - - assert File.dir?(Path.join(templates_root, "phx.gen.live")) - end - - if Keyword.get(assertions, :app_js?, true) do - app_js = File.read!(Path.join(project_dir, "assets/js/app.js")) - assert app_js =~ ~r/from "corex"/ - end - - assert_no_compilation_warnings(project_dir) - assert_tests_pass(project_dir) - format_project(project_dir) - assert_passes_formatter_check(project_dir) - - project_dir - end - - test "preserve: keeps home, adds corex_page, default tests pass", %{tmp_dir: tmp_dir} do - run_install_and_assert(tmp_dir, "demo_preserve", ["--preserve"]) - end - - test "no-preserve: overwrites home, patches PageControllerTest to expect Corex", %{ - tmp_dir: tmp_dir - } do - run_install_and_assert(tmp_dir, "demo_no_preserve", []) - end - - test "no-preserve + mode: overwrites home plus mode script and mode_toggle", %{tmp_dir: tmp_dir} do - run_install_and_assert(tmp_dir, "demo_mode", ["--mode"]) - end - - test "preserve + mode: preserve flow with mode", %{tmp_dir: tmp_dir} do - run_install_and_assert(tmp_dir, "demo_preserve_mode", ["--preserve", "--mode"]) - end - - test "theme: theme config in root and theme_toggle", %{tmp_dir: tmp_dir} do - run_install_and_assert(tmp_dir, "demo_theme", ["--theme", "neo:uno"]) - end - - test "languages: gettext locales wired", %{tmp_dir: tmp_dir} do - run_install_and_assert(tmp_dir, "demo_languages", ["--languages", "en:fr"]) - end - - test "rtl: RTL config in config.exs", %{tmp_dir: tmp_dir} do - run_install_and_assert(tmp_dir, "demo_rtl", ["--rtl", "ar"]) - end - - test "no-design: skips design files, no data-theme/data-mode", %{tmp_dir: tmp_dir} do - project_dir = run_install_and_assert(tmp_dir, "demo_no_design", ["--no-design"]) - - root_source = - Path.join(project_dir, "lib/demo_no_design_web/components/layouts/root.html.heex") - - root_content = File.read!(root_source) - - refute root_content =~ ~r/data-theme="neo"/, "should not add data-theme when no-design" - refute root_content =~ ~r/data-mode="light"/, "should not add data-mode when no-design" - end -end diff --git a/test/mix/tasks/corex.install_test.exs b/test/mix/tasks/corex.install_test.exs deleted file mode 100644 index 8210b68..0000000 --- a/test/mix/tasks/corex.install_test.exs +++ /dev/null @@ -1,321 +0,0 @@ -defmodule Mix.Tasks.Corex.InstallTest do - use ExUnit.Case, async: true - import Igniter.Test - - test "patches app.js with corex import" do - phx_test_project(app_name: :phx_patch_js) - |> Igniter.compose_task("corex.install", ["--no-design", "--yes"]) - |> assert_has_patch("assets/js/app.js", """ - +|import corex from "corex" - """) - end - - test "patches app.js with corex hooks" do - phx_test_project(app_name: :phx_patch_hooks) - |> Igniter.compose_task("corex.install", ["--no-design", "--yes"]) - |> assert_has_patch("assets/js/app.js", """ - +| hooks: {...colocatedHooks, ...corex}, - """) - end - - test "patches esbuild config with format=esm and splitting" do - phx_test_project(app_name: :phx_esbuild) - |> Igniter.compose_task("corex.install", ["--no-design", "--yes"]) - |> assert_has_patch("config/config.exs", """ - +| ~w(js/app.js --bundle --format=esm --splitting --target= - """) - end - - test "adds rtl_locales to config with --rtl" do - igniter = - phx_test_project(app_name: :phx_rtl) - |> Igniter.compose_task("corex.install", ["--no-design", "--yes", "--rtl", "ar"]) - - source = Rewrite.source!(igniter.rewrite, "config/config.exs") - content = Rewrite.Source.get(source, :content) - assert content =~ ~r/rtl_locales: \["ar"\]/ - end - - test "does not add use Corex twice when running install twice" do - igniter = - phx_test_project(app_name: :phx_idempotent) - |> Igniter.compose_task("corex.install", ["--no-design", "--yes"]) - |> Igniter.Test.apply_igniter!() - |> Igniter.compose_task("corex.install", ["--no-design", "--yes"]) - - source = Rewrite.source!(igniter.rewrite, "lib/phx_idempotent_web.ex") - content = Rewrite.Source.get(source, :content) - use_corex_matches = Regex.scan(~r/\buse Corex\b/, content) - - assert length(use_corex_matches) == 1, - "Expected exactly one 'use Corex', got #{length(use_corex_matches)}" - end - - test "does not warn when root layout already patched on second run" do - igniter = - phx_test_project(app_name: :phx_idempotent) - |> Igniter.compose_task("corex.install", ["--yes"]) - |> Igniter.Test.apply_igniter!() - |> Igniter.compose_task("corex.install", ["--yes"]) - - source = - Rewrite.source!(igniter.rewrite, "lib/phx_idempotent_web/components/layouts/root.html.heex") - - content = Rewrite.Source.get(source, :content) - assert content =~ ~r/type="module"/, "Expected type=\"module\" on script" - - assert content =~ ~r/data-theme=/ or content =~ ~r/data-mode=/, - "Expected data-theme or data-mode on html" - - root_layout_warning? = - Enum.any?(igniter.warnings, fn w -> - to_string(w) =~ ~r/root\.html\.heex/ and to_string(w) =~ ~r/Could not patch/ - end) - - refute root_layout_warning?, "Should not warn about root layout when already patched" - end - - test "install with --mode adds mode script to root and replaces app layout with Corex" do - igniter = - phx_test_project(app_name: :phx_mode) - |> Igniter.compose_task("corex.install", ["--yes", "--mode"]) - - root_source = - Rewrite.source!(igniter.rewrite, "lib/phx_mode_web/components/layouts/root.html.heex") - - root_content = Rewrite.Source.get(root_source, :content) - assert root_content =~ ~r/data-mode=/ - assert root_content =~ ~r/phx:set-mode/ - refute root_content =~ ~r/phx:set-theme/ - - layouts_source = Rewrite.source!(igniter.rewrite, "lib/phx_mode_web/components/layouts.ex") - layouts_content = Rewrite.Source.get(layouts_source, :content) - - assert layouts_content =~ ~r/class="typo layout"/, - "def app is replaced with Corex layout" - - assert layouts_content =~ ~r/toast_group/, - "layouts use toast_group instead of flash_group" - - refute layouts_content =~ ~r/def flash_group/, - "flash_group should be removed when no-preserve" - end - - test "install with --theme adds theme script to root" do - igniter = - phx_test_project(app_name: :phx_theme) - |> Igniter.compose_task("corex.install", ["--yes", "--theme", "neo:uno"]) - - root_source = - Rewrite.source!(igniter.rewrite, "lib/phx_theme_web/components/layouts/root.html.heex") - - root_content = Rewrite.Source.get(root_source, :content) - assert root_content =~ ~r/data-theme=/ - assert root_content =~ ~r/phx:set-theme/ - refute root_content =~ ~r/phx:set-mode/ - end - - test "install with design adds corex CSS imports" do - igniter = - phx_test_project(app_name: :phx_design) - |> Igniter.compose_task("corex.install", ["--yes"]) - - source = Rewrite.source!(igniter.rewrite, "assets/css/app.css") - content = Rewrite.Source.get(source, :content) - assert content =~ ~r/@import "\.\.\/corex\/main\.css"/ - - root_source = - Rewrite.source!(igniter.rewrite, "lib/phx_design_web/components/layouts/root.html.heex") - - root_content = Rewrite.Source.get(root_source, :content) - - assert root_content =~ ~r/lang="en"\s+data-theme="neo"\s+data-mode="light"/, - "default install uses simple root with static en/neo/light" - - refute root_content =~ ~r/phx:set-mode|phx:set-theme/, - "root should have no mode/theme script when neither --mode nor --theme" - end - - test "design adds Corex imports and uses data-mode for dark variant" do - igniter = - phx_test_project(app_name: :phx_design_daisy) - |> Igniter.compose_task("corex.install", ["--yes"]) - - source = Rewrite.source!(igniter.rewrite, "assets/css/app.css") - content = Rewrite.Source.get(source, :content) - - assert content =~ ~r/@import "\.\.\/corex\/main\.css"/, - "Should add Corex imports when design" - - assert content =~ ~r/@import "\.\.\/corex\/components\/toast\.css"/, - "Should add toast.css when design" - - refute content =~ ~r/\[data-theme=dark\]/, - "Should replace data-theme=dark with data-mode=dark" - - assert content =~ ~r/\[data-mode=dark\]/, "Should use data-mode=dark for dark variant" - end - - test "no-design does not touch daisyui or add data-theme/data-mode" do - igniter = - phx_test_project(app_name: :phx_no_design) - |> Igniter.compose_task("corex.install", ["--no-design", "--yes"]) - - source = Rewrite.source!(igniter.rewrite, "assets/css/app.css") - content = Rewrite.Source.get(source, :content) - - refute content =~ ~r/@import "\.\.\/corex\/main\.css"/, - "Should not add corex CSS imports when no-design" - - root_source = - Rewrite.source!(igniter.rewrite, "lib/phx_no_design_web/components/layouts/root.html.heex") - - root_content = Rewrite.Source.get(root_source, :content) - - refute root_content =~ ~r/data-theme="neo"/, - "Should not add data-theme when no-design" - - refute root_content =~ ~r/data-mode="light"/, - "Should not add data-mode when no-design" - end - - test "default keeps root route as home and overwrites home.html.heex" do - igniter = - phx_test_project(app_name: :phx_default_route) - |> Igniter.compose_task("corex.install", ["--yes"]) - - router_source = Rewrite.source!(igniter.rewrite, "lib/phx_default_route_web/router.ex") - router_content = Rewrite.Source.get(router_source, :content) - - assert router_content =~ ~r/get\s*\(\s*"\/"\s*,\s*PageController\s*,\s*:home\s*\)/, - "default no-preserve keeps get \"/\" PageController :home" - - home_source = - Rewrite.source!( - igniter.rewrite, - "lib/phx_default_route_web/controllers/page_html/home.html.heex" - ) - - home_content = Rewrite.Source.get(home_source, :content) - - assert home_content =~ ~r/Build/, - "home.html.heex has Corex landing content" - end - - test "preserve creates CorexLayouts, corex_app.css/js and adds get /corex without modifying root or home" do - igniter = - phx_test_project(app_name: :phx_preserve) - |> Igniter.compose_task("corex.install", ["--yes", "--preserve"]) - - corex_root = - Rewrite.source!( - igniter.rewrite, - "lib/phx_preserve_web/components/core_layouts/corex_root.html.heex" - ) - - corex_root_content = Rewrite.Source.get(corex_root, :content) - assert corex_root_content =~ ~r/data-theme=/ - assert corex_root_content =~ ~r/data-mode=/ - assert corex_root_content =~ ~r/corex_app\.css/ - assert corex_root_content =~ ~r/corex_app\.js/ - - root_source = - Rewrite.source!(igniter.rewrite, "lib/phx_preserve_web/components/layouts/root.html.heex") - - root_content = Rewrite.Source.get(root_source, :content) - - refute root_content =~ ~r/data-theme="neo"/, - "root.html.heex should not be patched with data-theme when preserve" - - router_source = Rewrite.source!(igniter.rewrite, "lib/phx_preserve_web/router.ex") - router_content = Rewrite.Source.get(router_source, :content) - - assert router_content =~ - ~r/get\s*\(?\s*"\/corex"\s*,\s*PageController\s*,\s*:corex_page\s*\)?/, - "router should have get /corex route" - - assert router_content =~ ~r/get\s*\(?\s*"\/"\s*,\s*PageController\s*,\s*:home\s*\)?/, - "home route should be kept when preserve" - - layouts_content = - Rewrite.source!(igniter.rewrite, "lib/phx_preserve_web/components/layouts.ex") - |> Rewrite.Source.get(:content) - - refute layouts_content =~ ~r/corex_app/, - "layouts.ex should not have corex_app when preserve" - - corex_app_css = - Rewrite.source!(igniter.rewrite, "assets/css/corex_app.css") - |> Rewrite.Source.get(:content) - - assert corex_app_css =~ ~r/@import "\.\.\/corex\/main\.css"/, - "corex_app.css should have Corex imports" - - core_layouts = - Rewrite.source!(igniter.rewrite, "lib/phx_preserve_web/components/core_layouts.ex") - |> Rewrite.Source.get(:content) - - assert core_layouts =~ ~r/CorexLayouts/ - assert core_layouts =~ ~r/embed_templates "core_layouts\/\*"/ - end - - test "install with --only uses hooks import and use Corex only" do - igniter = - phx_test_project(app_name: :phx_only) - |> Igniter.compose_task("corex.install", [ - "--no-design", - "--yes", - "--only", - "accordion:checkbox" - ]) - - app_js = Rewrite.source!(igniter.rewrite, "assets/js/app.js") - app_js_content = Rewrite.Source.get(app_js, :content) - - assert app_js_content =~ ~r/import \{ hooks \} from "corex"/, - "expected hooks import when --only" - - assert app_js_content =~ ~r/hooks\(\["Accordion", "Checkbox"\]\)/, - "expected hooks with PascalCase names" - - web_ex = Rewrite.source!(igniter.rewrite, "lib/phx_only_web.ex") - web_ex_content = Rewrite.Source.get(web_ex, :content) - - assert web_ex_content =~ ~r/use Corex, only: \[:accordion, :checkbox\]/, - "expected use Corex only" - end - - test "install with --prefix uses use Corex prefix" do - igniter = - phx_test_project(app_name: :phx_prefix) - |> Igniter.compose_task("corex.install", ["--no-design", "--yes", "--prefix", "ui"]) - - web_ex = Rewrite.source!(igniter.rewrite, "lib/phx_prefix_web.ex") - web_ex_content = Rewrite.Source.get(web_ex, :content) - - assert web_ex_content =~ ~r/use Corex, prefix: "ui"/, - "expected use Corex prefix" - end - - test "preserve does not add corex route twice when running install twice" do - igniter = - phx_test_project(app_name: :phx_preserve_idempotent) - |> Igniter.compose_task("corex.install", ["--yes", "--preserve"]) - |> Igniter.Test.apply_igniter!() - |> Igniter.compose_task("corex.install", ["--yes", "--preserve"]) - - router_source = - Rewrite.source!(igniter.rewrite, "lib/phx_preserve_idempotent_web/router.ex") - - router_content = Rewrite.Source.get(router_source, :content) - - corex_route_matches = - Regex.scan( - ~r/get\s*\(?\s*"\/corex"\s*,\s*PageController\s*,\s*:corex_page\s*\)?/, - router_content - ) - - assert length(corex_route_matches) == 1, - "Expected exactly one get /corex route when running install twice with preserve, got #{length(corex_route_matches)}" - end -end diff --git a/test/support/install_integration_case.ex b/test/support/install_integration_case.ex deleted file mode 100644 index ceedb6c..0000000 --- a/test/support/install_integration_case.ex +++ /dev/null @@ -1,72 +0,0 @@ -defmodule CorexTest.InstallIntegrationCase do - @moduledoc false - - def run_phoenix_project(tmp_dir, app_name \\ "demo") when is_binary(app_name) do - project_dir = Path.join(tmp_dir, app_name) - cwd = File.cwd!() - - {_, 0} = - System.cmd("mix", ["phx.new", project_dir, "--no-ecto", "--no-mailer", "--no-install"], - cd: cwd - ) - - env = System.get_env() |> Map.put("MIX_ENV", "dev") |> Map.to_list() - {_, 0} = System.cmd("mix", ["deps.get"], cd: project_dir, env: env) - {_, 0} = System.cmd("mix", ["compile"], cd: project_dir, env: env) - - project_dir - end - - def run_corex_install(project_dir, corex_path, args, opts \\ []) do - env = Keyword.get(opts, :env, System.get_env() |> Map.put("MIX_ENV", "dev") |> Map.to_list()) - - System.cmd( - "mix", - ["igniter.install", "corex@path:#{corex_path}", "--yes" | args], - cd: project_dir, - env: env - ) - end - - def mix_run!(args, app_path, opts \\ []) - when is_list(args) and is_binary(app_path) and is_list(opts) do - case mix_run(args, app_path, opts) do - {output, 0} -> - output - - {output, exit_code} -> - raise """ - mix command failed with exit code: #{inspect(exit_code)} - - mix #{Enum.join(args, " ")} - - #{output} - - Options: - cd: #{Path.expand(app_path)} - env: #{Keyword.get(opts, :env, []) |> inspect()} - """ - end - end - - def mix_run(args, app_path, opts \\ []) - when is_list(args) and is_binary(app_path) and is_list(opts) do - System.cmd("mix", args, [stderr_to_stdout: true, cd: Path.expand(app_path)] ++ opts) - end - - def assert_tests_pass(app_path) do - mix_run!(~w(test), app_path) - end - - def assert_passes_formatter_check(app_path) do - mix_run!(~w(format --check-formatted), app_path) - end - - def format_project(app_path) do - mix_run!(~w(format), app_path) - end - - def assert_no_compilation_warnings(app_path) do - mix_run!(["do", "clean", "compile", "--warnings-as-errors"], app_path) - end -end From 939ec424564f5759e52277b89f67916a9033f7bf Mon Sep 17 00:00:00 2001 From: karimsemmoud Date: Mon, 2 Mar 2026 17:17:52 +0700 Subject: [PATCH 17/21] Update elixir.yml --- .github/workflows/elixir.yml | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index dcbbc88..4cda3e8 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -38,12 +38,6 @@ jobs: - name: Install dependencies run: mix deps.get - - name: Install Phoenix archive - run: mix archive.install hex phx_new --force - - - name: Install Igniter archive - run: mix archive.install hex igniter_new --force - - name: Run tests and post coverage to Coveralls run: mix coveralls.github @@ -74,16 +68,6 @@ jobs: - name: Install dependencies run: mix deps.get - - name: Install Phoenix archive - run: mix archive.install hex phx_new --force - - - name: Install Igniter archive - run: mix archive.install hex igniter_new --force - - - name: Run integration tests - run: mix test --only integration - timeout-minutes: 30 - e2e-tests: name: E2E tests runs-on: ubuntu-22.04 From 9b368c05bfd09c0fd5633f2d2cf53e5156c27100 Mon Sep 17 00:00:00 2001 From: karimsemmoud Date: Mon, 2 Mar 2026 17:22:36 +0700 Subject: [PATCH 18/21] Update elixir.yml --- .github/workflows/elixir.yml | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 4cda3e8..899055f 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -41,33 +41,6 @@ jobs: - name: Run tests and post coverage to Coveralls run: mix coveralls.github - integration-tests: - name: Integration tests - runs-on: ubuntu-22.04 - env: - MIX_ENV: test - RUN_INTEGRATION: 1 - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Elixir - uses: erlef/setup-beam@v1 - with: - elixir-version: '1.15.2' - otp-version: '26.0' - - - name: Restore dependencies cache - uses: actions/cache@v3 - with: - path: deps - key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} - restore-keys: ${{ runner.os }}-mix- - - - name: Install dependencies - run: mix deps.get - e2e-tests: name: E2E tests runs-on: ubuntu-22.04 From 6a76052dd9dc79aebe3ea398465ac392c4746e8f Mon Sep 17 00:00:00 2001 From: karimsemmoud Date: Mon, 2 Mar 2026 17:44:40 +0700 Subject: [PATCH 19/21] add badges --- README.md | 6 ++++++ guides/installation.md | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/README.md b/README.md index 5c0f6a6..8c897a6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,10 @@ +![Hex.pm License](https://img.shields.io/hexpm/l/corex) +![Hex.pm Version](https://img.shields.io/hexpm/v/corex) +[![Coverage Status](https://coveralls.io/repos/github/corex-ui/corex/badge.svg?branch=corex-install)](https://coveralls.io/github/corex-ui/corex?branch=corex-install) +![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/corex-ui/corex/elixir.yml) +![GitHub branch check runs](https://img.shields.io/github/check-runs/corex-ui/corex/main) + # Corex Corex is an accessible and unstyled UI components library written in Elixir and TypeScript that integrates [Zag.js](https://zagjs.com/) state machines into the Phoenix Framework. diff --git a/guides/installation.md b/guides/installation.md index 2d8ce47..ba9d7f5 100644 --- a/guides/installation.md +++ b/guides/installation.md @@ -1,5 +1,11 @@ # Installation +![Hex.pm License](https://img.shields.io/hexpm/l/corex) +![Hex.pm Version](https://img.shields.io/hexpm/v/corex) +[![Coverage Status](https://coveralls.io/repos/github/corex-ui/corex/badge.svg?branch=corex-install)](https://coveralls.io/github/corex-ui/corex?branch=corex-install) +![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/corex-ui/corex/elixir.yml) +![GitHub branch check runs](https://img.shields.io/github/check-runs/corex-ui/corex/main) + ## Introduction Corex is an accessible and unstyled UI components library written in Elixir and TypeScript that integrates [Zag.js](https://zagjs.com/) state machines into the Phoenix Framework. From ca6416d81cddb336775b6d0fa4dd250f91957e89 Mon Sep 17 00:00:00 2001 From: karimsemmoud Date: Mon, 2 Mar 2026 17:58:35 +0700 Subject: [PATCH 20/21] remove template --- .gitignore | 1 + README.md | 2 - config/config.exs | 7 - e2e/.gitignore | 5 +- guides/installation.md | 26 - lib/corex.ex | 2 - priv/gettext/en/LC_MESSAGES/errors.po | 3 - priv/gettext/errors.pot | 4 - priv/templates/phx.gen.auth/AGENTS.md | 57 -- priv/templates/phx.gen.auth/auth.ex | 321 ----------- priv/templates/phx.gen.auth/auth_test.exs | 442 --------------- .../phx.gen.auth/confirmation_live.ex | 94 ---- .../phx.gen.auth/confirmation_live_test.exs | 118 ---- priv/templates/phx.gen.auth/conn_case.exs | 41 -- .../context_fixtures_functions.ex | 82 --- .../phx.gen.auth/context_functions.ex | 288 ---------- priv/templates/phx.gen.auth/login_live.ex | 134 ----- .../phx.gen.auth/login_live_test.exs | 109 ---- priv/templates/phx.gen.auth/migration.ex | 32 -- priv/templates/phx.gen.auth/notifier.ex | 84 --- .../phx.gen.auth/registration_controller.ex | 32 -- .../registration_controller_test.exs | 50 -- .../phx.gen.auth/registration_html.ex | 5 - .../phx.gen.auth/registration_live.ex | 89 --- .../phx.gen.auth/registration_live_test.exs | 82 --- .../phx.gen.auth/registration_new.html.heex | 32 -- priv/templates/phx.gen.auth/routes.ex | 42 -- priv/templates/phx.gen.auth/schema.ex | 137 ----- priv/templates/phx.gen.auth/schema_token.ex | 158 ------ priv/templates/phx.gen.auth/scope.ex | 33 -- .../phx.gen.auth/session_confirm.html.heex | 59 -- .../phx.gen.auth/session_controller.ex | 143 ----- .../phx.gen.auth/session_controller_test.exs | 224 -------- priv/templates/phx.gen.auth/session_html.ex | 9 - .../phx.gen.auth/session_new.html.heex | 73 --- .../phx.gen.auth/settings_controller.ex | 77 --- .../phx.gen.auth/settings_controller_test.exs | 148 ----- .../phx.gen.auth/settings_edit.html.heex | 49 -- priv/templates/phx.gen.auth/settings_html.ex | 5 - priv/templates/phx.gen.auth/settings_live.ex | 160 ------ .../phx.gen.auth/settings_live_test.exs | 212 -------- priv/templates/phx.gen.auth/test_cases.exs | 391 ------------- priv/templates/phx.gen.html/controller.ex | 67 --- .../phx.gen.html/controller_test.exs | 90 --- priv/templates/phx.gen.html/edit.html.heex | 8 - priv/templates/phx.gen.html/html.ex | 17 - priv/templates/phx.gen.html/index.html.heex | 25 - priv/templates/phx.gen.html/new.html.heex | 8 - .../phx.gen.html/resource_form.html.heex | 7 - priv/templates/phx.gen.html/show.html.heex | 18 - .../templates/phx.gen.live/core_components.ex | 513 ------------------ priv/templates/phx.gen.live/form.ex | 98 ---- priv/templates/phx.gen.live/index.ex | 74 --- priv/templates/phx.gen.live/live_test.exs | 128 ----- priv/templates/phx.gen.live/show.ex | 64 --- 55 files changed, 5 insertions(+), 5174 deletions(-) delete mode 100644 priv/gettext/en/LC_MESSAGES/errors.po delete mode 100644 priv/gettext/errors.pot delete mode 100644 priv/templates/phx.gen.auth/AGENTS.md delete mode 100644 priv/templates/phx.gen.auth/auth.ex delete mode 100644 priv/templates/phx.gen.auth/auth_test.exs delete mode 100644 priv/templates/phx.gen.auth/confirmation_live.ex delete mode 100644 priv/templates/phx.gen.auth/confirmation_live_test.exs delete mode 100644 priv/templates/phx.gen.auth/conn_case.exs delete mode 100644 priv/templates/phx.gen.auth/context_fixtures_functions.ex delete mode 100644 priv/templates/phx.gen.auth/context_functions.ex delete mode 100644 priv/templates/phx.gen.auth/login_live.ex delete mode 100644 priv/templates/phx.gen.auth/login_live_test.exs delete mode 100644 priv/templates/phx.gen.auth/migration.ex delete mode 100644 priv/templates/phx.gen.auth/notifier.ex delete mode 100644 priv/templates/phx.gen.auth/registration_controller.ex delete mode 100644 priv/templates/phx.gen.auth/registration_controller_test.exs delete mode 100644 priv/templates/phx.gen.auth/registration_html.ex delete mode 100644 priv/templates/phx.gen.auth/registration_live.ex delete mode 100644 priv/templates/phx.gen.auth/registration_live_test.exs delete mode 100644 priv/templates/phx.gen.auth/registration_new.html.heex delete mode 100644 priv/templates/phx.gen.auth/routes.ex delete mode 100644 priv/templates/phx.gen.auth/schema.ex delete mode 100644 priv/templates/phx.gen.auth/schema_token.ex delete mode 100644 priv/templates/phx.gen.auth/scope.ex delete mode 100644 priv/templates/phx.gen.auth/session_confirm.html.heex delete mode 100644 priv/templates/phx.gen.auth/session_controller.ex delete mode 100644 priv/templates/phx.gen.auth/session_controller_test.exs delete mode 100644 priv/templates/phx.gen.auth/session_html.ex delete mode 100644 priv/templates/phx.gen.auth/session_new.html.heex delete mode 100644 priv/templates/phx.gen.auth/settings_controller.ex delete mode 100644 priv/templates/phx.gen.auth/settings_controller_test.exs delete mode 100644 priv/templates/phx.gen.auth/settings_edit.html.heex delete mode 100644 priv/templates/phx.gen.auth/settings_html.ex delete mode 100644 priv/templates/phx.gen.auth/settings_live.ex delete mode 100644 priv/templates/phx.gen.auth/settings_live_test.exs delete mode 100644 priv/templates/phx.gen.auth/test_cases.exs delete mode 100644 priv/templates/phx.gen.html/controller.ex delete mode 100644 priv/templates/phx.gen.html/controller_test.exs delete mode 100644 priv/templates/phx.gen.html/edit.html.heex delete mode 100644 priv/templates/phx.gen.html/html.ex delete mode 100644 priv/templates/phx.gen.html/index.html.heex delete mode 100644 priv/templates/phx.gen.html/new.html.heex delete mode 100644 priv/templates/phx.gen.html/resource_form.html.heex delete mode 100644 priv/templates/phx.gen.html/show.html.heex delete mode 100644 priv/templates/phx.gen.live/core_components.ex delete mode 100644 priv/templates/phx.gen.live/form.ex delete mode 100644 priv/templates/phx.gen.live/index.ex delete mode 100644 priv/templates/phx.gen.live/live_test.exs delete mode 100644 priv/templates/phx.gen.live/show.ex diff --git a/.gitignore b/.gitignore index e85d27d..344acba 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,5 @@ corex-*.tar /installer/ /integration_test/ + /my_app/ diff --git a/README.md b/README.md index 8c897a6..4d1e48a 100644 --- a/README.md +++ b/README.md @@ -77,8 +77,6 @@ mix local.phx mix phx.new my_app ``` -## Dependencies - Add `corex` to your `mix.exs` dependencies: ```elixir diff --git a/config/config.exs b/config/config.exs index 45b0a18..a319901 100644 --- a/config/config.exs +++ b/config/config.exs @@ -10,13 +10,6 @@ config :phoenix, trim_on_html_eex_engine: false, sort_verified_routes_query_params: true -if Mix.env() == :test do - config :corex, CorexTest.Endpoint, - http: [port: 4002], - secret_key_base: "test_secret_key_base_for_component_tests_only", - server: false -end - if Mix.env() == :dev do corex_externals = ~w(accordion angle-slider avatar carousel checkbox clipboard collapsible combobox color-picker date-picker dialog editable floating-panel listbox marquee menu number-input password-input pin-input radio-group select signature-pad switch tabs timer toast toggle-group tree-view) diff --git a/e2e/.gitignore b/e2e/.gitignore index 557f6cb..f6fa1da 100644 --- a/e2e/.gitignore +++ b/e2e/.gitignore @@ -33,6 +33,9 @@ e2e-*.tar # In case you use Node.js/npm, you want to ignore these. npm-debug.log + /assets/node_modules/ + .env -/.expert/ \ No newline at end of file + +/.expert/ diff --git a/guides/installation.md b/guides/installation.md index ba9d7f5..7c4b428 100644 --- a/guides/installation.md +++ b/guides/installation.md @@ -67,32 +67,6 @@ mix local.phx mix phx.new my_app ``` -## Dependencies - -You can add Corex via Igniter (recommended) or manually. - -### Via Igniter - -From your Phoenix app: - -```bash -mix igniter.install corex -``` - -The installer shows a diff and prompts for confirmation. Add `--yes` to apply changes without prompting: - -```bash -mix igniter.install corex --yes -``` - -To use a local Corex source (e.g. when developing Corex or testing before a Hex release): - -```bash -mix igniter.install corex@path:../corex -``` - -### Manual - Add `corex` to your `mix.exs` dependencies: ```elixir diff --git a/lib/corex.ex b/lib/corex.ex index 79c39b9..e05de94 100644 --- a/lib/corex.ex +++ b/lib/corex.ex @@ -111,8 +111,6 @@ defmodule Corex do end end - def component_keys, do: Map.keys(@components) - defp include?(_name, :all, []), do: true defp include?(name, :all, except), do: name not in except defp include?(name, only, _except) when is_list(only), do: name in only diff --git a/priv/gettext/en/LC_MESSAGES/errors.po b/priv/gettext/en/LC_MESSAGES/errors.po deleted file mode 100644 index 5d9caa0..0000000 --- a/priv/gettext/en/LC_MESSAGES/errors.po +++ /dev/null @@ -1,3 +0,0 @@ -msgid "" -msgstr "" -"Language: en\n" diff --git a/priv/gettext/errors.pot b/priv/gettext/errors.pot deleted file mode 100644 index 3c0b4f3..0000000 --- a/priv/gettext/errors.pot +++ /dev/null @@ -1,4 +0,0 @@ -## This is a PO Template file. -## Run `mix gettext.extract` to bring this file up to date. -msgid "" -msgstr "" diff --git a/priv/templates/phx.gen.auth/AGENTS.md b/priv/templates/phx.gen.auth/AGENTS.md deleted file mode 100644 index c4b1a1b..0000000 --- a/priv/templates/phx.gen.auth/AGENTS.md +++ /dev/null @@ -1,57 +0,0 @@ -## Authentication - -- **Always** handle authentication flow at the router level with proper redirects -- **Always** be mindful of where to place routes. `phx.gen.auth` creates multiple router plugs<%= if live? do %> and `live_session` scopes<% end %>: - - A plug `:fetch_<%= scope_config.scope.assign_key %>_for_<%= schema.singular %>` that is included in the default browser pipeline - - A plug `:require_authenticated_<%= schema.singular %>` that redirects to the log in page when the <%= schema.singular %> is not authenticated<%= if live? do %> - - A `live_session :current_<%= schema.singular %>` scope - for routes that need the current <%= schema.singular %> but don't require authentication, similar to `:fetch_<%= scope_config.scope.assign_key %>_for_<%= schema.singular %>` - - A `live_session :require_authenticated_<%= schema.singular %>` scope - for routes that require authentication, similar to the plug with the same name<% end %> - - In both cases, a `@<%= scope_config.scope.assign_key %>` is assigned to the Plug connection<%= if live? do %> and LiveView socket<% end %> - - A plug `redirect_if_<%= schema.singular %>_is_authenticated` that redirects to a default path in case the <%= schema.singular %> is authenticated - useful for a registration page that should only be shown to unauthenticated <%= schema.plural %> -- **Always let the user know in which router scopes<%= if live? do%>, `live_session`,<% end %> and pipeline you are placing the route, AND SAY WHY** -- `phx.gen.auth` assigns the `<%= scope_config.scope.assign_key %>` assign - it **does not assign a `current_<%= schema.singular %>` assign** -- Always pass the assign `<%= scope_config.scope.assign_key %>` to context modules as first argument. When performing queries, use `<%= scope_config.scope.assign_key %>.<%= schema.singular %>` to filter the query results -- To derive/access `current_<%= schema.singular %>` in templates, **always use the `@<%= scope_config.scope.assign_key %>.<%= schema.singular %>`**, never use **`@current_<%= schema.singular %>`** in templates<%= if live? do %> or LiveViews -- **Never** duplicate `live_session` names. A `live_session :current_<%= schema.singular %>` can only be defined __once__ in the router, so all routes for the `live_session :current_<%= schema.singular %>` must be grouped in a single block<% end %> -- Anytime you hit `<%= scope_config.scope.assign_key %>` errors or the logged in session isn't displaying the right content, **always double check the router and ensure you are using the correct plug<%= if live? do %> and `live_session`<% end %> as described below** - -### Routes that require authentication - -<%= if live? do %>LiveViews that require login should **always be placed inside the __existing__ `live_session :require_authenticated_<%= schema.singular %>` block**: - - scope "/", AppWeb do - pipe_through [:browser, :require_authenticated_<%= schema.singular %>] - - live_session :require_authenticated_<%= schema.singular %>, - on_mount: [{<%= inspect auth_module %>, :require_authenticated}] do - # phx.gen.auth generated routes - live "/<%= schema.plural %>/settings", <%= inspect schema.alias %>Live.Settings, :edit - live "/<%= schema.plural %>/settings/confirm-email/:token", <%= inspect schema.alias %>Live.Settings, :confirm_email - # our own routes that require logged in <%= schema.singular %> - live "/", MyLiveThatRequiresAuth, :index - end - end - -<% end %>Controller routes must be placed in a scope that sets the `:require_authenticated_<%= schema.singular %>` plug: - - scope "/", AppWeb do - pipe_through [:browser, :require_authenticated_<%= schema.singular %>] - - get "/", MyControllerThatRequiresAuth, :index - end - -### Routes that work with or without authentication - -<%= if live? do %>LiveViews that can work with or without authentication, **always use the __existing__ `:current_<%= schema.singular %>` scope**, ie: - - scope "/", MyAppWeb do - pipe_through [:browser] - - live_session :current_<%= schema.singular %>, - on_mount: [{<%= inspect auth_module %>, :mount_<%= scope_config.scope.assign_key %>}] do - # our own routes that work with or without authentication - live "/", PublicLive - end - end - -<% end %>Controllers automatically have the `<%= scope_config.scope.assign_key %>` available if they use the `:browser` pipeline. diff --git a/priv/templates/phx.gen.auth/auth.ex b/priv/templates/phx.gen.auth/auth.ex deleted file mode 100644 index 14455ed..0000000 --- a/priv/templates/phx.gen.auth/auth.ex +++ /dev/null @@ -1,321 +0,0 @@ -defmodule <%= inspect auth_module %> do - use <%= inspect context.web_module %>, :verified_routes - - import Plug.Conn - import Phoenix.Controller - - alias <%= inspect context.module %> - alias <%= inspect scope_config.scope.module %> - - # Make the remember me cookie valid for 14 days. This should match - # the session validity setting in <%= inspect schema.alias %>Token. - @max_cookie_age_in_days 14 - @remember_me_cookie "_<%= web_app_name %>_<%= schema.singular %>_remember_me" - @remember_me_options [ - sign: true, - max_age: @max_cookie_age_in_days * 24 * 60 * 60, - same_site: "Lax" - ] - - # How old the session token should be before a new one is issued. When a request is made - # with a session token older than this value, then a new session token will be created - # and the session and remember-me cookies (if set) will be updated with the new token. - # Lowering this value will result in more tokens being created by active users. Increasing - # it will result in less time before a session token expires for a user to get issued a new - # token. This can be set to a value greater than `@max_cookie_age_in_days` to disable - # the reissuing of tokens completely. - @session_reissue_age_in_days 7 - - @doc """ - Logs the <%= schema.singular %> in. - - Redirects to the session's `:<%= schema.singular %>_return_to` path - or falls back to the `signed_in_path/1`. - """ - def log_in_<%= schema.singular %>(conn, <%= schema.singular %>, params \\ %{}) do - <%= schema.singular %>_return_to = get_session(conn, :<%= schema.singular %>_return_to) - - conn - |> create_or_extend_session(<%= schema.singular %>, params) - |> redirect(to: <%= schema.singular %>_return_to || signed_in_path(conn)) - end - - @doc """ - Logs the <%= schema.singular %> out. - - It clears all session data for safety. See renew_session. - """ - def log_out_<%= schema.singular %>(conn) do - <%= schema.singular %>_token = get_session(conn, :<%= schema.singular %>_token) - <%= schema.singular %>_token && <%= inspect context.alias %>.delete_<%= schema.singular %>_session_token(<%= schema.singular %>_token) - - if live_socket_id = get_session(conn, :live_socket_id) do - <%= inspect(endpoint_module) %>.broadcast(live_socket_id, "disconnect", %{}) - end - - conn - |> renew_session(nil) - |> delete_resp_cookie(@remember_me_cookie) - |> redirect(to: ~p"/") - end - - @doc """ - Authenticates the <%= schema.singular %> by looking into the session and remember me token. - - Will reissue the session token if it is older than the configured age. - """ - def fetch_<%= scope_config.scope.assign_key %>_for_<%= schema.singular %>(conn, _opts) do - with {token, conn} <- ensure_<%= schema.singular %>_token(conn), - {<%= schema.singular %>, token_inserted_at} <- <%= inspect context.alias %>.get_<%= schema.singular %>_by_session_token(token) do - conn - |> assign(:<%= scope_config.scope.assign_key %>, <%= inspect scope_config.scope.alias %>.for_<%= schema.singular %>(<%= schema.singular %>)) - |> maybe_reissue_<%= schema.singular %>_session_token(<%= schema.singular %>, token_inserted_at) - else - nil -> assign(conn, :<%= scope_config.scope.assign_key %>, <%= inspect scope_config.scope.alias %>.for_<%= schema.singular %>(nil)) - end - end - - defp ensure_<%= schema.singular %>_token(conn) do - if token = get_session(conn, :<%= schema.singular %>_token) do - {token, conn} - else - conn = fetch_cookies(conn, signed: [@remember_me_cookie]) - - if token = conn.cookies[@remember_me_cookie] do - {token, conn |> put_token_in_session(token) |> put_session(:<%= schema.singular %>_remember_me, true)} - else - nil - end - end - end - - # Reissue the session token if it is older than the configured reissue age. - defp maybe_reissue_<%= schema.singular %>_session_token(conn, <%= schema.singular %>, token_inserted_at) do - token_age = <%= inspect datetime_module %>.diff(<%= datetime_now %>, token_inserted_at, :day) - - if token_age >= @session_reissue_age_in_days do - create_or_extend_session(conn, <%= schema.singular %>, %{}) - else - conn - end - end - - # This function is the one responsible for creating session tokens - # and storing them safely in the session and cookies. It may be called - # either when logging in, during sudo mode, or to renew a session which - # will soon expire. - # - # When the session is created, rather than extended, the renew_session - # function will clear the session to avoid fixation attacks. See the - # renew_session function to customize this behaviour. - defp create_or_extend_session(conn, <%= schema.singular %>, params) do - token = <%= inspect context.alias %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>) - remember_me = get_session(conn, :<%= schema.singular %>_remember_me) - - conn - |> renew_session(<%= schema.singular %>) - |> put_token_in_session(token) - |> maybe_write_remember_me_cookie(token, params, remember_me) - end - - # Do not renew session if the <%= schema.singular %> is already logged in - # to prevent CSRF errors or data being lost in tabs that are still open - defp renew_session(conn, <%= schema.singular %>) when conn.assigns.<%= scope_config.scope.assign_key %>.<%= schema.singular %>.id == <%= schema.singular %>.id do - conn - end - - # This function renews the session ID and erases the whole - # session to avoid fixation attacks. If there is any data - # in the session you may want to preserve after log in/log out, - # you must explicitly fetch the session data before clearing - # and then immediately set it after clearing, for example: - # - # defp renew_session(conn, _<%= schema.singular %>) do - # delete_csrf_token() - # preferred_locale = get_session(conn, :preferred_locale) - # - # conn - # |> configure_session(renew: true) - # |> clear_session() - # |> put_session(:preferred_locale, preferred_locale) - # end - # - defp renew_session(conn, _<%= schema.singular %>) do - delete_csrf_token() - - conn - |> configure_session(renew: true) - |> clear_session() - end - - defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}, _), - do: write_remember_me_cookie(conn, token) - - defp maybe_write_remember_me_cookie(conn, token, _params, true), - do: write_remember_me_cookie(conn, token) - - defp maybe_write_remember_me_cookie(conn, _token, _params, _), do: conn - - defp write_remember_me_cookie(conn, token) do - conn - |> put_session(:<%= schema.singular %>_remember_me, true) - |> put_resp_cookie(@remember_me_cookie, token, @remember_me_options) - end - - <%= if live? do %>defp put_token_in_session(conn, token) do - conn - |> put_session(:<%= schema.singular %>_token, token) - |> put_session(:live_socket_id, <%= schema.singular %>_session_topic(token)) - end - - @doc """ - Disconnects existing sockets for the given tokens. - """ - def disconnect_sessions(tokens) do - Enum.each(tokens, fn %{token: token} -> - <%= inspect endpoint_module %>.broadcast(<%= schema.singular %>_session_topic(token), "disconnect", %{}) - end) - end - - defp <%= schema.singular %>_session_topic(token), do: "<%= schema.plural %>_sessions:#{Base.url_encode64(token)}" - - @doc """ - Handles mounting and authenticating the <%= scope_config.scope.assign_key %> in LiveViews. - - ## `on_mount` arguments - - * `:mount_<%= scope_config.scope.assign_key %>` - Assigns <%= scope_config.scope.assign_key %> - to socket assigns based on <%= schema.singular %>_token, or nil if - there's no <%= schema.singular %>_token or no matching <%= schema.singular %>. - - * `:require_authenticated` - Authenticates the <%= schema.singular %> from the session, - and assigns the <%= scope_config.scope.assign_key %> to socket assigns based - on <%= schema.singular %>_token. - Redirects to login page if there's no logged <%= schema.singular %>. - - ## Examples - - Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate - the `<%= scope_config.scope.assign_key %>`: - - defmodule <%= inspect context.web_module %>.PageLive do - use <%= inspect context.web_module %>, :live_view - - on_mount {<%= inspect auth_module %>, :mount_<%= scope_config.scope.assign_key %>} - ... - end - - Or use the `live_session` of your router to invoke the on_mount callback: - - live_session :authenticated, on_mount: [{<%= inspect auth_module %>, :require_authenticated}] do - live "/profile", ProfileLive, :index - end - """ - def on_mount(:mount_<%= scope_config.scope.assign_key %>, _params, session, socket) do - {:cont, mount_<%= scope_config.scope.assign_key %>(socket, session)} - end - - def on_mount(:require_authenticated, _params, session, socket) do - socket = mount_<%= scope_config.scope.assign_key %>(socket, session) - - if socket.assigns.<%= scope_config.scope.assign_key %> && socket.assigns.<%= scope_config.scope.assign_key %>.<%= schema.singular %> do - {:cont, socket} - else - socket = - socket - |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.") - |> Phoenix.LiveView.redirect(to: ~p"<%= schema.route_prefix %>/log-in") - - {:halt, socket} - end - end - - def on_mount(:require_sudo_mode, _params, session, socket) do - socket = mount_<%= scope_config.scope.assign_key %>(socket, session) - - if <%= inspect context.alias %>.sudo_mode?(socket.assigns.<%= scope_config.scope.assign_key %>.<%= schema.singular %>, -10) do - {:cont, socket} - else - socket = - socket - |> Phoenix.LiveView.put_flash(:error, "You must re-authenticate to access this page.") - |> Phoenix.LiveView.redirect(to: ~p"<%= schema.route_prefix %>/log-in") - - {:halt, socket} - end - end - - defp mount_<%= scope_config.scope.assign_key %>(socket, session) do - Phoenix.Component.assign_new(socket, :<%= scope_config.scope.assign_key %>, fn -> - {<%= schema.singular %>, _} = - if <%= schema.singular %>_token = session["<%= schema.singular %>_token"] do - <%= inspect context.alias %>.get_<%= schema.singular %>_by_session_token(<%= schema.singular %>_token) - end || {nil, nil} - - <%= inspect scope_config.scope.alias %>.for_<%= schema.singular %>(<%= schema.singular %>) - end) - end - - @doc "Returns the path to redirect to after log in." - # the <%= schema.singular %> was already logged in, redirect to settings - def signed_in_path(%Plug.Conn{assigns: %{<%= scope_config.scope.assign_key %>: %<%= inspect scope_config.scope.alias %>{<%= schema.singular %>: %<%= inspect context.alias %>.<%= inspect schema.alias %>{}}}}) do - ~p"<%= schema.route_prefix %>/settings" - end - - def signed_in_path(_), do: ~p"/" - - <% else %>defp put_token_in_session(conn, token) do - put_session(conn, :<%= schema.singular %>_token, token) - end - - @doc """ - Plug for routes that require sudo mode. - """ - def require_sudo_mode(conn, _opts) do - if <%= inspect context.alias %>.sudo_mode?(conn.assigns.<%= scope_config.scope.assign_key %>.<%= schema.singular %>, -10) do - conn - else - conn - |> put_flash(:error, "You must re-authenticate to access this page.") - |> maybe_store_return_to() - |> redirect(to: ~p"<%= schema.route_prefix %>/log-in") - |> halt() - end - end - - @doc """ - Plug for routes that require the <%= schema.singular %> to not be authenticated. - """ - def redirect_if_<%= schema.singular %>_is_authenticated(conn, _opts) do - if conn.assigns.<%= scope_config.scope.assign_key %> do - conn - |> redirect(to: signed_in_path(conn)) - |> halt() - else - conn - end - end - - defp signed_in_path(_conn), do: ~p"/" - - <% end %>@doc """ - Plug for routes that require the <%= schema.singular %> to be authenticated. - """ - def require_authenticated_<%= schema.singular %>(conn, _opts) do - if conn.assigns.<%= scope_config.scope.assign_key %> && conn.assigns.<%= scope_config.scope.assign_key %>.<%= schema.singular %> do - conn - else - conn - |> put_flash(:error, "You must log in to access this page.") - |> maybe_store_return_to() - |> redirect(to: ~p"<%= schema.route_prefix %>/log-in") - |> halt() - end - end - - defp maybe_store_return_to(%{method: "GET"} = conn) do - put_session(conn, :<%= schema.singular %>_return_to, current_path(conn)) - end - - defp maybe_store_return_to(conn), do: conn -end diff --git a/priv/templates/phx.gen.auth/auth_test.exs b/priv/templates/phx.gen.auth/auth_test.exs deleted file mode 100644 index 3fef928..0000000 --- a/priv/templates/phx.gen.auth/auth_test.exs +++ /dev/null @@ -1,442 +0,0 @@ -defmodule <%= inspect auth_module %>Test do - use <%= inspect context.web_module %>.ConnCase<%= test_case_options %> - - <%= if live? do %>alias Phoenix.LiveView - <% end %>alias <%= inspect context.module %> - alias <%= inspect context.module %>.<%= inspect scope_config.scope.alias %> - alias <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Auth - - import <%= inspect context.module %>Fixtures - - @remember_me_cookie "_<%= web_app_name %>_<%= schema.singular %>_remember_me" - @remember_me_cookie_max_age 60 * 60 * 24 * 14 - - setup %{conn: conn} do - conn = - conn - |> Map.replace!(:secret_key_base, <%= inspect endpoint_module %>.config(:secret_key_base)) - |> init_test_session(%{}) - - %{<%= schema.singular %>: %{<%= schema.singular %>_fixture() | authenticated_at: <%= datetime_now %>}, conn: conn} - end - - describe "log_in_<%= schema.singular %>/3" do - test "stores the <%= schema.singular %> token in the session", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - conn = <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(conn, <%= schema.singular %>) - assert token = get_session(conn, :<%= schema.singular %>_token)<%= if live? do %> - assert get_session(conn, :live_socket_id) == "<%= schema.plural %>_sessions:#{Base.url_encode64(token)}"<% end %> - assert redirected_to(conn) == ~p"/" - assert <%= inspect context.alias %>.get_<%= schema.singular %>_by_session_token(token) - end - - test "clears everything previously stored in the session", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - conn = conn |> put_session(:to_be_removed, "value") |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>) - refute get_session(conn, :to_be_removed) - end - - test "keeps session when re-authenticating", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - conn = - conn - |> assign(:<%= scope_config.scope.assign_key %>, <%= inspect scope_config.scope.alias %>.for_<%= schema.singular %>(<%= schema.singular %>)) - |> put_session(:to_be_removed, "value") - |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>) - - assert get_session(conn, :to_be_removed) - end - - test "clears session when <%= schema.singular %> does not match when re-authenticating", %{ - conn: conn, - <%= schema.singular %>: <%= schema.singular %> - } do - other_<%= schema.singular %> = <%= schema.singular %>_fixture() - - conn = - conn - |> assign(:<%= scope_config.scope.assign_key %>, <%= inspect scope_config.scope.alias %>.for_<%= schema.singular %>(other_<%= schema.singular %>)) - |> put_session(:to_be_removed, "value") - |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>) - - refute get_session(conn, :to_be_removed) - end - - test "redirects to the configured path", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - conn = conn |> put_session(:<%= schema.singular %>_return_to, "/hello") |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>) - assert redirected_to(conn) == "/hello" - end - - test "writes a cookie if remember_me is configured", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - conn = conn |> fetch_cookies() |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>, %{"remember_me" => "true"}) - assert get_session(conn, :<%= schema.singular %>_token) == conn.cookies[@remember_me_cookie] - assert get_session(conn, :<%= schema.singular %>_remember_me) == true - - assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] - assert signed_token != get_session(conn, :<%= schema.singular %>_token) - assert max_age == @remember_me_cookie_max_age - end<%= if live? do %> - - test "redirects to settings when <%= schema.singular %> is already logged in", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - conn = - conn - |> assign(:<%= scope_config.scope.assign_key %>, <%= inspect scope_config.scope.alias %>.for_<%= schema.singular %>(<%= schema.singular %>)) - |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>) - - assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/settings" - end<% end %> - - test "writes a cookie if remember_me was set in previous session", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - conn = conn |> fetch_cookies() |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>, %{"remember_me" => "true"}) - assert get_session(conn, :<%= schema.singular %>_token) == conn.cookies[@remember_me_cookie] - assert get_session(conn, :<%= schema.singular %>_remember_me) == true - - conn = - conn - |> recycle() - |> Map.replace!(:secret_key_base, <%= inspect endpoint_module %>.config(:secret_key_base)) - |> fetch_cookies() - |> init_test_session(%{<%= schema.singular %>_remember_me: true}) - - # the conn is already logged in and has the remember_me cookie set, - # now we log in again and even without explicitly setting remember_me, - # the cookie should be set again - conn = conn |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>, %{}) - assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] - assert signed_token != get_session(conn, :<%= schema.singular %>_token) - assert max_age == @remember_me_cookie_max_age - assert get_session(conn, :<%= schema.singular %>_remember_me) == true - end - end - - describe "logout_<%= schema.singular %>/1" do - test "erases session and cookies", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - <%= schema.singular %>_token = <%= inspect context.alias %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>) - - conn = - conn - |> put_session(:<%= schema.singular %>_token, <%= schema.singular %>_token) - |> put_req_cookie(@remember_me_cookie, <%= schema.singular %>_token) - |> fetch_cookies() - |> <%= inspect schema.alias %>Auth.log_out_<%= schema.singular %>() - - refute get_session(conn, :<%= schema.singular %>_token) - refute conn.cookies[@remember_me_cookie] - assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] - assert redirected_to(conn) == ~p"/" - refute <%= inspect context.alias %>.get_<%= schema.singular %>_by_session_token(<%= schema.singular %>_token) - end - - <%= if live? do %>test "broadcasts to the given live_socket_id", %{conn: conn} do - live_socket_id = "<%= schema.plural %>_sessions:abcdef-token" - <%= inspect(endpoint_module) %>.subscribe(live_socket_id) - - conn - |> put_session(:live_socket_id, live_socket_id) - |> <%= inspect(schema.alias) %>Auth.log_out_<%= schema.singular %>() - - assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id} - end - - <% end %>test "works even if <%= schema.singular %> is already logged out", %{conn: conn} do - conn = conn |> fetch_cookies() |> <%= inspect schema.alias %>Auth.log_out_<%= schema.singular %>() - refute get_session(conn, :<%= schema.singular %>_token) - assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] - assert redirected_to(conn) == ~p"/" - end - end - - describe "fetch_<%= scope_config.scope.assign_key %>_for_<%= schema.singular %>/2" do - test "authenticates <%= schema.singular %> from session", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - <%= schema.singular %>_token = <%= inspect context.alias %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>) - - conn = - conn |> put_session(:<%= schema.singular %>_token, <%= schema.singular %>_token) |> <%= inspect schema.alias %>Auth.fetch_<%= scope_config.scope.assign_key %>_for_<%= schema.singular %>([]) - - assert conn.assigns.<%= scope_config.scope.assign_key %>.<%= schema.singular %>.id == <%= schema.singular %>.id - assert conn.assigns.<%= scope_config.scope.assign_key %>.<%= schema.singular %>.authenticated_at == <%= schema.singular %>.authenticated_at - assert get_session(conn, :<%= schema.singular %>_token) == <%= schema.singular %>_token - end - - test "authenticates <%= schema.singular %> from cookies", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - logged_in_conn = - conn |> fetch_cookies() |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>, %{"remember_me" => "true"}) - - <%= schema.singular %>_token = logged_in_conn.cookies[@remember_me_cookie] - %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie] - - conn = - conn - |> put_req_cookie(@remember_me_cookie, signed_token) - |> <%= inspect schema.alias %>Auth.fetch_<%= scope_config.scope.assign_key %>_for_<%= schema.singular %>([]) - - assert conn.assigns.<%= scope_config.scope.assign_key %>.<%= schema.singular %>.id == <%= schema.singular %>.id - assert conn.assigns.<%= scope_config.scope.assign_key %>.<%= schema.singular %>.authenticated_at == <%= schema.singular %>.authenticated_at - assert get_session(conn, :<%= schema.singular %>_token) == <%= schema.singular %>_token - assert get_session(conn, :<%= schema.singular %>_remember_me)<%= if live? do %> - - assert get_session(conn, :live_socket_id) == - "<%= schema.plural %>_sessions:#{Base.url_encode64(<%= schema.singular %>_token)}"<% end %> - end - - test "does not authenticate if data is missing", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - _ = <%= inspect context.alias %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>) - conn = <%= inspect schema.alias %>Auth.fetch_<%= scope_config.scope.assign_key %>_for_<%= schema.singular %>(conn, []) - refute get_session(conn, :<%= schema.singular %>_token) - refute conn.assigns.<%= scope_config.scope.assign_key %> - end - - test "reissues a new token after a few days and refreshes cookie", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - logged_in_conn = - conn |> fetch_cookies() |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>, %{"remember_me" => "true"}) - - token = logged_in_conn.cookies[@remember_me_cookie] - %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie] - - offset_<%= schema.singular %>_token(token, -10, :day) - {<%= schema.singular %>, _} = <%= inspect context.alias %>.get_<%= schema.singular %>_by_session_token(token) - - conn = - conn - |> put_session(:<%= schema.singular %>_token, token) - |> put_session(:<%= schema.singular %>_remember_me, true) - |> put_req_cookie(@remember_me_cookie, signed_token) - |> <%= inspect schema.alias %>Auth.fetch_<%= scope_config.scope.assign_key %>_for_<%= schema.singular %>([]) - - assert conn.assigns.<%= scope_config.scope.assign_key %>.<%= schema.singular %>.id == <%= schema.singular %>.id - assert conn.assigns.<%= scope_config.scope.assign_key %>.<%= schema.singular %>.authenticated_at == <%= schema.singular %>.authenticated_at - assert new_token = get_session(conn, :<%= schema.singular %>_token) - assert new_token != token - assert %{value: new_signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] - assert new_signed_token != signed_token - assert max_age == @remember_me_cookie_max_age - end - end - - <%= if live? do %>describe "on_mount :mount_<%= scope_config.scope.assign_key %>" do - setup %{conn: conn} do - %{conn: <%= inspect schema.alias %>Auth.fetch_<%= scope_config.scope.assign_key %>_for_<%= schema.singular %>(conn, [])} - end - - test "assigns <%= scope_config.scope.assign_key %> based on a valid <%= schema.singular %>_token", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - <%= schema.singular %>_token = <%= inspect context.alias %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>) - session = conn |> put_session(:<%= schema.singular %>_token, <%= schema.singular %>_token) |> get_session() - - {:cont, updated_socket} = - <%= inspect schema.alias %>Auth.on_mount(:mount_<%= scope_config.scope.assign_key %>, %{}, session, %LiveView.Socket{}) - - assert updated_socket.assigns.<%= scope_config.scope.assign_key %>.<%= schema.singular %>.id == <%= schema.singular %>.id - end - - test "assigns nil to <%= scope_config.scope.assign_key %> assign if there isn't a valid <%= schema.singular %>_token", %{conn: conn} do - <%= schema.singular %>_token = "invalid_token" - session = conn |> put_session(:<%= schema.singular %>_token, <%= schema.singular %>_token) |> get_session() - - {:cont, updated_socket} = - <%= inspect schema.alias %>Auth.on_mount(:mount_<%= scope_config.scope.assign_key %>, %{}, session, %LiveView.Socket{}) - - assert updated_socket.assigns.<%= scope_config.scope.assign_key %> == nil - end - - test "assigns nil to <%= scope_config.scope.assign_key %> assign if there isn't a <%= schema.singular %>_token", %{conn: conn} do - session = conn |> get_session() - - {:cont, updated_socket} = - <%= inspect schema.alias %>Auth.on_mount(:mount_<%= scope_config.scope.assign_key %>, %{}, session, %LiveView.Socket{}) - - assert updated_socket.assigns.<%= scope_config.scope.assign_key %> == nil - end - end - - describe "on_mount :require_authenticated" do - test "authenticates <%= scope_config.scope.assign_key %> based on a valid <%= schema.singular %>_token", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - <%= schema.singular %>_token = <%= inspect context.alias %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>) - session = conn |> put_session(:<%= schema.singular %>_token, <%= schema.singular %>_token) |> get_session() - - {:cont, updated_socket} = - <%= inspect schema.alias %>Auth.on_mount(:require_authenticated, %{}, session, %LiveView.Socket{}) - - assert updated_socket.assigns.<%= scope_config.scope.assign_key %>.<%= schema.singular %>.id == <%= schema.singular %>.id - end - - test "redirects to login page if there isn't a valid <%= schema.singular %>_token", %{conn: conn} do - <%= schema.singular %>_token = "invalid_token" - session = conn |> put_session(:<%= schema.singular %>_token, <%= schema.singular %>_token) |> get_session() - - socket = %LiveView.Socket{ - endpoint: <%= inspect context.web_module %>.Endpoint, - assigns: %{__changed__: %{}, flash: %{}} - } - - {:halt, updated_socket} = <%= inspect schema.alias %>Auth.on_mount(:require_authenticated, %{}, session, socket) - assert updated_socket.assigns.<%= scope_config.scope.assign_key %> == nil - end - - test "redirects to login page if there isn't a <%= schema.singular %>_token", %{conn: conn} do - session = conn |> get_session() - - socket = %LiveView.Socket{ - endpoint: <%= inspect context.web_module %>.Endpoint, - assigns: %{__changed__: %{}, flash: %{}} - } - - {:halt, updated_socket} = <%= inspect schema.alias %>Auth.on_mount(:require_authenticated, %{}, session, socket) - assert updated_socket.assigns.<%= scope_config.scope.assign_key %> == nil - end - end - - describe "on_mount :require_sudo_mode" do - test "allows <%= schema.plural %> that have authenticated in the last 10 minutes", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - <%= schema.singular %>_token = <%= inspect context.alias %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>) - session = conn |> put_session(:<%= schema.singular %>_token, <%= schema.singular %>_token) |> get_session() - - socket = %LiveView.Socket{ - endpoint: <%= inspect(endpoint_module) %>, - assigns: %{__changed__: %{}, flash: %{}} - } - - assert {:cont, _updated_socket} = - <%= inspect schema.alias %>Auth.on_mount(:require_sudo_mode, %{}, session, socket) - end - - test "redirects when authentication is too old", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - eleven_minutes_ago = <%= datetime_now %> |> <%= inspect datetime_module %>.add(-11, :minute) - <%= schema.singular %> = %{<%= schema.singular %> | authenticated_at: eleven_minutes_ago} - <%= schema.singular %>_token = <%= inspect context.alias %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>) - {<%= schema.singular %>, token_inserted_at} = <%= inspect context.alias %>.get_<%= schema.singular %>_by_session_token(<%= schema.singular %>_token) - assert <%= inspect datetime_module %>.compare(token_inserted_at, <%= schema.singular %>.authenticated_at) == :gt - session = conn |> put_session(:<%= schema.singular %>_token, <%= schema.singular %>_token) |> get_session() - - socket = %LiveView.Socket{ - endpoint: <%= inspect context.web_module %>.Endpoint, - assigns: %{__changed__: %{}, flash: %{}} - } - - assert {:halt, _updated_socket} = - <%= inspect schema.alias %>Auth.on_mount(:require_sudo_mode, %{}, session, socket) - end - end<% else %>describe "require_sudo_mode/2" do - test "allows <%= schema.plural %> that have authenticated in the last 10 minutes", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - conn = - conn - |> fetch_flash() - |> assign(:<%= scope_config.scope.assign_key %>, <%= inspect scope_config.scope.alias %>.for_<%= schema.singular %>(<%= schema.singular %>)) - |> <%= inspect schema.alias %>Auth.require_sudo_mode([]) - - refute conn.halted - refute conn.status - end - - test "redirects when authentication is too old", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - eleven_minutes_ago = <%= datetime_now %> |> <%= inspect datetime_module %>.add(-11, :minute) - <%= schema.singular %> = %{<%= schema.singular %> | authenticated_at: eleven_minutes_ago} - <%= schema.singular %>_token = <%= inspect context.alias %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>) - {<%= schema.singular %>, token_inserted_at} = <%= inspect context.alias %>.get_<%= schema.singular %>_by_session_token(<%= schema.singular %>_token) - assert <%= inspect datetime_module %>.compare(token_inserted_at, <%= schema.singular %>.authenticated_at) == :gt - - conn = - conn - |> fetch_flash() - |> assign(:<%= scope_config.scope.assign_key %>, Scope.for_<%= schema.singular %>(<%= schema.singular %>)) - |> <%= inspect schema.alias %>Auth.require_sudo_mode([]) - - assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/log-in" - - assert Phoenix.Flash.get(conn.assigns.flash, :error) == - "You must re-authenticate to access this page." - end - end - - describe "redirect_if_<%= schema.singular %>_is_authenticated/2" do - setup %{conn: conn} do - %{conn: <%= inspect schema.alias %>Auth.fetch_<%= scope_config.scope.assign_key %>_for_<%= schema.singular %>(conn, [])} - end - - test "redirects if <%= schema.singular %> is authenticated", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - conn = - conn - |> assign(:<%= scope_config.scope.assign_key %>, <%= inspect scope_config.scope.alias %>.for_<%= schema.singular %>(<%= schema.singular %>)) - |> <%= inspect schema.alias %>Auth.redirect_if_<%= schema.singular %>_is_authenticated([]) - - assert conn.halted - assert redirected_to(conn) == ~p"/" - end - - test "does not redirect if <%= schema.singular %> is not authenticated", %{conn: conn} do - conn = <%= inspect schema.alias %>Auth.redirect_if_<%= schema.singular %>_is_authenticated(conn, []) - refute conn.halted - refute conn.status - end - end<% end %> - - describe "require_authenticated_<%= schema.singular %>/2" do - setup %{conn: conn} do - %{conn: <%= inspect schema.alias %>Auth.fetch_<%= scope_config.scope.assign_key %>_for_<%= schema.singular %>(conn, [])} - end - - test "redirects if <%= schema.singular %> is not authenticated", %{conn: conn} do - conn = conn |> fetch_flash() |> <%= inspect schema.alias %>Auth.require_authenticated_<%= schema.singular %>([]) - assert conn.halted - - assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/log-in" - - assert Phoenix.Flash.get(conn.assigns.flash, :error) == - "You must log in to access this page." - end - - test "stores the path to redirect to on GET", %{conn: conn} do - halted_conn = - %{conn | path_info: ["foo"], query_string: ""} - |> fetch_flash() - |> <%= inspect schema.alias %>Auth.require_authenticated_<%= schema.singular %>([]) - - assert halted_conn.halted - assert get_session(halted_conn, :<%= schema.singular %>_return_to) == "/foo" - - halted_conn = - %{conn | path_info: ["foo"], query_string: "bar=baz"} - |> fetch_flash() - |> <%= inspect schema.alias %>Auth.require_authenticated_<%= schema.singular %>([]) - - assert halted_conn.halted - assert get_session(halted_conn, :<%= schema.singular %>_return_to) == "/foo?bar=baz" - - halted_conn = - %{conn | path_info: ["foo"], query_string: "bar", method: "POST"} - |> fetch_flash() - |> <%= inspect schema.alias %>Auth.require_authenticated_<%= schema.singular %>([]) - - assert halted_conn.halted - refute get_session(halted_conn, :<%= schema.singular %>_return_to) - end - - test "does not redirect if <%= schema.singular %> is authenticated", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - conn = - conn - |> assign(:<%= scope_config.scope.assign_key %>, <%= inspect scope_config.scope.alias %>.for_<%= schema.singular %>(<%= schema.singular %>)) - |> <%= inspect schema.alias %>Auth.require_authenticated_<%= schema.singular %>([]) - - refute conn.halted - refute conn.status - end - end<%= if live? do %> - - describe "disconnect_sessions/1" do - test "broadcasts disconnect messages for each token" do - tokens = [%{token: "token1"}, %{token: "token2"}] - - for %{token: token} <- tokens do - <%= inspect context.web_module %>.Endpoint.subscribe("<%= schema.plural %>_sessions:#{Base.url_encode64(token)}") - end - - <%= inspect schema.alias %>Auth.disconnect_sessions(tokens) - - assert_receive %Phoenix.Socket.Broadcast{ - event: "disconnect", - topic: "<%= schema.plural %>_sessions:dG9rZW4x" - } - - assert_receive %Phoenix.Socket.Broadcast{ - event: "disconnect", - topic: "<%= schema.plural %>_sessions:dG9rZW4y" - } - end - end<% end %> -end diff --git a/priv/templates/phx.gen.auth/confirmation_live.ex b/priv/templates/phx.gen.auth/confirmation_live.ex deleted file mode 100644 index d55fc25..0000000 --- a/priv/templates/phx.gen.auth/confirmation_live.ex +++ /dev/null @@ -1,94 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.Confirmation do - use <%= inspect context.web_module %>, :live_view - - alias <%= inspect context.module %> - - @impl true - def render(assigns) do - ~H""" - ={@<%= scope_config.scope.assign_key %>}> -
    -
    - <.header>Welcome {@<%= schema.singular %>.email} -
    - - <.form - :if={!@<%= schema.singular %>.confirmed_at} - for={@form} - id="confirmation_form" - phx-mounted={JS.focus_first()} - phx-submit="submit" - action={~p"<%= schema.route_prefix %>/log-in?_action=confirmed"} - phx-trigger-action={@trigger_submit} - > - - <.button - name={@form[:remember_me].name} - value="true" - phx-disable-with="Confirming..." - class="btn btn-primary w-full" - > - Confirm and stay logged in - - <.button phx-disable-with="Confirming..." class="btn btn-primary btn-soft w-full mt-2"> - Confirm and log in only this time - - - - <.form - :if={@<%= schema.singular %>.confirmed_at} - for={@form} - id="login_form" - phx-submit="submit" - phx-mounted={JS.focus_first()} - action={~p"<%= schema.route_prefix %>/log-in"} - phx-trigger-action={@trigger_submit} - > - - <%%= if @<%= scope_config.scope.assign_key %> do %> - <.button phx-disable-with="Logging in..." class="btn btn-primary w-full"> - Log in - - <%% else %> - <.button - name={@form[:remember_me].name} - value="true" - phx-disable-with="Logging in..." - class="btn btn-primary w-full" - > - Keep me logged in on this device - - <.button phx-disable-with="Logging in..." class="btn btn-primary btn-soft w-full mt-2"> - Log me in only this time - - <%% end %> - - -

    .confirmed_at} class="alert alert-outline mt-8"> - Tip: If you prefer passwords, you can enable them in the <%= schema.singular %> settings. -

    -
    -
    - """ - end - - @impl true - def mount(%{"token" => token}, _session, socket) do - if <%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>_by_magic_link_token(token) do - form = to_form(%{"token" => token}, as: "<%= schema.singular %>") - - {:ok, assign(socket, <%= schema.singular %>: <%= schema.singular %>, form: form, trigger_submit: false), - temporary_assigns: [form: nil]} - else - {:ok, - socket - |> put_flash(:error, "Magic link is invalid or it has expired.") - |> push_navigate(to: ~p"<%= schema.route_prefix %>/log-in")} - end - end - - @impl true - def handle_event("submit", %{"<%= schema.singular %>" => params}, socket) do - {:noreply, assign(socket, form: to_form(params, as: "<%= schema.singular %>"), trigger_submit: true)} - end -end diff --git a/priv/templates/phx.gen.auth/confirmation_live_test.exs b/priv/templates/phx.gen.auth/confirmation_live_test.exs deleted file mode 100644 index f4dda1c..0000000 --- a/priv/templates/phx.gen.auth/confirmation_live_test.exs +++ /dev/null @@ -1,118 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.ConfirmationTest do - use <%= inspect context.web_module %>.ConnCase<%= test_case_options %> - - import Phoenix.LiveViewTest - import <%= inspect context.module %>Fixtures - - alias <%= inspect context.module %> - - setup do - %{unconfirmed_<%= schema.singular %>: unconfirmed_<%= schema.singular %>_fixture(), confirmed_<%= schema.singular %>: <%= schema.singular %>_fixture()} - end - - describe "Confirm <%= schema.singular %>" do - test "renders confirmation page for unconfirmed <%= schema.singular %>", %{conn: conn, unconfirmed_<%= schema.singular %>: <%= schema.singular %>} do - token = - extract_<%= schema.singular %>_token(fn url -> - <%= inspect context.alias %>.deliver_login_instructions(<%= schema.singular %>, url) - end) - - {:ok, _lv, html} = live(conn, ~p"<%= schema.route_prefix %>/log-in/#{token}") - assert html =~ "Confirm and stay logged in" - end - - test "renders login page for confirmed <%= schema.singular %>", %{conn: conn, confirmed_<%= schema.singular %>: <%= schema.singular %>} do - token = - extract_<%= schema.singular %>_token(fn url -> - <%= inspect context.alias %>.deliver_login_instructions(<%= schema.singular %>, url) - end) - - {:ok, _lv, html} = live(conn, ~p"<%= schema.route_prefix %>/log-in/#{token}") - refute html =~ "Confirm my account" - assert html =~ "Keep me logged in on this device" - end - - test "renders login page for already logged in <%= schema.singular %>", %{conn: conn, confirmed_<%= schema.singular %>: <%= schema.singular %>} do - conn = log_in_<%= schema.singular %>(conn, <%= schema.singular %>) - - token = - extract_<%= schema.singular %>_token(fn url -> - <%= inspect context.alias %>.deliver_login_instructions(<%= schema.singular %>, url) - end) - - {:ok, _lv, html} = live(conn, ~p"<%= schema.route_prefix %>/log-in/#{token}") - refute html =~ "Confirm my account" - assert html =~ "Log in" - end - - test "confirms the given token once", %{conn: conn, unconfirmed_<%= schema.singular %>: <%= schema.singular %>} do - token = - extract_<%= schema.singular %>_token(fn url -> - <%= inspect context.alias %>.deliver_login_instructions(<%= schema.singular %>, url) - end) - - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/log-in/#{token}") - - form = form(lv, "#confirmation_form", %{"<%= schema.singular %>" => %{"token" => token}}) - render_submit(form) - - conn = follow_trigger_action(form, conn) - - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ - "<%= inspect schema.alias %> confirmed successfully" - - assert <%= inspect context.alias %>.get_<%= schema.singular %>!(<%= schema.singular %>.id).confirmed_at - # we are logged in now - assert get_session(conn, :<%= schema.singular %>_token) - assert redirected_to(conn) == ~p"/" - - # log out, new conn - conn = build_conn() - - {:ok, _lv, html} = - live(conn, ~p"<%= schema.route_prefix %>/log-in/#{token}") - |> follow_redirect(conn, ~p"<%= schema.route_prefix %>/log-in") - - assert html =~ "Magic link is invalid or it has expired" - end - - test "logs confirmed <%= schema.singular %> in without changing confirmed_at", %{ - conn: conn, - confirmed_<%= schema.singular %>: <%= schema.singular %> - } do - token = - extract_<%= schema.singular %>_token(fn url -> - <%= inspect context.alias %>.deliver_login_instructions(<%= schema.singular %>, url) - end) - - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/log-in/#{token}") - - form = form(lv, "#login_form", %{"<%= schema.singular %>" => %{"token" => token}}) - render_submit(form) - - conn = follow_trigger_action(form, conn) - - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ - "Welcome back!" - - assert <%= inspect context.alias %>.get_<%= schema.singular %>!(<%= schema.singular %>.id).confirmed_at == <%= schema.singular %>.confirmed_at - - # log out, new conn - conn = build_conn() - - {:ok, _lv, html} = - live(conn, ~p"<%= schema.route_prefix %>/log-in/#{token}") - |> follow_redirect(conn, ~p"<%= schema.route_prefix %>/log-in") - - assert html =~ "Magic link is invalid or it has expired" - end - - test "raises error for invalid token", %{conn: conn} do - {:ok, _lv, html} = - live(conn, ~p"<%= schema.route_prefix %>/log-in/invalid-token") - |> follow_redirect(conn, ~p"<%= schema.route_prefix %>/log-in") - - assert html =~ "Magic link is invalid or it has expired" - end - end -end diff --git a/priv/templates/phx.gen.auth/conn_case.exs b/priv/templates/phx.gen.auth/conn_case.exs deleted file mode 100644 index de10dd7..0000000 --- a/priv/templates/phx.gen.auth/conn_case.exs +++ /dev/null @@ -1,41 +0,0 @@ - - @doc """ - Setup helper that registers and logs in <%= schema.plural %>. - - setup :register_and_log_in_<%= schema.singular %> - - It stores an updated connection and a registered <%= schema.singular %> in the - test context. - """ - def register_and_log_in_<%= schema.singular %>(%{conn: conn} = context) do - <%= schema.singular %> = <%= inspect context.module %>Fixtures.<%= schema.singular %>_fixture() - scope = <%= inspect scope_config.scope.module %>.for_<%= schema.singular %>(<%= schema.singular %>) - - opts = - context - |> Map.take([:token_authenticated_at]) - |> Enum.into([]) - - %{conn: log_in_<%= schema.singular %>(conn, <%= schema.singular %>, opts), <%= schema.singular %>: <%= schema.singular %>, scope: scope} - end - - @doc """ - Logs the given `<%= schema.singular %>` into the `conn`. - - It returns an updated `conn`. - """ - def log_in_<%= schema.singular %>(conn, <%= schema.singular %>, opts \\ []) do - token = <%= inspect context.module %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>) - - maybe_set_token_authenticated_at(token, opts[:token_authenticated_at]) - - conn - |> Phoenix.ConnTest.init_test_session(%{}) - |> Plug.Conn.put_session(:<%= schema.singular %>_token, token) - end - - defp maybe_set_token_authenticated_at(_token, nil), do: nil - - defp maybe_set_token_authenticated_at(token, authenticated_at) do - <%= inspect context.module %>Fixtures.override_token_authenticated_at(token, authenticated_at) - end diff --git a/priv/templates/phx.gen.auth/context_fixtures_functions.ex b/priv/templates/phx.gen.auth/context_fixtures_functions.ex deleted file mode 100644 index 290e226..0000000 --- a/priv/templates/phx.gen.auth/context_fixtures_functions.ex +++ /dev/null @@ -1,82 +0,0 @@ - import Ecto.Query - - alias <%= inspect context.module %> - alias <%= inspect scope_config.scope.module %> - - def unique_<%= schema.singular %>_email, do: "<%= schema.singular %>#{System.unique_integer()}@example.com" - def valid_<%= schema.singular %>_password, do: "hello world!" - - def valid_<%= schema.singular %>_attributes(attrs \\ %{}) do - Enum.into(attrs, %{ - email: unique_<%= schema.singular %>_email() - }) - end - - def unconfirmed_<%= schema.singular %>_fixture(attrs \\ %{}) do - {:ok, <%= schema.singular %>} = - attrs - |> valid_<%= schema.singular %>_attributes() - |> <%= inspect context.alias %>.register_<%= schema.singular %>() - - <%= schema.singular %> - end - - def <%= schema.singular %>_fixture(attrs \\ %{}) do - <%= schema.singular %> = unconfirmed_<%= schema.singular %>_fixture(attrs) - - token = - extract_<%= schema.singular %>_token(fn url -> - <%= inspect context.alias %>.deliver_login_instructions(<%= schema.singular %>, url) - end) - - {:ok, {<%= schema.singular %>, _expired_tokens}} = - <%= inspect context.alias %>.login_<%= schema.singular %>_by_magic_link(token) - - <%= schema.singular %> - end - - def <%= schema.singular %>_scope_fixture do - <%= schema.singular %> = <%= schema.singular %>_fixture() - <%= schema.singular %>_scope_fixture(<%= schema.singular %>) - end - - def <%= schema.singular %>_scope_fixture(<%= schema.singular %>) do - <%= inspect scope_config.scope.alias %>.for_<%= schema.singular %>(<%= schema.singular %>) - end - - def set_password(<%= schema.singular %>) do - {:ok, {<%= schema.singular %>, _expired_tokens}} = - <%= inspect context.alias %>.update_<%= schema.singular %>_password(<%= schema.singular %>, %{password: valid_<%= schema.singular %>_password()}) - - <%= schema.singular %> - end - - def extract_<%= schema.singular %>_token(fun) do - {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]") - [_, token | _] = String.split(captured_email.text_body, "[TOKEN]") - token - end - - def override_token_authenticated_at(token, authenticated_at) when is_binary(token) do - <%= inspect schema.repo %>.update_all( - from(t in <%= inspect context.alias %>.<%= inspect schema.alias %>Token, - where: t.token == ^token - ), - set: [authenticated_at: authenticated_at] - ) - end - - def generate_<%= schema.singular %>_magic_link_token(<%= schema.singular %>) do - {encoded_token, <%= schema.singular %>_token} = <%= inspect context.alias %>.<%= inspect schema.alias %>Token.build_email_token(<%= schema.singular %>, "login") - <%= inspect schema.repo %>.insert!(<%= schema.singular %>_token) - {encoded_token, <%= schema.singular %>_token.token} - end - - def offset_<%= schema.singular %>_token(token, amount_to_add, unit) do - dt = <%= inspect datetime_module %>.add(<%= datetime_now %>, amount_to_add, unit) - - <%= inspect schema.repo %>.update_all( - from(ut in <%= inspect context.alias %>.<%= inspect schema.alias %>Token, where: ut.token == ^token), - set: [inserted_at: dt, authenticated_at: dt] - ) - end diff --git a/priv/templates/phx.gen.auth/context_functions.ex b/priv/templates/phx.gen.auth/context_functions.ex deleted file mode 100644 index 1e96da5..0000000 --- a/priv/templates/phx.gen.auth/context_functions.ex +++ /dev/null @@ -1,288 +0,0 @@ - alias <%= inspect context.module %>.{<%= inspect schema.alias %>, <%= inspect schema.alias %>Token, <%= inspect schema.alias %>Notifier} - - ## Database getters - - @doc """ - Gets a <%= schema.singular %> by email. - - ## Examples - - iex> get_<%= schema.singular %>_by_email("foo@example.com") - %<%= inspect schema.alias %>{} - - iex> get_<%= schema.singular %>_by_email("unknown@example.com") - nil - - """ - def get_<%= schema.singular %>_by_email(email) when is_binary(email) do - Repo.get_by(<%= inspect schema.alias %>, email: email) - end - - @doc """ - Gets a <%= schema.singular %> by email and password. - - ## Examples - - iex> get_<%= schema.singular %>_by_email_and_password("foo@example.com", "correct_password") - %<%= inspect schema.alias %>{} - - iex> get_<%= schema.singular %>_by_email_and_password("foo@example.com", "invalid_password") - nil - - """ - def get_<%= schema.singular %>_by_email_and_password(email, password) - when is_binary(email) and is_binary(password) do - <%= schema.singular %> = Repo.get_by(<%= inspect schema.alias %>, email: email) - if <%= inspect schema.alias %>.valid_password?(<%= schema.singular %>, password), do: <%= schema.singular %> - end - - @doc """ - Gets a single <%= schema.singular %>. - - Raises `Ecto.NoResultsError` if the <%= inspect schema.alias %> does not exist. - - ## Examples - - iex> get_<%= schema.singular %>!(123) - %<%= inspect schema.alias %>{} - - iex> get_<%= schema.singular %>!(456) - ** (Ecto.NoResultsError) - - """ - def get_<%= schema.singular %>!(id), do: Repo.get!(<%= inspect schema.alias %>, id) - - ## <%= schema.human_singular %> registration - - @doc """ - Registers a <%= schema.singular %>. - - ## Examples - - iex> register_<%= schema.singular %>(%{field: value}) - {:ok, %<%= inspect schema.alias %>{}} - - iex> register_<%= schema.singular %>(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def register_<%= schema.singular %>(attrs) do - %<%= inspect schema.alias %>{} - |> <%= inspect schema.alias %>.email_changeset(attrs) - |> Repo.insert() - end - - ## Settings - - @doc """ - Checks whether the <%= schema.singular %> is in sudo mode. - - The <%= schema.singular %> is in sudo mode when the last authentication was done no further - than 20 minutes ago. The limit can be given as second argument in minutes. - """ - def sudo_mode?(<%= schema.singular %>, minutes \\ -20) - - def sudo_mode?(%<%= inspect schema.alias %>{authenticated_at: ts}, minutes) when is_struct(ts, <%= inspect datetime_module %>) do - <%= inspect datetime_module %>.after?(ts, <%= inspect datetime_module %>.utc_now() |> <%= inspect datetime_module %>.add(minutes, :minute)) - end - - def sudo_mode?(_<%= schema.singular %>, _minutes), do: false - - @doc """ - Returns an `%Ecto.Changeset{}` for changing the <%= schema.singular %> email. - - See `<%= inspect context.module %>.<%= inspect schema.alias %>.email_changeset/3` for a list of supported options. - - ## Examples - - iex> change_<%= schema.singular %>_email(<%= schema.singular %>) - %Ecto.Changeset{data: %<%= inspect schema.alias %>{}} - - """ - def change_<%= schema.singular %>_email(<%= schema.singular %>, attrs \\ %{}, opts \\ []) do - <%= inspect schema.alias %>.email_changeset(<%= schema.singular %>, attrs, opts) - end - - @doc """ - Updates the <%= schema.singular %> email using the given token. - - If the token matches, the <%= schema.singular %> email is updated and the token is deleted. - """ - def update_<%= schema.singular %>_email(<%= schema.singular %>, token) do - context = "change:#{<%= schema.singular %>.email}" - - Repo.transact(fn -> - with {:ok, query} <- <%= inspect schema.alias %>Token.verify_change_email_token_query(token, context), - %<%= inspect schema.alias %>Token{sent_to: email} <- Repo.one(query), - {:ok, <%= schema.singular %>} <- Repo.update(<%= inspect schema.alias %>.email_changeset(<%= schema.singular %>, %{email: email})), - {_count, _result} <- - Repo.delete_all(from(<%= inspect schema.alias %>Token, where: [<%= schema.singular %>_id: ^<%= schema.singular %>.id, context: ^context])) do - {:ok, <%= schema.singular %>} - else - _ -> {:error, :transaction_aborted} - end - end) - end - - @doc """ - Returns an `%Ecto.Changeset{}` for changing the <%= schema.singular %> password. - - See `<%= inspect context.module %>.<%= inspect schema.alias %>.password_changeset/3` for a list of supported options. - - ## Examples - - iex> change_<%= schema.singular %>_password(<%= schema.singular %>) - %Ecto.Changeset{data: %<%= inspect schema.alias %>{}} - - """ - def change_<%= schema.singular %>_password(<%= schema.singular %>, attrs \\ %{}, opts \\ []) do - <%= inspect schema.alias %>.password_changeset(<%= schema.singular %>, attrs, opts) - end - - @doc """ - Updates the <%= schema.singular %> password. - - Returns a tuple with the updated <%= schema.singular %>, as well as a list of expired tokens. - - ## Examples - - iex> update_<%= schema.singular %>_password(<%= schema.singular %>, %{password: ...}) - {:ok, {%<%= inspect schema.alias %>{}, [...]}} - - iex> update_<%= schema.singular %>_password(<%= schema.singular %>, %{password: "too short"}) - {:error, %Ecto.Changeset{}} - - """ - def update_<%= schema.singular %>_password(<%= schema.singular %>, attrs) do - <%= schema.singular %> - |> <%= inspect schema.alias %>.password_changeset(attrs) - |> update_<%= schema.singular %>_and_delete_all_tokens() - end - - ## Session - - @doc """ - Generates a session token. - """ - def generate_<%= schema.singular %>_session_token(<%= schema.singular %>) do - {token, <%= schema.singular %>_token} = <%= inspect schema.alias %>Token.build_session_token(<%= schema.singular %>) - Repo.insert!(<%= schema.singular %>_token) - token - end - - @doc """ - Gets the <%= schema.singular %> with the given signed token. - - If the token is valid `{<%= schema.singular %>, token_inserted_at}` is returned, otherwise `nil` is returned. - """ - def get_<%= schema.singular %>_by_session_token(token) do - {:ok, query} = <%= inspect schema.alias %>Token.verify_session_token_query(token) - Repo.one(query) - end - - @doc """ - Gets the <%= schema.singular %> with the given magic link token. - """ - def get_<%= schema.singular %>_by_magic_link_token(token) do - with {:ok, query} <- <%= inspect schema.alias %>Token.verify_magic_link_token_query(token), - {<%= schema.singular %>, _token} <- Repo.one(query) do - <%= schema.singular %> - else - _ -> nil - end - end - - @doc """ - Logs the <%= schema.singular %> in by magic link. - - There are three cases to consider: - - 1. The <%= schema.singular %> has already confirmed their email. They are logged in - and the magic link is expired. - - 2. The <%= schema.singular %> has not confirmed their email and no password is set. - In this case, the <%= schema.singular %> gets confirmed, logged in, and all tokens - - including session ones - are expired. In theory, no other tokens - exist but we delete all of them for best security practices. - - 3. The <%= schema.singular %> has not confirmed their email but a password is set. - This cannot happen in the default implementation but may be the - source of security pitfalls. See the "Mixing magic link and password registration" section of - `mix help phx.gen.auth`. - """ - def login_<%= schema.singular %>_by_magic_link(token) do - {:ok, query} = <%= inspect schema.alias %>Token.verify_magic_link_token_query(token) - - case Repo.one(query) do - # Prevent session fixation attacks by disallowing magic links for unconfirmed users with password - {%<%= inspect schema.alias %>{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) -> - raise """ - magic link log in is not allowed for unconfirmed users with a password set! - - This cannot happen with the default implementation, which indicates that you - might have adapted the code to a different use case. Please make sure to read the - "Mixing magic link and password registration" section of `mix help phx.gen.auth`. - """ - - {%<%= inspect schema.alias %>{confirmed_at: nil} = <%= schema.singular %>, _token} -> - <%= schema.singular %> - |> <%= inspect schema.alias %>.confirm_changeset() - |> update_<%= schema.singular %>_and_delete_all_tokens() - - {<%= schema.singular %>, token} -> - Repo.delete!(token) - {:ok, {<%= schema.singular %>, []}} - - nil -> - {:error, :not_found} - end - end - - @doc ~S""" - Delivers the update email instructions to the given <%= schema.singular %>. - - ## Examples - - iex> deliver_<%= schema.singular %>_update_email_instructions(<%= schema.singular %>, current_email, &url(~p"<%= schema.route_prefix %>/settings/confirm-email/#{&1}")) - {:ok, %{to: ..., body: ...}} - - """ - def deliver_<%= schema.singular %>_update_email_instructions(%<%= inspect schema.alias %>{} = <%= schema.singular %>, current_email, update_email_url_fun) - when is_function(update_email_url_fun, 1) do - {encoded_token, <%= schema.singular %>_token} = <%= inspect schema.alias %>Token.build_email_token(<%= schema.singular %>, "change:#{current_email}") - - Repo.insert!(<%= schema.singular %>_token) - <%= inspect schema.alias %>Notifier.deliver_update_email_instructions(<%= schema.singular %>, update_email_url_fun.(encoded_token)) - end - - @doc """ - Delivers the magic link login instructions to the given <%= schema.singular %>. - """ - def deliver_login_instructions(%<%= inspect schema.alias %>{} = <%= schema.singular %>, magic_link_url_fun) - when is_function(magic_link_url_fun, 1) do - {encoded_token, <%= schema.singular %>_token} = <%= inspect schema.alias %>Token.build_email_token(<%= schema.singular %>, "login") - Repo.insert!(<%= schema.singular %>_token) - <%= inspect schema.alias %>Notifier.deliver_login_instructions(<%= schema.singular %>, magic_link_url_fun.(encoded_token)) - end - - @doc """ - Deletes the signed token with the given context. - """ - def delete_<%= schema.singular %>_session_token(token) do - Repo.delete_all(from(<%= inspect schema.alias %>Token, where: [token: ^token, context: "session"])) - :ok - end - - ## Token helper - - defp update_<%= schema.singular %>_and_delete_all_tokens(changeset) do - Repo.transact(fn -> - with {:ok, <%= schema.singular %>} <- Repo.update(changeset) do - tokens_to_expire = Repo.all_by(<%= inspect schema.alias %>Token, <%= schema.singular %>_id: <%= schema.singular %>.id) - - Repo.delete_all(from(t in <%= inspect schema.alias %>Token, where: t.id in ^Enum.map(tokens_to_expire, & &1.id))) - - {:ok, {<%= schema.singular %>, tokens_to_expire}} - end - end) - end diff --git a/priv/templates/phx.gen.auth/login_live.ex b/priv/templates/phx.gen.auth/login_live.ex deleted file mode 100644 index 84afc3f..0000000 --- a/priv/templates/phx.gen.auth/login_live.ex +++ /dev/null @@ -1,134 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.Login do - use <%= inspect context.web_module %>, :live_view - - alias <%= inspect context.module %> - - @impl true - def render(assigns) do - ~H""" - ={@<%= scope_config.scope.assign_key %>}> -
    -
    - <.header> -

    Log in

    - <:subtitle> - <%%= if @<%= scope_config.scope.assign_key %> do %> - You need to reauthenticate to perform sensitive actions on your account. - <%% else %> - Don't have an account? <.link - navigate={~p"<%= schema.route_prefix %>/register"} - class="font-semibold text-brand hover:underline" - phx-no-format - >Sign up for an account now. - <%% end %> - - -
    - -
    - <.icon name="hero-information-circle" class="size-6 shrink-0" /> -
    -

    You are running the local mail adapter.

    -

    - To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page. -

    -
    -
    - - <.form - :let={f} - for={@form} - id="login_form_magic" - action={~p"<%= schema.route_prefix %>/log-in"} - phx-submit="submit_magic" - > - <.input - readonly={!!@<%= scope_config.scope.assign_key %>} - field={f[:email]} - type="email" - label="Email" - autocomplete="username" - spellcheck="false" - required - phx-mounted={JS.focus()} - /> - <.button class="btn btn-primary w-full"> - Log in with email - - - -
    or
    - - <.form - :let={f} - for={@form} - id="login_form_password" - action={~p"<%= schema.route_prefix %>/log-in"} - phx-submit="submit_password" - phx-trigger-action={@trigger_submit} - > - <.input - readonly={!!@<%= scope_config.scope.assign_key %>} - field={f[:email]} - type="email" - label="Email" - autocomplete="username" - spellcheck="false" - required - /> - <.input - field={@form[:password]} - type="password" - label="Password" - autocomplete="current-password" - spellcheck="false" - /> - <.button class="btn btn-primary w-full" name={@form[:remember_me].name} value="true"> - Log in and stay logged in - - <.button class="btn btn-primary btn-soft w-full mt-2"> - Log in only this time - - -
    -
    - """ - end - - @impl true - def mount(_params, _session, socket) do - email = - Phoenix.Flash.get(socket.assigns.flash, :email) || - get_in(socket.assigns, [:<%= scope_config.scope.assign_key %>, Access.key(:<%= schema.singular %>), Access.key(:email)]) - - form = to_form(%{"email" => email}, as: "<%= schema.singular %>") - - {:ok, assign(socket, form: form, trigger_submit: false)} - end - - @impl true - def handle_event("submit_password", _params, socket) do - {:noreply, assign(socket, :trigger_submit, true)} - end - - def handle_event("submit_magic", %{"<%= schema.singular %>" => %{"email" => email}}, socket) do - if <%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>_by_email(email) do - <%= inspect context.alias %>.deliver_login_instructions( - <%= schema.singular %>, - &url(~p"<%= schema.route_prefix %>/log-in/#{&1}") - ) - end - - info = - "If your email is in our system, you will receive instructions for logging in shortly." - - {:noreply, - socket - |> put_flash(:info, info) - |> push_navigate(to: ~p"<%= schema.route_prefix %>/log-in")} - end - - defp local_mail_adapter? do - Application.get_env(:<%= Mix.Phoenix.otp_app() %>, <%= inspect context.base_module %>.Mailer)[:adapter] == Swoosh.Adapters.Local - end -end diff --git a/priv/templates/phx.gen.auth/login_live_test.exs b/priv/templates/phx.gen.auth/login_live_test.exs deleted file mode 100644 index 4624042..0000000 --- a/priv/templates/phx.gen.auth/login_live_test.exs +++ /dev/null @@ -1,109 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.LoginTest do - use <%= inspect context.web_module %>.ConnCase<%= test_case_options %> - - import Phoenix.LiveViewTest - import <%= inspect context.module %>Fixtures - - describe "login page" do - test "renders login page", %{conn: conn} do - {:ok, _lv, html} = live(conn, ~p"<%= schema.route_prefix %>/log-in") - - assert html =~ "Log in" - assert html =~ "Register" - assert html =~ "Log in with email" - end - end - - describe "<%= schema.singular %> login - magic link" do - test "sends magic link email when <%= schema.singular %> exists", %{conn: conn} do - <%= schema.singular %> = <%= schema.singular %>_fixture() - - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/log-in") - - {:ok, _lv, html} = - form(lv, "#login_form_magic", <%= schema.singular %>: %{email: <%= schema.singular %>.email}) - |> render_submit() - |> follow_redirect(conn, ~p"<%= schema.route_prefix %>/log-in") - - assert html =~ "If your email is in our system" - - assert <%= inspect schema.repo %>.get_by!(<%= inspect context.module %>.<%= inspect schema.alias %>Token, <%= schema.singular %>_id: <%= schema.singular %>.id).context == - "login" - end - - test "does not disclose if <%= schema.singular %> is registered", %{conn: conn} do - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/log-in") - - {:ok, _lv, html} = - form(lv, "#login_form_magic", <%= schema.singular %>: %{email: "idonotexist@example.com"}) - |> render_submit() - |> follow_redirect(conn, ~p"<%= schema.route_prefix %>/log-in") - - assert html =~ "If your email is in our system" - end - end - - describe "<%= schema.singular %> login - password" do - test "redirects if <%= schema.singular %> logs in with valid credentials", %{conn: conn} do - <%= schema.singular %> = <%= schema.singular %>_fixture() |> set_password() - - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/log-in") - - form = - form(lv, "#login_form_password", - <%= schema.singular %>: %{email: <%= schema.singular %>.email, password: valid_<%= schema.singular %>_password(), remember_me: true} - ) - - conn = submit_form(form, conn) - - assert redirected_to(conn) == ~p"/" - end - - test "redirects to login page with a flash error if credentials are invalid", %{ - conn: conn - } do - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/log-in") - - form = - form(lv, "#login_form_password", <%= schema.singular %>: %{email: "test@email.com", password: "123456"}) - - render_submit(form, %{user: %{remember_me: true}}) - - conn = follow_trigger_action(form, conn) - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" - assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/log-in" - end - end - - describe "login navigation" do - test "redirects to registration page when the Register button is clicked", %{conn: conn} do - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/log-in") - - {:ok, _login_live, login_html} = - lv - |> element("main a", "Sign up") - |> render_click() - |> follow_redirect(conn, ~p"<%= schema.route_prefix %>/register") - - assert login_html =~ "Register" - end - end - - describe "re-authentication (sudo mode)" do - setup %{conn: conn} do - <%= schema.singular %> = <%= schema.singular %>_fixture() - %{<%= schema.singular %>: <%= schema.singular %>, conn: log_in_<%= schema.singular %>(conn, <%= schema.singular %>)} - end - - test "shows login page with email filled in", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - {:ok, _lv, html} = live(conn, ~p"<%= schema.route_prefix %>/log-in") - - assert html =~ "You need to reauthenticate" - refute html =~ "Register" - assert html =~ "Log in with email" - - assert html =~ - ~s(.Migrations.Create<%= Macro.camelize(schema.table) %>AuthTables do - use Ecto.Migration - - def change do<%= if Enum.any?(migration.extensions) do %><%= for extension <- migration.extensions do %> - <%= extension %><% end %> -<% end %> - create table(:<%= schema.table %><%= if schema.binary_id do %>, primary_key: false<% end %>) do -<%= if schema.binary_id do %> add :id, :binary_id, primary_key: true -<% end %> <%= migration.column_definitions[:email] %> - add :hashed_password, :string - add :confirmed_at, <%= inspect schema.timestamp_type %> - - timestamps(<%= if schema.timestamp_type != :naive_datetime, do: "type: #{inspect schema.timestamp_type}" %>) - end - - create unique_index(:<%= schema.table %>, [:email]) - - create table(:<%= schema.table %>_tokens<%= if schema.binary_id do %>, primary_key: false<% end %>) do -<%= if schema.binary_id do %> add :id, :binary_id, primary_key: true -<% end %> add :<%= schema.singular %>_id, references(:<%= schema.table %>, <%= if schema.binary_id do %>type: :binary_id, <% end %>on_delete: :delete_all), null: false - <%= migration.column_definitions[:token] %> - add :context, :string, null: false - add :sent_to, :string - add :authenticated_at, <%= inspect schema.timestamp_type %> - - timestamps(<%= if schema.timestamp_type != :naive_datetime, do: "type: #{inspect schema.timestamp_type}, " %>updated_at: false) - end - - create index(:<%= schema.table %>_tokens, [:<%= schema.singular %>_id]) - create unique_index(:<%= schema.table %>_tokens, [:context, :token]) - end -end diff --git a/priv/templates/phx.gen.auth/notifier.ex b/priv/templates/phx.gen.auth/notifier.ex deleted file mode 100644 index a74a96f..0000000 --- a/priv/templates/phx.gen.auth/notifier.ex +++ /dev/null @@ -1,84 +0,0 @@ -defmodule <%= inspect context.module %>.<%= inspect schema.alias %>Notifier do - import Swoosh.Email - - alias <%= inspect context.base_module %>.Mailer - alias <%= inspect context.module %>.<%= inspect schema.alias %> - - # Delivers the email using the application mailer. - defp deliver(recipient, subject, body) do - email = - new() - |> to(recipient) - |> from({"<%= inspect context.base_module %>", "contact@example.com"}) - |> subject(subject) - |> text_body(body) - - with {:ok, _metadata} <- Mailer.deliver(email) do - {:ok, email} - end - end - - @doc """ - Deliver instructions to update a <%= schema.singular %> email. - """ - def deliver_update_email_instructions(<%= schema.singular %>, url) do - deliver(<%= schema.singular %>.email, "Update email instructions", """ - - ============================== - - Hi #{<%= schema.singular %>.email}, - - You can change your email by visiting the URL below: - - #{url} - - If you didn't request this change, please ignore this. - - ============================== - """) - end - - @doc """ - Deliver instructions to log in with a magic link. - """ - def deliver_login_instructions(<%= schema.singular %>, url) do - case <%= schema.singular %> do - %<%= inspect schema.alias %>{confirmed_at: nil} -> deliver_confirmation_instructions(<%= schema.singular %>, url) - _ -> deliver_magic_link_instructions(<%= schema.singular %>, url) - end - end - - defp deliver_magic_link_instructions(<%= schema.singular %>, url) do - deliver(<%= schema.singular %>.email, "Log in instructions", """ - - ============================== - - Hi #{<%= schema.singular %>.email}, - - You can log into your account by visiting the URL below: - - #{url} - - If you didn't request this email, please ignore this. - - ============================== - """) - end - - defp deliver_confirmation_instructions(<%= schema.singular %>, url) do - deliver(<%= schema.singular %>.email, "Confirmation instructions", """ - - ============================== - - Hi #{<%= schema.singular %>.email}, - - You can confirm your account by visiting the URL below: - - #{url} - - If you didn't create an account with us, please ignore this. - - ============================== - """) - end -end diff --git a/priv/templates/phx.gen.auth/registration_controller.ex b/priv/templates/phx.gen.auth/registration_controller.ex deleted file mode 100644 index eb361e4..0000000 --- a/priv/templates/phx.gen.auth/registration_controller.ex +++ /dev/null @@ -1,32 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>RegistrationController do - use <%= inspect context.web_module %>, :controller - - alias <%= inspect context.module %> - alias <%= inspect schema.module %> - - def new(conn, _params) do - changeset = <%= inspect context.alias %>.change_<%= schema.singular %>_email(%<%= inspect schema.alias %>{}) - render(conn, :new, changeset: changeset) - end - - def create(conn, %{"<%= schema.singular %>" => <%= schema.singular %>_params}) do - case <%= inspect context.alias %>.register_<%= schema.singular %>(<%= schema.singular %>_params) do - {:ok, <%= schema.singular %>} -> - {:ok, _} = - <%= inspect context.alias %>.deliver_login_instructions( - <%= schema.singular %>, - &url(~p"<%= schema.route_prefix %>/log-in/#{&1}") - ) - - conn - |> put_flash( - :info, - "An email was sent to #{<%= schema.singular %>.email}, please access it to confirm your account." - ) - |> redirect(to: ~p"<%= schema.route_prefix %>/log-in") - - {:error, %Ecto.Changeset{} = changeset} -> - render(conn, :new, changeset: changeset) - end - end -end diff --git a/priv/templates/phx.gen.auth/registration_controller_test.exs b/priv/templates/phx.gen.auth/registration_controller_test.exs deleted file mode 100644 index 62db2c6..0000000 --- a/priv/templates/phx.gen.auth/registration_controller_test.exs +++ /dev/null @@ -1,50 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>RegistrationControllerTest do - use <%= inspect context.web_module %>.ConnCase<%= test_case_options %> - - import <%= inspect context.module %>Fixtures - - describe "GET <%= schema.route_prefix %>/register" do - test "renders registration page", %{conn: conn} do - conn = get(conn, ~p"<%= schema.route_prefix %>/register") - response = html_response(conn, 200) - assert response =~ "Register" - assert response =~ ~p"<%= schema.route_prefix %>/log-in" - assert response =~ ~p"<%= schema.route_prefix %>/register" - end - - test "redirects if already logged in", %{conn: conn} do - conn = conn |> log_in_<%= schema.singular %>(<%= schema.singular %>_fixture()) |> get(~p"<%= schema.route_prefix %>/register") - - assert redirected_to(conn) == ~p"/" - end - end - - describe "POST <%= schema.route_prefix %>/register" do - @tag :capture_log - test "creates account but does not log in", %{conn: conn} do - email = unique_<%= schema.singular %>_email() - - conn = - post(conn, ~p"<%= schema.route_prefix %>/register", %{ - "<%= schema.singular %>" => valid_<%= schema.singular %>_attributes(email: email) - }) - - refute get_session(conn, :<%= schema.singular %>_token) - assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/log-in" - - assert conn.assigns.flash["info"] =~ - ~r/An email was sent to .*, please access it to confirm your account/ - end - - test "render errors for invalid data", %{conn: conn} do - conn = - post(conn, ~p"<%= schema.route_prefix %>/register", %{ - "<%= schema.singular %>" => %{"email" => "with spaces"} - }) - - response = html_response(conn, 200) - assert response =~ "Register" - assert response =~ "must have the @ sign and no spaces" - end - end -end diff --git a/priv/templates/phx.gen.auth/registration_html.ex b/priv/templates/phx.gen.auth/registration_html.ex deleted file mode 100644 index 72d9bd4..0000000 --- a/priv/templates/phx.gen.auth/registration_html.ex +++ /dev/null @@ -1,5 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>RegistrationHTML do - use <%= inspect context.web_module %>, :html - - embed_templates "<%= schema.singular %>_registration_html/*" -end diff --git a/priv/templates/phx.gen.auth/registration_live.ex b/priv/templates/phx.gen.auth/registration_live.ex deleted file mode 100644 index 53490c4..0000000 --- a/priv/templates/phx.gen.auth/registration_live.ex +++ /dev/null @@ -1,89 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.Registration do - use <%= inspect context.web_module %>, :live_view - - alias <%= inspect context.module %> - alias <%= inspect schema.module %> - - @impl true - def render(assigns) do - ~H""" - ={@<%= scope_config.scope.assign_key %>}> -
    -
    - <.header> - Register for an account - <:subtitle> - Already registered? - <.link navigate={~p"<%= schema.route_prefix %>/log-in"} class="font-semibold text-brand hover:underline"> - Log in - - to your account now. - - -
    - - <.form for={@form} id="registration_form" phx-submit="save" phx-change="validate"> - <.input - field={@form[:email]} - type="email" - label="Email" - autocomplete="username" - spellcheck="false" - required - phx-mounted={JS.focus()} - /> - - <.button phx-disable-with="Creating account..." class="btn btn-primary w-full"> - Create an account - - -
    -
    - """ - end - - @impl true - def mount(_params, _session, %{assigns: %{<%= scope_config.scope.assign_key %>: %{<%= schema.singular %>: <%= schema.singular %>}}} = socket) - when not is_nil(<%= schema.singular %>) do - {:ok, redirect(socket, to: <%= inspect auth_module %>.signed_in_path(socket))} - end - - def mount(_params, _session, socket) do - changeset = <%= inspect context.alias %>.change_<%= schema.singular %>_email(%<%= inspect schema.alias %>{}, %{}, validate_unique: false) - - {:ok, assign_form(socket, changeset), temporary_assigns: [form: nil]} - end - - @impl true - def handle_event("save", %{"<%= schema.singular %>" => <%= schema.singular %>_params}, socket) do - case <%= inspect context.alias %>.register_<%= schema.singular %>(<%= schema.singular %>_params) do - {:ok, <%= schema.singular %>} -> - {:ok, _} = - <%= inspect context.alias %>.deliver_login_instructions( - <%= schema.singular %>, - &url(~p"<%= schema.route_prefix %>/log-in/#{&1}") - ) - - {:noreply, - socket - |> put_flash( - :info, - "An email was sent to #{<%= schema.singular %>.email}, please access it to confirm your account." - ) - |> push_navigate(to: ~p"<%= schema.route_prefix %>/log-in")} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign_form(socket, changeset)} - end - end - - def handle_event("validate", %{"<%= schema.singular %>" => <%= schema.singular %>_params}, socket) do - changeset = <%= inspect context.alias %>.change_<%= schema.singular %>_email(%<%= inspect schema.alias %>{}, <%= schema.singular %>_params, validate_unique: false) - {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))} - end - - defp assign_form(socket, %Ecto.Changeset{} = changeset) do - form = to_form(changeset, as: "<%= schema.singular %>") - assign(socket, form: form) - end -end diff --git a/priv/templates/phx.gen.auth/registration_live_test.exs b/priv/templates/phx.gen.auth/registration_live_test.exs deleted file mode 100644 index 104d82b..0000000 --- a/priv/templates/phx.gen.auth/registration_live_test.exs +++ /dev/null @@ -1,82 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.RegistrationTest do - use <%= inspect context.web_module %>.ConnCase<%= test_case_options %> - - import Phoenix.LiveViewTest - import <%= inspect context.module %>Fixtures - - describe "Registration page" do - test "renders registration page", %{conn: conn} do - {:ok, _lv, html} = live(conn, ~p"<%= schema.route_prefix %>/register") - - assert html =~ "Register" - assert html =~ "Log in" - end - - test "redirects if already logged in", %{conn: conn} do - result = - conn - |> log_in_<%= schema.singular %>(<%= schema.singular %>_fixture()) - |> live(~p"<%= schema.route_prefix %>/register") - |> follow_redirect(conn, ~p"/") - - assert {:ok, _conn} = result - end - - test "renders errors for invalid data", %{conn: conn} do - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/register") - - result = - lv - |> element("#registration_form") - |> render_change(<%= schema.singular %>: %{"email" => "with spaces"}) - - assert result =~ "Register" - assert result =~ "must have the @ sign and no spaces" - end - end - - describe "register <%= schema.singular %>" do - test "creates account but does not log in", %{conn: conn} do - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/register") - - email = unique_<%= schema.singular %>_email() - form = form(lv, "#registration_form", <%= schema.singular %>: valid_<%= schema.singular %>_attributes(email: email)) - - {:ok, _lv, html} = - render_submit(form) - |> follow_redirect(conn, ~p"<%= schema.route_prefix %>/log-in") - - assert html =~ - ~r/An email was sent to .*, please access it to confirm your account/ - end - - test "renders errors for duplicated email", %{conn: conn} do - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/register") - - <%= schema.singular %> = <%= schema.singular %>_fixture(%{email: "test@email.com"}) - - result = - lv - |> form("#registration_form", - <%= schema.singular %>: %{"email" => <%= schema.singular %>.email} - ) - |> render_submit() - - assert result =~ "has already been taken" - end - end - - describe "registration navigation" do - test "redirects to login page when the Log in button is clicked", %{conn: conn} do - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/register") - - {:ok, _login_live, login_html} = - lv - |> element("main a", "Log in") - |> render_click() - |> follow_redirect(conn, ~p"<%= schema.route_prefix %>/log-in") - - assert login_html =~ "Log in" - end - end -end diff --git a/priv/templates/phx.gen.auth/registration_new.html.heex b/priv/templates/phx.gen.auth/registration_new.html.heex deleted file mode 100644 index 1bf901e..0000000 --- a/priv/templates/phx.gen.auth/registration_new.html.heex +++ /dev/null @@ -1,32 +0,0 @@ -={@<%= scope_config.scope.assign_key %>}> -
    -
    - <.header> - Register for an account - <:subtitle> - Already registered? - <.link navigate={~p"<%= schema.route_prefix %>/log-in"} class="font-semibold text-brand hover:underline"> - Log in - - to your account now. - - -
    - - <.form :let={f} for={@changeset} action={~p"<%= schema.route_prefix %>/register"}> - <.input - field={f[:email]} - type="email" - label="Email" - autocomplete="username" - spellcheck="false" - required - phx-mounted={JS.focus()} - /> - - <.button phx-disable-with="Creating account..." class="btn btn-primary w-full"> - Create an account - - -
    -
    diff --git a/priv/templates/phx.gen.auth/routes.ex b/priv/templates/phx.gen.auth/routes.ex deleted file mode 100644 index 9f780d9..0000000 --- a/priv/templates/phx.gen.auth/routes.ex +++ /dev/null @@ -1,42 +0,0 @@ - - ## Authentication routes - - <%= if not live? do %>scope <%= router_scope %> do - pipe_through [:browser, :redirect_if_<%= schema.singular %>_is_authenticated] - - get "/<%= schema.plural %>/register", <%= inspect schema.alias %>RegistrationController, :new - post "/<%= schema.plural %>/register", <%= inspect schema.alias %>RegistrationController, :create - end - - <% end %>scope <%= router_scope %> do - pipe_through [:browser, :require_authenticated_<%= schema.singular %>]<%= if live? do %> - - live_session :require_authenticated_<%= schema.singular %>, - on_mount: [{<%= inspect auth_module %>, :require_authenticated}] do - live "/<%= schema.plural %>/settings", <%= inspect schema.alias %>Live.Settings, :edit - live "/<%= schema.plural %>/settings/confirm-email/:token", <%= inspect schema.alias %>Live.Settings, :confirm_email - end - - post "/<%= schema.plural %>/update-password", <%= inspect schema.alias %>SessionController, :update_password<% else %> - - get "/<%= schema.plural %>/settings", <%= inspect schema.alias %>SettingsController, :edit - put "/<%= schema.plural %>/settings", <%= inspect schema.alias %>SettingsController, :update - get "/<%= schema.plural %>/settings/confirm-email/:token", <%= inspect schema.alias %>SettingsController, :confirm_email<% end %> - end - - scope <%= router_scope %> do - pipe_through [:browser] - - <%= if live? do %>live_session :current_<%= schema.singular %>, - on_mount: [{<%= inspect auth_module %>, :mount_<%= scope_config.scope.assign_key %>}] do - live "/<%= schema.plural %>/register", <%= inspect schema.alias %>Live.Registration, :new - live "/<%= schema.plural %>/log-in", <%= inspect schema.alias %>Live.Login, :new - live "/<%= schema.plural %>/log-in/:token", <%= inspect schema.alias %>Live.Confirmation, :new - end - - post "/<%= schema.plural %>/log-in", <%= inspect schema.alias %>SessionController, :create - delete "/<%= schema.plural %>/log-out", <%= inspect schema.alias %>SessionController, :delete<% else %>get "/<%= schema.plural %>/log-in", <%= inspect schema.alias %>SessionController, :new - get "/<%= schema.plural %>/log-in/:token", <%= inspect schema.alias %>SessionController, :confirm - post "/<%= schema.plural %>/log-in", <%= inspect schema.alias %>SessionController, :create - delete "/<%= schema.plural %>/log-out", <%= inspect schema.alias %>SessionController, :delete<% end %> - end diff --git a/priv/templates/phx.gen.auth/schema.ex b/priv/templates/phx.gen.auth/schema.ex deleted file mode 100644 index 6efe510..0000000 --- a/priv/templates/phx.gen.auth/schema.ex +++ /dev/null @@ -1,137 +0,0 @@ -defmodule <%= inspect schema.module %> do - use Ecto.Schema - import Ecto.Changeset -<%= if schema.binary_id do %> - @primary_key {:id, :binary_id, autogenerate: true} - @foreign_key_type :binary_id<% end %> - schema <%= inspect schema.table %> do - field :email, :string - field :password, :string, virtual: true, redact: true - field :hashed_password, :string, redact: true - field :confirmed_at, <%= inspect schema.timestamp_type %> - field :authenticated_at, <%= inspect schema.timestamp_type %>, virtual: true - - timestamps(<%= if schema.timestamp_type != :naive_datetime, do: "type: #{inspect schema.timestamp_type}" %>) - end - - @doc """ - A <%= schema.singular %> changeset for registering or changing the email. - - It requires the email to change otherwise an error is added. - - ## Options - - * `:validate_unique` - Set to false if you don't want to validate the - uniqueness of the email, useful when displaying live validations. - Defaults to `true`. - """ - def email_changeset(<%= schema.singular %>, attrs, opts \\ []) do - <%= schema.singular %> - |> cast(attrs, [:email]) - |> validate_email(opts) - end - - defp validate_email(changeset, opts) do - changeset = - changeset - |> validate_required([:email]) - |> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/, - message: "must have the @ sign and no spaces" - ) - |> validate_length(:email, max: 160) - - if Keyword.get(opts, :validate_unique, true) do - changeset - |> unsafe_validate_unique(:email, <%= inspect schema.repo %>) - |> unique_constraint(:email) - |> validate_email_changed() - else - changeset - end - end - - defp validate_email_changed(changeset) do - if get_field(changeset, :email) && get_change(changeset, :email) == nil do - add_error(changeset, :email, "did not change") - else - changeset - end - end - - @doc """ - A <%= schema.singular %> changeset for changing the password. - - It is important to validate the length of the password, as long passwords may - be very expensive to hash for certain algorithms. - - ## Options - - * `:hash_password` - Hashes the password so it can be stored securely - in the database and ensures the password field is cleared to prevent - leaks in the logs. If password hashing is not needed and clearing the - password field is not desired (like when using this changeset for - validations on a LiveView form), this option can be set to `false`. - Defaults to `true`. - """ - def password_changeset(<%= schema.singular %>, attrs, opts \\ []) do - <%= schema.singular %> - |> cast(attrs, [:password]) - |> validate_confirmation(:password, message: "does not match password") - |> validate_password(opts) - end - - defp validate_password(changeset, opts) do - changeset - |> validate_required([:password]) - |> validate_length(:password, min: 12, max: 72) - # Examples of additional password validation: - # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") - # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") - # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") - |> maybe_hash_password(opts) - end - - defp maybe_hash_password(changeset, opts) do - hash_password? = Keyword.get(opts, :hash_password, true) - password = get_change(changeset, :password) - - if hash_password? && password && changeset.valid? do - changeset<%= if hashing_library.name == :bcrypt do %> - # If using Bcrypt, then further validate it is at most 72 bytes long - |> validate_length(:password, max: 72, count: :bytes)<% end %> - # Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that - # would keep the database transaction open longer and hurt performance. - |> put_change(:hashed_password, <%= inspect hashing_library.module %>.hash_pwd_salt(password)) - |> delete_change(:password) - else - changeset - end - end - - @doc """ - Confirms the account by setting `confirmed_at`. - """ - def confirm_changeset(<%= schema.singular %>) do - <%= case schema.timestamp_type do %> - <% :naive_datetime -> %>now = NaiveDateTime.utc_now(:second) - <% :utc_datetime -> %>now = DateTime.utc_now(:second) - <% :utc_datetime_usec -> %>now = DateTime.utc_now(:microsecond) - <% end %>change(<%= schema.singular %>, confirmed_at: now) - end - - @doc """ - Verifies the password. - - If there is no <%= schema.singular %> or the <%= schema.singular %> doesn't have a password, we call - `<%= inspect hashing_library.module %>.no_user_verify/0` to avoid timing attacks. - """ - def valid_password?(%<%= inspect schema.module %>{hashed_password: hashed_password}, password) - when is_binary(hashed_password) and byte_size(password) > 0 do - <%= inspect hashing_library.module %>.verify_pass(password, hashed_password) - end - - def valid_password?(_, _) do - <%= inspect hashing_library.module %>.no_user_verify() - false - end -end diff --git a/priv/templates/phx.gen.auth/schema_token.ex b/priv/templates/phx.gen.auth/schema_token.ex deleted file mode 100644 index 94e5923..0000000 --- a/priv/templates/phx.gen.auth/schema_token.ex +++ /dev/null @@ -1,158 +0,0 @@ -defmodule <%= inspect schema.module %>Token do - use Ecto.Schema - import Ecto.Query - alias <%= inspect schema.module %>Token - - @hash_algorithm :sha256 - @rand_size 32 - - # It is very important to keep the magic link token expiry short, - # since someone with access to the email may take over the account. - @magic_link_validity_in_minutes 15 - @change_email_validity_in_days 7 - @session_validity_in_days 14 -<%= if schema.binary_id do %> - @primary_key {:id, :binary_id, autogenerate: true} - @foreign_key_type :binary_id<% end %> - schema "<%= schema.table %>_tokens" do - field :token, :binary - field :context, :string - field :sent_to, :string - field :authenticated_at, <%= inspect schema.timestamp_type %> - belongs_to :<%= schema.singular %>, <%= inspect schema.module %> - - timestamps(<%= if schema.timestamp_type != :naive_datetime, do: "type: #{inspect schema.timestamp_type}, " %>updated_at: false) - end - - @doc """ - Generates a token that will be stored in a signed place, - such as session or cookie. As they are signed, those - tokens do not need to be hashed. - - The reason why we store session tokens in the database, even - though Phoenix already provides a session cookie, is because - Phoenix's default session cookies are not persisted, they are - simply signed and potentially encrypted. This means they are - valid indefinitely, unless you change the signing/encryption - salt. - - Therefore, storing them allows individual <%= schema.singular %> - sessions to be expired. The token system can also be extended - to store additional data, such as the device used for logging in. - You could then use this information to display all valid sessions - and devices in the UI and allow users to explicitly expire any - session they deem invalid. - """ - def build_session_token(<%= schema.singular %>) do - token = :crypto.strong_rand_bytes(@rand_size) - dt = <%= schema.singular %>.authenticated_at || <%= datetime_now %> - {token, %<%= inspect schema.alias %>Token{token: token, context: "session", <%= schema.singular %>_id: <%= schema.singular %>.id, authenticated_at: dt}} - end - - @doc """ - Checks if the token is valid and returns its underlying lookup query. - - The query returns the <%= schema.singular %> found by the token, if any, along with the token's creation time. - - The token is valid if it matches the value in the database and it has - not expired (after @session_validity_in_days). - """ - def verify_session_token_query(token) do - query = - from token in by_token_and_context_query(token, "session"), - join: <%= schema.singular %> in assoc(token, :<%= schema.singular %>), - where: token.inserted_at > ago(@session_validity_in_days, "day"), - select: {%{<%= schema.singular %> | authenticated_at: token.authenticated_at}, token.inserted_at} - - {:ok, query} - end - - @doc """ - Builds a token and its hash to be delivered to the <%= schema.singular %>'s email. - - The non-hashed token is sent to the <%= schema.singular %> email while the - hashed part is stored in the database. The original token cannot be reconstructed, - which means anyone with read-only access to the database cannot directly use - the token in the application to gain access. Furthermore, if the <%= schema.singular %> changes - their email in the system, the tokens sent to the previous email are no longer - valid. - - Users can easily adapt the existing code to provide other types of delivery methods, - for example, by phone numbers. - """ - def build_email_token(<%= schema.singular %>, context) do - build_hashed_token(<%= schema.singular %>, context, <%= schema.singular %>.email) - end - - defp build_hashed_token(<%= schema.singular %>, context, sent_to) do - token = :crypto.strong_rand_bytes(@rand_size) - hashed_token = :crypto.hash(@hash_algorithm, token) - - {Base.url_encode64(token, padding: false), - %<%= inspect schema.alias %>Token{ - token: hashed_token, - context: context, - sent_to: sent_to, - <%= schema.singular %>_id: <%= schema.singular %>.id - }} - end - - @doc """ - Checks if the token is valid and returns its underlying lookup query. - - If found, the query returns a tuple of the form `{<%= schema.singular %>, token}`. - - The given token is valid if it matches its hashed counterpart in the - database. This function also checks whether the token has expired. The context - of a magic link token is always "login". - """ - def verify_magic_link_token_query(token) do - case Base.url_decode64(token, padding: false) do - {:ok, decoded_token} -> - hashed_token = :crypto.hash(@hash_algorithm, decoded_token) - - query = - from token in by_token_and_context_query(hashed_token, "login"), - join: <%= schema.singular %> in assoc(token, :<%= schema.singular %>), - where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"), - where: token.sent_to == <%= schema.singular %>.email, - select: {<%= schema.singular %>, token} - - {:ok, query} - - :error -> - :error - end - end - - @doc """ - Checks if the token is valid and returns its underlying lookup query. - - The query returns the <%= schema.singular %>_token found by the token, if any. - - This is used to validate requests to change the <%= schema.singular %> - email. - The given token is valid if it matches its hashed counterpart in the - database and if it has not expired (after @change_email_validity_in_days). - The context must always start with "change:". - """ - def verify_change_email_token_query(token, "change:" <> _ = context) do - case Base.url_decode64(token, padding: false) do - {:ok, decoded_token} -> - hashed_token = :crypto.hash(@hash_algorithm, decoded_token) - - query = - from token in by_token_and_context_query(hashed_token, context), - where: token.inserted_at > ago(@change_email_validity_in_days, "day") - - {:ok, query} - - :error -> - :error - end - end - - defp by_token_and_context_query(token, context) do - from <%= inspect schema.alias %>Token, where: [token: ^token, context: ^context] - end -end diff --git a/priv/templates/phx.gen.auth/scope.ex b/priv/templates/phx.gen.auth/scope.ex deleted file mode 100644 index 2829aa2..0000000 --- a/priv/templates/phx.gen.auth/scope.ex +++ /dev/null @@ -1,33 +0,0 @@ -defmodule <%= inspect scope_config.scope.module %> do - @moduledoc """ - Defines the scope of the caller to be used throughout the app. - - The `<%= inspect scope_config.scope.module %>` allows public interfaces to receive - information about the caller, such as if the call is initiated from an - end-user, and if so, which user. Additionally, such a scope can carry fields - such as "super user" or other privileges for use as authorization, or to - ensure specific code paths can only be access for a given scope. - - It is useful for logging as well as for scoping pubsub subscriptions and - broadcasts when a caller subscribes to an interface or performs a particular - action. - - Feel free to extend the fields on this struct to fit the needs of - growing application requirements. - """ - - alias <%= inspect schema.module %> - - defstruct <%= schema.singular %>: nil - - @doc """ - Creates a scope for the given <%= schema.singular %>. - - Returns nil if no <%= schema.singular %> is given. - """ - def for_<%= schema.singular %>(%<%= inspect schema.alias %>{} = <%= schema.singular %>) do - %__MODULE__{<%= schema.singular %>: <%= schema.singular %>} - end - - def for_<%= schema.singular %>(nil), do: nil -end diff --git a/priv/templates/phx.gen.auth/session_confirm.html.heex b/priv/templates/phx.gen.auth/session_confirm.html.heex deleted file mode 100644 index 6d15837..0000000 --- a/priv/templates/phx.gen.auth/session_confirm.html.heex +++ /dev/null @@ -1,59 +0,0 @@ -={@<%= scope_config.scope.assign_key %>}> -
    -
    - <.header>Welcome {@<%= schema.singular %>.email} -
    - - <.form - :if={!@<%= schema.singular %>.confirmed_at} - for={@form} - id="confirmation_form" - action={~p"<%= schema.route_prefix %>/log-in?_action=confirmed"} - phx-mounted={JS.focus_first()} - > - - <.button - name={@form[:remember_me].name} - value="true" - phx-disable-with="Confirming..." - class="btn btn-primary w-full" - > - Confirm and stay logged in - - <.button phx-disable-with="Confirming..." class="btn btn-primary btn-soft w-full mt-2"> - Confirm and log in only this time - - - - <.form - :if={@<%= schema.singular %>.confirmed_at} - for={@form} - id="login_form" - action={~p"<%= schema.route_prefix %>/log-in"} - phx-mounted={JS.focus_first()} - > - - <%%= if @<%= scope_config.scope.assign_key %> do %> - <.button variant="primary" phx-disable-with="Logging in..." class="btn btn-primary w-full"> - Log in - - <%% else %> - <.button - name={@form[:remember_me].name} - value="true" - phx-disable-with="Logging in..." - class="btn btn-primary w-full" - > - Keep me logged in on this device - - <.button phx-disable-with="Logging in..." class="btn btn-primary btn-soft w-full mt-2"> - Log me in only this time - - <%% end %> - - -

    .confirmed_at} class="alert alert-outline mt-8"> - Tip: If you prefer passwords, you can enable them in the <%= schema.singular %> settings. -

    -
    -
    diff --git a/priv/templates/phx.gen.auth/session_controller.ex b/priv/templates/phx.gen.auth/session_controller.ex deleted file mode 100644 index 67877fe..0000000 --- a/priv/templates/phx.gen.auth/session_controller.ex +++ /dev/null @@ -1,143 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>SessionController do - use <%= inspect context.web_module %>, :controller - - alias <%= inspect context.module %> - alias <%= inspect auth_module %><%= if live? do %> - - def create(conn, %{"_action" => "confirmed"} = params) do - create(conn, params, "<%= schema.human_singular %> confirmed successfully.") - end - - def create(conn, params) do - create(conn, params, "Welcome back!") - end - - # magic link login - defp create(conn, %{"<%= schema.singular %>" => %{"token" => token} = <%= schema.singular %>_params}, info) do - case <%= inspect context.alias %>.login_<%= schema.singular %>_by_magic_link(token) do - {:ok, {<%= schema.singular %>, tokens_to_disconnect}} -> - <%= inspect schema.alias %>Auth.disconnect_sessions(tokens_to_disconnect) - - conn - |> put_flash(:info, info) - |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>, <%= schema.singular %>_params) - - _ -> - conn - |> put_flash(:error, "The link is invalid or it has expired.") - |> redirect(to: ~p"<%= schema.route_prefix %>/log-in") - end - end - - # email + password login - defp create(conn, %{"<%= schema.singular %>" => <%= schema.singular %>_params}, info) do - %{"email" => email, "password" => password} = <%= schema.singular %>_params - - if <%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>_by_email_and_password(email, password) do - conn - |> put_flash(:info, info) - |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>, <%= schema.singular %>_params) - else - # In order to prevent user enumeration attacks, don't disclose whether the email is registered. - conn - |> put_flash(:error, "Invalid email or password") - |> put_flash(:email, String.slice(email, 0, 160)) - |> redirect(to: ~p"<%= schema.route_prefix %>/log-in") - end - end - - def update_password(conn, %{"<%= schema.singular %>" => <%= schema.singular %>_params} = params) do - <%= schema.singular %> = conn.assigns.<%= scope_config.scope.assign_key %>.<%= schema.singular %> - true = <%= inspect context.alias %>.sudo_mode?(<%= schema.singular %>) - {:ok, {_<%= schema.singular %>, expired_tokens}} = <%= inspect context.alias %>.update_<%= schema.singular %>_password(<%= schema.singular %>, <%= schema.singular %>_params) - - # disconnect all existing LiveViews with old sessions - <%= inspect schema.alias %>Auth.disconnect_sessions(expired_tokens) - - conn - |> put_session(:<%= schema.singular %>_return_to, ~p"<%= schema.route_prefix %>/settings") - |> create(params, "Password updated successfully!") - end<% else %> - - def new(conn, _params) do - email = get_in(conn.assigns, [:<%= scope_config.scope.assign_key %>, Access.key(:<%= schema.singular %>), Access.key(:email)]) - form = Phoenix.Component.to_form(%{"email" => email}, as: "<%= schema.singular %>") - - render(conn, :new, form: form) - end - - # magic link login - def create(conn, %{"<%= schema.singular %>" => %{"token" => token} = <%= schema.singular %>_params} = params) do - info = - case params do - %{"_action" => "confirmed"} -> "<%= schema.human_singular %> confirmed successfully." - _ -> "Welcome back!" - end - - case <%= inspect context.alias %>.login_<%= schema.singular %>_by_magic_link(token) do - {:ok, {<%= schema.singular %>, _expired_tokens}} -> - conn - |> put_flash(:info, info) - |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>, <%= schema.singular %>_params) - - {:error, :not_found} -> - conn - |> put_flash(:error, "The link is invalid or it has expired.") - |> render(:new, form: Phoenix.Component.to_form(%{}, as: "<%= schema.singular %>")) - end - end - - # email + password login - def create(conn, %{"<%= schema.singular %>" => %{"email" => email, "password" => password} = <%= schema.singular %>_params}) do - if <%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>_by_email_and_password(email, password) do - conn - |> put_flash(:info, "Welcome back!") - |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>, <%= schema.singular %>_params) - else - form = Phoenix.Component.to_form(<%= schema.singular %>_params, as: "<%= schema.singular %>") - - # In order to prevent user enumeration attacks, don't disclose whether the email is registered. - conn - |> put_flash(:error, "Invalid email or password") - |> render(:new, form: form) - end - end - - # magic link request - def create(conn, %{"<%= schema.singular %>" => %{"email" => email}}) do - if <%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>_by_email(email) do - <%= inspect context.alias %>.deliver_login_instructions( - <%= schema.singular %>, - &url(~p"<%= schema.route_prefix %>/log-in/#{&1}") - ) - end - - info = - "If your email is in our system, you will receive instructions for logging in shortly." - - conn - |> put_flash(:info, info) - |> redirect(to: ~p"<%= schema.route_prefix %>/log-in") - end - - def confirm(conn, %{"token" => token}) do - if <%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>_by_magic_link_token(token) do - form = Phoenix.Component.to_form(%{"token" => token}, as: "<%= schema.singular %>") - - conn - |> assign(:<%= schema.singular %>, <%= schema.singular %>) - |> assign(:form, form) - |> render(:confirm) - else - conn - |> put_flash(:error, "Magic link is invalid or it has expired.") - |> redirect(to: ~p"<%= schema.route_prefix %>/log-in") - end - end<% end %> - - def delete(conn, _params) do - conn - |> put_flash(:info, "Logged out successfully.") - |> <%= inspect schema.alias %>Auth.log_out_<%= schema.singular %>() - end -end diff --git a/priv/templates/phx.gen.auth/session_controller_test.exs b/priv/templates/phx.gen.auth/session_controller_test.exs deleted file mode 100644 index bcd8b10..0000000 --- a/priv/templates/phx.gen.auth/session_controller_test.exs +++ /dev/null @@ -1,224 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>SessionControllerTest do - use <%= inspect context.web_module %>.ConnCase<%= test_case_options %> - - import <%= inspect context.module %>Fixtures - alias <%= inspect context.module %> - - setup do - %{unconfirmed_<%= schema.singular %>: unconfirmed_<%= schema.singular %>_fixture(), <%= schema.singular %>: <%= schema.singular %>_fixture()} - end<%= if not live? do %> - - describe "GET <%= schema.route_prefix %>/log-in" do - test "renders login page", %{conn: conn} do - conn = get(conn, ~p"<%= schema.route_prefix %>/log-in") - response = html_response(conn, 200) - assert response =~ "Log in" - assert response =~ ~p"<%= schema.route_prefix %>/register" - assert response =~ "Log in with email" - end - - test "renders login page with email filled in (sudo mode)", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - html = - conn - |> log_in_<%= schema.singular %>(<%= schema.singular %>) - |> get(~p"<%= schema.route_prefix %>/log-in") - |> html_response(200) - - assert html =~ "You need to reauthenticate" - refute html =~ "Register" - assert html =~ "Log in with email" - - assert html =~ - ~s(/log-in?mode=password") - response = html_response(conn, 200) - assert response =~ "Log in" - assert response =~ ~p"<%= schema.route_prefix %>/register" - assert response =~ "Log in with email" - end - end - - describe "GET <%= schema.route_prefix %>/log-in/:token" do - test "renders confirmation page for unconfirmed <%= schema.singular %>", %{conn: conn, unconfirmed_<%= schema.singular %>: <%= schema.singular %>} do - token = - extract_<%= schema.singular %>_token(fn url -> - <%= inspect context.alias %>.deliver_login_instructions(<%= schema.singular %>, url) - end) - - conn = get(conn, ~p"<%= schema.route_prefix %>/log-in/#{token}") - assert html_response(conn, 200) =~ "Confirm and stay logged in" - end - - test "renders login page for confirmed <%= schema.singular %>", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - token = - extract_<%= schema.singular %>_token(fn url -> - <%= inspect context.alias %>.deliver_login_instructions(<%= schema.singular %>, url) - end) - - conn = get(conn, ~p"<%= schema.route_prefix %>/log-in/#{token}") - html = html_response(conn, 200) - refute html =~ "Confirm my account" - assert html =~ "Log in" - end - - test "raises error for invalid token", %{conn: conn} do - conn = get(conn, ~p"<%= schema.route_prefix %>/log-in/invalid-token") - assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/log-in" - - assert Phoenix.Flash.get(conn.assigns.flash, :error) == - "Magic link is invalid or it has expired." - end - end<% end %> - - describe "POST <%= schema.route_prefix %>/log-in - email and password" do - test "logs the <%= schema.singular %> in", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - <%= schema.singular %> = set_password(<%= schema.singular %>) - - conn = - post(conn, ~p"<%= schema.route_prefix %>/log-in", %{ - "<%= schema.singular %>" => %{"email" => <%= schema.singular %>.email, "password" => valid_<%= schema.singular %>_password()} - }) - - assert get_session(conn, :<%= schema.singular %>_token) - assert redirected_to(conn) == ~p"/" - - # Now do a logged in request and assert on the menu - conn = get(conn, ~p"/") - response = html_response(conn, 200) - assert response =~ <%= schema.singular %>.email - assert response =~ ~p"<%= schema.route_prefix %>/settings" - assert response =~ ~p"<%= schema.route_prefix %>/log-out" - end - - test "logs the <%= schema.singular %> in with remember me", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - <%= schema.singular %> = set_password(<%= schema.singular %>) - - conn = - post(conn, ~p"<%= schema.route_prefix %>/log-in", %{ - "<%= schema.singular %>" => %{ - "email" => <%= schema.singular %>.email, - "password" => valid_<%= schema.singular %>_password(), - "remember_me" => "true" - } - }) - - assert conn.resp_cookies["_<%= web_app_name %>_<%= schema.singular %>_remember_me"] - assert redirected_to(conn) == ~p"/" - end - - test "logs the <%= schema.singular %> in with return to", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - <%= schema.singular %> = set_password(<%= schema.singular %>) - - conn = - conn - |> init_test_session(<%= schema.singular %>_return_to: "/foo/bar") - |> post(~p"<%= schema.route_prefix %>/log-in", %{ - "<%= schema.singular %>" => %{ - "email" => <%= schema.singular %>.email, - "password" => valid_<%= schema.singular %>_password() - } - }) - - assert redirected_to(conn) == "/foo/bar" - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!" - end - - test "<%= if live?, do: "redirects to login page", else: "emits error message" %> with invalid credentials", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - conn = - post(conn, ~p"<%= schema.route_prefix %>/log-in?mode=password", %{ - "<%= schema.singular %>" => %{"email" => <%= schema.singular %>.email, "password" => "invalid_password"} - }) - - <%= if live? do %>assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" - assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/log-in"<% else %>response = html_response(conn, 200) - assert response =~ "Log in" - assert response =~ "Invalid email or password"<% end %> - end - end - - describe "POST <%= schema.route_prefix %>/log-in - magic link" do - <%= if not live? do %>test "sends magic link email when <%= schema.singular %> exists", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - conn = - post(conn, ~p"<%= schema.route_prefix %>/log-in", %{ - "<%= schema.singular %>" => %{"email" => <%= schema.singular %>.email} - }) - - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" - assert <%= inspect schema.repo %>.get_by!(<%= inspect context.alias %>.<%= inspect schema.alias %>Token, <%= schema.singular %>_id: <%= schema.singular %>.id).context == "login" - end - - <% end %>test "logs the <%= schema.singular %> in", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - {token, _hashed_token} = generate_<%= schema.singular %>_magic_link_token(<%= schema.singular %>) - - conn = - post(conn, ~p"<%= schema.route_prefix %>/log-in", %{ - "<%= schema.singular %>" => %{"token" => token} - }) - - assert get_session(conn, :<%= schema.singular %>_token) - assert redirected_to(conn) == ~p"/" - - # Now do a logged in request and assert on the menu - conn = get(conn, ~p"/") - response = html_response(conn, 200) - assert response =~ <%= schema.singular %>.email - assert response =~ ~p"<%= schema.route_prefix %>/settings" - assert response =~ ~p"<%= schema.route_prefix %>/log-out" - end - - test "confirms unconfirmed <%= schema.singular %>", %{conn: conn, unconfirmed_<%= schema.singular %>: <%= schema.singular %>} do - {token, _hashed_token} = generate_<%= schema.singular %>_magic_link_token(<%= schema.singular %>) - refute <%= schema.singular %>.confirmed_at - - conn = - post(conn, ~p"<%= schema.route_prefix %>/log-in", %{ - "<%= schema.singular %>" => %{"token" => token}, - "_action" => "confirmed" - }) - - assert get_session(conn, :<%= schema.singular %>_token) - assert redirected_to(conn) == ~p"/" - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "<%= schema.human_singular %> confirmed successfully." - - assert <%= inspect context.alias %>.get_<%= schema.singular %>!(<%= schema.singular %>.id).confirmed_at - - # Now do a logged in request and assert on the menu - conn = get(conn, ~p"/") - response = html_response(conn, 200) - assert response =~ <%= schema.singular %>.email - assert response =~ ~p"<%= schema.route_prefix %>/settings" - assert response =~ ~p"<%= schema.route_prefix %>/log-out" - end - - test "<%= if live?, do: "redirects to login page", else: "emits error message" %> when magic link is invalid", %{conn: conn} do - conn = - post(conn, ~p"<%= schema.route_prefix %>/log-in", %{ - "<%= schema.singular %>" => %{"token" => "invalid"} - }) - - <%= if live? do %>assert Phoenix.Flash.get(conn.assigns.flash, :error) == - "The link is invalid or it has expired." - - assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/log-in"<% else %>assert html_response(conn, 200) =~ "The link is invalid or it has expired."<% end %> - end - end - - describe "DELETE <%= schema.route_prefix %>/log-out" do - test "logs the <%= schema.singular %> out", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - conn = conn |> log_in_<%= schema.singular %>(<%= schema.singular %>) |> delete(~p"<%= schema.route_prefix %>/log-out") - assert redirected_to(conn) == ~p"/" - refute get_session(conn, :<%= schema.singular %>_token) - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" - end - - test "succeeds even if the <%= schema.singular %> is not logged in", %{conn: conn} do - conn = delete(conn, ~p"<%= schema.route_prefix %>/log-out") - assert redirected_to(conn) == ~p"/" - refute get_session(conn, :<%= schema.singular %>_token) - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" - end - end -end diff --git a/priv/templates/phx.gen.auth/session_html.ex b/priv/templates/phx.gen.auth/session_html.ex deleted file mode 100644 index 0a02f38..0000000 --- a/priv/templates/phx.gen.auth/session_html.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>SessionHTML do - use <%= inspect context.web_module %>, :html - - embed_templates "<%= schema.singular %>_session_html/*" - - defp local_mail_adapter? do - Application.get_env(:<%= Mix.Phoenix.otp_app() %>, <%= inspect context.base_module %>.Mailer)[:adapter] == Swoosh.Adapters.Local - end -end diff --git a/priv/templates/phx.gen.auth/session_new.html.heex b/priv/templates/phx.gen.auth/session_new.html.heex deleted file mode 100644 index 28f3322..0000000 --- a/priv/templates/phx.gen.auth/session_new.html.heex +++ /dev/null @@ -1,73 +0,0 @@ -={@<%= scope_config.scope.assign_key %>}> -
    -
    - <.header> -

    Log in

    - <:subtitle> - <%%= if @<%= scope_config.scope.assign_key %> do %> - You need to reauthenticate to perform sensitive actions on your account. - <%% else %> - Don't have an account? <.link - navigate={~p"<%= schema.route_prefix %>/register"} - class="font-semibold text-brand hover:underline" - phx-no-format - >Sign up for an account now. - <%% end %> - - -
    - -
    - <.icon name="hero-information-circle" class="size-6 shrink-0" /> -
    -

    You are running the local mail adapter.

    -

    - To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page. -

    -
    -
    - - <.form :let={f} for={@form} as={:<%= schema.singular %>} id="login_form_magic" action={~p"<%= schema.route_prefix %>/log-in"}> - <.input - readonly={!!@<%= scope_config.scope.assign_key %>} - field={f[:email]} - type="email" - label="Email" - autocomplete="username" - spellcheck="false" - required - phx-mounted={JS.focus()} - /> - <.button class="btn btn-primary w-full"> - Log in with email - - - -
    or
    - - <.form :let={f} for={@form} as={:<%= schema.singular %>} id="login_form_password" action={~p"<%= schema.route_prefix %>/log-in"}> - <.input - readonly={!!@<%= scope_config.scope.assign_key %>} - field={f[:email]} - type="email" - label="Email" - autocomplete="username" - spellcheck="false" - required - /> - <.input - field={f[:password]} - type="password" - label="Password" - autocomplete="current-password" - spellcheck="false" - /> - <.button class="btn btn-primary w-full" name={@form[:remember_me].name} value="true"> - Log in and stay logged in - - <.button class="btn btn-primary btn-soft w-full mt-2"> - Log in only this time - - -
    -
    diff --git a/priv/templates/phx.gen.auth/settings_controller.ex b/priv/templates/phx.gen.auth/settings_controller.ex deleted file mode 100644 index d45d396..0000000 --- a/priv/templates/phx.gen.auth/settings_controller.ex +++ /dev/null @@ -1,77 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>SettingsController do - use <%= inspect context.web_module %>, :controller - - alias <%= inspect context.module %> - alias <%= inspect auth_module %> - - import <%= inspect auth_module %>, only: [require_sudo_mode: 2] - - plug :require_sudo_mode - plug :assign_email_and_password_changesets - - def edit(conn, _params) do - render(conn, :edit) - end - - def update(conn, %{"action" => "update_email"} = params) do - %{"<%= schema.singular %>" => <%= schema.singular %>_params} = params - <%= schema.singular %> = conn.assigns.<%= scope_config.scope.assign_key %>.<%= schema.singular %> - - case <%= inspect context.alias %>.change_<%= schema.singular %>_email(<%= schema.singular %>, <%= schema.singular %>_params) do - %{valid?: true} = changeset -> - <%= inspect context.alias %>.deliver_<%= schema.singular %>_update_email_instructions( - Ecto.Changeset.apply_action!(changeset, :insert), - <%= schema.singular %>.email, - &url(~p"<%= schema.route_prefix %>/settings/confirm-email/#{&1}") - ) - - conn - |> put_flash( - :info, - "A link to confirm your email change has been sent to the new address." - ) - |> redirect(to: ~p"<%= schema.route_prefix %>/settings") - - changeset -> - render(conn, :edit, email_changeset: %{changeset | action: :insert}) - end - end - - def update(conn, %{"action" => "update_password"} = params) do - %{"<%= schema.singular %>" => <%= schema.singular %>_params} = params - <%= schema.singular %> = conn.assigns.<%= scope_config.scope.assign_key %>.<%= schema.singular %> - - case <%= inspect context.alias %>.update_<%= schema.singular %>_password(<%= schema.singular %>, <%= schema.singular %>_params) do - {:ok, {<%= schema.singular %>, _}} -> - conn - |> put_flash(:info, "Password updated successfully.") - |> put_session(:<%= schema.singular %>_return_to, ~p"<%= schema.route_prefix %>/settings") - |> <%= inspect schema.alias %>Auth.log_in_<%= schema.singular %>(<%= schema.singular %>) - - {:error, changeset} -> - render(conn, :edit, password_changeset: changeset) - end - end - - def confirm_email(conn, %{"token" => token}) do - case <%= inspect context.alias %>.update_<%= schema.singular %>_email(conn.assigns.<%= scope_config.scope.assign_key %>.<%= schema.singular %>, token) do - {:ok, _<%= schema.singular %>} -> - conn - |> put_flash(:info, "Email changed successfully.") - |> redirect(to: ~p"<%= schema.route_prefix %>/settings") - - {:error, _} -> - conn - |> put_flash(:error, "Email change link is invalid or it has expired.") - |> redirect(to: ~p"<%= schema.route_prefix %>/settings") - end - end - - defp assign_email_and_password_changesets(conn, _opts) do - <%= schema.singular %> = conn.assigns.<%= scope_config.scope.assign_key %>.<%= schema.singular %> - - conn - |> assign(:email_changeset, <%= inspect context.alias %>.change_<%= schema.singular %>_email(<%= schema.singular %>)) - |> assign(:password_changeset, <%= inspect context.alias %>.change_<%= schema.singular %>_password(<%= schema.singular %>)) - end -end diff --git a/priv/templates/phx.gen.auth/settings_controller_test.exs b/priv/templates/phx.gen.auth/settings_controller_test.exs deleted file mode 100644 index 7831855..0000000 --- a/priv/templates/phx.gen.auth/settings_controller_test.exs +++ /dev/null @@ -1,148 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>SettingsControllerTest do - use <%= inspect context.web_module %>.ConnCase<%= test_case_options %> - - alias <%= inspect context.module %> - import <%= inspect context.module %>Fixtures - - setup :register_and_log_in_<%= schema.singular %> - - describe "GET <%= schema.route_prefix %>/settings" do - test "renders settings page", %{conn: conn} do - conn = get(conn, ~p"<%= schema.route_prefix %>/settings") - response = html_response(conn, 200) - assert response =~ "Settings" - end - - test "redirects if <%= schema.singular %> is not logged in" do - conn = build_conn() - conn = get(conn, ~p"<%= schema.route_prefix %>/settings") - assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/log-in" - end - - @tag token_authenticated_at: <%= inspect datetime_module %>.add(<%= datetime_now %>, -11, :minute) - test "redirects if <%= schema.singular %> is not in sudo mode", %{conn: conn} do - conn = get(conn, ~p"<%= schema.route_prefix %>/settings") - assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/log-in" - - assert Phoenix.Flash.get(conn.assigns.flash, :error) == - "You must re-authenticate to access this page." - end - end - - describe "PUT <%= schema.route_prefix %>/settings (change password form)" do - test "updates the <%= schema.singular %> password and resets tokens", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - new_password_conn = - put(conn, ~p"<%= schema.route_prefix %>/settings", %{ - "action" => "update_password", - "<%= schema.singular %>" => %{ - "password" => "new valid password", - "password_confirmation" => "new valid password" - } - }) - - assert redirected_to(new_password_conn) == ~p"<%= schema.route_prefix %>/settings" - - assert get_session(new_password_conn, :<%= schema.singular %>_token) != get_session(conn, :<%= schema.singular %>_token) - - assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~ - "Password updated successfully" - - assert <%= inspect context.alias %>.get_<%= schema.singular %>_by_email_and_password(<%= schema.singular %>.email, "new valid password") - end - - test "does not update password on invalid data", %{conn: conn} do - old_password_conn = - put(conn, ~p"<%= schema.route_prefix %>/settings", %{ - "action" => "update_password", - "<%= schema.singular %>" => %{ - "password" => "too short", - "password_confirmation" => "does not match" - } - }) - - response = html_response(old_password_conn, 200) - assert response =~ "Settings" - assert response =~ "should be at least 12 character(s)" - assert response =~ "does not match password" - - assert get_session(old_password_conn, :<%= schema.singular %>_token) == get_session(conn, :<%= schema.singular %>_token) - end - end - - describe "PUT <%= schema.route_prefix %>/settings (change email form)" do - @tag :capture_log - test "updates the <%= schema.singular %> email", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - conn = - put(conn, ~p"<%= schema.route_prefix %>/settings", %{ - "action" => "update_email", - "<%= schema.singular %>" => %{"email" => unique_<%= schema.singular %>_email()} - }) - - assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/settings" - - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ - "A link to confirm your email" - - assert <%= inspect context.alias %>.get_<%= schema.singular %>_by_email(<%= schema.singular %>.email) - end - - test "does not update email on invalid data", %{conn: conn} do - conn = - put(conn, ~p"<%= schema.route_prefix %>/settings", %{ - "action" => "update_email", - "<%= schema.singular %>" => %{"email" => "with spaces"} - }) - - response = html_response(conn, 200) - assert response =~ "Settings" - assert response =~ "must have the @ sign and no spaces" - end - end - - describe "GET <%= schema.route_prefix %>/settings/confirm-email/:token" do - setup %{<%= schema.singular %>: <%= schema.singular %>} do - email = unique_<%= schema.singular %>_email() - - token = - extract_<%= schema.singular %>_token(fn url -> - <%= inspect context.alias %>.deliver_<%= schema.singular %>_update_email_instructions(%{<%= schema.singular %> | email: email}, <%= schema.singular %>.email, url) - end) - - %{token: token, email: email} - end - - test "updates the <%= schema.singular %> email once", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>, token: token, email: email} do - conn = get(conn, ~p"<%= schema.route_prefix %>/settings/confirm-email/#{token}") - assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/settings" - - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ - "Email changed successfully" - - refute <%= inspect context.alias %>.get_<%= schema.singular %>_by_email(<%= schema.singular %>.email) - assert <%= inspect context.alias %>.get_<%= schema.singular %>_by_email(email) - - conn = get(conn, ~p"<%= schema.route_prefix %>/settings/confirm-email/#{token}") - - assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/settings" - - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ - "Email change link is invalid or it has expired" - end - - test "does not update email with invalid token", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - conn = get(conn, ~p"<%= schema.route_prefix %>/settings/confirm-email/oops") - assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/settings" - - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ - "Email change link is invalid or it has expired" - - assert <%= inspect context.alias %>.get_<%= schema.singular %>_by_email(<%= schema.singular %>.email) - end - - test "redirects if <%= schema.singular %> is not logged in", %{token: token} do - conn = build_conn() - conn = get(conn, ~p"<%= schema.route_prefix %>/settings/confirm-email/#{token}") - assert redirected_to(conn) == ~p"<%= schema.route_prefix %>/log-in" - end - end -end diff --git a/priv/templates/phx.gen.auth/settings_edit.html.heex b/priv/templates/phx.gen.auth/settings_edit.html.heex deleted file mode 100644 index 322d0fa..0000000 --- a/priv/templates/phx.gen.auth/settings_edit.html.heex +++ /dev/null @@ -1,49 +0,0 @@ -={@<%= scope_config.scope.assign_key %>}> -
    - <.header> - Account Settings - <:subtitle>Manage your account email address and password settings - -
    - - <.form :let={f} for={@email_changeset} action={~p"<%= schema.route_prefix %>/settings"} id="update_email"> - - - <.input - field={f[:email]} - type="email" - label="Email" - autocomplete="username" - spellcheck="false" - required - /> - - <.button variant="primary" phx-disable-with="Changing...">Change Email - - -
    - - <.form :let={f} for={@password_changeset} action={~p"<%= schema.route_prefix %>/settings"} id="update_password"> - - - <.input - field={f[:password]} - type="password" - label="New password" - autocomplete="new-password" - spellcheck="false" - required - /> - <.input - field={f[:password_confirmation]} - type="password" - label="Confirm new password" - autocomplete="new-password" - spellcheck="false" - required - /> - <.button variant="primary" phx-disable-with="Changing..."> - Save Password - - - diff --git a/priv/templates/phx.gen.auth/settings_html.ex b/priv/templates/phx.gen.auth/settings_html.ex deleted file mode 100644 index 7c8c9ea..0000000 --- a/priv/templates/phx.gen.auth/settings_html.ex +++ /dev/null @@ -1,5 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>SettingsHTML do - use <%= inspect context.web_module %>, :html - - embed_templates "<%= schema.singular %>_settings_html/*" -end diff --git a/priv/templates/phx.gen.auth/settings_live.ex b/priv/templates/phx.gen.auth/settings_live.ex deleted file mode 100644 index 6e051f8..0000000 --- a/priv/templates/phx.gen.auth/settings_live.ex +++ /dev/null @@ -1,160 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.Settings do - use <%= inspect context.web_module %>, :live_view - - on_mount {<%= inspect auth_module %>, :require_sudo_mode} - - alias <%= inspect context.module %> - - @impl true - def render(assigns) do - ~H""" - ={@<%= scope_config.scope.assign_key %>}> -
    - <.header> - Account Settings - <:subtitle>Manage your account email address and password settings - -
    - - <.form for={@email_form} id="email_form" phx-submit="update_email" phx-change="validate_email"> - <.input - field={@email_form[:email]} - type="email" - label="Email" - autocomplete="username" - spellcheck="false" - required - /> - <.button variant="primary" phx-disable-with="Changing...">Change Email - - -
    - - <.form - for={@password_form} - id="password_form" - action={~p"<%= schema.route_prefix %>/update-password"} - method="post" - phx-change="validate_password" - phx-submit="update_password" - phx-trigger-action={@trigger_submit} - > - - <.input - field={@password_form[:password]} - type="password" - label="New password" - autocomplete="new-password" - spellcheck="false" - required - /> - <.input - field={@password_form[:password_confirmation]} - type="password" - label="Confirm new password" - autocomplete="new-password" - spellcheck="false" - /> - <.button variant="primary" phx-disable-with="Saving..."> - Save Password - - - - """ - end - - @impl true - def mount(%{"token" => token}, _session, socket) do - socket = - case <%= inspect context.alias %>.update_<%= schema.singular %>_email(socket.assigns.<%= scope_config.scope.assign_key %>.<%= schema.singular %>, token) do - {:ok, _<%= schema.singular %>} -> - put_flash(socket, :info, "Email changed successfully.") - - {:error, _} -> - put_flash(socket, :error, "Email change link is invalid or it has expired.") - end - - {:ok, push_navigate(socket, to: ~p"<%= schema.route_prefix %>/settings")} - end - - def mount(_params, _session, socket) do - <%= schema.singular %> = socket.assigns.<%= scope_config.scope.assign_key %>.<%= schema.singular %> - email_changeset = <%= inspect context.alias %>.change_<%= schema.singular %>_email(<%= schema.singular %>, %{}, validate_unique: false) - password_changeset = <%= inspect context.alias %>.change_<%= schema.singular %>_password(<%= schema.singular %>, %{}, hash_password: false) - - socket = - socket - |> assign(:current_email, <%= schema.singular %>.email) - |> assign(:email_form, to_form(email_changeset)) - |> assign(:password_form, to_form(password_changeset)) - |> assign(:trigger_submit, false) - - {:ok, socket} - end - - @impl true - def handle_event("validate_email", params, socket) do - %{"<%= schema.singular %>" => <%= schema.singular %>_params} = params - - email_form = - socket.assigns.<%= scope_config.scope.assign_key %>.<%= schema.singular %> - |> <%= inspect context.alias %>.change_<%= schema.singular %>_email(<%= schema.singular %>_params, validate_unique: false) - |> Map.put(:action, :validate) - |> to_form() - - {:noreply, assign(socket, email_form: email_form)} - end - - def handle_event("update_email", params, socket) do - %{"<%= schema.singular %>" => <%= schema.singular %>_params} = params - <%= schema.singular %> = socket.assigns.<%= scope_config.scope.assign_key %>.<%= schema.singular %> - true = <%= inspect context.alias %>.sudo_mode?(<%= schema.singular %>) - - case <%= inspect context.alias %>.change_<%= schema.singular %>_email(<%= schema.singular %>, <%= schema.singular %>_params) do - %{valid?: true} = changeset -> - <%= inspect context.alias %>.deliver_<%= schema.singular %>_update_email_instructions( - Ecto.Changeset.apply_action!(changeset, :insert), - <%= schema.singular %>.email, - &url(~p"<%= schema.route_prefix %>/settings/confirm-email/#{&1}") - ) - - info = "A link to confirm your email change has been sent to the new address." - {:noreply, socket |> put_flash(:info, info)} - - changeset -> - {:noreply, assign(socket, :email_form, to_form(changeset, action: :insert))} - end - end - - def handle_event("validate_password", params, socket) do - %{"<%= schema.singular %>" => <%= schema.singular %>_params} = params - - password_form = - socket.assigns.<%= scope_config.scope.assign_key %>.<%= schema.singular %> - |> <%= inspect context.alias %>.change_<%= schema.singular %>_password(<%= schema.singular %>_params, hash_password: false) - |> Map.put(:action, :validate) - |> to_form() - - {:noreply, assign(socket, password_form: password_form)} - end - - def handle_event("update_password", params, socket) do - %{"<%= schema.singular %>" => <%= schema.singular %>_params} = params - <%= schema.singular %> = socket.assigns.<%= scope_config.scope.assign_key %>.<%= schema.singular %> - true = <%= inspect context.alias %>.sudo_mode?(<%= schema.singular %>) - - case <%= inspect context.alias %>.change_<%= schema.singular %>_password(<%= schema.singular %>, <%= schema.singular %>_params) do - %{valid?: true} = changeset -> - {:noreply, assign(socket, trigger_submit: true, password_form: to_form(changeset))} - - changeset -> - {:noreply, assign(socket, password_form: to_form(changeset, action: :insert))} - end - end -end diff --git a/priv/templates/phx.gen.auth/settings_live_test.exs b/priv/templates/phx.gen.auth/settings_live_test.exs deleted file mode 100644 index a025eec..0000000 --- a/priv/templates/phx.gen.auth/settings_live_test.exs +++ /dev/null @@ -1,212 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.SettingsTest do - use <%= inspect context.web_module %>.ConnCase<%= test_case_options %> - - alias <%= inspect context.module %> - import Phoenix.LiveViewTest - import <%= inspect context.module %>Fixtures - - describe "Settings page" do - test "renders settings page", %{conn: conn} do - {:ok, _lv, html} = - conn - |> log_in_<%= schema.singular %>(<%= schema.singular %>_fixture()) - |> live(~p"<%= schema.route_prefix %>/settings") - - assert html =~ "Change Email" - assert html =~ "Save Password" - end - - test "redirects if <%= schema.singular %> is not logged in", %{conn: conn} do - assert {:error, redirect} = live(conn, ~p"<%= schema.route_prefix %>/settings") - - assert {:redirect, %{to: path, flash: flash}} = redirect - assert path == ~p"<%= schema.route_prefix %>/log-in" - assert %{"error" => "You must log in to access this page."} = flash - end - - test "redirects if <%= schema.singular %> is not in sudo mode", %{conn: conn} do - {:ok, conn} = - conn - |> log_in_<%= schema.singular %>(<%= schema.singular %>_fixture(), - token_authenticated_at: <%= inspect datetime_module %>.add(<%= datetime_now %>, -11, :minute) - ) - |> live(~p"<%= schema.route_prefix %>/settings") - |> follow_redirect(conn, ~p"<%= schema.route_prefix %>/log-in") - - assert conn.resp_body =~ "You must re-authenticate to access this page." - end - end - - describe "update email form" do - setup %{conn: conn} do - <%= schema.singular %> = <%= schema.singular %>_fixture() - %{conn: log_in_<%= schema.singular %>(conn, <%= schema.singular %>), <%= schema.singular %>: <%= schema.singular %>} - end - - test "updates the <%= schema.singular %> email", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - new_email = unique_<%= schema.singular %>_email() - - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/settings") - - result = - lv - |> form("#email_form", %{ - "<%= schema.singular %>" => %{"email" => new_email} - }) - |> render_submit() - - assert result =~ "A link to confirm your email" - assert <%= inspect context.alias %>.get_<%= schema.singular %>_by_email(<%= schema.singular %>.email) - end - - test "renders errors with invalid data (phx-change)", %{conn: conn} do - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/settings") - - result = - lv - |> element("#email_form") - |> render_change(%{ - "action" => "update_email", - "<%= schema.singular %>" => %{"email" => "with spaces"} - }) - - assert result =~ "Change Email" - assert result =~ "must have the @ sign and no spaces" - end - - test "renders errors with invalid data (phx-submit)", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/settings") - - result = - lv - |> form("#email_form", %{ - "<%= schema.singular %>" => %{"email" => <%= schema.singular %>.email} - }) - |> render_submit() - - assert result =~ "Change Email" - assert result =~ "did not change" - end - end - - describe "update password form" do - setup %{conn: conn} do - <%= schema.singular %> = <%= schema.singular %>_fixture() - %{conn: log_in_<%= schema.singular %>(conn, <%= schema.singular %>), <%= schema.singular %>: <%= schema.singular %>} - end - - test "updates the <%= schema.singular %> password", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - new_password = valid_<%= schema.singular %>_password() - - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/settings") - - form = - form(lv, "#password_form", %{ - "<%= schema.singular %>" => %{ - "email" => <%= schema.singular %>.email, - "password" => new_password, - "password_confirmation" => new_password - } - }) - - render_submit(form) - - new_password_conn = follow_trigger_action(form, conn) - - assert redirected_to(new_password_conn) == ~p"<%= schema.route_prefix %>/settings" - - assert get_session(new_password_conn, :<%= schema.singular %>_token) != get_session(conn, :<%= schema.singular %>_token) - - assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~ - "Password updated successfully" - - assert <%= inspect context.alias %>.get_<%= schema.singular %>_by_email_and_password(<%= schema.singular %>.email, new_password) - end - - test "renders errors with invalid data (phx-change)", %{conn: conn} do - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/settings") - - result = - lv - |> element("#password_form") - |> render_change(%{ - "<%= schema.singular %>" => %{ - "password" => "too short", - "password_confirmation" => "does not match" - } - }) - - assert result =~ "Save Password" - assert result =~ "should be at least 12 character(s)" - assert result =~ "does not match password" - end - - test "renders errors with invalid data (phx-submit)", %{conn: conn} do - {:ok, lv, _html} = live(conn, ~p"<%= schema.route_prefix %>/settings") - - result = - lv - |> form("#password_form", %{ - "<%= schema.singular %>" => %{ - "password" => "too short", - "password_confirmation" => "does not match" - } - }) - |> render_submit() - - assert result =~ "Save Password" - assert result =~ "should be at least 12 character(s)" - assert result =~ "does not match password" - end - end - - describe "confirm email" do - setup %{conn: conn} do - <%= schema.singular %> = <%= schema.singular %>_fixture() - email = unique_<%= schema.singular %>_email() - - token = - extract_<%= schema.singular %>_token(fn url -> - <%= inspect context.alias %>.deliver_<%= schema.singular %>_update_email_instructions(%{<%= schema.singular %> | email: email}, <%= schema.singular %>.email, url) - end) - - %{conn: log_in_<%= schema.singular %>(conn, <%= schema.singular %>), token: token, email: email, <%= schema.singular %>: <%= schema.singular %>} - end - - test "updates the <%= schema.singular %> email once", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>, token: token, email: email} do - {:error, redirect} = live(conn, ~p"<%= schema.route_prefix %>/settings/confirm-email/#{token}") - - assert {:live_redirect, %{to: path, flash: flash}} = redirect - assert path == ~p"<%= schema.route_prefix %>/settings" - assert %{"info" => message} = flash - assert message == "Email changed successfully." - refute <%= inspect context.alias %>.get_<%= schema.singular %>_by_email(<%= schema.singular %>.email) - assert <%= inspect context.alias %>.get_<%= schema.singular %>_by_email(email) - - # use confirm token again - {:error, redirect} = live(conn, ~p"<%= schema.route_prefix %>/settings/confirm-email/#{token}") - assert {:live_redirect, %{to: path, flash: flash}} = redirect - assert path == ~p"<%= schema.route_prefix %>/settings" - assert %{"error" => message} = flash - assert message == "Email change link is invalid or it has expired." - end - - test "does not update email with invalid token", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do - {:error, redirect} = live(conn, ~p"<%= schema.route_prefix %>/settings/confirm-email/oops") - assert {:live_redirect, %{to: path, flash: flash}} = redirect - assert path == ~p"<%= schema.route_prefix %>/settings" - assert %{"error" => message} = flash - assert message == "Email change link is invalid or it has expired." - assert <%= inspect context.alias %>.get_<%= schema.singular %>_by_email(<%= schema.singular %>.email) - end - - test "redirects if <%= schema.singular %> is not logged in", %{token: token} do - conn = build_conn() - {:error, redirect} = live(conn, ~p"<%= schema.route_prefix %>/settings/confirm-email/#{token}") - assert {:redirect, %{to: path, flash: flash}} = redirect - assert path == ~p"<%= schema.route_prefix %>/log-in" - assert %{"error" => message} = flash - assert message == "You must log in to access this page." - end - end -end diff --git a/priv/templates/phx.gen.auth/test_cases.exs b/priv/templates/phx.gen.auth/test_cases.exs deleted file mode 100644 index 80bb6ed..0000000 --- a/priv/templates/phx.gen.auth/test_cases.exs +++ /dev/null @@ -1,391 +0,0 @@ - import <%= inspect context.module %>Fixtures - alias <%= inspect context.module %>.{<%= inspect schema.alias %>, <%= inspect schema.alias %>Token} - - describe "get_<%= schema.singular %>_by_email/1" do - test "does not return the <%= schema.singular %> if the email does not exist" do - refute <%= inspect context.alias %>.get_<%= schema.singular %>_by_email("unknown@example.com") - end - - test "returns the <%= schema.singular %> if the email exists" do - %{id: id} = <%= schema.singular %> = <%= schema.singular %>_fixture() - assert %<%= inspect schema.alias %>{id: ^id} = <%= inspect context.alias %>.get_<%= schema.singular %>_by_email(<%= schema.singular %>.email) - end - end - - describe "get_<%= schema.singular %>_by_email_and_password/2" do - test "does not return the <%= schema.singular %> if the email does not exist" do - refute <%= inspect context.alias %>.get_<%= schema.singular %>_by_email_and_password("unknown@example.com", "hello world!") - end - - test "does not return the <%= schema.singular %> if the password is not valid" do - <%= schema.singular %> = <%= schema.singular %>_fixture() |> set_password() - refute <%= inspect context.alias %>.get_<%= schema.singular %>_by_email_and_password(<%= schema.singular %>.email, "invalid") - end - - test "returns the <%= schema.singular %> if the email and password are valid" do - %{id: id} = <%= schema.singular %> = <%= schema.singular %>_fixture() |> set_password() - - assert %<%= inspect schema.alias %>{id: ^id} = - <%= inspect context.alias %>.get_<%= schema.singular %>_by_email_and_password(<%= schema.singular %>.email, valid_<%= schema.singular %>_password()) - end - end - - describe "get_<%= schema.singular %>!/1" do - test "raises if id is invalid" do - assert_raise Ecto.NoResultsError, fn -> - <%= inspect context.alias %>.get_<%= schema.singular %>!(<%= inspect schema.sample_id %>) - end - end - - test "returns the <%= schema.singular %> with the given id" do - %{id: id} = <%= schema.singular %> = <%= schema.singular %>_fixture() - assert %<%= inspect schema.alias %>{id: ^id} = <%= inspect context.alias %>.get_<%= schema.singular %>!(<%=schema.singular %>.id) - end - end - - describe "register_<%= schema.singular %>/1" do - test "requires email to be set" do - {:error, changeset} = <%= inspect context.alias %>.register_<%= schema.singular %>(%{}) - - assert %{email: ["can't be blank"]} = errors_on(changeset) - end - - test "validates email when given" do - {:error, changeset} = <%= inspect context.alias %>.register_<%= schema.singular %>(%{email: "not valid"}) - - assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset) - end - - test "validates maximum values for email for security" do - too_long = String.duplicate("db", 100) - {:error, changeset} = <%= inspect context.alias %>.register_<%= schema.singular %>(%{email: too_long}) - assert "should be at most 160 character(s)" in errors_on(changeset).email - end - - test "validates email uniqueness" do - %{email: email} = <%= schema.singular %>_fixture() - {:error, changeset} = <%= inspect context.alias %>.register_<%= schema.singular %>(%{email: email}) - assert "has already been taken" in errors_on(changeset).email - - # Now try with the uppercased email too, to check that email case is ignored. - {:error, changeset} = <%= inspect context.alias %>.register_<%= schema.singular %>(%{email: String.upcase(email)}) - assert "has already been taken" in errors_on(changeset).email - end - - test "registers <%= schema.plural %> without password" do - email = unique_<%= schema.singular %>_email() - {:ok, <%= schema.singular %>} = <%= inspect context.alias %>.register_<%= schema.singular %>(valid_<%= schema.singular %>_attributes(email: email)) - assert <%= schema.singular %>.email == email - assert is_nil(<%= schema.singular %>.hashed_password) - assert is_nil(<%= schema.singular %>.confirmed_at) - assert is_nil(<%= schema.singular %>.password) - end - end - - describe "sudo_mode?/2" do - test "validates the authenticated_at time" do - now = <%= inspect datetime_module %>.utc_now() - - assert <%= inspect context.alias %>.sudo_mode?(%<%= inspect schema.alias %>{authenticated_at: <%= inspect datetime_module %>.utc_now()}) - assert <%= inspect context.alias %>.sudo_mode?(%<%= inspect schema.alias %>{authenticated_at: <%= inspect datetime_module %>.add(now, -19, :minute)}) - refute <%= inspect context.alias %>.sudo_mode?(%<%= inspect schema.alias %>{authenticated_at: <%= inspect datetime_module %>.add(now, -21, :minute)}) - - # minute override - refute <%= inspect context.alias %>.sudo_mode?( - %<%= inspect schema.alias %>{authenticated_at: <%= inspect datetime_module %>.add(now, -11, :minute)}, - -10 - ) - - # not authenticated - refute <%= inspect context.alias %>.sudo_mode?(%<%= inspect schema.alias %>{}) - end - end - - describe "change_<%= schema.singular %>_email/3" do - test "returns a <%= schema.singular %> changeset" do - assert %Ecto.Changeset{} = changeset = <%= inspect context.alias %>.change_<%= schema.singular %>_email(%<%= inspect schema.alias %>{}) - assert changeset.required == [:email] - end - end - - describe "deliver_<%= schema.singular %>_update_email_instructions/3" do - setup do - %{<%= schema.singular %>: <%= schema.singular %>_fixture()} - end - - test "sends token through notification", %{<%= schema.singular %>: <%= schema.singular %>} do - token = - extract_<%= schema.singular %>_token(fn url -> - <%= inspect context.alias %>.deliver_<%= schema.singular %>_update_email_instructions(<%= schema.singular %>, "current@example.com", url) - end) - - {:ok, token} = Base.url_decode64(token, padding: false) - assert <%= schema.singular %>_token = Repo.get_by(<%= inspect schema.alias %>Token, token: :crypto.hash(:sha256, token)) - assert <%= schema.singular %>_token.<%= schema.singular %>_id == <%= schema.singular %>.id - assert <%= schema.singular %>_token.sent_to == <%= schema.singular %>.email - assert <%= schema.singular %>_token.context == "change:current@example.com" - end - end - - describe "update_<%= schema.singular %>_email/2" do - setup do - <%= schema.singular %> = unconfirmed_<%= schema.singular %>_fixture() - email = unique_<%= schema.singular %>_email() - - token = - extract_<%= schema.singular %>_token(fn url -> - <%= inspect context.alias %>.deliver_<%= schema.singular %>_update_email_instructions(%{<%= schema.singular %> | email: email}, <%= schema.singular %>.email, url) - end) - - %{<%= schema.singular %>: <%= schema.singular %>, token: token, email: email} - end - - test "updates the email with a valid token", %{<%= schema.singular %>: <%= schema.singular %>, token: token, email: email} do - assert {:ok, %{email: ^email}} = <%= inspect context.alias %>.update_<%= schema.singular %>_email(<%= schema.singular %>, token) - changed_<%= schema.singular %> = Repo.get!(<%= inspect schema.alias %>, <%= schema.singular %>.id) - assert changed_<%= schema.singular %>.email != <%= schema.singular %>.email - assert changed_<%= schema.singular %>.email == email - refute Repo.get_by(<%= inspect schema.alias %>Token, <%= schema.singular %>_id: <%= schema.singular %>.id) - end - - test "does not update email with invalid token", %{<%= schema.singular %>: <%= schema.singular %>} do - assert <%= inspect context.alias %>.update_<%= schema.singular %>_email(<%= schema.singular %>, "oops") == - {:error, :transaction_aborted} - - assert Repo.get!(<%= inspect schema.alias %>, <%= schema.singular %>.id).email == <%= schema.singular %>.email - assert Repo.get_by(<%= inspect schema.alias %>Token, <%= schema.singular %>_id: <%= schema.singular %>.id) - end - - test "does not update email if <%= schema.singular %> email changed", %{<%= schema.singular %>: <%= schema.singular %>, token: token} do - assert <%= inspect context.alias %>.update_<%= schema.singular %>_email(%{<%= schema.singular %> | email: "current@example.com"}, token) == - {:error, :transaction_aborted} - - assert Repo.get!(<%= inspect schema.alias %>, <%= schema.singular %>.id).email == <%= schema.singular %>.email - assert Repo.get_by(<%= inspect schema.alias %>Token, <%= schema.singular %>_id: <%= schema.singular %>.id) - end - - test "does not update email if token expired", %{<%= schema.singular %>: <%= schema.singular %>, token: token} do - {1, nil} = Repo.update_all(<%= inspect schema.alias %>Token, set: [inserted_at: ~N[2020-01-01 00:00:00]]) - - assert <%= inspect context.alias %>.update_<%= schema.singular %>_email(<%= schema.singular %>, token) == - {:error, :transaction_aborted} - - assert Repo.get!(<%= inspect schema.alias %>, <%= schema.singular %>.id).email == <%= schema.singular %>.email - assert Repo.get_by(<%= inspect schema.alias %>Token, <%= schema.singular %>_id: <%= schema.singular %>.id) - end - end - - describe "change_<%= schema.singular %>_password/3" do - test "returns a <%= schema.singular %> changeset" do - assert %Ecto.Changeset{} = changeset = <%= inspect context.alias %>.change_<%= schema.singular %>_password(%<%= inspect schema.alias %>{}) - assert changeset.required == [:password] - end - - test "allows fields to be set" do - changeset = - <%= inspect context.alias %>.change_<%= schema.singular %>_password( - %<%= inspect schema.alias %>{}, - %{ - "password" => "new valid password" - }, - hash_password: false - ) - - assert changeset.valid? - assert get_change(changeset, :password) == "new valid password" - assert is_nil(get_change(changeset, :hashed_password)) - end - end - - describe "update_<%= schema.singular %>_password/2" do - setup do - %{<%= schema.singular %>: <%= schema.singular %>_fixture()} - end - - test "validates password", %{<%= schema.singular %>: <%= schema.singular %>} do - {:error, changeset} = - <%= inspect context.alias %>.update_<%= schema.singular %>_password(<%= schema.singular %>, %{ - password: "not valid", - password_confirmation: "another" - }) - - assert %{ - password: ["should be at least 12 character(s)"], - password_confirmation: ["does not match password"] - } = errors_on(changeset) - end - - test "validates maximum values for password for security", %{<%= schema.singular %>: <%= schema.singular %>} do - too_long = String.duplicate("db", 100) - - {:error, changeset} = - <%= inspect context.alias %>.update_<%= schema.singular %>_password(<%= schema.singular %>, %{password: too_long}) - - assert "should be at most 72 character(s)" in errors_on(changeset).password - end - - test "updates the password", %{<%= schema.singular %>: <%= schema.singular %>} do - {:ok, {<%= schema.singular %>, expired_tokens}} = - <%= inspect context.alias %>.update_<%= schema.singular %>_password(<%= schema.singular %>, %{ - password: "new valid password" - }) - - assert expired_tokens == [] - assert is_nil(<%= schema.singular %>.password) - assert <%= inspect context.alias %>.get_<%= schema.singular %>_by_email_and_password(<%= schema.singular %>.email, "new valid password") - end - - test "deletes all tokens for the given <%= schema.singular %>", %{<%= schema.singular %>: <%= schema.singular %>} do - _ = <%= inspect context.alias %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>) - - {:ok, {_, _}} = - <%= inspect context.alias %>.update_<%= schema.singular %>_password(<%= schema.singular %>, %{ - password: "new valid password" - }) - - refute Repo.get_by(<%= inspect schema.alias %>Token, <%= schema.singular %>_id: <%= schema.singular %>.id) - end - end - - describe "generate_<%= schema.singular %>_session_token/1" do - setup do - %{<%= schema.singular %>: <%= schema.singular %>_fixture()} - end - - test "generates a token", %{<%= schema.singular %>: <%= schema.singular %>} do - token = <%= inspect context.alias %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>) - assert <%= schema.singular %>_token = Repo.get_by(<%= inspect schema.alias %>Token, token: token) - assert <%= schema.singular %>_token.context == "session" - assert <%= schema.singular %>_token.authenticated_at != nil - - # Creating the same token for another <%= schema.singular %> should fail - assert_raise Ecto.ConstraintError, fn -> - Repo.insert!(%<%= inspect schema.alias %>Token{ - token: <%= schema.singular %>_token.token, - <%= schema.singular %>_id: <%= schema.singular %>_fixture().id, - context: "session" - }) - end - end - - test "duplicates the authenticated_at of given <%= schema.singular %> in new token", %{<%= schema.singular %>: <%= schema.singular %>} do - <%= schema.singular %> = %{<%= schema.singular %> | authenticated_at: <%= inspect datetime_module %>.add(<%= datetime_now %>, -3600)} - token = <%= inspect context.alias %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>) - assert <%= schema.singular %>_token = Repo.get_by(<%= inspect schema.alias %>Token, token: token) - assert <%= schema.singular %>_token.authenticated_at == <%= schema.singular %>.authenticated_at - assert <%= inspect datetime_module %>.compare(<%= schema.singular %>_token.inserted_at, <%= schema.singular %>.authenticated_at) == :gt - end - end - - describe "get_<%= schema.singular %>_by_session_token/1" do - setup do - <%= schema.singular %> = <%= schema.singular %>_fixture() - token = <%= inspect context.alias %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>) - %{<%= schema.singular %>: <%= schema.singular %>, token: token} - end - - test "returns <%= schema.singular %> by token", %{<%= schema.singular %>: <%= schema.singular %>, token: token} do - assert {session_<%= schema.singular %>, token_inserted_at} = <%= inspect context.alias %>.get_<%= schema.singular %>_by_session_token(token) - assert session_<%= schema.singular %>.id == <%= schema.singular %>.id - assert session_<%= schema.singular %>.authenticated_at != nil - assert token_inserted_at != nil - end - - test "does not return <%= schema.singular %> for invalid token" do - refute <%= inspect context.alias %>.get_<%= schema.singular %>_by_session_token("oops") - end - - test "does not return <%= schema.singular %> for expired token", %{token: token} do - dt = ~N[2020-01-01 00:00:00] - {1, nil} = Repo.update_all(<%= inspect schema.alias %>Token, set: [inserted_at: dt, authenticated_at: dt]) - refute <%= inspect context.alias %>.get_<%= schema.singular %>_by_session_token(token) - end - end - - describe "get_<%= schema.singular %>_by_magic_link_token/1" do - setup do - <%= schema.singular %> = <%= schema.singular %>_fixture() - {encoded_token, _hashed_token} = generate_<%= schema.singular %>_magic_link_token(<%= schema.singular %>) - %{<%= schema.singular %>: <%= schema.singular %>, token: encoded_token} - end - - test "returns <%= schema.singular %> by token", %{<%= schema.singular %>: <%= schema.singular %>, token: token} do - assert session_<%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>_by_magic_link_token(token) - assert session_<%= schema.singular %>.id == <%= schema.singular %>.id - end - - test "does not return <%= schema.singular %> for invalid token" do - refute <%= inspect context.alias %>.get_<%= schema.singular %>_by_magic_link_token("oops") - end - - test "does not return <%= schema.singular %> for expired token", %{token: token} do - {1, nil} = Repo.update_all(<%= inspect schema.alias %>Token, set: [inserted_at: ~N[2020-01-01 00:00:00]]) - refute <%= inspect context.alias %>.get_<%= schema.singular %>_by_magic_link_token(token) - end - end - - describe "login_<%= schema.singular %>_by_magic_link/1" do - test "confirms <%= schema.singular %> and expires tokens" do - <%= schema.singular %> = unconfirmed_<%= schema.singular %>_fixture() - refute <%= schema.singular %>.confirmed_at - {encoded_token, hashed_token} = generate_<%= schema.singular %>_magic_link_token(<%= schema.singular %>) - - assert {:ok, {<%= schema.singular %>, [%{token: ^hashed_token}]}} = - <%= inspect context.alias %>.login_<%= schema.singular %>_by_magic_link(encoded_token) - - assert <%= schema.singular %>.confirmed_at - end - - test "returns <%= schema.singular %> and (deleted) token for confirmed <%= schema.singular %>" do - <%= schema.singular %> = <%= schema.singular %>_fixture() - assert <%= schema.singular %>.confirmed_at - {encoded_token, _hashed_token} = generate_<%= schema.singular %>_magic_link_token(<%= schema.singular %>) - assert {:ok, {^<%= schema.singular %>, []}} = <%= inspect context.alias %>.login_<%= schema.singular %>_by_magic_link(encoded_token) - # one time use only - assert {:error, :not_found} = <%= inspect context.alias %>.login_<%= schema.singular %>_by_magic_link(encoded_token) - end - - test "raises when unconfirmed <%= schema.singular %> has password set" do - <%= schema.singular %> = unconfirmed_<%= schema.singular %>_fixture() - {1, nil} = Repo.update_all(<%= inspect schema.alias %>, set: [hashed_password: "hashed"]) - {encoded_token, _hashed_token} = generate_<%= schema.singular %>_magic_link_token(<%= schema.singular %>) - - assert_raise RuntimeError, ~r/magic link log in is not allowed/, fn -> - <%= inspect context.alias %>.login_<%= schema.singular %>_by_magic_link(encoded_token) - end - end - end - - describe "delete_<%= schema.singular %>_session_token/1" do - test "deletes the token" do - <%= schema.singular %> = <%= schema.singular %>_fixture() - token = <%= inspect context.alias %>.generate_<%= schema.singular %>_session_token(<%= schema.singular %>) - assert <%= inspect context.alias %>.delete_<%= schema.singular %>_session_token(token) == :ok - refute <%= inspect context.alias %>.get_<%= schema.singular %>_by_session_token(token) - end - end - - describe "deliver_login_instructions/2" do - setup do - %{<%= schema.singular %>: unconfirmed_<%= schema.singular %>_fixture()} - end - - test "sends token through notification", %{<%= schema.singular %>: <%= schema.singular %>} do - token = - extract_<%= schema.singular %>_token(fn url -> - <%= inspect context.alias %>.deliver_login_instructions(<%= schema.singular %>, url) - end) - - {:ok, token} = Base.url_decode64(token, padding: false) - assert <%= schema.singular %>_token = Repo.get_by(<%= inspect schema.alias %>Token, token: :crypto.hash(:sha256, token)) - assert <%= schema.singular %>_token.<%= schema.singular %>_id == <%= schema.singular %>.id - assert <%= schema.singular %>_token.sent_to == <%= schema.singular %>.email - assert <%= schema.singular %>_token.context == "login" - end - end - - describe "inspect/2 for the <%= inspect schema.alias %> module" do - test "does not include password" do - refute inspect(%<%= inspect schema.alias %>{password: "123456"}) =~ "password: \"123456\"" - end - end diff --git a/priv/templates/phx.gen.html/controller.ex b/priv/templates/phx.gen.html/controller.ex deleted file mode 100644 index 7ef1927..0000000 --- a/priv/templates/phx.gen.html/controller.ex +++ /dev/null @@ -1,67 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Controller do - use <%= inspect context.web_module %>, :controller - - alias <%= inspect context.module %> - alias <%= inspect schema.module %> - - def index(conn, _params) do - <%= schema.plural %> = <%= inspect context.alias %>.list_<%= schema.plural %>(<%= conn_scope %>) - render(conn, :index, <%= schema.collection %>: <%= schema.plural %>) - end - - def new(conn, _params) do<%= if scope do %> - changeset = - <%= inspect context.alias %>.change_<%= schema.singular %>(<%= context_scope_prefix %>%<%= inspect schema.alias %>{ - <%= scope.schema_key %>: <%= conn_scope %>.<%= Enum.join(scope.access_path, ".") %> - }) -<% else %> - changeset = <%= inspect context.alias %>.change_<%= schema.singular %>(%<%= inspect schema.alias %>{})<% end %> - render(conn, :new, changeset: changeset) - end - - def create(conn, %{<%= inspect schema.singular %> => <%= schema.singular %>_params}) do - case <%= inspect context.alias %>.create_<%= schema.singular %>(<%= context_scope_prefix %><%= schema.singular %>_params) do - {:ok, <%= schema.singular %>} -> - conn - |> put_flash(:info, "<%= schema.human_singular %> created successfully.") - |> redirect(to: ~p"<%= scope_conn_route_prefix %><%= schema.route_prefix %>/#{<%= schema.singular %>}") - - {:error, %Ecto.Changeset{} = changeset} -> - render(conn, :new, changeset: changeset) - end - end - - def show(conn, %{"<%= primary_key %>" => <%= primary_key %>}) do - <%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>!(<%= context_scope_prefix %><%= primary_key %>) - render(conn, :show, <%= schema.singular %>: <%= schema.singular %>) - end - - def edit(conn, %{"<%= primary_key %>" => <%= primary_key %>}) do - <%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>!(<%= context_scope_prefix %><%= primary_key %>) - changeset = <%= inspect context.alias %>.change_<%= schema.singular %>(<%= context_scope_prefix %><%= schema.singular %>) - render(conn, :edit, <%= schema.singular %>: <%= schema.singular %>, changeset: changeset) - end - - def update(conn, %{"<%= primary_key %>" => <%= primary_key %>, <%= inspect schema.singular %> => <%= schema.singular %>_params}) do - <%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>!(<%= context_scope_prefix %><%= primary_key %>) - - case <%= inspect context.alias %>.update_<%= schema.singular %>(<%= context_scope_prefix %><%= schema.singular %>, <%= schema.singular %>_params) do - {:ok, <%= schema.singular %>} -> - conn - |> put_flash(:info, "<%= schema.human_singular %> updated successfully.") - |> redirect(to: ~p"<%= scope_conn_route_prefix %><%= schema.route_prefix %>/#{<%= schema.singular %>}") - - {:error, %Ecto.Changeset{} = changeset} -> - render(conn, :edit, <%= schema.singular %>: <%= schema.singular %>, changeset: changeset) - end - end - - def delete(conn, %{"<%= primary_key %>" => <%= primary_key %>}) do - <%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>!(<%= context_scope_prefix %><%= primary_key %>) - {:ok, _<%= schema.singular %>} = <%= inspect context.alias %>.delete_<%= schema.singular %>(<%= context_scope_prefix %><%= schema.singular %>) - - conn - |> put_flash(:info, "<%= schema.human_singular %> deleted successfully.") - |> redirect(to: ~p"<%= scope_conn_route_prefix %><%= schema.route_prefix %>") - end -end diff --git a/priv/templates/phx.gen.html/controller_test.exs b/priv/templates/phx.gen.html/controller_test.exs deleted file mode 100644 index 721b708..0000000 --- a/priv/templates/phx.gen.html/controller_test.exs +++ /dev/null @@ -1,90 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>ControllerTest do - use <%= inspect context.web_module %>.ConnCase - - import <%= inspect context.module %>Fixtures - - @create_attrs <%= Mix.Phoenix.to_text schema.params.create %> - @update_attrs <%= Mix.Phoenix.to_text schema.params.update %> - @invalid_attrs <%= Mix.Phoenix.to_text (for {key, _} <- schema.params.create, into: %{}, do: {key, nil}) %><%= if scope do %> - - setup :<%= scope.test_setup_helper %><% end %> - - describe "index" do - test "lists all <%= schema.plural %>", %{conn: conn<%= test_context_scope %>} do - conn = get(conn, ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>") - assert html_response(conn, 200) =~ "Listing <%= schema.human_plural %>" - end - end - - describe "new <%= schema.singular %>" do - test "renders form", %{conn: conn<%= test_context_scope %>} do - conn = get(conn, ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>/new") - assert html_response(conn, 200) =~ "New <%= schema.human_singular %>" - end - end - - describe "create <%= schema.singular %>" do - test "redirects to show when data is valid", %{conn: conn<%= test_context_scope %>} do - conn = post(conn, ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>", <%= schema.singular %>: @create_attrs) - - assert %{<%= primary_key %>: <%= primary_key %>} = redirected_params(conn) - assert redirected_to(conn) == ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>/#{<%= primary_key %>}" - - conn = get(conn, ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>/#{<%= primary_key %>}") - assert html_response(conn, 200) =~ "<%= schema.human_singular %> #{<%= primary_key %>}" - end - - test "renders errors when data is invalid", %{conn: conn<%= test_context_scope %>} do - conn = post(conn, ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>", <%= schema.singular %>: @invalid_attrs) - assert html_response(conn, 200) =~ "New <%= schema.human_singular %>" - end - end - - describe "edit <%= schema.singular %>" do - setup [:create_<%= schema.singular %>] - - test "renders form for editing chosen <%= schema.singular %>", %{conn: conn, <%= schema.singular %>: <%= schema.singular %><%= test_context_scope %>} do - conn = get(conn, ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>/#{<%= schema.singular %>}/edit") - assert html_response(conn, 200) =~ "Edit <%= schema.human_singular %>" - end - end - - describe "update <%= schema.singular %>" do - setup [:create_<%= schema.singular %>] - - test "redirects when data is valid", %{conn: conn, <%= schema.singular %>: <%= schema.singular %><%= test_context_scope %>} do - conn = put(conn, ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>/#{<%= schema.singular %>}", <%= schema.singular %>: @update_attrs) - assert redirected_to(conn) == ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>/#{<%= schema.singular %>}" - - conn = get(conn, ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>/#{<%= schema.singular %>}")<%= if schema.string_attr do %> - assert html_response(conn, 200) =~ <%= inspect Mix.Phoenix.Schema.default_param(schema, :update) %><% else %> - assert html_response(conn, 200)<% end %> - end - - test "renders errors when data is invalid", %{conn: conn, <%= schema.singular %>: <%= schema.singular %><%= test_context_scope %>} do - conn = put(conn, ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>/#{<%= schema.singular %>}", <%= schema.singular %>: @invalid_attrs) - assert html_response(conn, 200) =~ "Edit <%= schema.human_singular %>" - end - end - - describe "delete <%= schema.singular %>" do - setup [:create_<%= schema.singular %>] - - test "deletes chosen <%= schema.singular %>", %{conn: conn, <%= schema.singular %>: <%= schema.singular %><%= test_context_scope %>} do - conn = delete(conn, ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>/#{<%= schema.singular %>}") - assert redirected_to(conn) == ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>" - - assert_error_sent 404, fn -> - get(conn, ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>/#{<%= schema.singular %>}") - end - end - end - -<%= if scope do %> defp create_<%= schema.singular %>(%{scope: scope}) do - <%= schema.singular %> = <%= schema.singular %>_fixture(scope) -<% else %> defp create_<%= schema.singular %>(_) do - <%= schema.singular %> = <%= schema.singular %>_fixture() -<% end %> - %{<%= schema.singular %>: <%= schema.singular %>} - end -end diff --git a/priv/templates/phx.gen.html/edit.html.heex b/priv/templates/phx.gen.html/edit.html.heex deleted file mode 100644 index 36f7ead..0000000 --- a/priv/templates/phx.gen.html/edit.html.heex +++ /dev/null @@ -1,8 +0,0 @@ - <%= scope.assign_key %>={@<%= scope.assign_key %>}<% end %>> - <.header> - Edit <%= schema.human_singular %> {@<%= schema.singular %>.<%= primary_key %>} - <:subtitle>Use this form to manage <%= schema.singular %> records in your database. - - - <.<%= schema.singular %>_form changeset={@changeset} action={~p"<%= scope_assign_route_prefix %><%= schema.route_prefix %>/#{@<%= schema.singular %>}"} return_to={~p"<%= scope_assign_route_prefix %><%= schema.route_prefix %>"} /> - diff --git a/priv/templates/phx.gen.html/html.ex b/priv/templates/phx.gen.html/html.ex deleted file mode 100644 index a8a190b..0000000 --- a/priv/templates/phx.gen.html/html.ex +++ /dev/null @@ -1,17 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>HTML do - use <%= inspect context.web_module %>, :html - - embed_templates "<%= schema.singular %>_html/*" - - @doc """ - Renders a <%= schema.singular %> form. - - The form is defined in the template at - <%= schema.singular %>_html/<%= schema.singular %>_form.html.heex - """ - attr :changeset, Ecto.Changeset, required: true - attr :action, :string, required: true - attr :return_to, :string, default: nil - - def <%= schema.singular %>_form(assigns) -end diff --git a/priv/templates/phx.gen.html/index.html.heex b/priv/templates/phx.gen.html/index.html.heex deleted file mode 100644 index 842ec6b..0000000 --- a/priv/templates/phx.gen.html/index.html.heex +++ /dev/null @@ -1,25 +0,0 @@ - <%= scope.assign_key %>={@<%= scope.assign_key %>}<% end %>> - <.header> - Listing <%= schema.human_plural %> - <:actions> - <.button href={~p"<%= scope_assign_route_prefix %><%= schema.route_prefix %>/new"}> - <.icon name="hero-plus" /> New <%= schema.human_singular %> - - - - - <.table id="<%= schema.plural %>" rows={@<%= schema.collection %>} row_click={&JS.navigate(~p"<%= scope_assign_route_prefix %><%= schema.route_prefix %>/#{&1}")}><%= for {k, _} <- schema.attrs do %> - <:col :let={<%= schema.singular %>} label="<%= Phoenix.Naming.humanize(Atom.to_string(k)) %>">{<%= schema.singular %>.<%= k %>}<% end %> - <:action :let={<%= schema.singular %>}> -
    - <.link navigate={~p"<%= scope_assign_route_prefix %><%= schema.route_prefix %>/#{<%= schema.singular %>}"}>Show -
    - <.link navigate={~p"<%= scope_assign_route_prefix %><%= schema.route_prefix %>/#{<%= schema.singular %>}/edit"}>Edit - - <:action :let={<%= schema.singular %>}> - <.link href={~p"<%= scope_assign_route_prefix %><%= schema.route_prefix %>/#{<%= schema.singular %>}"} method="delete" data-confirm="Are you sure?"> - Delete - - - -
    diff --git a/priv/templates/phx.gen.html/new.html.heex b/priv/templates/phx.gen.html/new.html.heex deleted file mode 100644 index 9dce36f..0000000 --- a/priv/templates/phx.gen.html/new.html.heex +++ /dev/null @@ -1,8 +0,0 @@ - <%= scope.assign_key %>={@<%= scope.assign_key %>}<% end %>> - <.header> - New <%= schema.human_singular %> - <:subtitle>Use this form to manage <%= schema.singular %> records in your database. - - - <.<%= schema.singular %>_form changeset={@changeset} action={~p"<%= scope_assign_route_prefix %><%= schema.route_prefix %>"} return_to={~p"<%= scope_assign_route_prefix %><%= schema.route_prefix %>"} /> - diff --git a/priv/templates/phx.gen.html/resource_form.html.heex b/priv/templates/phx.gen.html/resource_form.html.heex deleted file mode 100644 index c0f98e1..0000000 --- a/priv/templates/phx.gen.html/resource_form.html.heex +++ /dev/null @@ -1,7 +0,0 @@ -<.form :let={f} for={@changeset} action={@action}> -<%= Mix.Tasks.Phx.Gen.Html.indent_inputs(inputs, 2) %> -
    - <.button variant="primary">Save <%= schema.human_singular %> - <.button :if={@return_to} href={@return_to}>Cancel -
    - diff --git a/priv/templates/phx.gen.html/show.html.heex b/priv/templates/phx.gen.html/show.html.heex deleted file mode 100644 index 87cf8db..0000000 --- a/priv/templates/phx.gen.html/show.html.heex +++ /dev/null @@ -1,18 +0,0 @@ - <%= scope.assign_key %>={@<%= scope.assign_key %>}<% end %>> - <.header> - <%= schema.human_singular %> {@<%= schema.singular %>.<%= primary_key %>} - <:subtitle>This is a <%= schema.singular %> record from your database. - <:actions> - <.button navigate={~p"<%= scope_assign_route_prefix %><%= schema.route_prefix %>"}> - <.icon name="hero-arrow-left" /> - - <.button variant="primary" navigate={~p"<%= scope_assign_route_prefix %><%= schema.route_prefix %>/#{@<%= schema.singular %>}/edit?return_to=show"}> - <.icon name="hero-pencil-square" /> Edit <%= schema.singular %> - - - - - <.list><%= for {k, _} <- schema.attrs do %> - <:item title="<%= Phoenix.Naming.humanize(Atom.to_string(k)) %>">{@<%= schema.singular %>.<%= k %>}<% end %> - - diff --git a/priv/templates/phx.gen.live/core_components.ex b/priv/templates/phx.gen.live/core_components.ex deleted file mode 100644 index 7445719..0000000 --- a/priv/templates/phx.gen.live/core_components.ex +++ /dev/null @@ -1,513 +0,0 @@ -defmodule <%= @web_namespace %>.CoreComponents do - @moduledoc """ - 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 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 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. - - """ - use Phoenix.Component<%= if @gettext do %> - use Gettext, backend: <%= @web_namespace %>.Gettext<% end %><%= if @live do %> - - alias Phoenix.LiveView.JS<% end %> - - @doc """ - Renders flash notices. - - ## Examples - - <.flash kind={:info} flash={@flash} /> - <.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back! - """ - attr :id, :string, doc: "the optional id of flash container" - attr :flash, :map, default: %{}, doc: "the map of flash messages to display" - attr :title, :string, default: nil - attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup" - attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container" - - slot :inner_block, doc: "the optional inner block that renders the flash message" - - def flash(assigns) do - assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end) - - ~H""" -
    - phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}<% else %> - data-flash<% end %> - role="alert" - class="toast toast-top toast-end z-50" - {@rest} - > -
    - <.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 with navigation support. - - ## Examples - - <.button>Send! - <.button phx-click="go" variant="primary">Send! - <.button navigate={~p"/"}>Home - """ - attr :rest, :global, include: ~w(href navigate patch method download name value disabled) - attr :class, :any - attr :variant, :string, values: ~w(primary) - slot :inner_block, required: true - - def button(%{rest: rest} = assigns) do - variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"} - - assigns = - assign_new(assigns, :class, fn -> - ["btn", Map.fetch!(variants, assigns[:variant])] - end) - - if rest[:href] || rest[:navigate] || rest[:patch] do - ~H""" - <.link class={@class} {@rest}> - {render_slot(@inner_block)} - - """ - else - ~H""" - - """ - end - end - - @doc """ - Renders an input with label and error messages. - - A `Phoenix.HTML.FormField` may be passed as argument, - which is used to retrieve the input name, id, and values. - Otherwise all attributes may be passed explicitly. - - ## Types - - This function accepts all HTML input types, considering that: - - * You may also set `type="select"` to render a ` - """ - end - - def input(%{type: "checkbox"} = assigns) do - assigns = - assign_new(assigns, :checked, fn -> - Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value]) - end) - - ~H""" -
    - - <.error :for={msg <- @errors}>{msg} -
    - """ - end - - def input(%{type: "select"} = assigns) do - ~H""" -
    - - <.error :for={msg <- @errors}>{msg} -
    - """ - end - - def input(%{type: "textarea"} = assigns) do - ~H""" -
    - - <.error :for={msg <- @errors}>{msg} -
    - """ - end - - # All other inputs text, datetime-local, url, password, etc. are handled here... - def input(assigns) do - ~H""" -
    - - <.error :for={msg <- @errors}>{msg} -
    - """ - end - - # Helper used by inputs to generate form errors - defp error(assigns) do - ~H""" -

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

    - """ - end - - @doc """ - Renders a header with title. - """ - slot :inner_block, required: true - slot :subtitle - slot :actions - - def header(assigns) do - ~H""" -
    -
    -

    - {render_slot(@inner_block)} -

    -

    - {render_slot(@subtitle)} -

    -
    -
    {render_slot(@actions)}
    -
    - """ - end - - @doc """ - Renders a table with generic styling. - - ## Examples - - <.table id="users" rows={@users}> - <:col :let={user} label="id">{user.id} - <:col :let={user} label="username">{user.username} - - """ - attr :id, :string, required: true - attr :rows, :list, required: true - attr :row_id, :any, default: nil, doc: "the function for generating the row id" - attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row" - - attr :row_item, :any, - default: &Function.identity/1, - doc: "the function for mapping each row before calling the :col and :action slots" - - slot :col, required: true do - attr :label, :string - end - - slot :action, doc: "the slot for showing user actions in the last table column" - - def table(assigns) do - assigns = - with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do - assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) - end - - ~H""" - - - - - - - - - - - - - -
    {col[:label]} - <%= maybe_eex_gettext.("Actions", @gettext) %> -
    - {render_slot(col, @row_item.(row))} - -
    - <%%= for action <- @action do %> - {render_slot(action, @row_item.(row))} - <%% end %> -
    -
    - """ - end - - @doc """ - Renders a data list. - - ## Examples - - <.list> - <:item title="Title">{@post.title} - <:item title="Views">{@post.views} - - """ - slot :item, required: true do - attr :title, :string, required: true - end - - def list(assigns) do - ~H""" -
      -
    • -
      -
      {item.title}
      -
      {render_slot(item)}
      -
      -
    • -
    - """ - end - - @doc """ - Renders a [Heroicon](https://heroicons.com). - - Heroicons come in three styles – outline, solid, and mini. - By default, the outline style is used, but solid and mini may - be applied by using the `-solid` and `-mini` suffix. - - You can customize the size and colors of the icons by setting - 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 `assets/vendor/heroicons.js`. - - ## Examples - - <.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, :any, default: "size-4" - - def icon(%{name: "hero-" <> _} = assigns) do - ~H""" - - """ - end<%= if @live do %> - - ## JS Commands - - def show(js \\ %JS{}, selector) do - JS.show(js, - to: selector, - time: 300, - transition: - {"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"} - ) - end - - def hide(js \\ %JS{}, selector) do - JS.hide(js, - to: selector, - time: 200, - transition: - {"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<% end %> - - @doc """ - Translates an error message using gettext. - """<%= if @gettext do %> - def translate_error({msg, opts}) do - # When using gettext, we typically pass the strings we want - # to translate as a static argument: - # - # # Translate the number of files with plural rules - # dngettext("errors", "1 file", "%{count} files", count) - # - # However the error messages in our forms and APIs are generated - # dynamically, so we need to translate them by calling Gettext - # with our gettext backend as first argument. Translations are - # available in the errors.po file (as we use the "errors" domain). - if count = opts[:count] do - Gettext.dngettext(<%= @web_namespace %>.Gettext, "errors", msg, msg, count, opts) - else - Gettext.dgettext(<%= @web_namespace %>.Gettext, "errors", msg, opts) - end - end<% else %> - def translate_error({msg, opts}) do - # You can make use of gettext to translate error messages by - # uncommenting and adjusting the following code: - - # if count = opts[:count] do - # Gettext.dngettext(<%= @web_namespace %>.Gettext, "errors", msg, msg, count, opts) - # else - # Gettext.dgettext(<%= @web_namespace %>.Gettext, "errors", msg, opts) - # end - - Enum.reduce(opts, msg, fn {key, value}, acc -> - String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end) - end) - end<% end %> - - @doc """ - Translates the errors for a field from a keyword list of errors. - """ - def translate_errors(errors, field) when is_list(errors) do - for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) - end -end diff --git a/priv/templates/phx.gen.live/form.ex b/priv/templates/phx.gen.live/form.ex deleted file mode 100644 index 6406031..0000000 --- a/priv/templates/phx.gen.live/form.ex +++ /dev/null @@ -1,98 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.Form do - use <%= inspect context.web_module %>, :live_view - - alias <%= inspect context.module %> - alias <%= inspect schema.module %> - - @impl true - def render(assigns) do - ~H""" - <%= scope.assign_key %>={@<%= scope.assign_key %>}<% end %>> - <.header> - {@page_title} - <:subtitle>Use this form to manage <%= schema.singular %> records in your database. - - - <.form for={@form} id="<%= schema.singular %>-form" phx-change="validate" phx-submit="save"> -<%= Mix.Tasks.Phx.Gen.Html.indent_inputs(inputs, 8) %> -
    - <.button phx-disable-with="Saving..." variant="primary">Save <%= schema.human_singular %> - <.button navigate={return_path(<%= assign_scope_prefix %>@return_to, @<%= schema.singular %>)}>Cancel -
    - -
    - """ - end - - @impl true - def mount(params, _session, socket) do - {:ok, - socket - |> assign(:return_to, return_to(params["return_to"])) - |> apply_action(socket.assigns.live_action, params)} - end - - defp return_to("show"), do: "show" - defp return_to(_), do: "index" - - defp apply_action(socket, :edit, %{"<%= primary_key %>" => <%= primary_key %>}) do - <%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>!(<%= context_scope_prefix %><%= primary_key %>) - - socket - |> assign(:page_title, "Edit <%= schema.human_singular %>") - |> assign(:<%= schema.singular %>, <%= schema.singular %>) - |> assign(:form, to_form(<%= inspect context.alias %>.change_<%= schema.singular %>(<%= context_scope_prefix %><%= schema.singular %>))) - end - - defp apply_action(socket, :new, _params) do - <%= schema.singular %> = %<%= inspect schema.alias %>{<%= if scope do %><%= scope.schema_key %>: <%= socket_scope %>.<%= Enum.join(scope.access_path, ".") %><% end %>} - - socket - |> assign(:page_title, "New <%= schema.human_singular %>") - |> assign(:<%= schema.singular %>, <%= schema.singular %>) - |> assign(:form, to_form(<%= inspect context.alias %>.change_<%= schema.singular %>(<%= context_scope_prefix %><%= schema.singular %>))) - end - - @impl true - def handle_event("validate", %{"<%= schema.singular %>" => <%= schema.singular %>_params}, socket) do - changeset = <%= inspect context.alias %>.change_<%= schema.singular %>(<%= context_scope_prefix %>socket.assigns.<%= schema.singular %>, <%= schema.singular %>_params) - {:noreply, assign(socket, form: to_form(changeset, action: :validate))} - end - - def handle_event("save", %{"<%= schema.singular %>" => <%= schema.singular %>_params}, socket) do - save_<%= schema.singular %>(socket, socket.assigns.live_action, <%= schema.singular %>_params) - end - - defp save_<%= schema.singular %>(socket, :edit, <%= schema.singular %>_params) do - case <%= inspect context.alias %>.update_<%= schema.singular %>(<%= context_scope_prefix %>socket.assigns.<%= schema.singular %>, <%= schema.singular %>_params) do - {:ok, <%= schema.singular %>} -> - {:noreply, - socket - |> put_flash(:info, "<%= schema.human_singular %> updated successfully") - <%= if scope do %>|> push_navigate( - to: return_path(<%= context_scope_prefix %>socket.assigns.return_to, <%= schema.singular %>) - )}<% else %>|> push_navigate(to: return_path(socket.assigns.return_to, <%= schema.singular %>))}<% end %> - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, form: to_form(changeset))} - end - end - - defp save_<%= schema.singular %>(socket, :new, <%= schema.singular %>_params) do - case <%= inspect context.alias %>.create_<%= schema.singular %>(<%= context_scope_prefix %><%= schema.singular %>_params) do - {:ok, <%= schema.singular %>} -> - {:noreply, - socket - |> put_flash(:info, "<%= schema.human_singular %> created successfully") - <%= if scope do %>|> push_navigate( - to: return_path(<%= context_scope_prefix %>socket.assigns.return_to, <%= schema.singular %>) - )}<% else %>|> push_navigate(to: return_path(socket.assigns.return_to, <%= schema.singular %>))}<% end %> - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, form: to_form(changeset))} - end - end - - defp return_path(<%= scope_param_prefix %>"index", _<%= schema.singular %>), do: ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>" - defp return_path(<%= scope_param_prefix %>"show", <%= schema.singular %>), do: ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>/#{<%= schema.singular %>}" -end diff --git a/priv/templates/phx.gen.live/index.ex b/priv/templates/phx.gen.live/index.ex deleted file mode 100644 index 2548459..0000000 --- a/priv/templates/phx.gen.live/index.ex +++ /dev/null @@ -1,74 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.Index do - use <%= inspect context.web_module %>, :live_view - - alias <%= inspect context.module %> - - @impl true - def render(assigns) do - ~H""" - <%= scope.assign_key %>={@<%= scope.assign_key %>}<% end %>> - <.header> - Listing <%= schema.human_plural %> - <:actions> - <.button variant="primary" navigate={~p"<%= scope_assign_route_prefix %><%= schema.route_prefix %>/new"}> - <.icon name="hero-plus" /> New <%= schema.human_singular %> - - - - - <.table - id="<%= schema.plural %>" - rows={@streams.<%= schema.collection %>} - row_click={fn {_id, <%= schema.singular %>} -> JS.navigate(~p"<%= scope_assign_route_prefix %><%= schema.route_prefix %>/#{<%= schema.singular %>}") end} - ><%= for {k, _} <- schema.attrs do %> - <:col :let={{_id, <%= schema.singular %>}} label="<%= Phoenix.Naming.humanize(Atom.to_string(k)) %>">{<%= schema.singular %>.<%= k %>}<% end %> - <:action :let={{_id, <%= schema.singular %>}}> -
    - <.link navigate={~p"<%= scope_assign_route_prefix %><%= schema.route_prefix %>/#{<%= schema.singular %>}"}>Show -
    - <.link navigate={~p"<%= scope_assign_route_prefix %><%= schema.route_prefix %>/#{<%= schema.singular %>}/edit"}>Edit - - <:action :let={{id, <%= schema.singular %>}}> - <.link - phx-click={JS.push("delete", value: %{<%= primary_key %>: <%= schema.singular %>.<%= primary_key %>}) |> hide("##{id}")} - data-confirm="Are you sure?" - > - Delete - - - -
    - """ - end - - @impl true - def mount(_params, _session, socket) do<%= if scope do %> - if connected?(socket) do - <%= inspect context.alias %>.subscribe_<%= schema.plural %>(<%= socket_scope %>) - end -<% end %> - {:ok, - socket - |> assign(:page_title, "Listing <%= schema.human_plural %>")<%= if primary_key != :id do %> - |> stream_configure(:<%= schema.collection %>, dom_id: &"<%= schema.collection %>-#{&1.<%= primary_key %>}")<% end %> - |> stream(:<%= schema.collection %>, list_<%= schema.plural %>(<%= socket_scope %>))} - end - - @impl true - def handle_event("delete", %{"<%= primary_key %>" => <%= primary_key %>}, socket) do - <%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>!(<%= context_scope_prefix %><%= primary_key %>) - {:ok, _} = <%= inspect context.alias %>.delete_<%= schema.singular %>(<%= context_scope_prefix %><%= schema.singular %>) - - {:noreply, stream_delete(socket, :<%= schema.collection %>, <%= schema.singular %>)} - end<%= if scope do %> - - @impl true - def handle_info({type, %<%= inspect schema.module %>{}}, socket) - when type in [:created, :updated, :deleted] do - {:noreply, stream(socket, :<%= schema.collection %>, list_<%= schema.plural %>(<%= socket_scope %>), reset: true)} - end<% end %> - - defp list_<%= schema.plural %>(<%= scope && scope.assign_key %>) do - <%= inspect context.alias %>.list_<%= schema.plural %>(<%= scope && scope.assign_key %>) - end -end diff --git a/priv/templates/phx.gen.live/live_test.exs b/priv/templates/phx.gen.live/live_test.exs deleted file mode 100644 index 3aba63b..0000000 --- a/priv/templates/phx.gen.live/live_test.exs +++ /dev/null @@ -1,128 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>LiveTest do - use <%= inspect context.web_module %>.ConnCase - - import Phoenix.LiveViewTest - import <%= inspect context.module %>Fixtures - - @create_attrs <%= Mix.Phoenix.to_text for {key, value} <- schema.params.create, into: %{}, do: {key, Mix.Phoenix.Schema.live_form_value(value)} %> - @update_attrs <%= Mix.Phoenix.to_text for {key, value} <- schema.params.update, into: %{}, do: {key, Mix.Phoenix.Schema.live_form_value(value)} %> - @invalid_attrs <%= Mix.Phoenix.to_text for {key, value} <- schema.params.create, into: %{}, do: {key, value |> Mix.Phoenix.Schema.live_form_value() |> Mix.Phoenix.Schema.invalid_form_value()} %><%= if scope do %> - - setup :<%= scope.test_setup_helper %> - - defp create_<%= schema.singular %>(%{scope: scope}) do - <%= schema.singular %> = <%= schema.singular %>_fixture(scope) -<% else %> - defp create_<%= schema.singular %>(_) do - <%= schema.singular %> = <%= schema.singular %>_fixture() -<% end %> - %{<%= schema.singular %>: <%= schema.singular %>} - end - - describe "Index" do - setup [:create_<%= schema.singular %>] - - test "lists all <%= schema.plural %>", <%= if schema.string_attr do %>%{conn: conn, <%= schema.singular %>: <%= schema.singular %><%= test_context_scope %>}<% else %>%{conn: conn<%= test_context_scope %>}<% end %> do - {:ok, _index_live, html} = live(conn, ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>") - - assert html =~ "Listing <%= schema.human_plural %>"<%= if schema.string_attr do %> - assert html =~ <%= schema.singular %>.<%= schema.string_attr %><% end %> - end - - test "saves new <%= schema.singular %>", %{conn: conn<%= test_context_scope %>} do - {:ok, index_live, _html} = live(conn, ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>") - - assert {:ok, form_live, _} = - index_live - |> element("a", "New <%= schema.human_singular %>") - |> render_click() - |> follow_redirect(conn, ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>/new") - - assert render(form_live) =~ "New <%= schema.human_singular %>" - - assert form_live - |> form("#<%= schema.singular %>-form", <%= schema.singular %>: @invalid_attrs) - |> render_change() =~ "<%= Mix.Phoenix.Schema.failed_render_change_message(schema) %>" - - assert {:ok, index_live, _html} = - form_live - |> form("#<%= schema.singular %>-form", <%= schema.singular %>: @create_attrs) - |> render_submit() - |> follow_redirect(conn, ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>") - - html = render(index_live) - assert html =~ "<%= schema.human_singular %> created successfully"<%= if schema.string_attr do %> - assert html =~ "some <%= schema.string_attr %>"<% end %> - end - - test "updates <%= schema.singular %> in listing", %{conn: conn, <%= schema.singular %>: <%= schema.singular %><%= test_context_scope %>} do - {:ok, index_live, _html} = live(conn, ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>") - - assert {:ok, form_live, _html} = - index_live - |> element("#<%= schema.collection %>-#{<%= schema.singular %>.<%= primary_key %>} a", "Edit") - |> render_click() - |> follow_redirect(conn, ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>/#{<%= schema.singular %>}/edit") - - assert render(form_live) =~ "Edit <%= schema.human_singular %>" - - assert form_live - |> form("#<%= schema.singular %>-form", <%= schema.singular %>: @invalid_attrs) - |> render_change() =~ "<%= Mix.Phoenix.Schema.failed_render_change_message(schema) %>" - - assert {:ok, index_live, _html} = - form_live - |> form("#<%= schema.singular %>-form", <%= schema.singular %>: @update_attrs) - |> render_submit() - |> follow_redirect(conn, ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>") - - html = render(index_live) - assert html =~ "<%= schema.human_singular %> updated successfully"<%= if schema.string_attr do %> - assert html =~ "some updated <%= schema.string_attr %>"<% end %> - end - - test "deletes <%= schema.singular %> in listing", %{conn: conn, <%= schema.singular %>: <%= schema.singular %><%= test_context_scope %>} do - {:ok, index_live, _html} = live(conn, ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>") - - assert index_live |> element("#<%= schema.collection %>-#{<%= schema.singular %>.<%= primary_key %>} a", "Delete") |> render_click() - refute has_element?(index_live, "#<%= schema.plural %>-#{<%= schema.singular %>.<%= primary_key %>}") - end - end - - describe "Show" do - setup [:create_<%= schema.singular %>] - - test "displays <%= schema.singular %>", %{conn: conn, <%= schema.singular %>: <%= schema.singular %><%= test_context_scope %>} do - {:ok, _show_live, html} = live(conn, ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>/#{<%= schema.singular %>}") - - assert html =~ "Show <%= schema.human_singular %>"<%= if schema.string_attr do %> - assert html =~ <%= schema.singular %>.<%= schema.string_attr %><% end %> - end - - test "updates <%= schema.singular %> and returns to show", %{conn: conn, <%= schema.singular %>: <%= schema.singular %><%= test_context_scope %>} do - {:ok, show_live, _html} = live(conn, ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>/#{<%= schema.singular %>}") - - assert {:ok, form_live, _} = - show_live - |> element("a", "Edit") - |> render_click() - |> follow_redirect(conn, ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>/#{<%= schema.singular %>}/edit?return_to=show") - - assert render(form_live) =~ "Edit <%= schema.human_singular %>" - - assert form_live - |> form("#<%= schema.singular %>-form", <%= schema.singular %>: @invalid_attrs) - |> render_change() =~ "<%= Mix.Phoenix.Schema.failed_render_change_message(schema) %>" - - assert {:ok, show_live, _html} = - form_live - |> form("#<%= schema.singular %>-form", <%= schema.singular %>: @update_attrs) - |> render_submit() - |> follow_redirect(conn, ~p"<%= scope_param_route_prefix %><%= schema.route_prefix %>/#{<%= schema.singular %>}") - - html = render(show_live) - assert html =~ "<%= schema.human_singular %> updated successfully"<%= if schema.string_attr do %> - assert html =~ "some updated <%= schema.string_attr %>"<% end %> - end - end -end diff --git a/priv/templates/phx.gen.live/show.ex b/priv/templates/phx.gen.live/show.ex deleted file mode 100644 index bf9e9e8..0000000 --- a/priv/templates/phx.gen.live/show.ex +++ /dev/null @@ -1,64 +0,0 @@ -defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.Show do - use <%= inspect context.web_module %>, :live_view - - alias <%= inspect context.module %> - - @impl true - def render(assigns) do - ~H""" - <%= scope.assign_key %>={@<%= scope.assign_key %>}<% end %>> - <.header> - <%= schema.human_singular %> {@<%= schema.singular %>.<%= primary_key %>} - <:subtitle>This is a <%= schema.singular %> record from your database. - <:actions> - <.button navigate={~p"<%= scope_assign_route_prefix %><%= schema.route_prefix %>"}> - <.icon name="hero-arrow-left" /> - - <.button variant="primary" navigate={~p"<%= scope_assign_route_prefix %><%= schema.route_prefix %>/#{@<%= schema.singular %>}/edit?return_to=show"}> - <.icon name="hero-pencil-square" /> Edit <%= schema.singular %> - - - - - <.list><%= for {k, _} <- schema.attrs do %> - <:item title="<%= Phoenix.Naming.humanize(Atom.to_string(k)) %>">{@<%= schema.singular %>.<%= k %>}<% end %> - - - """ - end - - @impl true - def mount(%{"<%= primary_key %>" => <%= primary_key %>}, _session, socket) do<%= if scope do %> - if connected?(socket) do - <%= inspect context.alias %>.subscribe_<%= schema.plural %>(<%= socket_scope %>) - end -<% end %> - {:ok, - socket - |> assign(:page_title, "Show <%= schema.human_singular %>") - |> assign(:<%= schema.singular %>, <%= inspect context.alias %>.get_<%= schema.singular %>!(<%= context_scope_prefix %><%= primary_key %>))} - end<%= if scope do %> - - @impl true - def handle_info( - {:updated, %<%= inspect schema.module %>{<%= primary_key %>: <%= primary_key %>} = <%= schema.singular %>}, - %{assigns: %{<%= schema.singular %>: %{<%= primary_key %>: <%= primary_key %>}}} = socket - ) do - {:noreply, assign(socket, :<%= schema.singular %>, <%= schema.singular %>)} - end - - def handle_info( - {:deleted, %<%= inspect schema.module %>{<%= primary_key %>: <%= primary_key %>}}, - %{assigns: %{<%= schema.singular %>: %{<%= primary_key %>: <%= primary_key %>}}} = socket - ) do - {:noreply, - socket - |> put_flash(:error, "The current <%= schema.singular %> was deleted.") - |> push_navigate(to: ~p"<%= scope_socket_route_prefix %><%= schema.route_prefix %>")} - end - - def handle_info({type, %<%= inspect schema.module %>{}}, socket) - when type in [:created, :updated, :deleted] do - {:noreply, socket} - end<% end %> -end From 24f7d6cc7cfce9a255e870605c7a9374808af8cf Mon Sep 17 00:00:00 2001 From: karimsemmoud Date: Mon, 2 Mar 2026 18:28:26 +0700 Subject: [PATCH 21/21] format + credo --- .sobelow-conf | 1 - coveralls.json | 9 ------- lib/components/accordion.ex | 8 ++++--- lib/components/accordion/connect.ex | 2 +- lib/components/angle_slider.ex | 20 +++++++++------- lib/components/angle_slider/connect.ex | 14 +++++------ lib/components/avatar.ex | 2 +- lib/components/avatar/connect.ex | 2 +- lib/components/carousel.ex | 12 +++++----- lib/components/carousel/connect.ex | 12 +++++----- lib/components/checkbox.ex | 15 +++++++----- lib/components/checkbox/connect.ex | 2 +- lib/components/clipboard.ex | 12 ++++++---- lib/components/clipboard/connect.ex | 2 +- lib/components/code.ex | 6 ++--- lib/components/collapsible.ex | 8 ++++--- lib/components/collapsible/connect.ex | 2 +- lib/components/color_picker.ex | 30 +++++++++++++----------- lib/components/color_picker/connect.ex | 16 ++++++------- lib/components/combobox.ex | 2 +- lib/components/combobox/connect.ex | 3 +-- lib/components/date-picker.ex | 6 +++-- lib/components/date-picker/connect.ex | 10 ++++---- lib/components/dialog.ex | 16 +++++++------ lib/components/dialog/connect.ex | 10 ++++---- lib/components/editable.ex | 12 +++++----- lib/components/editable/connect.ex | 14 +++++------ lib/components/floating_panel.ex | 20 ++++++++-------- lib/components/floating_panel/connect.ex | 20 ++++++++-------- lib/components/listbox.ex | 14 +++++------ lib/components/listbox/connect.ex | 14 +++++------ lib/components/marquee.ex | 16 +++++++------ lib/components/marquee/connect.ex | 2 +- lib/components/menu.ex | 8 ++++--- lib/components/menu/connect.ex | 2 +- lib/components/native_input.ex | 3 ++- lib/components/number_input.ex | 12 +++++----- lib/components/number_input/connect.ex | 12 +++++----- lib/components/password_input.ex | 10 ++++---- lib/components/password_input/connect.ex | 10 ++++---- lib/components/pin_input.ex | 2 +- lib/components/pin_input/connect.ex | 2 +- lib/components/radio_group.ex | 10 ++++---- lib/components/radio_group/connect.ex | 10 ++++---- lib/components/select.ex | 2 +- lib/components/select/connect.ex | 2 +- lib/components/signature_pad.ex | 18 +++++++------- lib/components/switch.ex | 15 +++++++----- lib/components/switch/connect.ex | 2 +- lib/components/tabs.ex | 8 ++++--- lib/components/tabs/connect.ex | 2 +- lib/components/timer.ex | 2 +- lib/components/timer/connect.ex | 2 +- lib/components/toast.ex | 2 +- lib/components/toggle-group/connect.ex | 2 +- lib/components/toggle_group.ex | 8 ++++--- lib/components/tree_view.ex | 12 ++++++---- lib/components/tree_view/connect.ex | 2 +- lib/corex/form.ex | 2 +- lib/mix/tasks/corex.code.ex | 3 +-- test/corex_test.exs | 6 ++--- test/test_helper.exs | 5 +--- 62 files changed, 263 insertions(+), 247 deletions(-) delete mode 100644 .sobelow-conf delete mode 100644 coveralls.json diff --git a/.sobelow-conf b/.sobelow-conf deleted file mode 100644 index 6936d78..0000000 --- a/.sobelow-conf +++ /dev/null @@ -1 +0,0 @@ -[exit: false, format: "txt", ignore_files: [], ignore: [], out: nil, private: false, router: nil, skip: true, threshold: :low, verbose: false, version: false] \ No newline at end of file diff --git a/coveralls.json b/coveralls.json deleted file mode 100644 index 224f4a1..0000000 --- a/coveralls.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "skip_files": [ - "anatomy\\.ex", - "lib/corex/flash\\.ex", - "lib/corex/positoning\\.ex", - "test/support/endpoint\\.ex", - "test/support/gettext\\.ex" - ] -} diff --git a/lib/components/accordion.ex b/lib/components/accordion.ex index 967e85b..5dc411c 100644 --- a/lib/components/accordion.ex +++ b/lib/components/accordion.ex @@ -290,8 +290,10 @@ defmodule Corex.Accordion do @doc type: :component use Phoenix.Component - alias Corex.Accordion.Anatomy.{Props, Root, Item} + alias Corex.Accordion.Anatomy.{Item, Props, Root} alias Corex.Accordion.Connect + alias Phoenix.LiveView + alias Phoenix.LiveView.JS import Corex.Helpers, only: [validate_value!: 1] @doc """ @@ -647,7 +649,7 @@ defmodule Corex.Accordion do """ def set_value(accordion_id, value) when is_binary(accordion_id) do - Phoenix.LiveView.JS.dispatch("phx:accordion:set-value", + JS.dispatch("phx:accordion:set-value", to: "##{accordion_id}", detail: %{value: validate_value!(value)}, bubbles: false @@ -667,7 +669,7 @@ defmodule Corex.Accordion do """ def set_value(socket, accordion_id, value) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(accordion_id) do - Phoenix.LiveView.push_event(socket, "accordion_set_value", %{ + LiveView.push_event(socket, "accordion_set_value", %{ accordion_id: accordion_id, value: validate_value!(value) }) diff --git a/lib/components/accordion/connect.ex b/lib/components/accordion/connect.ex index 1e53da4..5fc89b5 100644 --- a/lib/components/accordion/connect.ex +++ b/lib/components/accordion/connect.ex @@ -1,6 +1,6 @@ defmodule Corex.Accordion.Connect do @moduledoc false - alias Corex.Accordion.Anatomy.{Props, Root, Item} + alias Corex.Accordion.Anatomy.{Item, Props, Root} import Corex.Helpers, only: [validate_value!: 1] defp data_attr(true), do: "" diff --git a/lib/components/angle_slider.ex b/lib/components/angle_slider.ex index 3b25414..ff2c892 100644 --- a/lib/components/angle_slider.ex +++ b/lib/components/angle_slider.ex @@ -112,20 +112,22 @@ defmodule Corex.AngleSlider do use Phoenix.Component alias Corex.AngleSlider.Anatomy.{ + Control, + HiddenInput, + Label, + Marker, + MarkerGroup, Props, Root, - Label, - HiddenInput, - Control, + Text, Thumb, - ValueText, Value, - Text, - MarkerGroup, - Marker + ValueText } alias Corex.AngleSlider.Connect + alias Phoenix.LiveView + alias Phoenix.LiveView.JS attr(:id, :string, required: false, doc: "The id of the angle slider") attr(:value, :float, default: 0.0, doc: "The value or controlled value in degrees") @@ -224,7 +226,7 @@ defmodule Corex.AngleSlider do """ def set_value(angle_slider_id, value) when is_binary(angle_slider_id) and is_number(value) do - Phoenix.LiveView.JS.dispatch("phx:angle-slider:set-value", + JS.dispatch("phx:angle-slider:set-value", to: "##{angle_slider_id}", detail: %{value: value}, bubbles: false @@ -254,7 +256,7 @@ defmodule Corex.AngleSlider do value end - Phoenix.LiveView.push_event(socket, "angle_slider_set_value", %{ + LiveView.push_event(socket, "angle_slider_set_value", %{ angle_slider_id: angle_slider_id, value: angle }) diff --git a/lib/components/angle_slider/connect.ex b/lib/components/angle_slider/connect.ex index 9d70126..6bb242c 100644 --- a/lib/components/angle_slider/connect.ex +++ b/lib/components/angle_slider/connect.ex @@ -1,17 +1,17 @@ defmodule Corex.AngleSlider.Connect do @moduledoc false alias Corex.AngleSlider.Anatomy.{ + Control, + HiddenInput, + Label, + Marker, + MarkerGroup, Props, Root, - Label, - HiddenInput, - Control, + Text, Thumb, - ValueText, Value, - Text, - MarkerGroup, - Marker + ValueText } defp data_attr(true), do: "" diff --git a/lib/components/avatar.ex b/lib/components/avatar.ex index fbce1b0..65b9368 100644 --- a/lib/components/avatar.ex +++ b/lib/components/avatar.ex @@ -46,7 +46,7 @@ defmodule Corex.Avatar do @doc type: :component use Phoenix.Component - alias Corex.Avatar.Anatomy.{Props, Root, Image, Fallback, Skeleton} + alias Corex.Avatar.Anatomy.{Fallback, Image, Props, Root, Skeleton} alias Corex.Avatar.Connect attr(:id, :string, required: false, doc: "The id of the avatar") diff --git a/lib/components/avatar/connect.ex b/lib/components/avatar/connect.ex index d4bad30..c602213 100644 --- a/lib/components/avatar/connect.ex +++ b/lib/components/avatar/connect.ex @@ -1,6 +1,6 @@ defmodule Corex.Avatar.Connect do @moduledoc false - alias Corex.Avatar.Anatomy.{Props, Root, Image, Fallback, Skeleton} + alias Corex.Avatar.Anatomy.{Fallback, Image, Props, Root, Skeleton} @spec props(Props.t()) :: map() def props(assigns) do diff --git a/lib/components/carousel.ex b/lib/components/carousel.ex index b2e6cc7..10bc19f 100644 --- a/lib/components/carousel.ex +++ b/lib/components/carousel.ex @@ -57,15 +57,15 @@ defmodule Corex.Carousel do use Phoenix.Component alias Corex.Carousel.Anatomy.{ - Props, - Root, Control, - ItemGroup, + Indicator, + IndicatorGroup, Item, - PrevTrigger, + ItemGroup, NextTrigger, - IndicatorGroup, - Indicator + PrevTrigger, + Props, + Root } alias Corex.Carousel.Connect diff --git a/lib/components/carousel/connect.ex b/lib/components/carousel/connect.ex index 4d3f3f0..fe9d15e 100644 --- a/lib/components/carousel/connect.ex +++ b/lib/components/carousel/connect.ex @@ -1,15 +1,15 @@ defmodule Corex.Carousel.Connect do @moduledoc false alias Corex.Carousel.Anatomy.{ - Props, - Root, Control, - ItemGroup, + Indicator, + IndicatorGroup, Item, - PrevTrigger, + ItemGroup, NextTrigger, - IndicatorGroup, - Indicator + PrevTrigger, + Props, + Root } defp data_attr(true), do: "" diff --git a/lib/components/checkbox.ex b/lib/components/checkbox.ex index 24ea593..f12e6a1 100644 --- a/lib/components/checkbox.ex +++ b/lib/components/checkbox.ex @@ -225,8 +225,11 @@ defmodule Corex.Checkbox do @doc type: :component use Phoenix.Component - alias Corex.Checkbox.Anatomy.{Props, Root, HiddenInput, Control, Label, Indicator} + alias Corex.Checkbox.Anatomy.{Control, HiddenInput, Indicator, Label, Props, Root} alias Corex.Checkbox.Connect + alias Phoenix.HTML.Form + alias Phoenix.LiveView + alias Phoenix.LiveView.JS @doc """ Renders a checkbox component. @@ -331,7 +334,7 @@ defmodule Corex.Checkbox do |> assign(:errors, Enum.map(errors, &Corex.Gettext.translate_error(&1))) |> assign_new(:id, fn -> field.id end) |> assign_new(:name, fn -> field.name end) - |> assign(:checked, Phoenix.HTML.Form.normalize_value("checkbox", field.value)) + |> assign(:checked, Form.normalize_value("checkbox", field.value)) |> assign_new(:form, fn -> field.form.id end) checkbox(assigns) @@ -402,7 +405,7 @@ defmodule Corex.Checkbox do """ def set_checked(checkbox_id, checked) when is_binary(checkbox_id) and is_boolean(checked) do - Phoenix.LiveView.JS.dispatch("phx:checkbox:set-checked", + JS.dispatch("phx:checkbox:set-checked", to: "##{checkbox_id}", detail: %{checked: checked}, bubbles: false @@ -423,7 +426,7 @@ defmodule Corex.Checkbox do def set_checked(socket, checkbox_id, checked) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(checkbox_id) and is_boolean(checked) do - Phoenix.LiveView.push_event(socket, "checkbox_set_checked", %{ + LiveView.push_event(socket, "checkbox_set_checked", %{ checkbox_id: checkbox_id, checked: checked }) @@ -440,7 +443,7 @@ defmodule Corex.Checkbox do """ def toggle_checked(checkbox_id) when is_binary(checkbox_id) do - Phoenix.LiveView.JS.dispatch("phx:checkbox:toggle-checked", + JS.dispatch("phx:checkbox:toggle-checked", to: "##{checkbox_id}", bubbles: false ) @@ -459,7 +462,7 @@ defmodule Corex.Checkbox do """ def toggle_checked(socket, checkbox_id) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(checkbox_id) do - Phoenix.LiveView.push_event(socket, "checkbox_toggle_checked", %{ + LiveView.push_event(socket, "checkbox_toggle_checked", %{ checkbox_id: checkbox_id }) end diff --git a/lib/components/checkbox/connect.ex b/lib/components/checkbox/connect.ex index 5bba3a2..5a86f4b 100644 --- a/lib/components/checkbox/connect.ex +++ b/lib/components/checkbox/connect.ex @@ -1,6 +1,6 @@ defmodule Corex.Checkbox.Connect do @moduledoc false - alias Corex.Checkbox.Anatomy.{Props, Root, HiddenInput, Indicator, Control, Label} + alias Corex.Checkbox.Anatomy.{Control, HiddenInput, Indicator, Label, Props, Root} defp data_attr(true), do: "" defp data_attr(false), do: nil diff --git a/lib/components/clipboard.ex b/lib/components/clipboard.ex index 0168ff1..469b2a0 100644 --- a/lib/components/clipboard.ex +++ b/lib/components/clipboard.ex @@ -97,8 +97,10 @@ defmodule Corex.Clipboard do @doc type: :component use Phoenix.Component - alias Corex.Clipboard.Anatomy.{Props, Root, Label, Control, Input, Trigger} + alias Corex.Clipboard.Anatomy.{Control, Input, Label, Props, Root, Trigger} alias Corex.Clipboard.Connect + alias Phoenix.LiveView + alias Phoenix.LiveView.JS @doc """ Renders a clipboard component. @@ -231,7 +233,7 @@ defmodule Corex.Clipboard do """ def copy(clipboard_id) when is_binary(clipboard_id) do - Phoenix.LiveView.JS.dispatch("phx:clipboard:copy", + JS.dispatch("phx:clipboard:copy", to: "##{clipboard_id}", bubbles: false ) @@ -250,7 +252,7 @@ defmodule Corex.Clipboard do """ def copy(socket, clipboard_id) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(clipboard_id) do - Phoenix.LiveView.push_event(socket, "clipboard_copy", %{ + LiveView.push_event(socket, "clipboard_copy", %{ clipboard_id: clipboard_id }) end @@ -266,7 +268,7 @@ defmodule Corex.Clipboard do """ def set_value(clipboard_id, value) when is_binary(clipboard_id) and is_binary(value) do - Phoenix.LiveView.JS.dispatch("phx:clipboard:set-value", + JS.dispatch("phx:clipboard:set-value", to: "##{clipboard_id}", detail: %{value: value}, bubbles: false @@ -287,7 +289,7 @@ defmodule Corex.Clipboard do def set_value(socket, clipboard_id, value) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(clipboard_id) and is_binary(value) do - Phoenix.LiveView.push_event(socket, "clipboard_set_value", %{ + LiveView.push_event(socket, "clipboard_set_value", %{ clipboard_id: clipboard_id, value: value }) diff --git a/lib/components/clipboard/connect.ex b/lib/components/clipboard/connect.ex index a79be68..2510e73 100644 --- a/lib/components/clipboard/connect.ex +++ b/lib/components/clipboard/connect.ex @@ -1,6 +1,6 @@ defmodule Corex.Clipboard.Connect do @moduledoc false - alias Corex.Clipboard.Anatomy.{Props, Root, Label, Control, Input, Trigger} + alias Corex.Clipboard.Anatomy.{Control, Input, Label, Props, Root, Trigger} defp data_attr(true), do: "" defp data_attr(false), do: nil diff --git a/lib/components/code.ex b/lib/components/code.ex index a6b89b5..34fe73b 100644 --- a/lib/components/code.ex +++ b/lib/components/code.ex @@ -133,9 +133,8 @@ defmodule Corex.Code do defp lexer_for(language) do name = to_string(language) - registry = Module.concat(["Elixir", "Makeup", "Registry"]) - case apply(registry, :fetch_lexer_by_name, [name]) do + case Makeup.Registry.fetch_lexer_by_name(name) do {:ok, {lexer, _opts}} -> lexer :error -> nil end @@ -149,8 +148,7 @@ defmodule Corex.Code do |> Phoenix.HTML.safe_to_string() lexer -> - makeup = Module.concat(["Elixir", "Makeup"]) - apply(makeup, :highlight_inner_html, [assigns.code, [lexer: lexer]]) + Makeup.highlight_inner_html(assigns.code, lexer: lexer) end end end diff --git a/lib/components/collapsible.ex b/lib/components/collapsible.ex index f8b2959..afe4ce9 100644 --- a/lib/components/collapsible.ex +++ b/lib/components/collapsible.ex @@ -91,8 +91,10 @@ defmodule Corex.Collapsible do @doc type: :component use Phoenix.Component - alias Corex.Collapsible.Anatomy.{Props, Root, Trigger, Content} + alias Corex.Collapsible.Anatomy.{Content, Props, Root, Trigger} alias Corex.Collapsible.Connect + alias Phoenix.LiveView + alias Phoenix.LiveView.JS @doc """ Renders a collapsible component. @@ -189,7 +191,7 @@ defmodule Corex.Collapsible do """ def set_open(collapsible_id, open) when is_binary(collapsible_id) and is_boolean(open) do - Phoenix.LiveView.JS.dispatch("phx:collapsible:set-open", + JS.dispatch("phx:collapsible:set-open", to: "##{collapsible_id}", detail: %{open: open}, bubbles: false @@ -210,7 +212,7 @@ defmodule Corex.Collapsible do def set_open(socket, collapsible_id, open) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(collapsible_id) and is_boolean(open) do - Phoenix.LiveView.push_event(socket, "collapsible_set_open", %{ + LiveView.push_event(socket, "collapsible_set_open", %{ collapsible_id: collapsible_id, open: open }) diff --git a/lib/components/collapsible/connect.ex b/lib/components/collapsible/connect.ex index cc9be06..a622c50 100644 --- a/lib/components/collapsible/connect.ex +++ b/lib/components/collapsible/connect.ex @@ -1,6 +1,6 @@ defmodule Corex.Collapsible.Connect do @moduledoc false - alias Corex.Collapsible.Anatomy.{Props, Root, Trigger, Content} + alias Corex.Collapsible.Anatomy.{Content, Props, Root, Trigger} defp data_attr(true), do: "" defp data_attr(false), do: nil diff --git a/lib/components/color_picker.ex b/lib/components/color_picker.ex index 5d2f1ba..a968a14 100644 --- a/lib/components/color_picker.ex +++ b/lib/components/color_picker.ex @@ -57,22 +57,24 @@ defmodule Corex.ColorPicker do use Phoenix.Component alias Corex.ColorPicker.Anatomy.{ - Props, - Root, - Label, - HiddenInput, + Content, Control, - Trigger, + HiddenInput, + Label, Positioner, - Content, - TransparencyGrid, + PresetSwatch, + Props, + Root, Swatch, SwatchTrigger, - PresetSwatch + TransparencyGrid, + Trigger } alias Corex.ColorPicker.Connect alias Corex.ColorPicker.Initial + alias Phoenix.LiveView + alias Phoenix.LiveView.JS attr(:id, :string, required: false) @@ -329,7 +331,7 @@ defmodule Corex.ColorPicker do """ def set_open(color_picker_id, open) when is_binary(color_picker_id) and is_boolean(open) do - Phoenix.LiveView.JS.dispatch("phx:color-picker:set-open", + JS.dispatch("phx:color-picker:set-open", to: "##{color_picker_id}", detail: %{open: open}, bubbles: false @@ -342,7 +344,7 @@ defmodule Corex.ColorPicker do """ def set_open(socket, color_picker_id, open) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(color_picker_id) do - Phoenix.LiveView.push_event(socket, "color_picker_set_open", %{ + LiveView.push_event(socket, "color_picker_set_open", %{ color_picker_id: color_picker_id, open: open }) @@ -354,7 +356,7 @@ defmodule Corex.ColorPicker do Value can be any color string (e.g. `"#ff0000"`, `"rgba(255, 0, 0, 1)"`). """ def set_value(color_picker_id, value) when is_binary(color_picker_id) and is_binary(value) do - Phoenix.LiveView.JS.dispatch("phx:color-picker:set-value", + JS.dispatch("phx:color-picker:set-value", to: "##{color_picker_id}", detail: %{value: value}, bubbles: false @@ -367,7 +369,7 @@ defmodule Corex.ColorPicker do """ def set_value(socket, color_picker_id, value) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(color_picker_id) do - Phoenix.LiveView.push_event(socket, "color_picker_set_value", %{ + LiveView.push_event(socket, "color_picker_set_value", %{ color_picker_id: color_picker_id, value: to_string(value) }) @@ -380,7 +382,7 @@ defmodule Corex.ColorPicker do """ def set_format(color_picker_id, format) when is_binary(color_picker_id) and format in ["rgba", "hsla", "hsba", "hex"] do - Phoenix.LiveView.JS.dispatch("phx:color-picker:set-format", + JS.dispatch("phx:color-picker:set-format", to: "##{color_picker_id}", detail: %{format: format}, bubbles: false @@ -393,7 +395,7 @@ defmodule Corex.ColorPicker do """ def set_format(socket, color_picker_id, format) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(color_picker_id) do - Phoenix.LiveView.push_event(socket, "color_picker_set_format", %{ + LiveView.push_event(socket, "color_picker_set_format", %{ color_picker_id: color_picker_id, format: to_string(format) }) diff --git a/lib/components/color_picker/connect.ex b/lib/components/color_picker/connect.ex index 27d1b43..e9760c0 100644 --- a/lib/components/color_picker/connect.ex +++ b/lib/components/color_picker/connect.ex @@ -1,18 +1,18 @@ defmodule Corex.ColorPicker.Connect do @moduledoc false alias Corex.ColorPicker.Anatomy.{ - Props, - Root, - Label, - HiddenInput, + Content, Control, - Trigger, + HiddenInput, + Label, Positioner, - Content, - TransparencyGrid, + PresetSwatch, + Props, + Root, Swatch, SwatchTrigger, - PresetSwatch + TransparencyGrid, + Trigger } import Corex.Helpers, only: [get_boolean: 1] diff --git a/lib/components/combobox.ex b/lib/components/combobox.ex index 8427018..cd48cc4 100644 --- a/lib/components/combobox.ex +++ b/lib/components/combobox.ex @@ -225,8 +225,8 @@ defmodule Corex.Combobox do @doc type: :component use Phoenix.Component + alias Corex.Combobox.Anatomy.{Content, Control, Input, Label, Positioner, Props, Root} alias Corex.Combobox.Connect - alias Corex.Combobox.Anatomy.{Props, Root, Label, Control, Input, Positioner, Content} @doc """ Renders a combobox component. diff --git a/lib/components/combobox/connect.ex b/lib/components/combobox/connect.ex index 883e457..76cb4f8 100644 --- a/lib/components/combobox/connect.ex +++ b/lib/components/combobox/connect.ex @@ -1,7 +1,6 @@ defmodule Corex.Combobox.Connect do @moduledoc false - alias Corex.Combobox.Anatomy.Control - alias Corex.Combobox.Anatomy.{Props, Root, Label, Input, Positioner, Content} + alias Corex.Combobox.Anatomy.{Content, Control, Input, Label, Positioner, Props, Root} import Corex.Helpers, only: [get_boolean: 1, get_default_boolean: 2, get_boolean: 2, validate_value!: 1] diff --git a/lib/components/date-picker.ex b/lib/components/date-picker.ex index 04855c1..b36feaa 100644 --- a/lib/components/date-picker.ex +++ b/lib/components/date-picker.ex @@ -251,6 +251,8 @@ defmodule Corex.DatePicker do use Phoenix.Component alias Corex.DatePicker.{Anatomy, Connect} + alias Phoenix.LiveView + alias Phoenix.LiveView.JS @doc """ Renders a date picker component. @@ -605,7 +607,7 @@ defmodule Corex.DatePicker do """ def set_value(date_picker_id, value) when is_binary(date_picker_id) and is_binary(value) do - Phoenix.LiveView.JS.dispatch("phx:date-picker:set-value", + JS.dispatch("phx:date-picker:set-value", to: "##{date_picker_id}", detail: %{value: value}, bubbles: false @@ -626,7 +628,7 @@ defmodule Corex.DatePicker do def set_value(socket, date_picker_id, value) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(date_picker_id) and is_binary(value) do - Phoenix.LiveView.push_event(socket, "date_picker_set_value", %{ + LiveView.push_event(socket, "date_picker_set_value", %{ date_picker_id: date_picker_id, value: value }) diff --git a/lib/components/date-picker/connect.ex b/lib/components/date-picker/connect.ex index df4f356..79b44ad 100644 --- a/lib/components/date-picker/connect.ex +++ b/lib/components/date-picker/connect.ex @@ -1,14 +1,14 @@ defmodule Corex.DatePicker.Connect do @moduledoc false alias Corex.DatePicker.Anatomy.{ - Props, - Root, - Label, + Content, Control, Input, - Trigger, + Label, Positioner, - Content + Props, + Root, + Trigger } defp data_attr(true), do: "" diff --git a/lib/components/dialog.ex b/lib/components/dialog.ex index e78c651..9f85b0b 100644 --- a/lib/components/dialog.ex +++ b/lib/components/dialog.ex @@ -127,17 +127,19 @@ defmodule Corex.Dialog do use Phoenix.Component alias Corex.Dialog.Anatomy.{ - Props, - Trigger, Backdrop, - Positioner, + CloseTrigger, Content, - Title, Description, - CloseTrigger + Positioner, + Props, + Title, + Trigger } alias Corex.Dialog.Connect + alias Phoenix.LiveView + alias Phoenix.LiveView.JS @doc """ Renders a dialog component. @@ -329,7 +331,7 @@ defmodule Corex.Dialog do """ def set_open(dialog_id, open) when is_binary(dialog_id) and is_boolean(open) do - Phoenix.LiveView.JS.dispatch("phx:dialog:set-open", + JS.dispatch("phx:dialog:set-open", to: "##{dialog_id}", detail: %{open: open}, bubbles: false @@ -350,7 +352,7 @@ defmodule Corex.Dialog do def set_open(socket, dialog_id, open) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(dialog_id) and is_boolean(open) do - Phoenix.LiveView.push_event(socket, "dialog_set_open", %{ + LiveView.push_event(socket, "dialog_set_open", %{ dialog_id: dialog_id, open: open }) diff --git a/lib/components/dialog/connect.ex b/lib/components/dialog/connect.ex index 06a3161..031d908 100644 --- a/lib/components/dialog/connect.ex +++ b/lib/components/dialog/connect.ex @@ -1,14 +1,14 @@ defmodule Corex.Dialog.Connect do @moduledoc false alias Corex.Dialog.Anatomy.{ - Props, - Trigger, Backdrop, - Positioner, + CloseTrigger, Content, - Title, Description, - CloseTrigger + Positioner, + Props, + Title, + Trigger } defp data_attr(true), do: "" diff --git a/lib/components/editable.ex b/lib/components/editable.ex index 8c019b9..c172c7f 100644 --- a/lib/components/editable.ex +++ b/lib/components/editable.ex @@ -60,16 +60,16 @@ defmodule Corex.Editable do use Phoenix.Component alias Corex.Editable.Anatomy.{ - Props, - Root, Area, - Label, + CancelTrigger, + EditTrigger, Input, + Label, Preview, - EditTrigger, - Triggers, + Props, + Root, SubmitTrigger, - CancelTrigger + Triggers } alias Corex.Editable.Connect diff --git a/lib/components/editable/connect.ex b/lib/components/editable/connect.ex index cd8ae57..3a0fbe8 100644 --- a/lib/components/editable/connect.ex +++ b/lib/components/editable/connect.ex @@ -1,17 +1,17 @@ defmodule Corex.Editable.Connect do @moduledoc false alias Corex.Editable.Anatomy.{ - Props, - Root, Area, - Label, + CancelTrigger, + Control, + EditTrigger, Input, + Label, Preview, - EditTrigger, - Control, - Triggers, + Props, + Root, SubmitTrigger, - CancelTrigger + Triggers } defp data_attr(true), do: "" diff --git a/lib/components/floating_panel.ex b/lib/components/floating_panel.ex index 588f8dc..c9a4858 100644 --- a/lib/components/floating_panel.ex +++ b/lib/components/floating_panel.ex @@ -75,19 +75,19 @@ defmodule Corex.FloatingPanel do use Phoenix.Component alias Corex.FloatingPanel.Anatomy.{ - Props, - Root, - Trigger, - Positioner, - Content, - Title, - Header, Body, - DragTrigger, - ResizeTrigger, CloseTrigger, + Content, Control, - StageTrigger + DragTrigger, + Header, + Positioner, + Props, + ResizeTrigger, + Root, + StageTrigger, + Title, + Trigger } alias Corex.FloatingPanel.Connect diff --git a/lib/components/floating_panel/connect.ex b/lib/components/floating_panel/connect.ex index b2b759a..10fb986 100644 --- a/lib/components/floating_panel/connect.ex +++ b/lib/components/floating_panel/connect.ex @@ -1,19 +1,19 @@ defmodule Corex.FloatingPanel.Connect do @moduledoc false alias Corex.FloatingPanel.Anatomy.{ - Props, - Root, - Trigger, - Positioner, - Content, - Title, - Header, Body, - DragTrigger, - ResizeTrigger, CloseTrigger, + Content, Control, - StageTrigger + DragTrigger, + Header, + Positioner, + Props, + ResizeTrigger, + Root, + StageTrigger, + Title, + Trigger } defp data_attr(true), do: "" diff --git a/lib/components/listbox.ex b/lib/components/listbox.ex index 4b6d68f..e516b39 100644 --- a/lib/components/listbox.ex +++ b/lib/components/listbox.ex @@ -149,17 +149,17 @@ defmodule Corex.Listbox do use Phoenix.Component alias Corex.Listbox.Anatomy.{ - Props, - Root, - Label, - ValueText, - Input, Content, + Input, + Item, ItemGroup, ItemGroupLabel, - Item, + ItemIndicator, ItemText, - ItemIndicator + Label, + Props, + Root, + ValueText } alias Corex.Listbox.Connect diff --git a/lib/components/listbox/connect.ex b/lib/components/listbox/connect.ex index 3d2d6a0..239b0b2 100644 --- a/lib/components/listbox/connect.ex +++ b/lib/components/listbox/connect.ex @@ -1,17 +1,17 @@ defmodule Corex.Listbox.Connect do @moduledoc false alias Corex.Listbox.Anatomy.{ - Props, - Root, - Label, - ValueText, - Input, Content, + Input, + Item, ItemGroup, ItemGroupLabel, - Item, + ItemIndicator, ItemText, - ItemIndicator + Label, + Props, + Root, + ValueText } import Corex.Helpers, only: [validate_value!: 1] diff --git a/lib/components/marquee.ex b/lib/components/marquee.ex index de1dff3..89966f3 100644 --- a/lib/components/marquee.ex +++ b/lib/components/marquee.ex @@ -76,8 +76,10 @@ defmodule Corex.Marquee do @doc type: :component use Phoenix.Component - alias Corex.Marquee.Anatomy.{Props, Root, Edge, Viewport, Content, Item} + alias Corex.Marquee.Anatomy.{Content, Edge, Item, Props, Root, Viewport} alias Corex.Marquee.Connect + alias Phoenix.LiveView + alias Phoenix.LiveView.JS attr(:id, :string, required: false) @@ -217,7 +219,7 @@ defmodule Corex.Marquee do Pauses the marquee from client-side. Returns a `Phoenix.LiveView.JS` command. """ def pause(marquee_id) when is_binary(marquee_id) do - Phoenix.LiveView.JS.dispatch("phx:marquee:pause", to: "##{marquee_id}", bubbles: false) + JS.dispatch("phx:marquee:pause", to: "##{marquee_id}", bubbles: false) end @doc """ @@ -225,14 +227,14 @@ defmodule Corex.Marquee do """ def pause(socket, marquee_id) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(marquee_id) do - Phoenix.LiveView.push_event(socket, "marquee_pause", %{marquee_id: marquee_id}) + LiveView.push_event(socket, "marquee_pause", %{marquee_id: marquee_id}) end @doc """ Resumes the marquee from client-side. Returns a `Phoenix.LiveView.JS` command. """ def resume(marquee_id) when is_binary(marquee_id) do - Phoenix.LiveView.JS.dispatch("phx:marquee:resume", to: "##{marquee_id}", bubbles: false) + JS.dispatch("phx:marquee:resume", to: "##{marquee_id}", bubbles: false) end @doc """ @@ -240,14 +242,14 @@ defmodule Corex.Marquee do """ def resume(socket, marquee_id) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(marquee_id) do - Phoenix.LiveView.push_event(socket, "marquee_resume", %{marquee_id: marquee_id}) + LiveView.push_event(socket, "marquee_resume", %{marquee_id: marquee_id}) end @doc """ Toggles the pause state from client-side. Returns a `Phoenix.LiveView.JS` command. """ def toggle_pause(marquee_id) when is_binary(marquee_id) do - Phoenix.LiveView.JS.dispatch("phx:marquee:toggle-pause", to: "##{marquee_id}", bubbles: false) + JS.dispatch("phx:marquee:toggle-pause", to: "##{marquee_id}", bubbles: false) end @doc """ @@ -255,6 +257,6 @@ defmodule Corex.Marquee do """ def toggle_pause(socket, marquee_id) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(marquee_id) do - Phoenix.LiveView.push_event(socket, "marquee_toggle_pause", %{marquee_id: marquee_id}) + LiveView.push_event(socket, "marquee_toggle_pause", %{marquee_id: marquee_id}) end end diff --git a/lib/components/marquee/connect.ex b/lib/components/marquee/connect.ex index 71ef9c8..c2e840e 100644 --- a/lib/components/marquee/connect.ex +++ b/lib/components/marquee/connect.ex @@ -1,6 +1,6 @@ defmodule Corex.Marquee.Connect do @moduledoc false - alias Corex.Marquee.Anatomy.{Props, Root, Edge, Viewport, Content, Item} + alias Corex.Marquee.Anatomy.{Content, Edge, Item, Props, Root, Viewport} defp data_attr(true), do: "" defp data_attr(false), do: nil diff --git a/lib/components/menu.ex b/lib/components/menu.ex index 59721da..036c02e 100644 --- a/lib/components/menu.ex +++ b/lib/components/menu.ex @@ -305,8 +305,10 @@ defmodule Corex.Menu do @doc type: :component use Phoenix.Component - alias Corex.Menu.Anatomy.{Props, Root, Trigger, Item, Group} + alias Corex.Menu.Anatomy.{Group, Item, Props, Root, Trigger} alias Corex.Menu.Connect + alias Phoenix.LiveView + alias Phoenix.LiveView.JS @doc """ Renders a menu component. @@ -655,7 +657,7 @@ defmodule Corex.Menu do """ def set_open(menu_id, open) when is_binary(menu_id) do - Phoenix.LiveView.JS.dispatch("phx:menu:set-open", + JS.dispatch("phx:menu:set-open", to: "[id=\"menu:#{menu_id}\"]", detail: %{open: open}, bubbles: false @@ -675,7 +677,7 @@ defmodule Corex.Menu do """ def set_open(socket, menu_id, open) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(menu_id) do - Phoenix.LiveView.push_event(socket, "menu_set_open", %{ + LiveView.push_event(socket, "menu_set_open", %{ menu_id: menu_id, open: open }) diff --git a/lib/components/menu/connect.ex b/lib/components/menu/connect.ex index 597b9ab..6e86fd0 100644 --- a/lib/components/menu/connect.ex +++ b/lib/components/menu/connect.ex @@ -1,6 +1,6 @@ defmodule Corex.Menu.Connect do @moduledoc false - alias Corex.Menu.Anatomy.{Props, Root, Trigger, Item, Group} + alias Corex.Menu.Anatomy.{Group, Item, Props, Root, Trigger} defp data_attr(true), do: "" defp data_attr(false), do: nil diff --git a/lib/components/native_input.ex b/lib/components/native_input.ex index 88e0b2d..64130c1 100644 --- a/lib/components/native_input.ex +++ b/lib/components/native_input.ex @@ -86,6 +86,7 @@ defmodule Corex.NativeInput do @doc type: :component use Phoenix.Component + alias Phoenix.HTML.Form @types ~w(text textarea date datetime-local time month week email url tel search color number password checkbox radio select) @@ -149,7 +150,7 @@ defmodule Corex.NativeInput do assigns |> assign_new(:id, fn -> "native-input-#{System.unique_integer([:positive])}" end) |> assign_new(:checked, fn -> - Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value]) + Form.normalize_value("checkbox", assigns[:value]) end) ~H""" diff --git a/lib/components/number_input.ex b/lib/components/number_input.ex index 0624fd1..b4311c2 100644 --- a/lib/components/number_input.ex +++ b/lib/components/number_input.ex @@ -73,15 +73,15 @@ defmodule Corex.NumberInput do use Phoenix.Component alias Corex.NumberInput.Anatomy.{ - Props, - Root, - Label, Control, - Input, - TriggerGroup, DecrementTrigger, IncrementTrigger, - Scrubber + Input, + Label, + Props, + Root, + Scrubber, + TriggerGroup } alias Corex.NumberInput.Connect diff --git a/lib/components/number_input/connect.ex b/lib/components/number_input/connect.ex index ae35c52..4a1f9a4 100644 --- a/lib/components/number_input/connect.ex +++ b/lib/components/number_input/connect.ex @@ -1,15 +1,15 @@ defmodule Corex.NumberInput.Connect do @moduledoc false alias Corex.NumberInput.Anatomy.{ - Props, - Root, - Label, Control, - Input, - TriggerGroup, DecrementTrigger, IncrementTrigger, - Scrubber + Input, + Label, + Props, + Root, + Scrubber, + TriggerGroup } defp data_attr(true), do: "" diff --git a/lib/components/password_input.ex b/lib/components/password_input.ex index 30cc98d..4fbc116 100644 --- a/lib/components/password_input.ex +++ b/lib/components/password_input.ex @@ -189,13 +189,13 @@ defmodule Corex.PasswordInput do use Phoenix.Component alias Corex.PasswordInput.Anatomy.{ - Props, - Root, - Label, Control, + Indicator, Input, - VisibilityTrigger, - Indicator + Label, + Props, + Root, + VisibilityTrigger } alias Corex.PasswordInput.Connect diff --git a/lib/components/password_input/connect.ex b/lib/components/password_input/connect.ex index e9f986a..e71bf3f 100644 --- a/lib/components/password_input/connect.ex +++ b/lib/components/password_input/connect.ex @@ -1,13 +1,13 @@ defmodule Corex.PasswordInput.Connect do @moduledoc false alias Corex.PasswordInput.Anatomy.{ - Props, - Root, - Label, Control, + Indicator, Input, - VisibilityTrigger, - Indicator + Label, + Props, + Root, + VisibilityTrigger } defp data_attr(true), do: "" diff --git a/lib/components/pin_input.ex b/lib/components/pin_input.ex index 5f313ba..4d0c9a5 100644 --- a/lib/components/pin_input.ex +++ b/lib/components/pin_input.ex @@ -46,7 +46,7 @@ defmodule Corex.PinInput do @doc type: :component use Phoenix.Component - alias Corex.PinInput.Anatomy.{Props, Root, Label, HiddenInput, Control, Input} + alias Corex.PinInput.Anatomy.{Control, HiddenInput, Input, Label, Props, Root} alias Corex.PinInput.Connect import Corex.Helpers, only: [validate_value!: 1] diff --git a/lib/components/pin_input/connect.ex b/lib/components/pin_input/connect.ex index 598e1dc..6b0c1be 100644 --- a/lib/components/pin_input/connect.ex +++ b/lib/components/pin_input/connect.ex @@ -1,6 +1,6 @@ defmodule Corex.PinInput.Connect do @moduledoc false - alias Corex.PinInput.Anatomy.{Props, Root, Label, HiddenInput, Control, Input} + alias Corex.PinInput.Anatomy.{Control, HiddenInput, Input, Label, Props, Root} import Corex.Helpers, only: [validate_value!: 1] defp data_attr(true), do: "" diff --git a/lib/components/radio_group.ex b/lib/components/radio_group.ex index 5f33e8f..4b76072 100644 --- a/lib/components/radio_group.ex +++ b/lib/components/radio_group.ex @@ -60,14 +60,14 @@ defmodule Corex.RadioGroup do use Phoenix.Component alias Corex.RadioGroup.Anatomy.{ - Props, - Root, - Label, Indicator, Item, - ItemText, ItemControl, - ItemHiddenInput + ItemHiddenInput, + ItemText, + Label, + Props, + Root } alias Corex.RadioGroup.Connect diff --git a/lib/components/radio_group/connect.ex b/lib/components/radio_group/connect.ex index 3d47955..6df850f 100644 --- a/lib/components/radio_group/connect.ex +++ b/lib/components/radio_group/connect.ex @@ -1,14 +1,14 @@ defmodule Corex.RadioGroup.Connect do @moduledoc false alias Corex.RadioGroup.Anatomy.{ - Props, - Root, - Label, Indicator, Item, - ItemText, ItemControl, - ItemHiddenInput + ItemHiddenInput, + ItemText, + Label, + Props, + Root } defp data_attr(true), do: "" diff --git a/lib/components/select.ex b/lib/components/select.ex index 81c5f05..1d303ca 100644 --- a/lib/components/select.ex +++ b/lib/components/select.ex @@ -426,8 +426,8 @@ defmodule Corex.Select do ''' use Phoenix.Component + alias Corex.Select.Anatomy.{Content, Control, Label, Positioner, Props, Root} alias Corex.Select.Connect - alias Corex.Select.Anatomy.{Props, Root, Label, Control, Positioner, Content} attr(:id, :string, required: false) attr(:collection, :list, default: []) diff --git a/lib/components/select/connect.ex b/lib/components/select/connect.ex index e92ec78..a4a0bd4 100644 --- a/lib/components/select/connect.ex +++ b/lib/components/select/connect.ex @@ -1,6 +1,6 @@ defmodule Corex.Select.Connect do @moduledoc false - alias Corex.Select.Anatomy.{Props, Root, Label, Control, Positioner, Content} + alias Corex.Select.Anatomy.{Content, Control, Label, Positioner, Props, Root} import Corex.Helpers, only: [get_boolean: 1, validate_value!: 1] diff --git a/lib/components/signature_pad.ex b/lib/components/signature_pad.ex index c98db5c..e31c48f 100644 --- a/lib/components/signature_pad.ex +++ b/lib/components/signature_pad.ex @@ -242,17 +242,19 @@ defmodule Corex.SignaturePad do use Phoenix.Component alias Corex.SignaturePad.Anatomy.{ - Props, - Root, - Label, + ClearTrigger, Control, - Segment, Guide, - ClearTrigger, - HiddenInput + HiddenInput, + Label, + Props, + Root, + Segment } alias Corex.SignaturePad.Connect + alias Phoenix.LiveView + alias Phoenix.LiveView.JS @doc """ Renders a signature pad component. @@ -457,7 +459,7 @@ defmodule Corex.SignaturePad do """ def clear(signature_pad_id) when is_binary(signature_pad_id) do - Phoenix.LiveView.JS.dispatch("phx:signature-pad:clear", + JS.dispatch("phx:signature-pad:clear", to: "##{signature_pad_id}", detail: %{id: signature_pad_id}, bubbles: false @@ -476,7 +478,7 @@ defmodule Corex.SignaturePad do """ def clear(socket, signature_pad_id) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(signature_pad_id) do - Phoenix.LiveView.push_event(socket, "signature_pad_clear", %{ + LiveView.push_event(socket, "signature_pad_clear", %{ signature_pad_id: signature_pad_id }) end diff --git a/lib/components/switch.ex b/lib/components/switch.ex index 6431c87..31d593f 100644 --- a/lib/components/switch.ex +++ b/lib/components/switch.ex @@ -204,8 +204,11 @@ defmodule Corex.Switch do @doc type: :component use Phoenix.Component - alias Corex.Switch.Anatomy.{Props, Root, HiddenInput, Control, Thumb, Label} + alias Corex.Switch.Anatomy.{Control, HiddenInput, Label, Props, Root, Thumb} alias Corex.Switch.Connect + alias Phoenix.HTML.Form + alias Phoenix.LiveView + alias Phoenix.LiveView.JS @doc """ Renders a switch component. @@ -306,7 +309,7 @@ defmodule Corex.Switch do |> assign(:errors, Enum.map(errors, &Corex.Gettext.translate_error(&1))) |> assign_new(:id, fn -> field.id end) |> assign_new(:name, fn -> field.name end) - |> assign(:checked, Phoenix.HTML.Form.normalize_value("checkbox", field.value)) + |> assign(:checked, Form.normalize_value("checkbox", field.value)) |> assign_new(:form, fn -> field.form.id end) switch(assigns) @@ -376,7 +379,7 @@ defmodule Corex.Switch do """ def set_checked(switch_id, checked) when is_binary(switch_id) and is_boolean(checked) do - Phoenix.LiveView.JS.dispatch("phx:switch:set-checked", + JS.dispatch("phx:switch:set-checked", to: "##{switch_id}", detail: %{checked: checked}, bubbles: false @@ -397,7 +400,7 @@ defmodule Corex.Switch do def set_checked(socket, switch_id, checked) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(switch_id) and is_boolean(checked) do - Phoenix.LiveView.push_event(socket, "switch_set_checked", %{ + LiveView.push_event(socket, "switch_set_checked", %{ switch_id: switch_id, checked: checked }) @@ -414,7 +417,7 @@ defmodule Corex.Switch do """ def toggle_checked(switch_id) when is_binary(switch_id) do - Phoenix.LiveView.JS.dispatch("phx:switch:toggle-checked", + JS.dispatch("phx:switch:toggle-checked", to: "##{switch_id}", bubbles: false ) @@ -433,7 +436,7 @@ defmodule Corex.Switch do """ def toggle_checked(socket, switch_id) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(switch_id) do - Phoenix.LiveView.push_event(socket, "switch_toggle_checked", %{ + LiveView.push_event(socket, "switch_toggle_checked", %{ switch_id: switch_id }) end diff --git a/lib/components/switch/connect.ex b/lib/components/switch/connect.ex index 5273114..2d301bb 100644 --- a/lib/components/switch/connect.ex +++ b/lib/components/switch/connect.ex @@ -1,6 +1,6 @@ defmodule Corex.Switch.Connect do @moduledoc false - alias Corex.Switch.Anatomy.{Props, Root, HiddenInput, Control, Thumb, Label} + alias Corex.Switch.Anatomy.{Control, HiddenInput, Label, Props, Root, Thumb} defp data_attr(true), do: "" defp data_attr(false), do: nil diff --git a/lib/components/tabs.ex b/lib/components/tabs.ex index f7683b4..a4c47b0 100644 --- a/lib/components/tabs.ex +++ b/lib/components/tabs.ex @@ -236,8 +236,10 @@ defmodule Corex.Tabs do @doc type: :component use Phoenix.Component - alias Corex.Tabs.Anatomy.{Props, Root, List, Trigger, Content} + alias Corex.Tabs.Anatomy.{Content, List, Props, Root, Trigger} alias Corex.Tabs.Connect + alias Phoenix.LiveView + alias Phoenix.LiveView.JS @doc """ Renders a tabs component. @@ -595,7 +597,7 @@ defmodule Corex.Tabs do """ def set_value(tabs_id, value) when is_binary(tabs_id) do - Phoenix.LiveView.JS.dispatch("phx:tabs:set-value", + JS.dispatch("phx:tabs:set-value", to: "##{tabs_id}", detail: %{value: validate_tabs_value!(value)}, bubbles: false @@ -620,7 +622,7 @@ defmodule Corex.Tabs do """ def set_value(socket, tabs_id, value) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(tabs_id) do - Phoenix.LiveView.push_event(socket, "tabs_set_value", %{ + LiveView.push_event(socket, "tabs_set_value", %{ tabs_id: tabs_id, value: validate_tabs_value!(value) }) diff --git a/lib/components/tabs/connect.ex b/lib/components/tabs/connect.ex index 0326f50..94aa5a0 100644 --- a/lib/components/tabs/connect.ex +++ b/lib/components/tabs/connect.ex @@ -1,6 +1,6 @@ defmodule Corex.Tabs.Connect do @moduledoc false - alias Corex.Tabs.Anatomy.{Props, Root, List, Trigger, Content} + alias Corex.Tabs.Anatomy.{Content, List, Props, Root, Trigger} defp data_attr(true), do: "" defp data_attr(false), do: nil diff --git a/lib/components/timer.ex b/lib/components/timer.ex index 5404a9f..f9383d6 100644 --- a/lib/components/timer.ex +++ b/lib/components/timer.ex @@ -63,7 +63,7 @@ defmodule Corex.Timer do @doc type: :component use Phoenix.Component - alias Corex.Timer.Anatomy.{Props, Root, Area, Control, Item, Separator, ActionTrigger} + alias Corex.Timer.Anatomy.{ActionTrigger, Area, Control, Item, Props, Root, Separator} alias Corex.Timer.Connect attr(:id, :string, required: false) diff --git a/lib/components/timer/connect.ex b/lib/components/timer/connect.ex index 30d6468..027ecc5 100644 --- a/lib/components/timer/connect.ex +++ b/lib/components/timer/connect.ex @@ -1,6 +1,6 @@ defmodule Corex.Timer.Connect do @moduledoc false - alias Corex.Timer.Anatomy.{Props, Root, Area, Control, Item, Separator, ActionTrigger} + alias Corex.Timer.Anatomy.{ActionTrigger, Area, Control, Item, Props, Root, Separator} defp data_attr(true), do: "" defp data_attr(false), do: nil diff --git a/lib/components/toast.ex b/lib/components/toast.ex index 2eb12d1..c6589ec 100644 --- a/lib/components/toast.ex +++ b/lib/components/toast.ex @@ -472,7 +472,7 @@ defmodule Corex.Toast do _ -> "info" end - Phoenix.LiveView.JS.dispatch("toast:create", + JS.dispatch("toast:create", to: "##{toast_group_id}", detail: %{ title: title, diff --git a/lib/components/toggle-group/connect.ex b/lib/components/toggle-group/connect.ex index 7965e2e..41200f9 100644 --- a/lib/components/toggle-group/connect.ex +++ b/lib/components/toggle-group/connect.ex @@ -1,6 +1,6 @@ defmodule Corex.ToggleGroup.Connect do @moduledoc false - alias Corex.ToggleGroup.Anatomy.{Props, Root, Item} + alias Corex.ToggleGroup.Anatomy.{Item, Props, Root} defp data_attr(true), do: "" defp data_attr(false), do: nil diff --git a/lib/components/toggle_group.ex b/lib/components/toggle_group.ex index 61281ec..2378aab 100644 --- a/lib/components/toggle_group.ex +++ b/lib/components/toggle_group.ex @@ -131,8 +131,10 @@ defmodule Corex.ToggleGroup do @doc type: :component use Phoenix.Component - alias Corex.ToggleGroup.Anatomy.{Props, Root, Item} + alias Corex.ToggleGroup.Anatomy.{Item, Props, Root} alias Corex.ToggleGroup.Connect + alias Phoenix.LiveView + alias Phoenix.LiveView.JS @doc """ Renders a toggle group component. @@ -248,7 +250,7 @@ defmodule Corex.ToggleGroup do """ def set_value(toggle_group_id, value) when is_binary(toggle_group_id) do - Phoenix.LiveView.JS.dispatch("phx:toggle-group:set-value", + JS.dispatch("phx:toggle-group:set-value", to: "##{toggle_group_id}", detail: %{value: Connect.validate_value!(value)} ) @@ -267,7 +269,7 @@ defmodule Corex.ToggleGroup do """ def set_value(socket, toggle_group_id, value) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(toggle_group_id) do - Phoenix.LiveView.push_event(socket, "toggle-group_set_value", %{ + LiveView.push_event(socket, "toggle-group_set_value", %{ id: toggle_group_id, value: Connect.validate_value!(value) }) diff --git a/lib/components/tree_view.ex b/lib/components/tree_view.ex index 52f3037..eaef8fb 100644 --- a/lib/components/tree_view.ex +++ b/lib/components/tree_view.ex @@ -117,8 +117,10 @@ defmodule Corex.TreeView do @doc type: :component use Phoenix.Component - alias Corex.TreeView.Anatomy.{Props, Root, Label, Branch, Item} + alias Corex.TreeView.Anatomy.{Branch, Item, Label, Props, Root} alias Corex.TreeView.Connect + alias Phoenix.LiveView + alias Phoenix.LiveView.JS import Corex.Helpers, only: [validate_value!: 1] @doc """ @@ -356,7 +358,7 @@ defmodule Corex.TreeView do @doc type: :api @doc "Sets the tree expanded value from client-side." def set_expanded_value(tree_view_id, value) when is_binary(tree_view_id) do - Phoenix.LiveView.JS.dispatch("phx:tree-view:set-expanded-value", + JS.dispatch("phx:tree-view:set-expanded-value", to: "##{tree_view_id}", detail: %{value: validate_value!(value)}, bubbles: false @@ -366,7 +368,7 @@ defmodule Corex.TreeView do @doc type: :api @doc "Sets the tree selected value from client-side." def set_selected_value(tree_view_id, value) when is_binary(tree_view_id) do - Phoenix.LiveView.JS.dispatch("phx:tree-view:set-selected-value", + JS.dispatch("phx:tree-view:set-selected-value", to: "##{tree_view_id}", detail: %{value: validate_value!(value)}, bubbles: false @@ -377,7 +379,7 @@ defmodule Corex.TreeView do @doc "Sets the tree expanded value from server-side." def set_expanded_value(socket, tree_view_id, value) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(tree_view_id) do - Phoenix.LiveView.push_event(socket, "tree_view_set_expanded_value", %{ + LiveView.push_event(socket, "tree_view_set_expanded_value", %{ tree_view_id: tree_view_id, value: validate_value!(value) }) @@ -387,7 +389,7 @@ defmodule Corex.TreeView do @doc "Sets the tree selected value from server-side." def set_selected_value(socket, tree_view_id, value) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(tree_view_id) do - Phoenix.LiveView.push_event(socket, "tree_view_set_selected_value", %{ + LiveView.push_event(socket, "tree_view_set_selected_value", %{ tree_view_id: tree_view_id, value: validate_value!(value) }) diff --git a/lib/components/tree_view/connect.ex b/lib/components/tree_view/connect.ex index b109bf0..9d76868 100644 --- a/lib/components/tree_view/connect.ex +++ b/lib/components/tree_view/connect.ex @@ -1,6 +1,6 @@ defmodule Corex.TreeView.Connect do @moduledoc false - alias Corex.TreeView.Anatomy.{Props, Root, Label, Item, Branch} + alias Corex.TreeView.Anatomy.{Branch, Item, Label, Props, Root} import Corex.Helpers, only: [validate_value!: 1] defp data_attr(true), do: "" diff --git a/lib/corex/form.ex b/lib/corex/form.ex index a90e1b2..e6c4dee 100644 --- a/lib/corex/form.ex +++ b/lib/corex/form.ex @@ -149,9 +149,9 @@ defmodule Corex.Form do ''' + alias Ecto.Changeset alias Phoenix.Component alias Phoenix.HTML.Form - alias Ecto.Changeset @doc """ Returns the form id. diff --git a/lib/mix/tasks/corex.code.ex b/lib/mix/tasks/corex.code.ex index 01f640e..03aa6e1 100644 --- a/lib/mix/tasks/corex.code.ex +++ b/lib/mix/tasks/corex.code.ex @@ -77,8 +77,7 @@ defmodule Mix.Tasks.Corex.Code do end defp generate!(full_path) do - makeup = Module.concat(["Elixir", "Makeup"]) - stylesheet = apply(makeup, :stylesheet, [:default_style]) + stylesheet = Makeup.stylesheet(:default_style) full_path |> Path.dirname() diff --git a/test/corex_test.exs b/test/corex_test.exs index 6d50a46..2533bd4 100644 --- a/test/corex_test.exs +++ b/test/corex_test.exs @@ -24,7 +24,7 @@ defmodule CorexTest do assert is_binary(to_string(result)) assert_raise UndefinedFunctionError, fn -> - apply(OnlyCode, :action, [%{}]) + OnlyCode.action(%{}) end end @@ -39,7 +39,7 @@ defmodule CorexTest do assert is_binary(to_string(result)) assert_raise UndefinedFunctionError, fn -> - apply(ExceptCode, :code, [%{code: "x"}]) + ExceptCode.code(%{code: "x"}) end end @@ -54,7 +54,7 @@ defmodule CorexTest do assert is_binary(to_string(result)) assert_raise UndefinedFunctionError, fn -> - apply(PrefixedComponents, :code, [%{code: "x"}]) + PrefixedComponents.code(%{code: "x"}) end end end diff --git a/test/test_helper.exs b/test/test_helper.exs index 9676277..409695c 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,6 +1,3 @@ Application.ensure_all_started(:phoenix_live_view) -exclude = - if System.get_env("CI") && !System.get_env("RUN_INTEGRATION"), do: [integration: true], else: [] - -ExUnit.start(exclude: exclude) +ExUnit.start()