diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index ffa9e4def..91759195b 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -4,7 +4,7 @@ {"lib/mix/tasks/phoenix_kit.doctor.ex", :unknown_function}, {"lib/mix/tasks/phoenix_kit.install.ex", :unknown_function}, {"lib/mix/tasks/phoenix_kit.update.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit.gen.admin_page.ex", :unknown_function}, + {"lib/mix/tasks/phoenix_kit.gen.admin.page.ex", :unknown_function}, {"lib/mix/tasks/phoenix_kit.gen.user.dashboard.ex", :unknown_function}, # Conditional compilation pattern match in update.ex (Code.ensure_loaded?) {"lib/mix/tasks/phoenix_kit.update.ex", :pattern_match, 1}, @@ -21,7 +21,7 @@ {"lib/mix/tasks/phoenix_kit.gen.migration.ex", :callback_info_missing, 1}, {"lib/mix/tasks/phoenix_kit.install.ex", :callback_info_missing, 2}, {"lib/mix/tasks/phoenix_kit.update.ex", :callback_info_missing, 3}, - {"lib/mix/tasks/phoenix_kit.gen.admin_page.ex", :callback_info_missing}, + {"lib/mix/tasks/phoenix_kit.gen.admin.page.ex", :callback_info_missing}, {"lib/mix/tasks/phoenix_kit.gen.user.dashboard.ex", :callback_info_missing}, {"lib/mix/tasks/phoenix_kit.modernize_layouts.ex", :callback_info_missing, 1}, {"lib/mix/tasks/phoenix_kit.assets.rebuild.ex", :callback_info_missing, 1}, diff --git a/lib/mix/tasks/phoenix_kit.gen.admin.page.ex b/lib/mix/tasks/phoenix_kit.gen.admin.page.ex new file mode 100644 index 000000000..1401a9295 --- /dev/null +++ b/lib/mix/tasks/phoenix_kit.gen.admin.page.ex @@ -0,0 +1,396 @@ +defmodule Mix.Tasks.PhoenixKit.Gen.Admin.Page do + @moduledoc """ + Igniter task that generates admin page with automatic route registration. + + ## Usage + + mix phoenix_kit.gen.admin.page "Reports Dashboard" + + ## Arguments + + - `title` - The display title for the page (e.g., "Reports Dashboard") + + ## Options + + - `--url` - The URL path for the page (optional, derived from title if not provided) + - `--category` - The category name (optional, defaults to "General") + - `--icon` - Heroicon name for the tab (optional, defaults to "hero-document-text") + - `--permission` - Permission key for parent tab (optional, defaults to "dashboard") + - `--category-icon` - Heroicon name for the category (optional, defaults to "hero-folder") + + ## Parent/Child Tab Behavior + + - First page in a category creates both parent and child tabs + - Subsequent pages in the same category only add the child tab + - Parent tab path points to the first child's URL + - Routes are automatically generated via the `live_view` field + + ## Examples + + # Simple - uses all defaults + mix phoenix_kit.gen.admin.page "Reports Dashboard" + + # With custom category + mix phoenix_kit.gen.admin.page "User Management" --category="Users" + + # With custom icon + mix phoenix_kit.gen.admin.page "Analytics" --icon="hero-chart-bar" + + # Full control + mix phoenix_kit.gen.admin.page "Reports" --url="/admin/analytics/reports" --category="Analytics" --icon="hero-chart-bar" + + """ + + @shortdoc "Generates admin page with automatic route registration" + + use Igniter.Mix.Task + + alias Igniter.Code.Common + alias Igniter.Project.Config + alias PhoenixKit.Install.IgniterHelpers + alias Sourceror.Zipper + + @impl Igniter.Mix.Task + def info(_argv, _composing_task) do + %Igniter.Mix.Task.Info{ + group: :phoenix_kit, + example: "mix phoenix_kit.gen.admin.page \"Reports Dashboard\"", + schema: [ + url: :string, + category: :string, + icon: :string, + permission: :string, + category_icon: :string + ], + aliases: [u: :url, c: :category, i: :icon, p: :permission, ci: :category_icon] + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + opts = igniter.args.options + argv = igniter.args.argv + + case parse_args(argv, opts) do + {:ok, {title, category, url}} -> + igniter + |> generate_admin_page(title, category, url, opts) + + {:error, message} -> + Igniter.add_notice(igniter, """ + ❌ Error: #{message} + + Usage: mix phoenix_kit.gen.admin.page + Example: mix phoenix_kit.gen.admin.page "Reports Dashboard" + """) + end + end + + @impl Mix.Task + def run(argv) do + if "--help" in argv or "-h" in argv do + Mix.shell().info(""" + Generates admin page with automatic route registration. + + Usage: + + mix phoenix_kit.gen.admin.page "Reports Dashboard" + + Arguments: + + title - The display title for the page + + Options: + + --url - The URL path (optional, derived from title) + --category - The category name (optional, defaults to "General") + --icon - Heroicon name (optional, defaults to "hero-document-text") + --permission - Permission key (optional, defaults to "dashboard") + --category-icon - Heroicon name for category (optional, defaults to "hero-folder") + + Examples: + + # Simple + mix phoenix_kit.gen.admin.page "Reports Dashboard" + + # With category + mix phoenix_kit.gen.admin.page "Users" --category="People" + + # With URL + mix phoenix_kit.gen.admin.page "Reports" --url="/admin/analytics/reports" + + Notes: + - Routes are auto-generated via live_view config + - First page in category creates parent tab + - Run: mix compile --force && restart server + """) + + :ok + else + super(argv) + end + end + + defp parse_args(argv, opts) do + positional_args = Enum.reject(argv, &String.starts_with?(&1, "--")) + + case positional_args do + [title] -> + url = Keyword.get(opts, :url, slugify(title)) + category = Keyword.get(opts, :category, "General") + {:ok, {title, category, url}} + + [] -> + {:error, "not enough arguments. Expected: <title>"} + + _ -> + {:error, "invalid arguments"} + end + end + + defp generate_admin_page(igniter, title, category, url, opts) do + icon = Keyword.get(opts, :icon, "hero-document-text") + + cond do + !String.starts_with?(url, "/") -> + Igniter.add_issue(igniter, {:fatal, "URL must start with '/'", []}) + + byte_size(title) > 100 -> + Igniter.add_issue(igniter, {:fatal, "Title must be less than 100 characters", []}) + + true -> + igniter + |> create_page_live_view(title, category, url) + |> add_admin_tabs(title, category, url, icon, opts) + |> print_success_message(title, category, url) + end + end + + defp create_page_live_view(igniter, title, category, url) do + category_module_name = Macro.camelize(String.replace(category, " ", "_")) + page_name = derive_page_name(title) + + app_name = IgniterHelpers.get_parent_app_name(igniter) + web_module = IgniterHelpers.get_parent_app_module_web(igniter) + + web_module_string = + web_module + |> to_string() + |> String.replace_prefix("Elixir.", "") + + template_path = + case :code.priv_dir(:phoenix_kit) do + priv_dir when is_list(priv_dir) or is_binary(priv_dir) -> + Path.join(priv_dir, "templates/admin_category_page.ex") + + _ -> + "priv/templates/admin_category_page.ex" + end + + case File.read(template_path) do + {:ok, template_content} -> + rendered_content = + template_content + |> String.replace("<%= @web_module_prefix %>", web_module_string) + |> String.replace("<%= @page_name %>", to_string(page_name)) + |> String.replace("<%= @page_title %>", to_string(title)) + |> String.replace("<%= @url %>", to_string(url)) + |> String.replace("<%= @category %>", category_module_name) + + file_path = build_live_view_file_path(app_name, category, page_name) + + Igniter.create_new_file(igniter, file_path, rendered_content, on_format: :skip) + + {:error, reason} -> + igniter + |> Igniter.add_issue({:fatal, "Failed to read template file: #{reason}", []}) + end + end + + defp add_admin_tabs(igniter, title, category, url, icon, opts) do + web_module = IgniterHelpers.get_parent_app_module_web(igniter) + category_icon = Keyword.get(opts, :category_icon, "hero-folder") + permission = Keyword.get(opts, :permission, "dashboard") + + parent_id = derive_parent_tab_id(category) + child_id = derive_child_tab_id(category, derive_page_name(title)) + + child_live_view_module = build_live_view_module(web_module, category, derive_page_name(title)) + + child_tab = %{ + id: child_id, + label: title, + icon: icon, + path: url, + parent: parent_id, + permission: permission, + priority: calculate_child_priority(category, derive_page_name(title)), + live_view: {child_live_view_module, :index} + } + + # Create parent tab (points to first child's URL) + parent_tab = %{ + id: parent_id, + label: category, + icon: category_icon, + path: url, + permission: permission, + priority: calculate_parent_priority(category), + group: :admin_modules, + subtab_display: :when_active, + highlight_with_subtabs: false + } + + # Use IgniterConfig to modify the config + Config.configure( + igniter, + "config.exs", + :phoenix_kit, + [:admin_dashboard_tabs], + [parent_tab, child_tab], + updater: fn zipper -> + case extract_current_value(zipper) do + {:ok, existing_tabs} when is_list(existing_tabs) -> + parent_exists? = Enum.any?(existing_tabs, fn t -> t[:id] == parent_id end) + child_exists? = Enum.any?(existing_tabs, fn t -> t[:id] == child_id end) + + if child_exists? do + {:error, + "A page with ID #{inspect(child_id)} already exists in category '#{category}'"} + else + siblings = + Enum.filter(existing_tabs, fn t -> + t[:parent] == parent_id or (t[:id] == parent_id and parent_exists?) + end) + + url_duplicate? = + Enum.any?(siblings, fn t -> + t[:path] == url and t[:id] != child_id + end) + + if url_duplicate? do + {:error, "A page with URL '#{url}' already exists in category '#{category}'"} + else + updated_tabs = + if parent_exists? do + existing_tabs ++ [child_tab] + else + existing_tabs ++ [parent_tab, child_tab] + end + + {:ok, Common.replace_code(zipper, updated_tabs)} + end + end + + _ -> + {:ok, Common.replace_code(zipper, [parent_tab, child_tab])} + end + end + ) + end + + defp derive_parent_tab_id(category) do + category + |> String.downcase() + |> String.replace(" ", "_") + |> then(&:"admin_#{&1}") + end + + defp derive_child_tab_id(category, page_name) do + category_slug = category |> String.downcase() |> String.replace(" ", "_") + page_slug = page_name |> String.downcase() |> String.replace(" ", "_") + :"admin_#{category_slug}_#{page_slug}" + end + + defp derive_page_name(title) do + title + |> String.downcase() + |> String.replace(~r/[^a-z0-9\s]/, "") + |> String.replace(~r/\s+/, "_") + |> Macro.camelize() + end + + defp build_live_view_module(web_module, category, page_name) do + category_module_name = Macro.camelize(String.replace(category, " ", "_")) + Module.concat([web_module, PhoenixKit, Admin, category_module_name, page_name]) + end + + defp calculate_parent_priority(category) do + category + |> String.downcase() + |> :erlang.phash2() + |> rem(90) + |> Kernel.+(700) + end + + defp calculate_child_priority(category, page_name) do + parent_prio = calculate_parent_priority(category) + + offset = + page_name + |> :erlang.phash2() + |> rem(9) + |> Kernel.+(1) + + parent_prio + offset + end + + defp slugify(title) do + title + |> String.downcase() + |> String.replace(~r/[^a-z0-9\s-]/, "") + |> String.replace(~r/\s+/, "-") + |> then(&"/admin/#{&1}") + end + + # Extracts the current value from a zipper + defp extract_current_value(zipper) do + current_node = Zipper.node(zipper) + + case Code.eval_quoted(current_node) do + {value, _binding} -> {:ok, value} + end + rescue + _ -> :error + end + + defp print_success_message(igniter, title, category, url) do + web_module = IgniterHelpers.get_parent_app_module_web(igniter) + + web_module_string = + web_module + |> to_string() + |> String.replace_prefix("Elixir.", "") + + category_module_name = Macro.camelize(String.replace(category, " ", "_")) + page_name = derive_page_name(title) + + page_module = + "#{web_module_string}.PhoenixKit.Admin.#{category_module_name}.#{page_name}" + + Igniter.add_notice(igniter, """ + ✅ Admin page generated! + + Page: #{page_module} + Category: #{category} + URL: #{url} + + 📝 Routes are auto-generated via live_view config + + Then: mix compile --force && restart server + """) + end + + defp build_live_view_file_path(app_name, category, page_name) do + web_path = + app_name + |> to_string() + |> Kernel.<>("_web") + |> String.downcase() + + category_path = category |> String.replace(" ", "_") |> String.downcase() + file_name = String.downcase(page_name) + + "lib/#{web_path}/phoenix_kit/admin/#{category_path}/#{file_name}.ex" + end +end diff --git a/lib/mix/tasks/phoenix_kit.gen.admin_page.ex b/lib/mix/tasks/phoenix_kit.gen.admin_page.ex deleted file mode 100644 index 37fa32ea7..000000000 --- a/lib/mix/tasks/phoenix_kit.gen.admin_page.ex +++ /dev/null @@ -1,586 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.Gen.AdminPage do - @moduledoc """ - Igniter task that generates admin pages with automatic route registration. - - ## Usage - - mix phoenix_kit.gen.admin_page MyCategory MyPage "Page Title" --url="/admin/my-page" - - ## Arguments - - - `category` - The category name (becomes parent tab) - - `page_name` - The name for the page module (PascalCase) - - `page_title` - The display title for the page - - ## Options - - - `--url` - The URL path for the page (required) - - `--icon` - Heroicon name for the child tab (optional, defaults to "hero-document-text") - - `--permission` - Permission key for parent tab (optional, defaults to "dashboard") - - `--category-icon` - Heroicon name for the parent tab (optional, defaults to "hero-folder"). Only used when creating a new parent. - - ## Parent/Child Tab Behavior - - - First page in a category creates both parent and child tabs - - Subsequent pages in the same category only add the child tab - - Parent tab path points to the first child's URL - - Routes are automatically generated via the `live_view` field - - """ - - @shortdoc "Generates admin page with automatic route registration" - - use Igniter.Mix.Task - - alias Igniter.Code.Common - alias Igniter.Project.Config - alias PhoenixKit.Install.IgniterHelpers - alias Sourceror.Zipper - - @impl Igniter.Mix.Task - def info(_argv, _composing_task) do - %Igniter.Mix.Task.Info{ - group: :phoenix_kit, - example: - "mix phoenix_kit.gen.admin_page Analytics Reports \"Reports Dashboard\" --url=\"/admin/analytics/reports\"", - schema: [ - url: :string, - icon: :string, - permission: :string, - category_icon: :string - ], - aliases: [u: :url, i: :icon, p: :permission, ci: :category_icon] - } - end - - @impl Igniter.Mix.Task - def igniter(igniter) do - # Get options and arguments from igniter context - opts = igniter.args.options - argv = igniter.args.argv - - case parse_args(argv, opts) do - {:ok, {category, page_name, page_title}} -> - igniter - |> generate_admin_page(category, page_name, page_title, opts) - - {:error, message} -> - Igniter.add_notice(igniter, """ - ❌ Error: #{message} - - Usage: mix phoenix_kit.gen.admin_page <category> <page_name> <page_title> --url=<url> - Example: mix phoenix_kit.gen.admin_page Analytics Reports "Reports Dashboard" --url="/admin/analytics/reports" - """) - end - end - - @impl Mix.Task - def run(argv) do - # Handle --help flag manually - if "--help" in argv or "-h" in argv do - Mix.shell().info(""" - Generates admin page with automatic route registration. - - Usage: - - mix phoenix_kit.gen.admin_page MyCategory MyPage "Page Title" --url="/admin/my-page" - - Arguments: - - category - The category name (becomes parent tab) - page_name - The name for the page module (PascalCase) - page_title - The display title for the page - - Options: - - --url - The URL path for the page (required) - --icon - Heroicon name for the child tab (optional, defaults to "hero-document-text") - --permission - Permission key for parent tab (optional, defaults to "dashboard") - --category-icon - Heroicon name for the parent tab (optional, defaults to "hero-folder") - - Example: - - mix phoenix_kit.gen.admin_page Analytics Reports "Reports Dashboard" \\ - --url="/admin/analytics/reports" - - Parent/Child Tab Behavior: - - - First page in a category creates both parent and child tabs - - Subsequent pages in the same category only add the child tab - - Parent tab path points to the first child's URL - - Routes are automatically generated via the live_view field - - After generation, run: mix compile --force - """) - - :ok - else - # Delegate to Igniter.Mix.Task for standard execution - super(argv) - end - end - - defp parse_args(argv, opts) do - # Filter out option arguments (starting with --) from argv - positional_args = Enum.reject(argv, &String.starts_with?(&1, "--")) - - case positional_args do - [category, page_name, page_title] -> - url = Keyword.get(opts, :url) - - if url do - {:ok, {category, page_name, page_title}} - else - {:error, "--url option is required"} - end - - args when length(args) < 3 -> - {:error, "not enough arguments"} - - _ -> - {:error, "invalid arguments"} - end - end - - defp generate_admin_page(igniter, category, page_name, page_title, opts) do - url = Keyword.get(opts, :url) - icon = Keyword.get(opts, :icon, "hero-document-text") - - # Validate inputs - cond do - !url -> - Igniter.add_issue(igniter, {:fatal, "--url option is required", []}) - - !String.starts_with?(url, "/") -> - Igniter.add_issue(igniter, {:fatal, "URL must start with '/'", []}) - - byte_size(page_title) > 100 -> - Igniter.add_issue(igniter, {:fatal, "Page title must be less than 100 characters", []}) - - true -> - igniter - |> create_template_based_live_view(page_name, page_title, url, category) - |> add_admin_tabs(category, page_name, page_title, url, icon, opts) - |> print_success_message(category, page_name, page_title, url) - end - end - - defp create_template_based_live_view(igniter, page_name, page_title, url, category) do - # Create nested module name with category - category_module_name = Macro.camelize(String.replace(category, " ", "_")) - - app_name = IgniterHelpers.get_parent_app_name(igniter) - web_module = IgniterHelpers.get_parent_app_module_web(igniter) - - web_module_string = - web_module - |> to_string() - |> String.replace_prefix("Elixir.", "") - - # First, create the page LiveView - igniter = - create_page_live_view( - igniter, - app_name, - web_module_string, - category_module_name, - page_name, - page_title, - url, - category - ) - - # Then, create the category index LiveView - category_slug = String.downcase(category |> String.replace(" ", "_")) - category_url = "/admin/#{category_slug}" - - create_category_index_live_view( - igniter, - app_name, - web_module_string, - category_module_name, - category, - category_url - ) - end - - defp create_page_live_view( - igniter, - app_name, - web_module_string, - category_module_name, - page_name, - page_title, - url, - category - ) do - module_name = "Live.Admin.#{category_module_name}.#{page_name}" - - # Read the EEx template file - template_path = - case :code.priv_dir(:phoenix_kit) do - priv_dir when is_list(priv_dir) or is_binary(priv_dir) -> - Path.join(priv_dir, "templates/admin_category_page.ex") - - _ -> - "priv/templates/admin_category_page.ex" - end - - case File.read(template_path) do - {:ok, template_content} -> - _full_module_name = Module.concat([web_module_string, "PhoenixKit", module_name]) - - # Use string replacement to avoid EEx/HEEX conflicts - rendered_content = - template_content - |> String.replace("<%= @web_module_prefix %>", web_module_string) - |> String.replace("<%= @page_name %>", to_string(page_name)) - |> String.replace("<%= @page_title %>", to_string(page_title)) - |> String.replace("<%= @url %>", to_string(url)) - |> String.replace( - "<%= @category %>", - category_module_name - ) - - # Build the correct path - file_path = build_live_view_file_path(app_name, category, page_name) - - # Use create_new_file to avoid module parsing that might corrupt HEEX syntax - # Also skip formatting to preserve HEEX template syntax - Igniter.create_new_file(igniter, file_path, rendered_content, on_format: :skip) - - {:error, reason} -> - igniter - |> Igniter.add_issue({:fatal, "Failed to read template file: #{reason}", []}) - end - end - - defp create_category_index_live_view( - igniter, - app_name, - web_module_string, - category_module_name, - category, - category_url - ) do - # Read the category index template file - template_path = - case :code.priv_dir(:phoenix_kit) do - priv_dir when is_list(priv_dir) or is_binary(priv_dir) -> - Path.join(priv_dir, "templates/admin_category_index_page.ex") - - _ -> - "priv/templates/admin_category_index_page.ex" - end - - case File.read(template_path) do - {:ok, template_content} -> - # Use string replacement to avoid EEx/HEEX conflicts - rendered_content = - template_content - |> String.replace("<%= @web_module_prefix %>", web_module_string) - |> String.replace("<%= @category %>", category_module_name) - |> String.replace("<%= @url %>", to_string(category_url)) - - # Build the correct path for the index file - category_slug = String.downcase(category |> String.replace(" ", "_")) - - web_path = - app_name - |> to_string() - |> Kernel.<>("_web") - |> String.downcase() - - file_path = "lib/#{web_path}/phoenix_kit/live/admin/#{category_slug}/index.ex" - - # Use create_new_file to avoid module parsing that might corrupt HEEX syntax - Igniter.create_new_file(igniter, file_path, rendered_content, on_format: :skip) - - {:error, reason} -> - igniter - |> Igniter.add_issue({:fatal, "Failed to read template file: #{reason}", []}) - end - end - - defp add_admin_tabs(igniter, category, page_name, page_title, url, icon, opts) do - web_module = IgniterHelpers.get_parent_app_module_web(igniter) - category_icon = Keyword.get(opts, :category_icon, "hero-folder") - permission = Keyword.get(opts, :permission, "dashboard") - - parent_id = derive_parent_tab_id(category) - child_id = derive_child_tab_id(category, page_name) - - # Build the full LiveView module path for child page - child_live_view_module = build_live_view_module(web_module, category, page_name) - - # Build the category index LiveView module path for parent - parent_live_view_module = build_category_index_module(web_module, category) - - # Calculate parent URL from category name - category_slug = String.downcase(category |> String.replace(" ", "_")) - parent_url = "/admin/#{category_slug}" - - # Create child tab config - child_tab = %{ - id: child_id, - label: page_title, - icon: icon, - path: url, - parent: parent_id, - priority: calculate_child_priority(category, page_name), - live_view: {child_live_view_module, :index} - } - - # Use IgniterConfig to modify the config - Config.configure( - igniter, - "config.exs", - :phoenix_kit, - [:admin_dashboard_tabs], - # Default value if config doesn't exist - [ - create_parent_tab( - category, - parent_id, - parent_url, - parent_live_view_module, - category_icon, - permission - ), - child_tab - ], - updater: fn zipper -> - case extract_current_value(zipper) do - {:ok, existing_tabs} when is_list(existing_tabs) -> - # Check if parent tab exists - parent_exists? = Enum.any?(existing_tabs, fn t -> t[:id] == parent_id end) - - # Validate: Check for duplicate child tab ID - child_exists? = Enum.any?(existing_tabs, fn t -> t[:id] == child_id end) - - if child_exists? do - {:error, - "A page with ID #{inspect(child_id)} already exists in category '#{category}'. " <> - "Use a different page name."} - else - # Get existing children of this parent (siblings) - siblings = - Enum.filter(existing_tabs, fn t -> - t[:parent] == parent_id or (t[:id] == parent_id and parent_exists?) - end) - - # Validate: Check for duplicate URL in the same category - url_duplicate? = - Enum.any?(siblings, fn t -> - t[:path] == url and t[:id] != child_id - end) - - if url_duplicate? do - {:error, - "A page with URL '#{url}' already exists in category '#{category}'. " <> - "Use a different URL."} - else - # Validate: Check for duplicate label in the same category - label_duplicate? = - Enum.any?(siblings, fn t -> - t[:label] == page_title and t[:id] != child_id - end) - - if label_duplicate? do - {:error, - "A page with label '#{page_title}' already exists in category '#{category}'. " <> - "Use a different page title."} - else - updated_tabs = - if parent_exists? do - # Parent exists, just add child - existing_tabs ++ [child_tab] - else - # Create parent tab with its own index page - parent_tab = - create_parent_tab( - category, - parent_id, - parent_url, - parent_live_view_module, - category_icon, - permission - ) - - existing_tabs ++ [parent_tab, child_tab] - end - - {:ok, Common.replace_code(zipper, updated_tabs)} - end - end - end - - _ -> - # Config doesn't exist, create with parent and child - {:ok, - Common.replace_code( - zipper, - [ - create_parent_tab( - category, - parent_id, - parent_url, - parent_live_view_module, - category_icon, - permission - ), - child_tab - ] - )} - end - end - ) - end - - defp create_parent_tab(category, parent_id, path, live_view_module, icon, permission) do - %{ - id: parent_id, - label: category, - icon: icon, - path: path, - permission: permission, - priority: calculate_parent_priority(category), - group: :admin_modules, - subtab_display: :when_active, - highlight_with_subtabs: false, - live_view: {live_view_module, :index} - } - end - - defp derive_parent_tab_id(category) do - category - |> String.downcase() - |> String.replace(" ", "_") - |> then(&:"admin_#{&1}") - end - - defp derive_child_tab_id(category, page_name) do - category_slug = category |> String.downcase() |> String.replace(" ", "_") - page_slug = page_name |> String.downcase() |> String.replace(" ", "_") - :"admin_#{category_slug}_#{page_slug}" - end - - defp build_live_view_module(web_module, category, page_name) do - category_module_name = Macro.camelize(String.replace(category, " ", "_")) - Module.concat([web_module, PhoenixKit, Live, Admin, category_module_name, page_name]) - end - - defp build_category_index_module(web_module, category) do - category_module_name = Macro.camelize(String.replace(category, " ", "_")) - Module.concat([web_module, PhoenixKit, Live, Admin, category_module_name, Index]) - end - - defp calculate_parent_priority(category) do - # Hash category to get a stable priority between 700-790 - category - |> String.downcase() - |> :erlang.phash2() - |> rem(90) - |> Kernel.+(700) - end - - defp calculate_child_priority(category, page_name) do - # Parent priority + 1-9 based on page name hash - parent_prio = calculate_parent_priority(category) - - offset = - page_name - |> String.downcase() - |> :erlang.phash2() - |> rem(9) - |> Kernel.+(1) - - parent_prio + offset - end - - # Extracts the current value from a zipper - defp extract_current_value(zipper) do - current_node = Zipper.node(zipper) - - case Code.eval_quoted(current_node) do - {value, _binding} -> {:ok, value} - end - rescue - _ -> :error - end - - defp print_success_message(igniter, category, page_name, _page_title, url) do - web_module_name = IgniterHelpers.get_parent_app_module_web_string(igniter) - - # Generate the full module name for display - category_module_name = Macro.camelize(String.replace(category, " ", "_")) - - page_module_name = - "#{web_module_name}.PhoenixKit.Live.Admin.#{category_module_name}.#{page_name}" - - index_module_name = - "#{web_module_name}.PhoenixKit.Live.Admin.#{category_module_name}.Index" - - category_slug = String.downcase(category |> String.replace(" ", "_")) - category_url = "/admin/#{category_slug}" - - parent_id = derive_parent_tab_id(category) - child_id = derive_child_tab_id(category, page_name) - - Igniter.add_notice(igniter, """ - ✅ Admin page generated successfully! - - Page Created: #{page_module_name} - Index Created: #{index_module_name} - Added to category: #{category} - Page URL: #{url} - Category URL: #{category_url} - - What was done: - 1. ✓ Created category index LiveView at #{category_url} - 2. ✓ Created page LiveView at #{url} - 3. ✓ Added parent tab (#{parent_id}) and child tab (#{child_id}) to :admin_dashboard_tabs - - 📝 Important: Routes are automatically generated - - Routes are automatically generated via the live_view field in the config. - No manual router configuration is needed. - - Next steps: - 1. Force recompile: mix compile --force - 2. Restart your server - 3. Visit #{category_url} for the category index - 4. Visit #{url} for the page - 5. Implement the page functionality in the LiveView modules - 6. Customize the page content as needed - - Parent/Child Tab Structure: - - Parent tab: #{category} (#{parent_id}) - - Child tab: #{page_name} (#{child_id}) - - Parent path points to first child's URL - - Subsequent pages in the same category will only add child tabs - """) - end - - # Builds the full file path for the LiveView template - defp build_live_view_file_path(app_name, category, page_name) do - # Use the app_name to get the correct web directory name - # E.g., phoenix_kit_parent_project -> phoenix_kit_parent_project_web - web_path = - app_name - |> to_string() - |> Kernel.<>("_web") - |> String.downcase() - - # Convert category to path-safe format - category_path = - category - |> String.replace(" ", "_") - |> String.downcase() - - # Convert page name to lowercase for the file - file_name = String.downcase(page_name) - - # Return the full file path - "lib/#{web_path}/phoenix_kit/live/admin/#{category_path}/#{file_name}.ex" - end -end diff --git a/lib/mix/tasks/phoenix_kit.gen.user.dashboard.ex b/lib/mix/tasks/phoenix_kit.gen.user.dashboard.ex index 5b7a7617b..c71e8c162 100644 --- a/lib/mix/tasks/phoenix_kit.gen.user.dashboard.ex +++ b/lib/mix/tasks/phoenix_kit.gen.user.dashboard.ex @@ -17,6 +17,7 @@ defmodule Mix.Tasks.PhoenixKit.Gen.User.Dashboard do - `--icon` - Heroicon name for the tab (optional, defaults to "hero-document") - `--description` - Brief description for the tab (optional) - `--category-icon` - Heroicon name for the category (optional, defaults to "hero-folder"). Only used when creating a new category. + - `--index` - Generate as main dashboard index (skips config, for overriding /dashboard) ## Examples @@ -32,6 +33,9 @@ defmodule Mix.Tasks.PhoenixKit.Gen.User.Dashboard do # Full control mix phoenix_kit.gen.user.dashboard "Example" --url="/custom/path" --icon="hero-user" + # Generate as main dashboard index (overrides /dashboard) + mix phoenix_kit.gen.user.dashboard "Dashboard" --url="/dashboard" --index --description="Welcome" + """ @shortdoc "Generates user dashboard page with tab configuration" @@ -51,7 +55,8 @@ defmodule Mix.Tasks.PhoenixKit.Gen.User.Dashboard do category: :string, icon: :string, description: :string, - category_icon: :string + category_icon: :string, + index: :boolean ], aliases: [u: :url, c: :category, i: :icon, d: :description, ci: :category_icon] } @@ -67,7 +72,7 @@ defmodule Mix.Tasks.PhoenixKit.Gen.User.Dashboard do {:ok, {tab_title, category, url}} -> igniter |> create_live_view(tab_title, url, opts) - |> add_dashboard_tab(category, tab_title, url, opts) + |> maybe_add_dashboard_tab(category, tab_title, url, opts) {:error, message} -> Igniter.add_notice(igniter, """ @@ -101,6 +106,7 @@ defmodule Mix.Tasks.PhoenixKit.Gen.User.Dashboard do --icon - Heroicon name for the tab (optional, defaults to "hero-document") --description - Brief description for the tab (optional) --category-icon - Heroicon name for the category (optional, defaults to "hero-folder") + --index - Generate as main dashboard index (skips config, for overriding /dashboard) Examples: @@ -116,10 +122,13 @@ defmodule Mix.Tasks.PhoenixKit.Gen.User.Dashboard do # Full control mix phoenix_kit.gen.user.dashboard "Example" --url="/custom/path" --icon="hero-user" + # Generate as main dashboard index (overrides /dashboard) + mix phoenix_kit.gen.user.dashboard "Dashboard" --url="/dashboard" --index --description="Welcome" + Notes: - Creates a LiveView file for the dashboard page - - Adds tab configuration to config/config.exs under :user_dashboard_categories + - Adds tab configuration to config/config.exs under :user_dashboard_categories (unless --index) - After adding the route, run: mix compile --force """) @@ -218,6 +227,17 @@ defmodule Mix.Tasks.PhoenixKit.Gen.User.Dashboard do end end + defp maybe_add_dashboard_tab(igniter, category, tab_title, url, opts) do + is_index = Keyword.get(opts, :index, false) + + if is_index do + # Skip adding to config for index pages + print_index_success_message(igniter, tab_title, url) + else + add_dashboard_tab(igniter, category, tab_title, url, opts) + end + end + defp update_dashboard_categories_config( igniter, category, @@ -247,7 +267,6 @@ defmodule Mix.Tasks.PhoenixKit.Gen.User.Dashboard do end defp print_success_message(igniter, category, tab_title, url) do - app_name = IgniterHelpers.get_parent_app_name(igniter) web_module = IgniterHelpers.get_parent_app_module_web(igniter) web_module_string = @@ -259,20 +278,49 @@ defmodule Mix.Tasks.PhoenixKit.Gen.User.Dashboard do live_view_module = "#{web_module_string}.PhoenixKit.Dashboard.#{page_name}" Igniter.add_notice(igniter, """ - ✅ Dashboard page generated successfully! + ✅ Dashboard page generated! - Page Created: #{live_view_module} + Page: #{live_view_module} Tab: #{tab_title} Category: #{category} URL: #{url} - What was done: - 1. ✓ Created LiveView file at #{build_live_view_file_path(app_name, page_name)} - 2. ✓ Added tab to user dashboard configuration in config/config.exs + 📝 Add route: - 📝 Next Steps: + scope "/" do + pipe_through :browser - 1. Add the route to your router (inside a dashboard live_session): + live_session :user_dashboard, + on_mount: [ + {PhoenixKitWeb.Users.Auth, :phoenix_kit_ensure_authenticated_scope}, + {PhoenixKitWeb.Dashboard.ContextProvider, :default} + ] do + live "#{url}", #{live_view_module}, :index + end + end + + Then: mix compile --force && restart server + """) + end + + defp print_index_success_message(igniter, tab_title, url) do + web_module = IgniterHelpers.get_parent_app_module_web(igniter) + + web_module_string = + web_module + |> to_string() + |> String.replace_prefix("Elixir.", "") + + page_name = camelize(tab_title) + live_view_module = "#{web_module_string}.PhoenixKit.Dashboard.#{page_name}" + + Igniter.add_notice(igniter, """ + ✅ Dashboard index page generated! + + Page: #{live_view_module} + URL: #{url} + + 📝 Add route BEFORE phoenix_kit_routes(): scope "/" do pipe_through :browser @@ -286,11 +334,10 @@ defmodule Mix.Tasks.PhoenixKit.Gen.User.Dashboard do end end - 2. Force recompile: mix compile --force - - 3. Restart your server to see the new tab in the dashboard sidebar. + import PhoenixKitWeb.Integration + phoenix_kit_routes() - The tab will appear under the "#{category}" category in the user dashboard. + Then: mix compile --force && restart server """) end diff --git a/priv/templates/admin_category_index_page.ex b/priv/templates/admin_category_index_page.ex index e102a231a..c48a4f919 100644 --- a/priv/templates/admin_category_index_page.ex +++ b/priv/templates/admin_category_index_page.ex @@ -1,4 +1,4 @@ -defmodule <%= @web_module_prefix %>.PhoenixKit.Live.Admin.<%= @category %>.Index do +defmodule <%= @web_module_prefix %>.PhoenixKit.Admin.<%= @category %>.Index do @moduledoc """ Index page for the <%= @category %> admin category. """ @@ -23,7 +23,7 @@ defmodule <%= @web_module_prefix %>.PhoenixKit.Live.Admin.<%= @category %>.Index def render(assigns) do ~H""" <div class="min-h-screen bg-base-200"> - <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> + <div class="max-w-7xl px-4 sm:px-6 lg:px-8 py-8"> <.flash_messages flash={@flash} /> <div class="bg-base-100 shadow-sm rounded-lg p-6"> diff --git a/priv/templates/admin_category_page.ex b/priv/templates/admin_category_page.ex index 3cecca4ef..322770805 100644 --- a/priv/templates/admin_category_page.ex +++ b/priv/templates/admin_category_page.ex @@ -1,4 +1,4 @@ -defmodule <%= @web_module_prefix %>.PhoenixKit.Live.Admin.<%= @category %>.<%= @page_name %> do +defmodule <%= @web_module_prefix %>.PhoenixKit.Admin.<%= @category %>.<%= @page_name %> do @moduledoc """ Admin LiveView for <%= @page_title %> in <%= @category %> category. """ @@ -23,7 +23,7 @@ defmodule <%= @web_module_prefix %>.PhoenixKit.Live.Admin.<%= @category %>.<%= @ def render(assigns) do ~H""" <div class="min-h-screen bg-base-200"> - <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> + <div class="max-w-7xl px-4 sm:px-6 lg:px-8 py-8"> <.flash_messages flash={@flash} /> <div class="bg-base-100 shadow-sm rounded-lg p-6"> diff --git a/priv/templates/user_dashboard_page.ex b/priv/templates/user_dashboard_page.ex index 7088d73f8..9e7d3556b 100644 --- a/priv/templates/user_dashboard_page.ex +++ b/priv/templates/user_dashboard_page.ex @@ -6,6 +6,7 @@ defmodule <%= @web_module_prefix %>.PhoenixKit.Dashboard.<%= @page_name %> do use <%= @web_module_prefix %>, :live_view import PhoenixKitWeb.LayoutHelpers, only: [dashboard_assigns: 1] + import PhoenixKitWeb.Components.Core.UserDashboardHeader, only: [user_dashboard_header: 1] @impl true def mount(_params, _session, socket) do