# Resources

Incant is experimental; resource DSL and renderer details may still change before a first stable release.


```elixir
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](authorization.md) for policy scoping and [Design and theming](design.md) 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:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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.

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

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

```elixir
filter :expensive, :boolean, filter: MyApp.Admin.Filters.ExpensiveProduct
```

```elixir
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:

```elixir
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.

```elixir
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.

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
%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:

```elixir
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:

```elixir
%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:

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