diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 000000000..ce8170b81 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,19 @@ +{ + "name": "naymspace-backpex", + "owner": { + "name": "Naymspace" + }, + "metadata": { + "description": "Official Backpex plugin for Claude Code" + }, + "plugins": [ + { + "name": "backpex", + "source": "./", + "description": "Skills and agents for building with Backpex — the Phoenix LiveView admin panel", + "version": "0.1.0", + "homepage": "https://github.com/naymspace/backpex", + "keywords": ["elixir", "phoenix", "liveview", "admin", "backpex"] + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 000000000..2b1ba84b2 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "backpex", + "description": "Skills and agents for building with Backpex", + "version": "0.1.0", + "author": { + "name": "Naymspace" + } +} diff --git a/guides/ai_development/ai-development.md b/guides/ai_development/ai-development.md new file mode 100644 index 000000000..b7c6a054c --- /dev/null +++ b/guides/ai_development/ai-development.md @@ -0,0 +1,78 @@ +# Claude Code Plugin + +Backpex ships with a [Claude Code](https://docs.anthropic.com/en/docs/claude-code) plugin that gives AI assistants deep knowledge of Backpex conventions, APIs, and patterns. This means Claude can generate correct Backpex code (filters, fields, actions, and LiveResources) without you having to explain the framework from scratch. + +## Install the Backpex Plugin + +The plugin is distributed as a Claude Code marketplace directly from the Backpex repository. + +### Add the Marketplace + +Open Claude Code and run: + +```bash +/plugin marketplace add naymspace/backpex +``` + +This registers the Backpex marketplace so you can browse and install plugins from it. + +### Install the Plugin + +```bash +/plugin install backpex@naymspace-backpex +``` + +After installation, run `/reload-plugins` to activate the plugin. + +## What the Plugin Provides + +The Backpex plugin includes skills, specialized knowledge modules that Claude uses automatically when working on Backpex projects. + +### Create Live Resource + +The `create-live-resource` skill helps scaffold complete LiveResource modules. It covers `adapter_config`, required callbacks (`singular_name/0`, `plural_name/0`, `fields/0`, `layout/1`), optional callbacks like `can?/3` and `filters/0`, and router setup with `live_resources/3`. + +Example: "Create a LiveResource for my Product schema with name, price, and category fields" + +### Create Field + +The `create-field` skill covers all 17 built-in field types and how to create custom fields implementing `Backpex.Field`. It includes the config schema, required callbacks (`render_value/1`, `render_form/1`), common field options, and template assigns. + +Example: "Add a color picker field to my ProductLive resource" + +### Create Filter + +The `create-filter` skill covers all built-in filter types (Boolean, Select, MultiSelect, Range) and custom filters. It includes required callbacks, how to wire filters into `filters/0`, and options like presets and defaults. + +Example: "Add a published filter to PostLive" + +### Create Item Action + +The `create-item-action` skill helps create custom actions for table rows and the show page. It covers the `handle/3` vs `link/2` pattern, form fields with confirmation dialogs, and how to modify the default actions (show, edit, delete). + +Example: "Add an archive action that soft-deletes selected posts" + +### Create Resource Action + +The `create-resource-action` skill helps create resource-level actions like exports, imports, or invitations. It covers the required callbacks (`title/0`, `label/0`, `fields/0`, `changeset/3`, `handle/2`) and schemaless changeset patterns. + +Example: "Create an export action that lets users download posts as CSV" + +### Upgrade + +The `upgrade` skill assists with Backpex version upgrades. It reads the relevant upgrade guides, identifies breaking changes, and applies migrations systematically. + +Example: "Upgrade Backpex from 0.16 to 0.18" + +## Invoking Skills + +All skills are triggered automatically by Claude when it detects relevant work. You can also invoke them directly: + +```bash +/backpex:create-live-resource +/backpex:create-field +/backpex:create-filter +/backpex:create-item-action +/backpex:create-resource-action +/backpex:upgrade +``` diff --git a/mix.exs b/mix.exs index 01fe6a4d1..941d5edb3 100644 --- a/mix.exs +++ b/mix.exs @@ -142,6 +142,9 @@ defmodule Backpex.MixProject do # Get Started "guides/get_started/installation.md", + # AI Assisted Development + "guides/ai_development/ai-development.md", + # Live Resource "guides/live_resource/what-is-a-live-resource.md", "guides/live_resource/templates.md", @@ -218,6 +221,7 @@ defmodule Backpex.MixProject do Introduction: ~r/README/, About: ~r/guides\/about\/.?/, "Get Started": ~r/guides\/get_started\/.?/, + "AI Assisted Development": ~r/guides\/ai_development\/.?/, "Live Resource": ~r/guides\/live_resource\/.?/, Fields: ~r/guides\/fields\/.?/, Filter: ~r/guides\/filter\/.?/, diff --git a/skills/create-field/SKILL.md b/skills/create-field/SKILL.md new file mode 100644 index 000000000..59209bf97 --- /dev/null +++ b/skills/create-field/SKILL.md @@ -0,0 +1,201 @@ +--- +name: create-field +description: Use when creating custom Backpex field types, implementing the Backpex.Field behaviour, or adding fields to a LiveResource's fields/0 callback. +--- + +# Creating Backpex Fields + +You are an expert at creating fields for Backpex, a Phoenix LiveView admin panel library. When the user wants to add or create a field, follow this process: + +1. **Determine if a built-in field works** from the list below +2. **If custom**, generate a module implementing `Backpex.Field` +3. **Wire it into the LiveResource** by updating the `fields/0` callback + +## Built-in Field Modules + +| Module | Use for | +|--------|---------| +| `Backpex.Fields.Text` | Single-line text inputs | +| `Backpex.Fields.Textarea` | Multi-line text inputs | +| `Backpex.Fields.Number` | Numeric values | +| `Backpex.Fields.Boolean` | Checkboxes / toggles | +| `Backpex.Fields.Select` | Dropdown with static options | +| `Backpex.Fields.MultiSelect` | Multi-value dropdown | +| `Backpex.Fields.Date` | Date picker | +| `Backpex.Fields.DateTime` | Date and time picker | +| `Backpex.Fields.Time` | Time picker | +| `Backpex.Fields.Currency` | Formatted currency values | +| `Backpex.Fields.URL` | URLs with link rendering | +| `Backpex.Fields.Email` | Email addresses | +| `Backpex.Fields.BelongsTo` | belongs_to associations | +| `Backpex.Fields.HasMany` | has_many associations | +| `Backpex.Fields.HasManyThrough` | has_many through associations | +| `Backpex.Fields.InlineCRUD` | Inline editing of embeds_many / has_many | +| `Backpex.Fields.Upload` | File uploads | + +## Common Field Options (available on all fields) + +| Option | Type | Description | +|--------|------|-------------| +| `module` | atom | **Required.** The field module | +| `label` | string | **Required.** Display label | +| `searchable` | boolean | Enable search on this column | +| `orderable` | boolean | Enable column sorting | +| `visible` | `fn assigns -> bool` | Controls visibility on all views except index | +| `can?` | `fn assigns -> bool` | Controls visibility on all views including index | +| `only` | list | Restrict to specific views: `:new`, `:edit`, `:show`, `:index` | +| `except` | list | Hide from specific views | +| `panel` | atom | Group into a named panel | +| `index_editable` | boolean or `fn assigns -> bool` | Enable inline editing on index | +| `align` | `:left`, `:center`, `:right` | Column alignment on index | +| `align_label` | `:top`, `:center`, `:bottom`, or `fn assigns -> atom` | Label alignment in forms | +| `index_column_class` | string or `fn assigns -> string` | Extra CSS class on index column | +| `render` | `fn assigns -> HEEx` | Override value rendering | +| `render_form` | `fn assigns -> HEEx` | Override form rendering | +| `help_text` | string or `fn assigns -> string` | Text below form input | +| `default` | `fn assigns -> value` | Default value for new items | +| `select` | `dynamic(...)` | Ecto dynamic expression for computed/virtual fields | +| `custom_alias` | atom | Custom alias for the field in queries | +| `translate_error` | `fn {msg, meta} -> {msg, meta}` | Custom error message formatting | + +## Creating a Custom Field + +Implement `Backpex.Field` with a `@config_schema` for field-specific options. + +### Required Callbacks + +```elixir +@callback render_value(assigns :: map()) :: %Phoenix.LiveView.Rendered{} +@callback render_form(assigns :: map()) :: %Phoenix.LiveView.Rendered{} +``` + +`render_value/1` is used on both index and show views. `render_form/1` is used on new and edit views. + +### Callbacks With Defaults (overridable) + +These are provided by `use Backpex.Field` and can be overridden as needed: + +```elixir +@callback render_index_form(assigns) # For index_editable support (only truly optional callback) +@callback display_field(field) # Default: returns field name +@callback schema(field, schema) # Default: returns the schema +@callback association?(field) # Default: false +@callback assign_uploads(field, socket) # Default: returns socket unchanged +@callback before_changeset(changeset, attrs, metadata, repo, field, assigns) # 6-arity +@callback search_condition(schema_name :: binary(), field_name :: binary(), search_string :: binary()) # Default: ilike +``` + +### Key Assigns Available in Templates + +| Assign | Description | +|--------|-------------| +| `@value` | Current field value | +| `@name` | Field key atom | +| `@field_options` | Merged field options map | +| `@form` | Phoenix.HTML.Form (in form renders) | +| `@item` | The full resource item struct | +| `@live_action` | `:index`, `:edit`, `:new`, or `:show` | +| `@readonly` | Boolean from readonly option | +| `@myself` | LiveComponent reference for phx-target | + +### Example Custom Field + +```elixir +defmodule MyAppWeb.Fields.ColorPicker do + @config_schema [ + palette: [ + doc: "List of allowed hex colors.", + type: {:list, :string} + ] + ] + + use Backpex.Field, config_schema: @config_schema + + @impl Backpex.Field + def render_value(assigns) do + ~H""" +
+ + {@value} +
+ """ + end + + @impl Backpex.Field + def render_form(assigns) do + ~H""" +
+ + <:label align={Backpex.Field.align_label(@field_options, assigns, :center)}> + + + + +
+ """ + end +end +``` + +### Using it in a LiveResource + +```elixir +@impl Backpex.LiveResource +def fields do + [ + color: %{ + module: MyAppWeb.Fields.ColorPicker, + label: "Color", + palette: ["#ff0000", "#00ff00", "#0000ff"] + } + ] +end +``` + +## Declaring Fields in a LiveResource + +`fields/0` returns a keyword list. Each key is the Ecto schema field atom, each value is a map of options. + +```elixir +@impl Backpex.LiveResource +def fields do + [ + title: %{ + module: Backpex.Fields.Text, + label: "Title", + searchable: true + }, + body: %{ + module: Backpex.Fields.Textarea, + label: "Body", + except: [:index] + }, + category: %{ + module: Backpex.Fields.BelongsTo, + label: "Category", + display_field: :name, + searchable: true, + live_resource: MyAppWeb.CategoryLive + }, + inserted_at: %{ + module: Backpex.Fields.DateTime, + label: "Created At", + only: [:index, :show] + } + ] +end +``` + +## Conventions + +- **File location**: `lib/my_app_web/fields/.ex` +- **Module naming**: `MyAppWeb.Fields.` +- **Always declare `@config_schema`** before `use Backpex.Field` for custom field-specific options +- **Use `Layout.field_container`** and `Layout.input_label` in `render_form/1` for consistent form layout +- **Use `BackpexForm.input`** for standard input rendering with error handling diff --git a/skills/create-filter/SKILL.md b/skills/create-filter/SKILL.md new file mode 100644 index 000000000..894eae0a1 --- /dev/null +++ b/skills/create-filter/SKILL.md @@ -0,0 +1,178 @@ +--- +name: create-filter +description: Use when adding a filter to a Backpex LiveResource, creating a filter module, or the user asks about filtering data in Backpex index views. +--- + +# Creating Backpex Filters + +When the user wants to add a filter: + +1. Pick the right filter type from the quick reference +2. Generate the filter module +3. Wire it into the LiveResource's `filters/0` callback + +## Quick Reference + +| Type | Use for | `use` module | Key callbacks | +|------|---------|-------------|---------------| +| Boolean | Checkbox predicates (published yes/no) | `Backpex.Filters.Boolean` | `options/1` returning `%{label, key, predicate}` maps | +| Select | Single-value dropdown | `Backpex.Filters.Select` | `prompt/0`, `options/1` returning `{label, value}` tuples | +| MultiSelect | Multi-value dropdown with checkboxes | `Backpex.Filters.MultiSelect` | `prompt/0`, `options/1` returning `{label, value}` tuples | +| Range | Date, datetime, or number ranges | `Backpex.Filters.Range` | `type/0` returning `:date`, `:datetime`, or `:number` | +| Custom | Anything else | `Backpex.Filter` | `query/4`, `render/1`, `render_form/1` | + +All built-in filters auto-implement `query/4`, `render/1`, and `render_form/1`. Do NOT re-implement those unless you need custom behavior. + +All filters have these overridable callbacks with defaults: +- `label/0`: filter label (optional callback, can also be set in `filters/0` map) +- `can?/1`: visibility control, receives assigns, default `true` +- `type/1`: Ecto type for validation (default `:string`), receives assigns +- `changeset/3`: custom changeset validation (default: no-op) +- `validate/2`: public validation API, builds changeset from `type/1` and `changeset/3` + +## Boolean Filter + +Multiple options selectable via checkboxes, combined with OR. Predicates use `Ecto.Query.dynamic/2`. + +```elixir +defmodule MyAppWeb.Filters.PostPublished do + use Backpex.Filters.Boolean + + import Ecto.Query + + @impl Backpex.Filter + def label, do: "Published?" + + @impl Backpex.Filters.Boolean + def options(_assigns) do + [ + %{label: "Published", key: "published", predicate: dynamic([x], x.published)}, + %{label: "Not published", key: "not_published", predicate: dynamic([x], not x.published)} + ] + end +end +``` + +## Select Filter + +Single-value dropdown. Default `query/4` does `WHERE field = value`. + +```elixir +defmodule MyAppWeb.Filters.PostCategorySelect do + use Backpex.Filters.Select + + import Ecto.Query + + alias MyApp.Repo + + @impl Backpex.Filter + def label, do: "Category" + + @impl Backpex.Filters.Select + def prompt, do: "Select category ..." + + @impl Backpex.Filters.Select + def options(_assigns) do + from(c in MyApp.Category, select: {c.name, c.id}, order_by: c.name) |> Repo.all() + end +end +``` + +## MultiSelect Filter + +Same as Select but allows multiple values. Default `query/4` does `WHERE field IN values`. Uses `prompt/0` (implement with `@impl Backpex.Filters.Select`) and `options/1` (implement with `@impl Backpex.Filters.MultiSelect`). Note: MultiSelect internally sets `@behaviour Backpex.Filters.Select` for the prompt callback. + +## Range Filter + +Renders "From" and "To" inputs. Note: `type/0` is arity 0, not arity 1. + +```elixir +defmodule MyAppWeb.Filters.PostLikeRange do + use Backpex.Filters.Range + + @impl Backpex.Filters.Range + def type, do: :number + + @impl Backpex.Filter + def label, do: "Likes" +end +``` + +For dates use `def type, do: :date`. For datetimes use `def type, do: :datetime`. + +## Custom Filter + +Use `Backpex.Filter` directly when no built-in type fits. You must implement `query/4`, `render/1`, and `render_form/1`. Note: `use Backpex.Filter` does not import HEEx sigils. You need `use Phoenix.Component` for `~H` support. + +```elixir +defmodule MyAppWeb.Filters.PostCustom do + use Phoenix.Component + use Backpex.Filter + + import Ecto.Query + + @impl Backpex.Filter + def label, do: "Custom" + + @impl Backpex.Filter + def query(query, attribute, value, _assigns) do + where(query, [x], field(x, ^attribute) == ^value) + end + + @impl Backpex.Filter + def render(assigns) do + ~H"{@value}" + end + + @impl Backpex.Filter + def render_form(assigns) do + ~H""" + + """ + end +end +``` + +## Wiring Into a LiveResource + +`filters/0` returns a keyword list. Each key is the schema field atom being filtered. + +```elixir +@impl Backpex.LiveResource +def filters do + [ + published: %{ + module: MyAppWeb.Filters.PostPublished + }, + category_id: %{ + module: MyAppWeb.Filters.PostCategorySelect, + label: "Category" + }, + likes: %{ + module: MyAppWeb.Filters.PostLikeRange, + label: "Likes", + presets: [ + %{label: "Over 100", values: fn -> %{"start" => 100, "end" => nil} end}, + %{label: "1-99", values: fn -> %{"start" => 1, "end" => 99} end} + ] + } + ] +end +``` + +### Filter map keys + +| Key | Required | Description | +|-----|----------|-------------| +| `:module` | yes | The filter module | +| `:label` | no | Overrides the module's `label/0` | +| `:default` | no | Pre-selected value on initial page load | +| `:presets` | no | Quick-select shortcuts: `[%{label: String.t(), values: (-> value)}]` | + +## Conventions + +- **Module naming**: `MyAppWeb.Filters.` (e.g. `MyAppWeb.Filters.PostPublished`) +- **File location**: `lib/my_app_web/filters/.ex` +- **The keyword list key** in `filters/0` must match the database column or foreign key +- **Always `import Ecto.Query`** when using `dynamic/2` or writing custom queries +- **Database queries in `options/1`** are fine since it runs at render time diff --git a/skills/create-item-action/SKILL.md b/skills/create-item-action/SKILL.md new file mode 100644 index 000000000..e0bfa5f17 --- /dev/null +++ b/skills/create-item-action/SKILL.md @@ -0,0 +1,207 @@ +--- +name: create-item-action +description: Use when creating custom Backpex item actions, adding action buttons to table rows or the show page, or modifying the default edit/delete/show actions. +--- + +# Creating Backpex Item Actions + +You are an expert at creating item actions for Backpex. Item actions operate on one or more selected items (rows) in the index view, or on a single item in the show view. + +## Built-in Item Actions + +| Module | Description | +|--------|-------------| +| `Backpex.ItemActions.Show` | Navigate to show page (uses `link/2`) | +| `Backpex.ItemActions.Edit` | Navigate to edit page (uses `link/2`) | +| `Backpex.ItemActions.Delete` | Delete selected items with confirmation (uses `handle/3`) | + +Default placements: +- `show`: row only +- `edit`: row + show page +- `delete`: row + index toolbar + show page + +## Creating a Custom Item Action + +Use `use BackpexWeb, :item_action` to set up the behaviour. + +### Required Callbacks + +| Callback | Signature | Notes | +|----------|-----------|-------| +| `icon/2` | `(assigns, item) -> HEEx` | Action button icon | +| `label/2` | `(assigns, item) -> string` | Tooltip text. `item` is `nil` for index-placement buttons | +| One of `handle/3` or `link/2` | | Exactly one must be defined | + +### `handle/3` vs `link/2` + +- **`handle/3`**: Server-side action. Receives `(socket, items, params)`. Returns `{:ok, socket}` or `{:error, changeset}` (only with form fields). +- **`link/2`**: Client-side navigation. Receives `(assigns, item)`. Returns a URL string. Enables Ctrl+click. + +Defining both raises a `CompileError`. + +### Optional Callbacks + +| Callback | Default | Notes | +|----------|---------|-------| +| `fields/0` | `[]` | Field definitions for a form modal | +| `confirm/1` | none | Confirmation text. **Required** when `fields/0` is non-empty | +| `confirm_label/1` | `"Apply"` | Confirm button text | +| `cancel_label/1` | `"Cancel"` | Cancel button text | +| `changeset/3` | none | **Required** when `fields/0` is non-empty | +| `base_schema/1` | schemaless changeset | Override to seed form with existing data | + +### Example: Simple Action Without Form + +```elixir +defmodule MyAppWeb.ItemActions.ArchivePost do + use BackpexWeb, :item_action + + import Ecto.Query + + alias MyApp.Repo + + @impl Backpex.ItemAction + def icon(assigns, _item) do + ~H""" + + """ + end + + @impl Backpex.ItemAction + def label(_assigns, nil), do: "Archive" + def label(_assigns, item), do: "Archive #{item.title}" + + @impl Backpex.ItemAction + def confirm(_assigns), do: "Are you sure you want to archive the selected items?" + + @impl Backpex.ItemAction + def confirm_label(_assigns), do: "Archive" + + @impl Backpex.ItemAction + def handle(socket, items, _params) do + ids = Enum.map(items, & &1.id) + + from(p in MyApp.Post, where: p.id in ^ids) + |> Repo.update_all(set: [archived_at: DateTime.utc_now(:second)]) + + {:ok, Phoenix.LiveView.put_flash(socket, :info, "Items archived.")} + end +end +``` + +### Example: Action With Form Fields + +```elixir +defmodule MyAppWeb.ItemActions.DuplicatePost do + use BackpexWeb, :item_action + + import Ecto.Changeset + + alias MyApp.Repo + + @impl Backpex.ItemAction + def icon(assigns, _item) do + ~H""" + + """ + end + + @impl Backpex.ItemAction + def label(_assigns, nil), do: "Duplicate" + def label(_assigns, item), do: "Duplicate #{item.title}" + + @impl Backpex.ItemAction + def fields do + [ + title: %{ + module: Backpex.Fields.Text, + label: "New Title" + } + ] + end + + @impl Backpex.ItemAction + def confirm(_assigns), do: "Enter a title for the duplicate." + + @impl Backpex.ItemAction + def confirm_label(_assigns), do: "Duplicate" + + @impl Backpex.ItemAction + def base_schema(assigns) do + [item | _] = assigns.selected_items + item + end + + @impl Backpex.ItemAction + def changeset(item, attrs, _metadata) do + item + |> cast(attrs, [:title]) + |> validate_required([:title]) + end + + @impl Backpex.ItemAction + def handle(socket, _items, data) do + attrs = Map.from_struct(data) |> Map.drop([:id, :inserted_at, :updated_at]) + + case Repo.insert(MyApp.Post.changeset(%MyApp.Post{}, attrs, [])) do + {:ok, _post} -> {:ok, Phoenix.LiveView.put_flash(socket, :info, "Post duplicated.")} + {:error, _} -> {:ok, Phoenix.LiveView.put_flash(socket, :error, "Failed to duplicate.")} + end + end +end +``` + +### Example: Navigation Action (link) + +```elixir +defmodule MyAppWeb.ItemActions.ViewOnSite do + use BackpexWeb, :item_action + + @impl Backpex.ItemAction + def icon(assigns, _item) do + ~H""" + + """ + end + + @impl Backpex.ItemAction + def label(_assigns, _item), do: "View on site" + + @impl Backpex.ItemAction + def link(_assigns, item), do: "/posts/#{item.slug}" +end +``` + +## Wiring Into a LiveResource + +The `item_actions/1` callback receives the default actions (show, edit, delete) and returns a modified keyword list. + +```elixir +@impl Backpex.LiveResource +def item_actions(default_actions) do + default_actions + |> Keyword.delete(:delete) + |> Enum.concat( + archive: %{module: MyAppWeb.ItemActions.ArchivePost, only: [:row, :index]}, + duplicate: %{module: MyAppWeb.ItemActions.DuplicatePost, only: [:row, :show]} + ) +end +``` + +### Placement Options + +| Placement | Description | +|-----------|-------------| +| `:row` | Icon button in each table row | +| `:index` | Button in index toolbar (acts on selected items) | +| `:show` | Button on the show/detail page | + +Use `only: [...]` or `except: [...]` in the action map to control placement. + +## Conventions + +- **File location**: `lib/my_app_web/item_actions/.ex` +- **Module naming**: `MyAppWeb.ItemActions.` +- **Always handle `nil` item** in `label/2` (for index-placement buttons) +- **Include a `type:` key** in field maps when using schemaless changesets (no `base_schema/1` override) +- **Authorization** is handled via `can?/3` in the LiveResource, using the action's keyword key as the action name diff --git a/skills/create-live-resource/SKILL.md b/skills/create-live-resource/SKILL.md new file mode 100644 index 000000000..bb65880a0 --- /dev/null +++ b/skills/create-live-resource/SKILL.md @@ -0,0 +1,212 @@ +--- +name: create-live-resource +description: Use when scaffolding a new Backpex LiveResource, setting up admin CRUD views, or configuring adapter_config, fields, filters, and routing for a resource. +--- + +# Creating a Backpex LiveResource + +You are an expert at creating LiveResources for Backpex. When the user wants to create a new admin resource, follow this process: + +1. **Identify the Ecto schema** the resource will manage +2. **Generate the LiveResource module** with adapter_config, fields, and callbacks +3. **Add the route** to the router + +## LiveResource Module Structure + +```elixir +defmodule MyAppWeb.PostLive do + use Backpex.LiveResource, + adapter_config: [ + schema: MyApp.Post, + repo: MyApp.Repo, + update_changeset: &MyApp.Post.changeset/3, + create_changeset: &MyApp.Post.changeset/3 + ] + + @impl Backpex.LiveResource + def layout(_assigns), do: {MyAppWeb.Layouts, :admin} + + @impl Backpex.LiveResource + def singular_name, do: "Post" + + @impl Backpex.LiveResource + def plural_name, do: "Posts" + + @impl Backpex.LiveResource + def fields do + [ + title: %{ + module: Backpex.Fields.Text, + label: "Title", + searchable: true + } + ] + end +end +``` + +## `use Backpex.LiveResource` Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `adapter_config` | keyword | **required** | Ecto adapter configuration (see below) | +| `adapter` | atom | `Backpex.Adapters.Ecto` | Data layer adapter | +| `primary_key` | atom | `:id` | Primary key field | +| `per_page_options` | list | `[15, 50, 100]` | Selectable page sizes | +| `per_page_default` | integer | `15` | Default page size | +| `init_order` | map or fn | `%{by: :id, direction: :asc}` | Initial sort order | +| `fluid?` | boolean | `false` | Full-width layout | +| `full_text_search` | atom | `nil` | PostgreSQL tsvector column name | +| `save_and_continue_button?` | boolean | `false` | Show "Save & Continue" button | +| `pubsub` | keyword | nil (falls back to `:backpex, :pubsub_server` app config, topic defaults to module name) | `[server: MyApp.PubSub]` | +| `on_mount` | atom/list | nil | LiveView on_mount hooks | + +## adapter_config (Ecto) + +| Key | Required | Description | +|-----|----------|-------------| +| `schema` | yes | Ecto schema module | +| `repo` | yes | Ecto repo module | +| `update_changeset` | no | `fn item, attrs, metadata -> changeset` | +| `create_changeset` | no | `fn item, attrs, metadata -> changeset` | +| `item_query` | no | `fn query, live_action, assigns -> query` | + +The `metadata` keyword list contains `:assigns` and `:target` (the form field that triggered the change). + +The `item_query` function must build on the incoming `query` argument (use `from p in query, ...`), not the schema directly. + +## Required Callbacks + +| Callback | Returns | Description | +|----------|---------|-------------| +| `singular_name/0` | string | e.g. `"Post"` | +| `plural_name/0` | string | e.g. `"Posts"` | +| `fields/0` | keyword list | Field definitions | +| `layout/1` | `{module, :function}` or `fn assigns -> ...` | Layout to use | + +## Optional Callbacks + +| Callback | Default | Description | +|----------|---------|-------------| +| `can?/3` | always true | `fn assigns, action, item -> bool` | +| `filters/0` | `[]` | Filter definitions | +| `filters/1` | delegates to `filters/0` | Assigns-aware variant for dynamic filters | +| `panels/0` | `[]` | Field grouping: `[key: "Label"]` | +| `metrics/0` | `[]` | Index page metrics | +| `resource_actions/0` | `[]` | Resource-level actions | +| `item_actions/1` | returns default_actions unchanged | Modify default item actions | +| `on_item_created/2` | noop | `fn socket, item -> socket` | +| `on_item_updated/2` | noop | `fn socket, item -> socket` | +| `on_item_deleted/2` | noop | `fn socket, item -> socket` | +| `return_to/5` | index page | Custom redirect after save | +| `index_row_class/4` | nil | Custom CSS for table rows | +| `render_resource_slot/3` | default HTML | Override UI slots | +| `translate/1` | delegates to `Backpex.translate/1` | Override UI strings | + +## Router Setup + +```elixir +import Backpex.Router + +scope "/admin", MyAppWeb do + pipe_through :browser + + backpex_routes() + + live_session :admin, on_mount: Backpex.InitAssigns do + live_resources "/posts", PostLive + live_resources "/users", UserLive + live_resources "/categories", CategoryLive, only: [:index, :show] + end +end +``` + +`backpex_routes()` must appear once per scope. `live_resources/3` generates routes for Index, Form (new/edit), and Show views. + +Options for `live_resources/3`: +- `only: [:index, :show, :new, :edit]` to restrict routes +- `except: [:new]` to exclude specific routes + +## Complete Example + +```elixir +defmodule MyAppWeb.ProductLive do + use Backpex.LiveResource, + adapter_config: [ + schema: MyApp.Product, + repo: MyApp.Repo, + update_changeset: &MyApp.Product.changeset/3, + create_changeset: &MyApp.Product.changeset/3, + item_query: &__MODULE__.item_query/3 + ], + per_page_default: 25, + init_order: %{by: :inserted_at, direction: :desc} + + import Ecto.Query + + @impl Backpex.LiveResource + def layout(_assigns), do: {MyAppWeb.Layouts, :admin} + + @impl Backpex.LiveResource + def singular_name, do: "Product" + + @impl Backpex.LiveResource + def plural_name, do: "Products" + + @impl Backpex.LiveResource + def panels do + [details: "Details", metadata: "Metadata"] + end + + @impl Backpex.LiveResource + def fields do + [ + name: %{ + module: Backpex.Fields.Text, + label: "Name", + searchable: true, + panel: :details + }, + price: %{ + module: Backpex.Fields.Currency, + label: "Price", + panel: :details + }, + category: %{ + module: Backpex.Fields.BelongsTo, + label: "Category", + display_field: :name, + live_resource: MyAppWeb.CategoryLive, + panel: :details + }, + published: %{ + module: Backpex.Fields.Boolean, + label: "Published", + index_editable: true + }, + inserted_at: %{ + module: Backpex.Fields.DateTime, + label: "Created At", + only: [:index, :show], + panel: :metadata + } + ] + end + + @impl Backpex.LiveResource + def can?(_assigns, :delete, item), do: not item.published + def can?(_assigns, _action, _item), do: true + + def item_query(query, _live_action, _assigns) do + from p in query, where: is_nil(p.archived_at) + end +end +``` + +## Conventions + +- **File location**: `lib/my_app_web/live/_live.ex` +- **Module naming**: `MyAppWeb.Live` (e.g. `MyAppWeb.ProductLive`) +- **Changeset functions** should accept 3 arguments: `item`, `attrs`, `metadata` +- **Use `layout/1` callback** instead of the `layout:` option (avoids compile-time dependencies) +- **Always build on the incoming query** in `item_query/3` diff --git a/skills/create-resource-action/SKILL.md b/skills/create-resource-action/SKILL.md new file mode 100644 index 000000000..2396faeee --- /dev/null +++ b/skills/create-resource-action/SKILL.md @@ -0,0 +1,152 @@ +--- +name: create-resource-action +description: Use when creating Backpex resource actions for global operations like bulk exports, invitations, imports, or any action that applies to the resource as a whole rather than individual items. +--- + +# Creating Backpex Resource Actions + +You are an expert at creating resource actions for Backpex. Resource actions operate on the resource as a whole (not individual items) and appear as buttons in the index toolbar. They open a slide-over with a form. + +## Required Callbacks + +| Callback | Signature | Description | +|----------|-----------|-------------| +| `title/0` | `-> string` | Slide-over title | +| `label/0` | `-> string` | Button text in index toolbar | +| `fields/0` | `-> keyword list` | Form field definitions | +| `changeset/3` | `(change, attrs, metadata) -> changeset` | Validate form data. `metadata` has `:assigns` and `:target` keys | +| `handle/2` | `(socket, data) -> {:ok, socket} \| {:error, changeset}` | Execute the action with validated data | + +## Optional Callbacks + +| Callback | Default | Description | +|----------|---------|-------------| +| `base_schema/1` | schemaless changeset | Override to use a real Ecto schema | + +## Example: Simple Resource Action + +```elixir +defmodule MyAppWeb.ResourceActions.InviteUser do + use Backpex.ResourceAction + + import Ecto.Changeset + + @impl Backpex.ResourceAction + def title, do: "Invite User" + + @impl Backpex.ResourceAction + def label, do: "Invite" + + @impl Backpex.ResourceAction + def fields do + [ + email: %{ + module: Backpex.Fields.Text, + label: "Email", + type: :string + }, + role: %{ + module: Backpex.Fields.Select, + label: "Role", + options: [Admin: "admin", User: "user"], + prompt: "Select role...", + type: :string + } + ] + end + + @impl Backpex.ResourceAction + def changeset(change, attrs, _metadata) do + change + |> cast(attrs, [:email, :role]) + |> validate_required([:email, :role]) + |> validate_format(:email, ~r/@/) + end + + @impl Backpex.ResourceAction + def handle(socket, data) do + case MyApp.Accounts.send_invitation(data.email, data.role) do + :ok -> + {:ok, Phoenix.LiveView.put_flash(socket, :info, "Invitation sent to #{data.email}.")} + + {:error, reason} -> + {:ok, Phoenix.LiveView.put_flash(socket, :error, "Failed: #{reason}")} + end + end +end +``` + +## Example: Export Action + +```elixir +defmodule MyAppWeb.ResourceActions.ExportPosts do + use Backpex.ResourceAction + + import Ecto.Changeset + + @impl Backpex.ResourceAction + def title, do: "Export Posts" + + @impl Backpex.ResourceAction + def label, do: "Export" + + @impl Backpex.ResourceAction + def fields do + [ + format: %{ + module: Backpex.Fields.Select, + label: "Format", + options: [CSV: "csv", JSON: "json"], + prompt: "Select format...", + type: :string + } + ] + end + + @impl Backpex.ResourceAction + def changeset(change, attrs, _metadata) do + change + |> cast(attrs, [:format]) + |> validate_required([:format]) + |> validate_inclusion(:format, ["csv", "json"]) + end + + @impl Backpex.ResourceAction + def handle(socket, data) do + # Trigger export... + {:ok, Phoenix.LiveView.put_flash(socket, :info, "Export started in #{data.format} format.")} + end +end +``` + +## Wiring Into a LiveResource + +```elixir +@impl Backpex.LiveResource +def resource_actions do + [ + invite: %{module: MyAppWeb.ResourceActions.InviteUser}, + export: %{module: MyAppWeb.ResourceActions.ExportPosts} + ] +end +``` + +The keyword key (e.g. `:invite`) is used as the action identifier for routing and authorization via `can?/3`. + +## Key Differences From Item Actions + +| Aspect | Resource Action | Item Action | +|--------|----------------|-------------| +| Scope | Whole resource | Selected items | +| UI | Slide-over form | Modal dialog | +| Callbacks | `title/0`, `label/0`, `handle/2` | `icon/2`, `label/2`, `handle/3` | +| Form | Always has fields | Optional | +| Location | Index toolbar only | Row, index toolbar, show page | + +## Conventions + +- **File location**: `lib/my_app_web/resource_actions/.ex` +- **Module naming**: `MyAppWeb.ResourceActions.` +- **Always include `type:` key** in each field map (e.g. `type: :string`). This is required for the schemaless changeset to work. +- **Authorization** is handled via `can?(assigns, :action_key, nil)` in the LiveResource (item is always `nil`) +- **Return `{:error, changeset}`** from `handle/2` to keep the form open and show validation errors diff --git a/skills/upgrade/SKILL.md b/skills/upgrade/SKILL.md new file mode 100644 index 000000000..f38c1ec85 --- /dev/null +++ b/skills/upgrade/SKILL.md @@ -0,0 +1,114 @@ +--- +name: upgrade +description: Use when upgrading Backpex to a newer version, handling breaking changes, or migrating code after a version bump. Also use when the user asks about what changed between versions. +--- + +# Upgrading Backpex + +You are an expert at upgrading Backpex versions. When the user wants to upgrade, follow this process: + +1. **Determine current and target versions** from `mix.exs` +2. **Read the relevant upgrade guides** for each version in between +3. **Apply changes** systematically, one breaking change at a time +4. **Verify** the project compiles and tests pass after each change + +## Finding the Current Version + +Check `mix.exs` for the Backpex dependency: + +```elixir +{:backpex, "~> 0.17.0"} +``` + +## Upgrade Guides Location + +All upgrade guides are at `guides/upgrading/` in the Backpex repository (also available on HexDocs). Read these files directly to get the exact migration steps: + +| Version | File | +|---------|------| +| 0.18 | `guides/upgrading/v0.18.md` | +| 0.17 | `guides/upgrading/v0.17.md` | +| 0.16 | `guides/upgrading/v0.16.md` | +| 0.15 | `guides/upgrading/v0.15.md` | +| 0.14 | `guides/upgrading/v0.14.md` | +| 0.13 | `guides/upgrading/v0.13.md` | +| 0.12 | `guides/upgrading/v0.12.md` | +| 0.11 | `guides/upgrading/v0.11.md` | +| 0.10 | `guides/upgrading/v0.10.md` | +| 0.9 | `guides/upgrading/v0.9.md` | +| 0.8 | `guides/upgrading/v0.8.md` | +| 0.7 | `guides/upgrading/v0.7.md` | +| 0.6 | `guides/upgrading/v0.6.md` | +| 0.5 | `guides/upgrading/v0.5.md` | +| 0.3 | `guides/upgrading/v0.3.md` | +| 0.2 | `guides/upgrading/v0.2.md` | + +## Upgrade Process + +1. **Read ALL upgrade guides** between current and target version. For example, upgrading from 0.15 to 0.18 requires reading v0.16, v0.17, and v0.18 guides. + +2. **Bump the dependency** in `mix.exs`: + ```elixir + {:backpex, "~> 0.18.0"} + ``` + +3. **Run `mix deps.get`** to fetch the new version. + +4. **Apply breaking changes** from each guide in order. Common categories: + - Removed or renamed options + - New required callbacks + - Changed callback signatures + - Moved or renamed components + - Removed dependencies + - New Gettext translation strings + +5. **Compile and fix warnings**: `mix compile --warnings-as-errors` + +6. **Run tests** to catch regressions. + +## Common Breaking Change Patterns + +### Callback replaces option +```elixir +# Before (option) +use Backpex.LiveResource, layout: {MyAppWeb.Layouts, :admin} + +# After (callback) +use Backpex.LiveResource, ... + +@impl Backpex.LiveResource +def layout(_assigns), do: {MyAppWeb.Layouts, :admin} +``` + +### New required callback added +Read the upgrade guide for the default value and implement it in affected modules. + +### Changed callback signature +Search your codebase for the old callback name and update all implementations. + +### Removed dependency +Check if your code directly uses the removed module/function and replace with the suggested alternative. + +### New Gettext strings + +Each Backpex release may add new translatable strings. After upgrading: + +1. Copy the updated Gettext template from the Backpex dependency into your application: + ```bash + cp deps/backpex/priv/gettext/backpex.pot priv/gettext/backpex.pot + ``` + Alternatively, download it from GitHub at `https://github.com/naymspace/backpex/blob//priv/gettext/backpex.pot` (replace `` with your target version tag). + +2. **Remove `elixir-autogen` comments** from the copied `.pot` file. The Backpex source file contains `#, elixir-autogen, elixir-format` comment lines. If left in place, running `mix gettext.extract --merge` will delete all Backpex translations from your PO files (Gettext treats autogen entries as auto-generated and removes those not found in your source code). Strip them: + ```bash + sed -i '' 's/#, elixir-autogen, elixir-format/#, elixir-format/g' priv/gettext/backpex.pot + ``` + +3. Merge the new strings into your existing PO files: + ```bash + mix gettext.merge priv/gettext + ``` + +4. Translate any new `msgid` entries in your `priv/gettext//LC_MESSAGES/backpex.po` files. + +The `.pot` file on the `develop` branch may contain unreleased translations. Always use the version tag that matches your Backpex version.