Resources describe tabular records, detail views, forms, filters, search, and actions. They can load through Ecto conventions, declarative service queries, or application-owned callbacks.

defmodule MyApp.Admin.Resources.Order do
  use Incant.Resource,
    schema: MyApp.Orders.Order,
    repo: MyApp.Repo

  query &MyApp.Admin.Queries.orders_index/2
  index &MyApp.Admin.Data.orders/2
  read &MyApp.Admin.Data.order/2

  table density: :compact do
    column :number, link: true
    column :customer, value: & &1.customer.email
    column :api_key, secret: true
    column :total, format: :money
    column :status, as: :badge

    filter :status, :select
    filter :inserted_at, :date_range

    transformer :sales_performance do
      query_transformer &MyApp.Admin.Filters.sales_performance/3
    end

    search &MyApp.Admin.Filters.order_search/3
  end
end

See also Authorization for policy scoping and Themes and LiveView for table styling.

Naming and labels

Incant infers sentence-style labels and title-style surface names through Incant.Naming. Common technical terms such as API, ID, LLM, OAuth, RPC, and URL are defaults, not fixed policy. Extend, override, or remove them globally:

config :incant, :naming,
  use_defaults: true,
  terms: %{
    openai: "OpenAI",
    live_view: "LiveView",
    quack_db: "QuackDB",
    ram: false
  }

An admin can add service-specific phrases without sending the vocabulary across RPC:

use Incant.Admin,
  title: "LLM Proxy",
  naming: [
    terms: %{
      openai_codex: "OpenAI Codex",
      req_llm: "ReqLLM"
    }
  ]

The owning service resolves inferred labels into its portable contract. The central renderer preserves those labels exactly. Explicit DSL text always wins and is never reformatted:

column :key_id, label: "API key"
filter :provider, :select, label: "Vendor"
action :disable, label: "Disable token", callback: :disable
row_detail :payload, label: "Request and response"

Option declarations accept value-to-label maps, ordered keywords, rich option maps, legacy tuples, and inferred values. All forms normalize to portable %{label:, value:} maps:

filter :provider, :select,
  options: %{
    "openai" => "OpenAI",
    "openai-codex" => "OpenAI Codex"
  }

filter :status, :select,
  options: [draft: "Draft", pending: "Pending review", active: "Active"]

filter :region, :select,
  options: [
    %{value: "legacy", label: "Legacy", disabled: true}
  ]

Maps are sorted by label; keywords and lists preserve declaration order. Dynamic distinct options preserve their stored value as the default label and may provide a service-owned option_label callback when domain formatting is required.

Use secret: true, sensitive: true, or redacted: true on columns and form fields that must not expose raw values through table/detail models or portable contracts:

table do
  column :provider
  column :token, secret: true
  column :user_message, sensitive: true
end

form do
  field :token, :password, redacted: true
end

These flags are presentation and transport-safety hints. They do not replace authorization policies for deciding who may reach a surface or action.

Filters

Resource filters are rendered and applied through Incant.Filter, a behaviour-backed registry. Built-ins include :text, :select, :multi_select, :date_range, and :boolean. For Ecto schema-backed resources, built-in query filters cast submitted values through Ecto.Type.cast/2, so field types such as integers, decimals, dates, datetimes, booleans, and Ecto.Enum values bind as typed query params.

filter :status, :select, options: [:draft, :published]
filter :inserted_at, :date_range

Override an individual filter with a module that implements Incant.Filter:

filter :expensive, :boolean, filter: MyApp.Admin.Filters.ExpensiveProduct
defmodule MyApp.Admin.Filters.ExpensiveProduct do
  @behaviour Incant.Filter

  use Phoenix.Component
  import Incant.Live.Components

  def control(filter, value, _assigns) do
    assigns = %{filter: filter, value: value}

    ~H"""
    <.select
      name={"table[filters][#{@filter.name}]"}
      value={@value}
      prompt="Price"
      options={[{"Expensive", "true"}, {"Cheap", "false"}]}
    />
    """
  end

  def match?(_filter, _row, value) when value in [nil, ""], do: true
  def match?(_filter, row, "true"), do: row.price_cents >= 10_000
  def match?(_filter, row, "false"), do: row.price_cents < 10_000

  def apply_query(_filter, queryable, _value, _context), do: queryable
end

match?/3 is used for in-memory rows. apply_query/4 is reserved for query-backed resources and custom data sources. To apply all submitted filter values to a queryable, use:

Incant.Filter.apply_filters(resource.table.filters, queryable, params["filter"], context)

Resource forms

Incant form metadata is changeset-first for Ecto resources. The form DSL describes admin presentation and ordering; schemas and changesets remain the source of truth for data and validation.

defmodule MyApp.Admin.Resources.Product do
  use Incant.Resource, schema: MyApp.Catalog.Product, repo: MyApp.Repo

  changeset &MyApp.Catalog.Product.changeset/2

  form do
    field :name
    field :status, :select, options: [:draft, :active, :archived]
    field :price, :number
  end
