# PhoenixKitEntities
Dynamic content types for PhoenixKit. Define custom entities (like "Product", "Team Member", "FAQ") with flexible field schemas — no database migrations needed per entity.
## Table of Contents
- [What this provides](#what-this-provides)
- [Quick start](#quick-start)
- [Dependency types](#dependency-types)
- [Project structure](#project-structure)
- [Entity definitions](#entity-definitions)
- [Entity data records](#entity-data-records)
- [Field types](#field-types)
- [Admin UI](#admin-ui)
- [Multi-language support](#multi-language-support)
- [Public forms](#public-forms)
- [Filesystem mirroring](#filesystem-mirroring)
- [Events & PubSub](#events--pubsub)
- [Available callbacks](#available-callbacks)
- [Mix tasks](#mix-tasks)
- [Testing](#testing)
- [Troubleshooting](#troubleshooting)
## What this provides
- Dynamic entity definitions with JSONB field schemas (no migrations per entity)
- 12 field types: text, textarea, email, url, rich_text, number, boolean, date, select, radio, checkbox, file
- Complete admin UI (LiveView) for managing entity definitions and data records
- Multi-language support (auto-enabled when 2+ languages are active)
- Collaborative editing with FIFO locking and presence tracking
- Public form builder with honeypot, time-based validation, and rate limiting
- Filesystem mirroring for export/import of entity definitions and data
- PubSub events for real-time updates across admin sessions
- Sitemap integration for published entity data
- Zero-config auto-discovery — just add the dependency
## Quick start
Add to your parent app's `mix.exs`:
```elixir
{:phoenix_kit_entities, "~> 0.1"}
```
Run `mix deps.get` and start the server. The module appears in:
- **Admin sidebar** (under Modules section) — browse entities and their data
- **Admin > Modules** — toggle it on/off
- **Admin > Roles** — grant/revoke access per role
- **Admin > Settings > Entities** — configure module settings
Enable the system:
```elixir
PhoenixKitEntities.enable_system()
```
Create your first entity:
```elixir
{:ok, entity} = PhoenixKitEntities.create_entity(%{
name: "product",
display_name: "Product",
display_name_plural: "Products",
icon: "hero-cube",
created_by_uuid: admin_user.uuid,
fields_definition: [
%{"type" => "text", "key" => "name", "label" => "Name", "required" => true},
%{"type" => "number", "key" => "price", "label" => "Price"},
%{"type" => "textarea", "key" => "description", "label" => "Description"},
%{"type" => "select", "key" => "category", "label" => "Category",
"options" => ["Electronics", "Clothing", "Food"]}
]
})
```
Create data records:
```elixir
{:ok, record} = PhoenixKitEntities.EntityData.create(%{
entity_uuid: entity.uuid,
title: "iPhone 15",
status: "published",
created_by_uuid: admin_user.uuid,
data: %{
"name" => "iPhone 15",
"price" => 999,
"description" => "Latest iPhone model",
"category" => "Electronics"
}
})
```
## Dependency types
### Local development (`path:`)
```elixir
{:phoenix_kit_entities, path: "../phoenix_kit_entities"}
```
Changes to the module's source are picked up automatically on recompile.
### Git dependency (`git:`)
```elixir
{:phoenix_kit_entities, git: "https://github.com/BeamLabEU/phoenix_kit_entities.git"}
```
After updating the remote: `mix deps.update phoenix_kit_entities`, then `mix deps.compile phoenix_kit_entities --force` + restart the server.
### Hex package
```elixir
{:phoenix_kit_entities, "~> 0.1.0"}
```
## Project structure
```
lib/
phoenix_kit_entities.ex # Main module (schema + PhoenixKit.Module behaviour)
phoenix_kit_entities/
entity_data.ex # Data record schema and CRUD
field_type.ex # Field type struct
field_types.ex # Field type registry (12 types)
form_builder.ex # Dynamic form generation + validation
events.ex # PubSub broadcast/subscribe
presence.ex # Phoenix.Presence for editing
presence_helpers.ex # FIFO locking, session tracking
routes.ex # Admin + public route definitions
sitemap_source.ex # Sitemap integration
components/
entity_form.ex # Embeddable public form component
controllers/
entity_form_controller.ex # Public form submission handler
migrations/
v1.ex # Migration module (called by parent app)
mirror/
exporter.ex # Entity/data export to JSON
importer.ex # Entity/data import from JSON
storage.ex # File storage for mirror
mix_tasks/
export.ex # mix phoenix_kit_entities.export
import.ex # mix phoenix_kit_entities.import
web/
entities.ex # Entity list LiveView (inline template)
entity_form.ex # Entity definition builder LiveView
data_navigator.ex # Data record browser LiveView
data_form.ex # Data record form LiveView (handles new/show/edit)
entities_settings.ex # Module settings LiveView
hooks.ex # Shared LiveView hooks
```
## Entity definitions
Entity definitions are blueprints for custom content types. Each entity has a name, display names, and a JSONB array of field definitions.
```elixir
# List all entities
PhoenixKitEntities.list_entities()
# Get by name
PhoenixKitEntities.get_entity_by_name("product")
# Create
{:ok, entity} = PhoenixKitEntities.create_entity(%{...})
# Update
{:ok, entity} = PhoenixKitEntities.update_entity(entity, %{status: "published"})
# Delete (cascades to all data records)
{:ok, entity} = PhoenixKitEntities.delete_entity(entity)
```
### Name constraints
- Must be unique, snake_case, 2-50 characters
- Format: `^[a-z][a-z0-9_]*$`
- Examples: `product`, `team_member`, `faq_item`
### Status workflow
Entities support three statuses: `draft`, `published`, `archived`.
## Entity data records
Data records are instances of an entity definition. Field values are stored in a JSONB `data` column.
```elixir
alias PhoenixKitEntities.EntityData
# List records for an entity
EntityData.list_by_entity(entity.uuid)
# Filter by status
EntityData.list_by_entity_and_status(entity.uuid, "published")
# Search by title
EntityData.search_by_title("iPhone", entity.uuid)
# Get by slug
EntityData.get_by_slug(entity.uuid, "iphone-15")
# CRUD
{:ok, record} = EntityData.create(%{...})
{:ok, record} = EntityData.update(record, %{...})
{:ok, record} = EntityData.delete(record)
```
### Manual ordering
Entities can use auto sort (by creation date) or manual sort (by position). Configure via the entity's `settings`:
```elixir
PhoenixKitEntities.update_sort_mode(entity, "manual")
```
## Field types
| Category | Types | Notes |
|----------|-------|-------|
| Basic | `text`, `textarea`, `email`, `url`, `rich_text` | Rich text is HTML-sanitized |
| Numeric | `number` | Accepts integers and floats |
| Boolean | `boolean` | Toggle/checkbox |
| Date | `date` | Date picker |
| Choice | `select`, `radio`, `checkbox` | Require `options` array |
| Media | `file`, `image` | Coming soon |
| Relations | `relation` | Coming soon |
Each field definition is a map with:
```elixir
%{
"type" => "text", # Required
"key" => "title", # Required, unique per entity
"label" => "Title", # Required
"required" => true, # Optional, default false
"default" => "", # Optional
"options" => ["A", "B"], # Required for select/radio/checkbox
"validation" => %{...} # Optional validation rules
}
```
Use the helper functions:
```elixir
alias PhoenixKitEntities.FieldTypes
FieldTypes.text_field("name", "Full Name", required: true)
FieldTypes.select_field("category", "Category", ["Tech", "Business"])
FieldTypes.boolean_field("featured", "Featured", default: true)
```
## Admin UI
Admin routes are registered via `PhoenixKitEntities.Routes` (returned by `route_module/0`):
| Route | LiveView | Purpose |
|-------|----------|---------|
| `/admin/entities` | `Web.Entities` | List all entity definitions |
| `/admin/entities/new` | `Web.EntityForm` | Create entity definition |
| `/admin/entities/:id/edit` | `Web.EntityForm` | Edit entity definition |
| `/admin/entities/:name/data` | `Web.DataNavigator` | Browse entity records |
| `/admin/entities/:name/data/new` | `Web.DataForm` | Create record |
| `/admin/entities/:name/data/:uuid` | `Web.DataForm` | Edit record |
| `/admin/settings/entities` | `Web.EntitiesSettings` | Module settings |
## Multi-language support
Multilang is auto-enabled when PhoenixKit has 2+ languages configured. Both the entity definition (labels/description) and each data record support translations.
### Entity definition translations
Translatable fields: `display_name`, `display_name_plural`, `description`.
Storage: `entity.settings["translations"]` JSONB.
```elixir
%{
"translations" => %{
"es-ES" => %{
"display_name" => "Producto",
"display_name_plural" => "Productos",
"description" => "Catálogo de productos"
}
}
}
```
Only fields that differ from the primary language need to be stored — missing keys fall back to the primary column value on read.
**Editing (admin UI):** the entity create/edit form renders language tabs above the translatable fields. No opt-in required — tabs appear automatically when the Languages module has 2+ languages.
**API:**
```elixir
alias PhoenixKitEntities, as: Entities
# Read
Entities.get_entity_translations(entity)
# => %{"es-ES" => %{"display_name" => "Producto", ...}}
Entities.get_entity_translation(entity, "es-ES")
# => %{"display_name" => "Producto", "display_name_plural" => "Productos", ...}
# Write (empty string removes a per-field override)
Entities.set_entity_translation(entity, "es-ES", %{"display_name" => "Producto"})
# Remove all translations for a language
Entities.remove_entity_translation(entity, "es-ES")
```
### Reading translated metadata
Every query function accepts an optional `lang:` keyword. When provided, the returned struct has `display_name` / `display_name_plural` / `description` resolved to that locale (missing fields fall back to primary):
```elixir
Entities.list_entities(lang: "es-ES")
Entities.list_active_entities(lang: "es-ES")
Entities.get_entity(uuid, lang: "es-ES")
Entities.get_entity!(uuid, lang: "es-ES")
Entities.get_entity_by_name("product", lang: "es-ES")
Entities.list_entity_summaries(lang: "es-ES") # sidebar/navigation summaries
```
Without `lang:`, raw primary-language column values are returned (backward compatible).
Manual resolution is also available:
```elixir
resolved = Entities.resolve_language(entity, "es-ES")
resolved_list = Entities.resolve_languages(entities, "es-ES")
```
### Data record translations
Field values inside `entity_data.data` use a nested JSONB structure with a primary-language marker:
```elixir
%{
"_primary_language" => "en-US",
"en-US" => %{"_title" => "Hello", "body" => "..."},
"es-ES" => %{"_title" => "Hola"} # overrides only
}
```
The `_title` key carries the translated title (the DB `title` column still stores the primary-language title). All `EntityData` query functions accept `lang:` for automatic resolution:
```elixir
alias PhoenixKitEntities.EntityData
EntityData.get!(uuid, lang: "es-ES")
EntityData.list_by_entity(entity_uuid, lang: "es-ES")
EntityData.search_by_title("Hola", entity_uuid, lang: "es-ES")
EntityData.published_records(entity_uuid, lang: "es-ES")
EntityData.get_by_slug(entity_uuid, "my-slug", lang: "es-ES")
```
See `lib/phoenix_kit_entities/OVERVIEW.md` § "Multi-Language Support" for the full translation API (title translations, primary-language changes, compact-mode tabs).
## Public URL resolution
`PhoenixKitEntities.EntityData` exposes locale-aware URL builders for public-facing links (replaces the hand-wired `"/#{record.slug}"` pattern that silently drops locale prefixes on non-default routes).
### Pattern resolution chain
1. `entity.settings["sitemap_url_pattern"]` — per-entity override (e.g. `"/blog/:slug"`)
2. Router introspection — explicit route (`live "/pages/:slug", ...`) or catchall (`/:entity_name/:slug`)
3. Per-entity setting `sitemap_entity_<name>_pattern`
4. Global setting `sitemap_entities_pattern` (with `:entity_name` / `:slug` / `:id` placeholders)
5. Fallback `/<entity_name>/:slug`
Placeholders: `:slug` (falls back to the record UUID when the slug is nil) and `:id` (the UUID).
### Locale prefix policy
Matches `PhoenixKit.Utils.Routes.path/2`:
- `locale:` omitted or `nil` → no prefix
- Single-language mode → no prefix
- Locale matches the primary language → no prefix (default locale served at the unprefixed URL)
- Other locales → prefixed with the base code (`/es/...`, `/ru/...`)
### Helpers
```elixir
alias PhoenixKitEntities.EntityData
EntityData.public_path(entity, record)
# => "/products/my-item"
EntityData.public_path(entity, record, locale: "es-ES")
# => "/es/products/my-item"
EntityData.public_path(entity, record, locale: "en-US") # primary language
# => "/products/my-item"
EntityData.public_url(entity, record, base_url: "https://shop.example.com")
# => "https://shop.example.com/products/my-item"
# Batch usage — pre-build the routes cache once
cache = PhoenixKitEntities.UrlResolver.build_routes_cache()
Enum.map(records, &EntityData.public_path(entity, &1, locale: locale, routes_cache: cache))
```
If `:base_url` is omitted, `public_url/3` falls back to the `site_url` setting.
## Public forms
Entities can expose public submission forms. Enable in entity settings, then embed:
```html
<EntityForm entity_slug="contact" />
```
Or use the controller endpoint. Public forms include:
- Honeypot field for bot detection
- Time-based validation (minimum 3 seconds)
- Rate limiting (5 submissions per 60 seconds)
- Browser/OS/device metadata capture
## Filesystem mirroring
Export and import entity definitions and data as JSON files:
```bash
mix phoenix_kit_entities.export
mix phoenix_kit_entities.import
```
Or programmatically:
```elixir
PhoenixKitEntities.Mirror.Exporter.export_all(path)
PhoenixKitEntities.Mirror.Importer.import_all(path)
```
## Events & PubSub
Subscribe to real-time events:
```elixir
alias PhoenixKitEntities.Events
# Entity lifecycle
Events.subscribe_to_entities()
# Receives: {:entity_created, uuid}, {:entity_updated, uuid}, {:entity_deleted, uuid}
# Data lifecycle (all entities)
Events.subscribe_to_all_data()
# Receives: {:data_created, entity_uuid, data_uuid}, etc.
# Data lifecycle (specific entity)
Events.subscribe_to_entity_data(entity_uuid)
```
## Available callbacks
This module implements `PhoenixKit.Module` with these callbacks:
| Callback | Value |
|----------|-------|
| `module_key/0` | `"entities"` |
| `module_name/0` | `"Entities"` |
| `enabled?/0` | Reads `entities_enabled` setting |
| `enable_system/0` | Sets `entities_enabled` to true |
| `disable_system/0` | Sets `entities_enabled` to false |
| `permission_metadata/0` | Icon: `hero-cube-transparent` |
| `admin_tabs/0` | Entities tab with dynamic entity children |
| `settings_tabs/0` | Settings tab under admin settings |
| `children/0` | `[PhoenixKitEntities.Presence]` |
| `css_sources/0` | `[:phoenix_kit_entities]` |
| `route_module/0` | `PhoenixKitEntities.Routes` |
| `get_config/0` | Returns enabled status, limits, stats |
## Mix tasks
```bash
# Export all entities and data to JSON
mix phoenix_kit_entities.export
# Import entities and data from JSON
mix phoenix_kit_entities.import
```
## Database
Database tables and migrations are managed by the parent PhoenixKit project. This repo provides `PhoenixKitEntities.Migrations.V1` as a library module that the parent app's migrations call — there are no migrations to run in this repo directly.
```elixir
# Two tables:
# phoenix_kit_entities — entity definitions (blueprints)
# phoenix_kit_entity_data — data records (instances)
# Both use UUIDv7 primary keys
```
## Testing
```bash
# Create test database
createdb phoenix_kit_entities_test
# Run all tests
mix test
# Run only unit tests (no DB needed)
mix test --exclude integration
```
## Troubleshooting
### Module not appearing in admin
1. Verify the dependency is in `mix.exs` and `mix deps.get` was run
2. Check `PhoenixKitEntities.enabled?()` returns `true`
3. Run `PhoenixKitEntities.enable_system()` if needed
### "entities_enabled" setting not found
The settings are seeded by the migration. If using PhoenixKit core migrations, they're created by V17. If standalone, run the `PhoenixKitEntities.Migrations.V1` migration.
### Entity name validation fails
Names must be snake_case, start with a letter, 2-50 characters. Examples: `product`, `team_member`, `faq_item`. Invalid: `Product`, `123abc`, `a`.
### Changes not taking effect after editing
Force a clean rebuild: `mix deps.clean phoenix_kit_entities && mix deps.get && mix deps.compile phoenix_kit_entities --force && mix compile --force`
> **Note:** Most production deploys see the entity tables created by core PhoenixKit's versioned migration `V17`. The local `PhoenixKitEntities.Migrations.V1` module provides an idempotent (`IF NOT EXISTS`) migration that's the source of truth for the test schema and for standalone host apps that don't use core's installer. The test helper creates the `uuid_generate_v7()` Postgres function directly when a test database is available.