Packages
phoenix_kit
1.7.42
1.7.208
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
Files
Jump to
Current section
Files
lib/modules/entities/web/entity_form.html.heex
<PhoenixKitWeb.Components.LayoutWrapper.app_layout
flash={@flash}
phoenix_kit_current_scope={assigns[:phoenix_kit_current_scope]}
page_title={
gettext("%{project_title} %{action} %{entity}",
project_title: @project_title,
action: if(@entity.id, do: gettext("Edit"), else: gettext("New")),
entity: gettext("Entity")
)
}
current_path={@url_path}
project_title={@project_title}
current_locale={@current_locale}
>
<div class="container flex flex-col mx-auto px-4 py-6">
<%!-- Header Section --%>
<header class="w-full mb-6">
<%!-- Back Button (Left aligned) --%>
<.link
navigate={PhoenixKit.Utils.Routes.path("/admin/entities")}
class="btn btn-outline btn-primary btn-sm"
>
<.icon name="hero-arrow-left" class="w-4 h-4 mr-2" /> {gettext("Back to Entities")}
</.link>
<%!-- Title Section --%>
<div class="text-center">
<h1 class="text-2xl sm:text-4xl font-bold text-base-content mb-3">
{if @entity.id, do: gettext("Edit Entity"), else: gettext("Create New Entity")}
</h1>
<p class="text-base sm:text-lg text-base-content">
{gettext("Define your custom content type with dynamic fields")}
</p>
</div>
</header>
<%!-- Readonly Banner --%>
<%= if @readonly? do %>
<div class="alert alert-info mb-6">
<.icon name="hero-eye" class="w-5 h-5" />
<span>
{gettext(
"This entity is currently being edited by another user. You are in view-only mode."
)}
</span>
</div>
<% end %>
<.form
:let={f}
for={@changeset}
phx-change="validate"
phx-debounce="500"
phx-submit="save"
class="space-y-8"
>
<%!-- Entity Metadata Section --%>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">
<.icon name="hero-information-circle" class="w-6 h-6" /> {gettext(
"Entity Information"
)}
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<%!-- Entity Name (Singular) --%>
<div>
<.label for="entity_display_name">{gettext("Entity Name (Singular)")} *</.label>
<.input
field={f[:display_name]}
type="text"
placeholder={gettext("Brand")}
phx-debounce="300"
disabled={@readonly?}
/>
<.label class="label">
<span class="label-text-alt">
{gettext("Singular form (e.g., \"Brand\")")}
</span>
</.label>
</div>
<%!-- Entity Name (Plural) --%>
<div>
<.label for="entity_display_name_plural">
{gettext("Entity Name (Plural)")} *
</.label>
<.input
field={f[:display_name_plural]}
type="text"
placeholder={gettext("Brands")}
phx-debounce="300"
disabled={@readonly?}
/>
<.label class="label">
<span class="label-text-alt">
{gettext("Plural form (e.g., \"Brands\")")}
</span>
</.label>
</div>
<%!-- Slug --%>
<div>
<.label for="entity_name">
{gettext("Slug")} *
<button
type="button"
class="btn btn-ghost btn-xs ml-2"
phx-click="generate_entity_slug"
title={gettext("Generate from Entity Name")}
disabled={@readonly?}
>
<.icon name="hero-arrow-path" class="w-3 h-3" /> {gettext("Generate")}
</button>
</.label>
<.input
field={f[:name]}
type="text"
placeholder={gettext("brand")}
phx-debounce="300"
disabled={@readonly?}
/>
<.label class="label">
<span class="label-text-alt">
{gettext("snake_case identifier used in the system")}
</span>
</.label>
</div>
<%!-- Icon --%>
<div>
<.label for="entity_icon">{gettext("Icon (Optional)")}</.label>
<div class="flex gap-2">
<div class="flex-1">
<.input
field={f[:icon]}
type="text"
placeholder={gettext("hero-document-text")}
phx-debounce="300"
disabled={@readonly?}
/>
</div>
<button
type="button"
phx-click="open_icon_picker"
class="btn btn-outline btn-square"
title={gettext("Browse icons")}
disabled={@readonly?}
>
<.icon name="hero-squares-2x2" class="w-5 h-5" />
</button>
<%= if f[:icon].value && f[:icon].value != "" do %>
<button
type="button"
phx-click="clear_icon"
class="btn btn-outline btn-square btn-error"
title={gettext("Clear icon")}
disabled={@readonly?}
>
<.icon name="hero-x-mark" class="w-5 h-5" />
</button>
<%= if String.starts_with?(f[:icon].value, "hero-") do %>
<div class="btn btn-square btn-ghost pointer-events-none">
<.icon name={f[:icon].value} class="w-6 h-6" />
</div>
<% end %>
<% end %>
</div>
<.label class="label">
<span class="label-text-alt">
{gettext("Heroicon name or click Browse")}
</span>
</.label>
</div>
<%!-- Status --%>
<div>
<.label for="entity_status">{gettext("Status")} *</.label>
<select
id="entity_status"
name={f[:status].name}
class="select select-bordered w-full"
required
disabled={@readonly?}
>
<option value="published" selected={f[:status].value == "published"}>
{gettext("Published (active)")}
</option>
<option value="draft" selected={f[:status].value == "draft"}>
{gettext("Draft (not visible)")}
</option>
<option value="archived" selected={f[:status].value == "archived"}>
{gettext("Archived (hidden)")}
</option>
</select>
<.label class="label">
<span class="label-text-alt">
{gettext("Only published can be used")}
</span>
</.label>
</div>
</div>
<%!-- Description --%>
<div>
<.label for="entity_description">{gettext("Description (Optional)")}</.label>
<textarea
id="entity_description"
name={f[:description].name}
placeholder={gettext("Describe what this entity represents...")}
class="textarea textarea-bordered w-full"
rows="3"
phx-debounce="300"
disabled={@readonly?}
>{f[:description].value}</textarea>
</div>
</div>
</div>
<%!-- Fields Section --%>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h2 class="card-title text-2xl">
<.icon name="hero-list-bullet" class="w-6 h-6" />
{ngettext(
"%{count} Field Definition",
"%{count} Field Definitions",
length(@fields),
count: length(@fields)
)}
</h2>
<button
type="button"
class="btn btn-primary"
phx-click="add_field"
disabled={@readonly?}
>
<.icon name="hero-plus" class="w-4 h-4 mr-2" /> {gettext("Add Field")}
</button>
</div>
<%= if Enum.empty?(@fields) do %>
<%!-- Empty Fields State --%>
<div class="text-center py-8 border-2 border-dashed border-base-300 rounded-lg">
<div class="text-4xl mb-4 opacity-50">📝</div>
<h3 class="text-lg font-semibold text-base-content/60 mb-2">
{gettext("No Fields Yet")}
</h3>
<p class="text-base-content/50 mb-4">
{gettext("Add fields to define what data this entity can store")}
</p>
<button
type="button"
class="btn btn-primary"
phx-click="add_field"
disabled={@readonly?}
>
<.icon name="hero-plus" class="w-4 h-4 mr-2" /> {gettext("Add Your First Field")}
</button>
</div>
<% else %>
<%!-- Fields List --%>
<div class="space-y-4">
<%= for {field, index} <- Enum.with_index(@fields) do %>
<div class="card bg-base-200 border border-base-300">
<div class="card-body p-4">
<div class="flex items-center justify-between">
<%!-- Move buttons (stacked vertically) at the beginning --%>
<div class="flex flex-col -space-y-1 mr-3">
<button
type="button"
class={"btn btn-ghost btn-xs px-1.5 #{if index == 0, do: "invisible", else: ""}"}
phx-click="move_field_up"
phx-value-index={index}
title={gettext("Move up")}
disabled={index == 0 or @readonly?}
>
<.icon name="hero-chevron-up" class="w-3 h-3" />
</button>
<button
type="button"
class={"btn btn-ghost btn-xs px-1.5 #{if index >= length(@fields) - 1, do: "invisible", else: ""}"}
phx-click="move_field_down"
phx-value-index={index}
title={gettext("Move down")}
disabled={index >= length(@fields) - 1 or @readonly?}
>
<.icon name="hero-chevron-down" class="w-3 h-3" />
</button>
</div>
<div class="flex items-center justify-between flex-1">
<%!-- Field Icon & Info --%>
<div class="flex items-center space-x-2">
<.icon
name={field_type_icon(field["type"])}
class="w-5 h-5 text-primary"
/>
<div>
<div class="font-medium">{field["label"]}</div>
<div class="text-sm text-base-content/60">
{field["key"]} · {field_type_label(field["type"])}
{if field["required"], do: " · #{gettext("Required")}"}
</div>
</div>
</div>
<%!-- Field Actions --%>
<div class="flex items-center space-x-2">
<%= if @delete_confirm_index == index do %>
<%!-- Delete confirmation buttons --%>
<button
type="button"
class="btn btn-error btn-sm"
phx-click="delete_field"
phx-value-index={index}
disabled={@readonly?}
>
{gettext("Confirm?")}
</button>
<button
type="button"
class="btn btn-outline btn-sm"
phx-click="cancel_delete_field"
disabled={@readonly?}
>
{gettext("Cancel")}
</button>
<% else %>
<%!-- Edit button --%>
<button
type="button"
class="btn btn-primary btn-sm"
phx-click="edit_field"
phx-value-index={index}
disabled={@readonly?}
>
<.icon name="hero-pencil" class="w-4 h-4" />
</button>
<%!-- Delete button --%>
<button
type="button"
class="btn btn-error btn-sm"
phx-click="confirm_delete_field"
phx-value-index={index}
title={gettext("Delete field")}
disabled={@readonly?}
>
<.icon name="hero-trash" class="w-4 h-4" />
</button>
<% end %>
</div>
</div>
</div>
<%!-- Field Options Preview --%>
<%= if requires_options?(field["type"]) && field["options"] do %>
<div class="mt-2 text-sm text-base-content/60">
<span class="font-medium">{gettext("Options")}:</span>
{Enum.join(field["options"], ", ")}
</div>
<% end %>
</div>
</div>
<% end %>
</div>
<% end %>
</div>
</div>
<%!-- Public Form Configuration Section --%>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="card-title text-2xl">
<.icon name="hero-globe-alt" class="w-6 h-6" />
{gettext("Public Form Configuration")}
</h2>
<p class="text-sm text-base-content/70 mt-1">
{gettext("Enable this entity to be used as an embeddable form on public pages")}
</p>
</div>
</div>
<%!-- Enable Public Form Toggle --%>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
name="public_form_enabled"
class="toggle toggle-primary"
checked={get_in(@entity.settings, ["public_form_enabled"]) || false}
phx-click="toggle_public_form"
disabled={@readonly?}
/>
<div>
<span class="label-text font-semibold">
{gettext("Enable Public Form")}
</span>
<p class="text-xs text-base-content/60 mt-1">
{gettext("Allow this entity to be embedded as a form on public pages")}
</p>
</div>
</label>
</div>
<%= if get_in(@entity.settings, ["public_form_enabled"]) do %>
<div class="divider"></div>
<%!-- Form Configuration --%>
<div class="space-y-4">
<%!-- Form Title --%>
<div>
<.label>{gettext("Form Title")}</.label>
<input
type="text"
name="public_form_title"
value={get_in(@entity.settings, ["public_form_title"]) || @entity.display_name}
placeholder={@entity.display_name}
class="input input-bordered w-full"
phx-blur="update_public_form_setting"
phx-debounce="500"
disabled={@readonly?}
/>
</div>
<%!-- Form Description --%>
<div>
<.label>{gettext("Form Description (Optional)")}</.label>
<textarea
name="public_form_description"
placeholder={gettext("Describe what this form is for...")}
class="textarea textarea-bordered w-full"
rows="2"
phx-blur="update_public_form_setting"
phx-debounce="500"
disabled={@readonly?}
>{get_in(@entity.settings, ["public_form_description"]) || ""}</textarea>
</div>
<%!-- Submit Button Text --%>
<div>
<.label>{gettext("Submit Button Text")}</.label>
<input
type="text"
name="public_form_submit_text"
value={
get_in(@entity.settings, ["public_form_submit_text"]) || gettext("Submit")
}
placeholder={gettext("Submit")}
class="input input-bordered w-full"
phx-blur="update_public_form_setting"
phx-debounce="500"
disabled={@readonly?}
/>
</div>
<%!-- Success Message --%>
<div>
<.label>{gettext("Success Message")}</.label>
<textarea
name="public_form_success_message"
placeholder={gettext("Thank you for your submission!")}
class="textarea textarea-bordered w-full"
rows="2"
phx-blur="update_public_form_setting"
phx-debounce="500"
disabled={@readonly?}
>{get_in(@entity.settings, ["public_form_success_message"]) || gettext("Thank you for your submission!")}</textarea>
</div>
<%!-- Field Selection --%>
<%= if not Enum.empty?(@fields) do %>
<div>
<.label>{gettext("Form Fields")}</.label>
<p class="text-sm text-base-content/60 mb-3">
{gettext(
"Select which fields to include in the public form. Fields not selected will only be visible in the admin data viewer."
)}
</p>
<div class="space-y-2 max-h-64 overflow-y-auto border border-base-300 rounded-lg p-3">
<%= for field <- @fields do %>
<label class="flex items-center gap-3 p-2 hover:bg-base-200 rounded cursor-pointer">
<input
type="checkbox"
name="public_form_field"
value={field["key"]}
checked={
field["key"] in (get_in(@entity.settings, ["public_form_fields"]) ||
[])
}
phx-click="toggle_public_form_field"
phx-value-field={field["key"]}
class="checkbox checkbox-primary"
disabled={@readonly?}
/>
<div class="flex items-center gap-2 flex-1">
<.icon
name={field_type_icon(field["type"])}
class="w-4 h-4 text-primary"
/>
<div>
<span class="font-medium">{field["label"]}</span>
<span class="text-xs text-base-content/50 ml-2">
({field["key"]})
</span>
</div>
</div>
<span class="badge badge-sm badge-outline">
{field_type_label(field["type"])}
</span>
</label>
<% end %>
</div>
</div>
<% else %>
<div class="alert alert-warning">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<span>
{gettext(
"Add fields to this entity first before configuring the public form."
)}
</span>
</div>
<% end %>
<%!-- Usage Example --%>
<%= if not Enum.empty?(get_in(@entity.settings, ["public_form_fields"]) || []) do %>
<div class="alert alert-info">
<.icon name="hero-information-circle" class="w-5 h-5" />
<div class="flex-1">
<p class="font-semibold mb-1">{gettext("Embed this form:")}</p>
<code class="text-xs bg-base-300 px-2 py-1 rounded">
<EntityForm entity_slug="{@entity.name}" />
</code>
</div>
</div>
<% end %>
<div class="divider"></div>
<%!-- Security Section --%>
<div>
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
<.icon name="hero-shield-check" class="w-5 h-5" />
{gettext("Security")}
</h3>
<div class="space-y-4">
<%!-- Collect Metadata Toggle --%>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
name="public_form_collect_metadata"
class="toggle toggle-primary"
checked={
get_in(@entity.settings, ["public_form_collect_metadata"]) != false
}
phx-click="toggle_security_setting"
phx-value-setting="public_form_collect_metadata"
disabled={@readonly?}
/>
<div>
<span class="label-text font-medium">
{gettext("Collect Submission Metadata")}
</span>
<p class="text-xs text-base-content/60 mt-1">
{gettext(
"Record IP address, browser, device info, and referrer for each submission"
)}
</p>
</div>
</label>
</div>
<%!-- Debug Mode Toggle --%>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
name="public_form_debug_mode"
class="toggle toggle-warning"
checked={get_in(@entity.settings, ["public_form_debug_mode"]) || false}
phx-click="toggle_security_setting"
phx-value-setting="public_form_debug_mode"
disabled={@readonly?}
/>
<div>
<span class="label-text font-medium">
{gettext("Debug Mode")}
</span>
<p class="text-xs text-base-content/60 mt-1">
{gettext(
"Show detailed error messages when security checks fail (for troubleshooting only)"
)}
</p>
</div>
</label>
</div>
<%= if get_in(@entity.settings, ["public_form_debug_mode"]) do %>
<div class="alert alert-warning">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<span class="text-sm">
{gettext(
"Debug mode is enabled. Detailed security errors will be shown to users. Disable this in production."
)}
</span>
</div>
<% end %>
<%!-- Honeypot Protection --%>
<div class="border border-base-300 rounded-lg p-4">
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
name="public_form_honeypot"
class="toggle toggle-primary"
checked={get_in(@entity.settings, ["public_form_honeypot"]) || false}
phx-click="toggle_security_setting"
phx-value-setting="public_form_honeypot"
disabled={@readonly?}
/>
<div>
<span class="label-text font-medium">
{gettext("Honeypot Protection")}
</span>
<p class="text-xs text-base-content/60 mt-1">
{gettext("Add a hidden field that bots typically fill out")}
</p>
</div>
</label>
</div>
<%= if get_in(@entity.settings, ["public_form_honeypot"]) do %>
<div class="mt-3 pl-14">
<.label class="text-sm">{gettext("When triggered:")}</.label>
<select
name="public_form_honeypot_action"
class="select select-bordered select-sm w-full max-w-xs mt-1"
phx-change="update_security_action"
phx-value-setting="public_form_honeypot_action"
disabled={@readonly?}
>
<option
value="reject_silent"
selected={
get_in(@entity.settings, ["public_form_honeypot_action"]) ==
"reject_silent" ||
is_nil(get_in(@entity.settings, ["public_form_honeypot_action"]))
}
>
{gettext("Reject silently (fake success)")}
</option>
<option
value="reject_error"
selected={
get_in(@entity.settings, ["public_form_honeypot_action"]) ==
"reject_error"
}
>
{gettext("Reject with error message")}
</option>
<option
value="save_suspicious"
selected={
get_in(@entity.settings, ["public_form_honeypot_action"]) ==
"save_suspicious"
}
>
{gettext("Save but mark as suspicious")}
</option>
<option
value="save_log"
selected={
get_in(@entity.settings, ["public_form_honeypot_action"]) ==
"save_log"
}
>
{gettext("Save and log warning")}
</option>
</select>
</div>
<% end %>
</div>
<%!-- Time-based Validation --%>
<div class="border border-base-300 rounded-lg p-4">
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
name="public_form_time_check"
class="toggle toggle-primary"
checked={get_in(@entity.settings, ["public_form_time_check"]) || false}
phx-click="toggle_security_setting"
phx-value-setting="public_form_time_check"
disabled={@readonly?}
/>
<div>
<span class="label-text font-medium">
{gettext("Time-based Validation")}
</span>
<p class="text-xs text-base-content/60 mt-1">
{gettext(
"Check if submission happens too quickly (less than 3 seconds)"
)}
</p>
</div>
</label>
</div>
<%= if get_in(@entity.settings, ["public_form_time_check"]) do %>
<div class="mt-3 pl-14">
<.label class="text-sm">{gettext("When triggered:")}</.label>
<select
name="public_form_time_check_action"
class="select select-bordered select-sm w-full max-w-xs mt-1"
phx-change="update_security_action"
phx-value-setting="public_form_time_check_action"
disabled={@readonly?}
>
<option
value="reject_error"
selected={
get_in(@entity.settings, ["public_form_time_check_action"]) ==
"reject_error" ||
is_nil(
get_in(@entity.settings, ["public_form_time_check_action"])
)
}
>
{gettext("Reject with error message")}
</option>
<option
value="reject_silent"
selected={
get_in(@entity.settings, ["public_form_time_check_action"]) ==
"reject_silent"
}
>
{gettext("Reject silently (fake success)")}
</option>
<option
value="save_suspicious"
selected={
get_in(@entity.settings, ["public_form_time_check_action"]) ==
"save_suspicious"
}
>
{gettext("Save but mark as suspicious")}
</option>
<option
value="save_log"
selected={
get_in(@entity.settings, ["public_form_time_check_action"]) ==
"save_log"
}
>
{gettext("Save and log warning")}
</option>
</select>
</div>
<% end %>
</div>
<%!-- Rate Limiting --%>
<div class="border border-base-300 rounded-lg p-4">
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
name="public_form_rate_limit"
class="toggle toggle-primary"
checked={get_in(@entity.settings, ["public_form_rate_limit"]) || false}
phx-click="toggle_security_setting"
phx-value-setting="public_form_rate_limit"
disabled={@readonly?}
/>
<div>
<span class="label-text font-medium">
{gettext("Rate Limiting")}
</span>
<p class="text-xs text-base-content/60 mt-1">
{gettext("Limit submissions to 5 per minute per IP address")}
</p>
</div>
</label>
</div>
<%= if get_in(@entity.settings, ["public_form_rate_limit"]) do %>
<div class="mt-3 pl-14">
<.label class="text-sm">{gettext("When triggered:")}</.label>
<select
name="public_form_rate_limit_action"
class="select select-bordered select-sm w-full max-w-xs mt-1"
phx-change="update_security_action"
phx-value-setting="public_form_rate_limit_action"
disabled={@readonly?}
>
<option
value="reject_error"
selected={
get_in(@entity.settings, ["public_form_rate_limit_action"]) ==
"reject_error" ||
is_nil(
get_in(@entity.settings, ["public_form_rate_limit_action"])
)
}
>
{gettext("Reject with error message")}
</option>
<option
value="reject_silent"
selected={
get_in(@entity.settings, ["public_form_rate_limit_action"]) ==
"reject_silent"
}
>
{gettext("Reject silently (fake success)")}
</option>
<option
value="save_suspicious"
selected={
get_in(@entity.settings, ["public_form_rate_limit_action"]) ==
"save_suspicious"
}
>
{gettext("Save but mark as suspicious")}
</option>
<option
value="save_log"
selected={
get_in(@entity.settings, ["public_form_rate_limit_action"]) ==
"save_log"
}
>
{gettext("Save and log warning")}
</option>
</select>
</div>
<% end %>
</div>
</div>
</div>
<%!-- Form Statistics --%>
<% stats = get_in(@entity.settings, ["public_form_stats"]) || %{} %>
<div class="divider"></div>
<div>
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
<.icon name="hero-chart-bar" class="w-5 h-5" />
{gettext("Form Statistics")}
</h3>
<div class="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200">
<div class="stat">
<div class="stat-figure text-primary">
<.icon name="hero-document-text" class="w-8 h-8" />
</div>
<div class="stat-title">{gettext("Total Submissions")}</div>
<div class="stat-value text-primary">
{stats["total_submissions"] || 0}
</div>
</div>
<div class="stat">
<div class="stat-figure text-success">
<.icon name="hero-check-circle" class="w-8 h-8" />
</div>
<div class="stat-title">{gettext("Successful")}</div>
<div class="stat-value text-success">
{stats["successful_submissions"] || 0}
</div>
</div>
<div class="stat">
<div class="stat-figure text-error">
<.icon name="hero-x-circle" class="w-8 h-8" />
</div>
<div class="stat-title">{gettext("Rejected")}</div>
<div class="stat-value text-error">
{stats["rejected_submissions"] || 0}
</div>
</div>
</div>
<%!-- Security trigger breakdown --%>
<%= if stats["honeypot_triggers"] || stats["too_fast_triggers"] || stats["rate_limited_triggers"] do %>
<div class="mt-4">
<h4 class="text-sm font-medium text-base-content/70 mb-2">
{gettext("Security Triggers")}
</h4>
<div class="flex flex-wrap gap-2">
<%= if stats["honeypot_triggers"] do %>
<div class="badge badge-warning gap-1">
<.icon name="hero-bug-ant" class="w-3 h-3" />
{gettext("Honeypot")}: {stats["honeypot_triggers"]}
</div>
<% end %>
<%= if stats["too_fast_triggers"] do %>
<div class="badge badge-warning gap-1">
<.icon name="hero-bolt" class="w-3 h-3" />
{gettext("Too Fast")}: {stats["too_fast_triggers"]}
</div>
<% end %>
<%= if stats["rate_limited_triggers"] do %>
<div class="badge badge-warning gap-1">
<.icon name="hero-clock" class="w-3 h-3" />
{gettext("Rate Limited")}: {stats["rate_limited_triggers"]}
</div>
<% end %>
</div>
</div>
<% end %>
<%!-- Last submission time --%>
<%= if stats["last_submission_at"] do %>
<div class="mt-4 text-sm text-base-content/60">
{gettext("Last submission")}: {format_stats_datetime(
stats["last_submission_at"]
)}
</div>
<% end %>
<%!-- Reset Stats Button (only show if there are stats) --%>
<%= if stats["total_submissions"] do %>
<div class="mt-4">
<button
type="button"
class="btn btn-ghost btn-sm"
phx-click="reset_form_stats"
disabled={@readonly?}
>
<.icon name="hero-arrow-path" class="w-4 h-4" />
{gettext("Reset Statistics")}
</button>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
</div>
<%!-- Backup Settings Section --%>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="card-title text-2xl">
<.icon name="hero-arrow-down-tray" class="w-6 h-6" />
{gettext("Backup Settings")}
</h2>
<p class="text-sm text-base-content/70 mt-1">
{gettext("Configure automatic backup to file for this entity")}
</p>
</div>
<%= if @entity.id do %>
<button
type="button"
class="btn btn-primary btn-sm"
phx-click="export_entity_now"
disabled={@readonly?}
>
<.icon name="hero-arrow-down-tray" class="w-4 h-4" />
{gettext("Export Now")}
</button>
<% end %>
</div>
<%= if @entity.id do %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<%!-- Definition Sync Toggle --%>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
class="toggle toggle-primary"
checked={PhoenixKit.Modules.Entities.mirror_definitions_enabled?(@entity)}
phx-click="toggle_backup_definitions"
disabled={@readonly?}
/>
<div>
<span class="label-text font-semibold">
{gettext("Sync Definition to File")}
</span>
<p class="text-xs text-base-content/60 mt-1">
{gettext("Automatically export entity schema when changes are saved")}
</p>
</div>
</label>
</div>
<%!-- Records Sync Toggle --%>
<div class={"form-control #{unless PhoenixKit.Modules.Entities.mirror_definitions_enabled?(@entity), do: "opacity-50"}"}>
<label class="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
class="toggle toggle-primary"
checked={PhoenixKit.Modules.Entities.mirror_data_enabled?(@entity)}
phx-click="toggle_backup_data"
disabled={
@readonly? or
not PhoenixKit.Modules.Entities.mirror_definitions_enabled?(@entity)
}
/>
<div>
<span class="label-text font-semibold">
{gettext("Sync Records to File")}
</span>
<p class="text-xs text-base-content/60 mt-1">
{gettext(
"Automatically export data records when they are created or updated"
)}
</p>
</div>
</label>
</div>
</div>
<%!-- File Info --%>
<div class="mt-4 p-4 bg-base-200 rounded-lg">
<div class="flex items-center gap-2 text-sm">
<.icon name="hero-folder" class="w-4 h-4 text-base-content/70" />
<span class="text-base-content/70">{gettext("Export path")}:</span>
<code class="text-xs bg-base-300 px-2 py-1 rounded break-all">
{@mirror_path}/{@entity.name}.json
</code>
</div>
</div>
<% else %>
<%!-- New entity - show message --%>
<div class="alert alert-info">
<.icon name="hero-information-circle" class="w-5 h-5" />
<span>
{gettext("Save the entity first to configure backup settings.")}
</span>
</div>
<% end %>
</div>
</div>
<%!-- Form Actions --%>
<div class="flex justify-between items-center">
<div class="flex gap-2">
<.link
navigate={PhoenixKit.Utils.Routes.path("/admin/entities")}
class="btn btn-outline"
>
{gettext("Cancel")}
</.link>
<button type="button" phx-click="reset" class="btn btn-warning" disabled={@readonly?}>
<.icon name="hero-arrow-path" class="w-4 h-4" />
{gettext("Reset Changes")}
</button>
</div>
<button
type="submit"
class="btn btn-primary"
disabled={!@changeset.valid? or @readonly?}
>
{if @entity.id, do: gettext("Update Entity"), else: gettext("Create Entity")}
</button>
</div>
</.form>
<%!-- Field Form Modal --%>
<%= if @show_field_form do %>
<div class="modal modal-open" phx-click="cancel_field">
<div class="modal-box w-11/12 max-w-2xl" phx-click="stop_propagation">
<h3 class="font-bold text-lg mb-4">
{if @editing_field_index, do: gettext("Edit Field"), else: gettext("Add New Field")}
</h3>
<%!-- Field Error Alert --%>
<%= if @field_error do %>
<div class="alert alert-error mb-4">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<span>{@field_error}</span>
</div>
<% end %>
<.form
for={%{}}
phx-change="update_field_form"
phx-submit="save_field"
class="space-y-4"
>
<%!-- Field Type --%>
<div>
<.label>{gettext("Field Type")} *</.label>
<select
name="field[type]"
class="select select-bordered w-full"
phx-debounce="300"
value={@field_form["type"]}
>
<%= for {category_key, _label} <- PhoenixKit.Modules.Entities.FieldTypes.category_list() do %>
<optgroup label={field_category_label(category_key)}>
<%= for type <- PhoenixKit.Modules.Entities.FieldTypes.by_category(category_key) do %>
<option value={type.name} selected={@field_form["type"] == type.name}>
{field_type_label(type.name)}
</option>
<% end %>
</optgroup>
<% end %>
</select>
</div>
<%!-- Field Label and Key --%>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<.label>{gettext("Field Label")} *</.label>
<input
type="text"
name="field[label]"
class="input input-bordered w-full"
placeholder={gettext("Field Name")}
value={@field_form["label"]}
phx-debounce="300"
required
/>
<.label class="label">
<span class="label-text-alt">{gettext("Display name for users")}</span>
</.label>
</div>
<div>
<.label>
{gettext("Slug")} *
<button
type="button"
class="btn btn-ghost btn-xs ml-2"
phx-click="generate_field_key"
title={gettext("Generate from Field Label")}
>
<.icon name="hero-arrow-path" class="w-3 h-3" /> {gettext("Generate")}
</button>
</.label>
<input
type="text"
name="field[key]"
class="input input-bordered w-full"
placeholder={gettext("field_name")}
value={@field_form["key"]}
phx-debounce="300"
required
/>
<.label class="label">
<span class="label-text-alt">{gettext("snake_case identifier")}</span>
</.label>
</div>
</div>
<%!-- Required and Default Value --%>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<.label class="label cursor-pointer">
<span class="label-text">{gettext("Required Field")}</span>
<input
type="checkbox"
name="field[required]"
class="toggle toggle-primary"
value="true"
checked={@field_form["required"]}
/>
</.label>
</div>
<div>
<.label>{gettext("Default Value (Optional)")}</.label>
<input
type="text"
name="field[default]"
class="input input-bordered w-full"
phx-debounce="300"
value={@field_form["default"]}
/>
</div>
</div>
<%!-- File Upload Configuration (only for file type) --%>
<%= if @field_form["type"] == "file" do %>
<div class="space-y-4 p-4 bg-base-200 rounded-lg">
<div class="text-sm font-semibold text-base-content/70">
{gettext("File Upload Settings")}
</div>
<%!-- Max Files --%>
<div class="form-control">
<.label>{gettext("Maximum Files")}</.label>
<input
type="number"
name="field[max_entries]"
value={@field_form["max_entries"] || 5}
min="1"
max="20"
class="input input-bordered"
placeholder="5"
phx-debounce="300"
/>
<.label class="label">
<span class="label-text-alt">
{gettext("Max number of files users can upload (1-20)")}
</span>
</.label>
</div>
<%!-- Max File Size --%>
<div class="form-control">
<.label>{gettext("Max File Size (MB)")}</.label>
<input
type="number"
name="field[max_file_size_mb]"
value={bytes_to_mb(@field_form["max_file_size"] || 15_728_640)}
min="1"
max="100"
step="0.1"
class="input input-bordered"
placeholder="15"
phx-debounce="300"
/>
<.label class="label">
<span class="label-text-alt">
{gettext("Maximum size per file in megabytes (1-100 MB)")}
</span>
</.label>
</div>
<%!-- Accepted File Types --%>
<div class="form-control">
<.label>{gettext("Accepted File Types")}</.label>
<textarea
name="field[accept]"
rows="3"
class="textarea textarea-bordered"
placeholder=".pdf, .jpg, .png"
phx-debounce="300"
><%= format_accept_list(@field_form["accept"]) %></textarea>
<.label class="label">
<span class="label-text-alt">
{gettext(
"Comma-separated list of extensions (e.g., .pdf, .jpg, .png, .step, .dwg)"
)}
</span>
</.label>
</div>
</div>
<% end %>
<%!-- Options (for choice fields) --%>
<%= if requires_options?(@field_form["type"]) do %>
<div>
<div class="flex items-center justify-between mb-2">
<.label>{gettext("Options")} *</.label>
<button
type="button"
class="btn btn-sm btn-outline"
phx-click="add_option"
>
<.icon name="hero-plus" class="w-4 h-4 mr-1" /> {gettext("Add Option")}
</button>
</div>
<div class="space-y-2">
<%= for {option, option_index} <- Enum.with_index(@field_form["options"] || []) do %>
<div class="flex gap-2">
<input
type="text"
name={"option[#{option_index}]"}
value={option}
class="input input-bordered flex-1"
phx-change="update_option"
phx-value-index={option_index}
phx-debounce="300"
placeholder={gettext("Option value")}
/>
<button
type="button"
class="btn btn-error btn-sm"
phx-click="remove_option"
phx-value-index={option_index}
>
<.icon name="hero-trash" class="w-4 h-4" />
</button>
</div>
<% end %>
</div>
<%= if Enum.empty?(@field_form["options"] || []) do %>
<div class="text-center text-base-content/60 py-4">
{gettext("No options added yet. Click \"Add Option\" to get started.")}
</div>
<% end %>
</div>
<% end %>
<%!-- Modal Actions --%>
<div class="modal-action">
<button
type="button"
class="btn btn-outline"
phx-click="cancel_field"
>
{gettext("Cancel")}
</button>
<button
type="submit"
class="btn btn-primary"
>
{if @editing_field_index, do: gettext("Update Field"), else: gettext("Add Field")}
</button>
</div>
</.form>
</div>
</div>
<% end %>
<%!-- Icon Picker Modal --%>
<%= if @show_icon_picker do %>
<div
class="fixed inset-0 bg-base-content/50 z-50 flex items-center justify-center p-4"
phx-click="close_icon_picker"
>
<div
class="bg-base-100 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] flex flex-col"
phx-click="stop_propagation"
>
<%!-- Modal Header --%>
<div class="flex items-center justify-between p-6 border-b border-base-300">
<h2 class="text-2xl font-bold flex items-center gap-2">
<.icon name="hero-squares-2x2" class="w-6 h-6" /> {gettext("Select an Icon")}
</h2>
<button
type="button"
phx-click="close_icon_picker"
class="btn btn-ghost btn-sm btn-circle"
>
<.icon name="hero-x-mark" class="w-5 h-5" />
</button>
</div>
<%!-- Search Bar --%>
<div class="p-4 border-b border-base-300">
<.form for={%{}} phx-change="search_icons" phx-submit="search_icons">
<div class="join w-full">
<input
type="text"
name="search"
value={@icon_search}
placeholder={gettext("Search icons...")}
class="input input-bordered join-item flex-1"
phx-debounce="300"
/>
<button type="submit" class="btn btn-primary join-item">
<.icon name="hero-magnifying-glass" class="w-4 h-4" />
</button>
</div>
</.form>
</div>
<%!-- Category Tabs --%>
<div class="px-4 py-2 border-b border-base-300 overflow-x-auto">
<div class="tabs tabs-boxed inline-flex">
<%= for category <- @icon_categories do %>
<button
type="button"
phx-click="filter_by_category"
phx-value-category={category}
class={[
"tab",
@selected_category == category && "tab-active"
]}
>
{icon_category_label(category)}
</button>
<% end %>
</div>
</div>
<%!-- Icon Grid --%>
<div class="flex-1 overflow-y-auto p-6">
<%= if Enum.empty?(@available_icons) do %>
<div class="text-center py-12">
<div class="text-4xl mb-4 opacity-50">🔍</div>
<p class="text-base-content/70">
{gettext("No icons found matching your search")}
</p>
</div>
<% else %>
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3">
<%= for {icon_name, display_name} <- @available_icons do %>
<button
type="button"
phx-click="select_icon"
phx-value-icon={icon_name}
class="btn btn-outline flex flex-col h-auto py-3 gap-1 hover:btn-primary min-h-0"
title={display_name}
>
<.icon name={icon_name} class="w-6 h-6 flex-shrink-0" />
<span class="text-xs leading-tight text-center break-words w-full">
{display_name}
</span>
</button>
<% end %>
</div>
<% end %>
</div>
<%!-- Modal Footer --%>
<div class="p-4 border-t border-base-300 bg-base-200">
<div class="flex justify-between items-center text-sm text-base-content/70">
<span>
{ngettext(
"%{count} icon available",
"%{count} icons available",
length(@available_icons),
count: length(@available_icons)
)}
</span>
<button type="button" phx-click="close_icon_picker" class="btn btn-ghost btn-sm">
{gettext("Cancel")}
</button>
</div>
</div>
</div>
</div>
<% end %>
</div>
</PhoenixKitWeb.Components.LayoutWrapper.app_layout>