end

When no form fields are declared, Incant.Forms.fields/1 can infer fields from Ecto-style schema.__schema__/1, excluding :id, :inserted_at, and :updated_at.

Actions and row details

Resource tables can declare row, bulk, and page actions. Actions are semantic commands; adapters decide whether they render as inline buttons, menus, command palettes, drawers, or full pages.

table do
  column :name, link: true

  action :archive,
    label: "Archive",
    tone: :danger,
    confirm: true,
    callback: &MyApp.Admin.Actions.archive_product/2

  row_detail :activity, label: "Activity"

  actions do
    bulk :export_selected,
      label: "Export selected",
      result: :download,
      callback: &MyApp.Admin.Exports.products/2

    page :sync_catalog,
      label: "Sync catalog",
      async: true,
      result: :job,
      callback: &MyApp.Admin.Actions.sync_catalog/1
  end
end

action/2 and row/2 both declare row actions. bulk/2 declares actions that operate on selected rows. page/2 declares resource-level actions. Row actions may declare when they apply without conflating applicability with authorization:

action :disable,
  available_if: [enabled: true],
  confirm: "Disable this provider token?",
  callback: :disable

action :enable,
  available_if: :can_enable?,
  callback: :enable

Incant evaluates available_if while building each service row and again before execution. Declarative keyword/map conditions compare row fields exactly; callbacks receive the row and action context. Atom callbacks resolve against the owning resource module; functions and {Module, :function} remain available for explicit cross-module calls.

Callbacks receive action-specific context such as %{action:, id:, row:, selected_ids:, resource:} and the LiveView assigns. They can return semantic action results:

Incant.ActionResult.toast("Archived")
Incant.ActionResult.refresh([:table, :widgets])
Incant.ActionResult.navigate("/admin/resources/orders")
Incant.ActionResult.download(export_id, label: "CSV export")
Incant.ActionResult.job(job_id, label: "Sync started")
Incant.ActionResult.open_surface(surface)
Incant.ActionResult.error("Cannot archive this row")

Shorthand returns are normalized for convenience: :ok, a message string, {:ok, message}, and {:error, message}.

Schema-backed query resources

Ordinary Ecto resources only declare their schema, repo, and table semantics:

defmodule MyApp.Admin.Resources.Product do
  use Incant.Resource,
    schema: MyApp.Catalog.Product,
    repo: MyApp.Repo

  table default_sort: [inserted_at: :desc] do
    column :name, link: true
    column :status
    column :inserted_at, format: :datetime

    filter :status, :select, options: :distinct
    search [:name]
  end
end

For local admins this query runs in the application VM. For remote admins the central Incant UI sends table state over SafeRPC and the same query runs inside the owning service VM. Repos, schemas, scoped Ecto queries, and callbacks never cross the RPC boundary.

Incant applies authorization scope, declared search, typed filters, exact count, page clamping, allowlisted sorting with a primary-key tie-breaker, limit, and offset. options: :distinct returns at most 100 ordered schema values in the existing page metadata and option representation; the portable contract exposes only options_from to the renderer.

Application-owned query escape hatch

Use index/2 and read/2 when a resource is not a straightforward schema query. These callbacks still execute in the application/service namespace, so custom storage, authorization scoping, projections, and transactions remain application-owned.

A callback may return a plain list for a small collection. Incant then performs in-memory search, filtering, sorting, and pagination. Large custom collections should return the existing %Incant.Result{} with authoritative rows and count; Incant does not process or paginate those rows a second time:

%Incant.Result{
  rows: rows,
  total_count: total,
  meta: %{page: page, page_size: page_size}
}

For Ecto-backed callbacks, Incant.Ecto removes repetitive allowlisted sorting and exact pagination while leaving filters, joins, and projections application-owned:

query = Incant.Ecto.sort(query, table_state, [:name, :inserted_at],
  default: {:inserted_at, :desc}
)

{query, page} = Incant.Ecto.page(query, MyApp.Repo, table_state)
rows = query |> select([product], %{id: product.id, name: product.name}) |> MyApp.Repo.all()

%Incant.Result{
  rows: rows,
  total_count: page.total,
  meta: Map.take(page, [:page, :page_size])
}

The requested sort field must be in the explicit allowlist. A stable :id tie-breaker is applied by default.

Custom callback results can also provide bounded filter options without introducing a separate option model:

%Incant.Result{
  rows: rows,
  total_count: total,
  meta: %{
    page: page,
    page_size: page_size,
    options: %{"model" => model_options}
  }
}

Declare the corresponding custom-result control with filter :model, :combobox, options_from: :model. Option values use the same existing {label, value} or %{label:, value:} representations as selects.

The DSL also accepts explicit callback declarations when the function names differ:

index &MyApp.Admin.Products.index/2
read &MyApp.Admin.Products.read/2
# or local atom shorthand
index :search
read :lookup