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: "}
_ ->
{: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