# The list engine: how to make a list

Every list in every Tutelora app is one PHP definition drawn by one Vue component (plan CORE §3.19; mockup
`list.html`). People (`App\Lists\PersonList`) is the first; Roles, Input Fields, the logs and every product list follow.

## 1 · Define it

```php
final class InvoiceList extends ListDefinition
{
    public function key(): string { return 'invoice'; }                 // URL, saved views, Lists::ALL
    public function permission(): ?string { return 'invoice.view'; }     // to open it; null: the viewer's own rows (S14)
    public function exportPermission(): ?string { return 'invoice.export'; }
    public function defaultSort(): string { return '-issued'; }

    protected function base(): Builder                                    // inside the workspace; eager-load what rows read
    {
        return Invoice::query()->select('invoice.*')->with('customer');
    }

    protected function search(Builder $query, string $term): void { /* the search box */ }

    public function fields(): array
    {
        return [
            Field::text('number', 'invoice.field.number')->column()
                ->sortable(fn (Builder $q, string $dir) => $q->orderBy('invoice.number', $dir)->orderBy('invoice.id'))
                ->exported(fn (Invoice $i) => $i->number),
            Field::choice('status', 'invoice.field.status')->segment()      // the one-line choice above, with counts
                ->options(fn () => LookupCategory::options('invoice_status'))
                ->filterable(fn (Builder $q, string $v) => $q->where('invoice.status', $v)),
            Field::date('issued', 'invoice.field.issued')->column()
                ->sortable(…)->filterable(…)->exported(…),
        ];
    }

    public function row(Model $record, User $viewer): array { /* public_id, href, cells, can, what its cells need */ }
    public function bulk(): array { /* key => label, icon, ability, danger, reason */ }
    public function perform(string $action, Model $record, ?string $reason): void { /* one row */ }
}
```

Then add it to `App\Support\Lists\Lists::ALL`, and its permissions to the catalogue.

- **Kinds:** `text` (contains), `choice` (one of `options()`, from Input Fields or the data, never a typed list),
  `date` (a range: `f[x][from]`, `f[x][to]`), `boolean` (`1`/`0`).
- **A field does only what it allows:** `column(shown:)`, `sortable()`, `filterable()`, `exported()`, `segment()`.
- **A two-value status is never a column** (plan §3.18): it is the row's live toggle, and filters as the segment.
- **Sorts end with a unique column** (`->orderBy('x.id')`), so pages and the export never repeat or skip a row.

A list of **the viewer's own rows** (S14's notifications: every person's, across their workspaces) returns `null` from
`permission()`: it is open to everyone signed in, and its `base()` must read only the signed-in person's rows
(`MyNotificationList`); it keeps no saved views (`savedViews()` false).

A list of **one record's rows** (S15: an import's rows, `ImportRowList`) is set by its page (`for($import)`) and reads
nothing without it; it keeps no saved views. A family of lists that differ only by kind (S15's import lists) is an
abstract definition with one small subclass per kind, each registered under its own key and permission.

A list's rows before any view, `records()` (its `base()`), are what search may find of its kind (S16, `search.md`):
a `RecordKind` names the list, so search never finds a record the list wouldn't show to the same person.

## 2 · Draw it

```vue
<DataList :list="list" :search-label="$t('list.search')">
    <template #cell-number="{ row, value }">…</template>   <!-- any cell the page draws itself -->
    <template #actions="{ row }">…</template>              <!-- icon-only, fixed slots -->
</DataList>
```

The controller sends `'list' => $list->page(ListView::from($request, $list), $user, $saved)`; see
`PersonController::index` for opening a saved view or the person's default.

## What the engine does for every list

| | |
|---|---|
| **The view in the URL** | `?q=&f[status]=active&sort=-name&cols=name,role&per=25&page=2`; a link opens the same list. A filter, sort, column or page size the definition lacks is refused with a 400 (`InvalidListView`), never ignored |
| **One query** | the page, the export and the bulk actions run `query($view)`: a file never holds more than the page shows (TuteDesk's rule) |
| **Export** | `GET /list/{key}/export` with the view: CSV, the view's columns in order, a byte-order mark, formula cells made text (`CsvExport`). Up to 1,000 rows at once; more is refused with a note until exports run as jobs (S11, S14) |
| **Many at once** | `POST /list/{key}/bulk`: each row is re-checked with the action's ability; the page's flags are never trusted. The bar offers an action only for the selected rows it may touch, and says how many |
| **Saved views** | `saved_view`: a person's, or shared with everyone who can see the list; one may be their default. The page says when the list has changed from the view, with Save and Reset. A list behind a platform permission has none (`savedViews()`; a saved view belongs to a workspace), so the page sends `views: null` and draws no menu |
| **Counts** | the segment field's choices count the rows with the rest of the view applied |

## The guards

`Guards/ListGuardTest` (no hand-made table in a page; every list's permissions in the catalogue),
`Lists/ListEngineTest` (refusals, the URL view, export equals view, bulk re-checks, saved views), and the tenant
sweep, which reads every list over a workspace table from another workspace (`TenantSweep`).
