# SelectoComponents
> Alpha software. Expect API and behavior changes while the core LiveView package continues to settle.
`selecto_components` provides Phoenix LiveView components for building interactive query UIs on top of `selecto`.
It is the package you use when you want users to:
- build filters visually
- choose fields for detail and aggregate views
- switch between built-in result views
- save/export/share views
- email or schedule exports through host-app integrations
## What It Includes
- `SelectoComponents.Explorer` as the preferred top-level exploration surface
- `SelectoComponents.Form` for query-building and view configuration
- `SelectoComponents.Results` for result rendering
- built-in views:
- `Detail`
- `Aggregate`
- `Graph`
- extension-driven view support such as map views via `selecto_postgis`
- exported-view, email-export, and scheduled-export integration points
## Requirements
- Phoenix 1.7+
- Elixir ~> 1.18
- `selecto >= 0.4.6 and < 0.6.0`
- an adapter package such as `selecto_db_postgresql >= 0.4.4 and < 0.6.0`
- `selecto_mix >= 0.4.6 and < 0.6.0` if you want generators and installation helpers
## Installation
```elixir
def deps do
[
{:selecto_components, ">= 0.4.8 and < 0.6.0"},
{:selecto, ">= 0.4.6 and < 0.6.0"},
{:selecto_db_postgresql, ">= 0.4.4 and < 0.6.0"},
{:selecto_mix, ">= 0.4.6 and < 0.6.0"}
]
end
```
Then run the recommended integration task:
```bash
mix deps.get
mix selecto.components.integrate
mix assets.build
```
That wires:
- colocated SelectoComponents hooks into your LiveSocket config
- Tailwind `@source` coverage for component templates
## Minimal Usage
`SelectoComponents.Explorer` is now the preferred render surface.
Current migration note:
- use `SelectoComponents.Form` in the parent LiveView for the existing event-handler and `get_initial_state/2` compatibility path
- render `SelectoComponents.Explorer` instead of rendering `Form` and `Results` separately
```elixir
defmodule MyAppWeb.ProductLive do
use MyAppWeb, :live_view
use SelectoComponents.Form
alias MyApp.SelectoDomains.ProductDomain
alias MyApp.Repo
def mount(_params, _session, socket) do
selecto = ProductDomain.new(Repo)
views = [
{:detail, SelectoComponents.Views.Detail, "Detail", %{}},
{:aggregate, SelectoComponents.Views.Aggregate, "Aggregate", %{}},
{:graph, SelectoComponents.Views.Graph, "Graph", %{}}
]
{:ok, assign(socket, get_initial_state(views, selecto))}
end
def render(assigns) do
~H"""
<.live_component module={SelectoComponents.Explorer} id="product-explorer" {assigns} />
"""
end
end
```
## Config-Driven Explorer
You can also hand `Explorer` a smaller config struct while keeping the current parent LiveView compatibility boot path:
```elixir
config = %SelectoComponents.Explorer.Config{
id: "products",
selecto: ProductDomain.new(Repo),
views: [
SelectoComponents.Views.spec(:detail, SelectoComponents.Views.Detail, "Detail", %{}),
SelectoComponents.Views.spec(:aggregate, SelectoComponents.Views.Aggregate, "Aggregate", %{}),
SelectoComponents.Views.spec(:graph, SelectoComponents.Views.Graph, "Graph", %{})
],
title: "Products Explorer",
presentation: %{timezone: "America/New_York"}
}
```
The current runtime still expects the parent LiveView to own state/event setup. `Explorer.Config` is the first host-facing config seam, not a full replacement for that compatibility path yet.
## Common Add-Ons
You can start from `Explorer` and progressively add host-app integrations.
Compatibility note:
- `Form` + `Results` still work
- new docs/examples should prefer `Explorer`
### Saved Views
Use a host module that persists saved views and assign it to the LiveView.
Typical generation path:
```bash
mix selecto.gen.saved_views MyApp.SavedView MyApp.SavedViewContext
```
### Exported Views
Use `SelectoComponents.ExportedViews` when you want signed iframe/embed snapshots of current views.
### Email And Scheduled Exports
Assign these modules when you want the Export tab to send or manage exports:
```elixir
assign(socket,
export_delivery_module: MyApp.ExportDelivery,
scheduled_export_module: MyApp.ScheduledExports,
scheduled_export_context: scoped_context
)
```
The host app owns actual delivery and scheduling. `selecto_components` stays scheduler-neutral.
Recommended execution model: use Oban (or another worker system) to run due scheduled exports via `SelectoComponents.ScheduledExports.Service.run_scheduled_export/3`.
### Extension Views
Map views and other extension-provided view systems are merged from domain extensions rather than hard-coded into the package.
## Query Contracts
`SelectoComponents.QueryContract.build/1` returns the constrained
`Selecto.Domain.query_contract/1` projection for Components-facing tooling. It
accepts an authored domain, a normalized domain, or a configured Selecto struct
without changing the existing Explorer/Form runtime path.
Use `SelectoComponents.QueryContract.json_document/1` or `encode_json/2` when a
consumer needs a `query_contract.json`-ready artifact with string keys and
JSON-compatible values. Pass `query_contract_url` and `query_guide_url` to add
discovery links for tools that need to move between the JSON contract and its
Markdown guide. The JSON and Markdown Plugs also emit HTTP `Link` headers for
the same pair of artifacts, plus byte-accurate `ETag` headers with
`If-None-Match` support for conditional GETs.
Use `SelectoComponents.QueryContract.validate_intent/2` to check a generated
detail, aggregate, or graph query intent against a query contract before handing
it to runtime query code. Host apps can also mount
`SelectoComponents.QueryContract.IntentValidator.Plug` to expose the same
validation over an HTTP POST endpoint.
Host apps can mount `SelectoComponents.QueryContract.Plug` for a small JSON
endpoint:
```elixir
forward "/selecto/orders/query-contract.json",
SelectoComponents.QueryContract.Plug,
domain: MyApp.SelectoDomains.Orders.domain()
```
For the full host integration path, including generated action forms,
capability policy, choice-source diagnostics, exported views, scheduled exports,
and reload/result semantics, see `docs/developer_integration_guide.md`.
## Generated Domain Action Forms
Domains can expose write-contract actions under `:actions`. Selecto Components
projects those actions into the existing row-action modal path with generated
ids like `domain_action_form_archive`. A detail view can select one of those ids
as `row_click_action`; clicking a row opens `SelectoComponents.Modal.ActionFormModal`
with the normalized action metadata, target row, inputs, confirmation state, and
preview/apply request template.
The modal does not execute writes directly. The host LiveView handles
`{:selecto_action_form_submit, payload}` and calls its own preview/apply
adapter, usually through `SelectoComponents.ActionFormHost.handle_submit/3`.
After a successful apply, the host should refresh the active Selecto query so
the row state reflects the write result.
Bulk-scoped domain actions can be projected separately with
`SelectoComponents.Actions.bulk_actions/2`. That helper returns the same
live-component payload shape as generated row action forms, but defaults the
target template to selected row ids.
`SelectoComponents.EnhancedTable.BulkActions` can receive `:action_contract`,
`:write_contract`, `:domain`, or `:selecto` assigns. Bulk-scoped domain actions
from that contract are added to the bulk menu as generated action forms; opening
one sends the same detail-modal event with `target.ids` set to the current
selection.
The older explicit `actions:` bulk-action API has been removed before 1.0. New
bulk actions should be expressed in the domain action contract.
### Action DSL Shape
An action is host-owned metadata in the Selecto domain. The execution contract
can use static values, or reference normalized form inputs with `{:input, id}`:
```elixir
defaction(:set_estimate, %{
id: :set_estimate,
label: "Set estimate",
type: :update,
scope: :row,
target: :work_item,
capability: "work_items.estimation",
inputs: %{
estimate_hours: %{type: :integer, label: "Estimate hours", required: true, min: 0}
},
confirmation: %{required: false, destructive: false},
execution: %{
kind: :updato,
operation: :update,
set: %{estimate_hours: {:input, :estimate_hours}}
},
links: %{
preview: "/api/v1/updato/work-items/actions/set_estimate/preview",
apply: "/api/v1/updato/work-items/actions/set_estimate/apply"
}
})
```
The generated modal builds this request shape and sends it to the parent
LiveView:
```elixir
%{
intent: "apply",
action_id: "set_estimate",
action_label: "Set estimate",
action_scope: "row",
action_operation: "update",
capability: "work_items.estimation",
target: %{"id" => 42},
inputs: %{"estimate_hours" => "8"},
confirmation_required?: false,
endpoints: %{
"preview" => %{"href" => "/api/v1/updato/work-items/actions/set_estimate/preview"},
"apply" => %{"href" => "/api/v1/updato/work-items/actions/set_estimate/apply"}
},
request: %{
"action" => "set_estimate",
"target" => %{"id" => 42},
"inputs" => %{"estimate_hours" => "8"},
"confirmed" => false
}
}
```
The host should treat this as an intent, not as permission to write directly.
Typical host responsibilities are:
- Re-check target-aware action availability before showing the modal.
- Preview/apply through a server-owned adapter.
- Normalize and whitelist writable fields in that adapter.
- Refresh the active Selecto query and close or reset the modal after apply.
- Use flash/error messages for the result surface the host wants.
## Custom View Systems
`selecto_components` supports external view packages through `SelectoComponents.Views.System`.
That is the contract used when you want to publish a package like:
- `selecto_components_view_<slug>`
and register it into a host LiveView with `SelectoComponents.Views.spec/4`.
## Status
Current `0.4.x` scope:
- core query UI flows are usable but still alpha
- exported views, one-off email export, and scheduled export management are available
- custom and extension view support exists, but host apps still own persistence and delivery concerns
- advanced graph/dashboard integrations still need real-world hardening
## Demos And Tutorials
- `selecto_livebooks`
- `selecto_northwind`
- hosted demo: `testselecto.fly.dev`
- runnable example app: `selecto_example`