---
paths:
  - 'app-modules/*/src/Filament/Pages/**'
---

# Pages

## Non-Resource Filament pages need an explicit table-rendering view + deferLoading-aware tests
A `Filament\Pages\Page implements HasTable` (not a Resource) does NOT render its table automatically — it must set `protected string $view = '<module>::filament.pages.<slug>';` pointing at a Blade file containing `<x-filament-panels::page>{{ $this->table }}</x-filament-panels::page>` (see `ManageFaqs`/`ViewEvChatHistory`/`BookingsRevenueReport`). Omitting this silently renders an empty page — no error, just a blank `fi-page-content`.

Filament v4 tables default to deferred loading. In Pest/Livewire tests, call `->loadTable()` before any `assertSee()`/`assertCanSeeTableRecords()` on a freshly-mounted component, or the table body won't be in the rendered HTML yet.

For custom-data (`->records()`-backed, non-Eloquent) tables: use `->callTableAction($name, $record, data: [...])` / `->mountTableAction(...)` (not the generic `->callAction()`, which targets page-level actions and misses table header/record actions), and use `->assertMountedActionModalSee(...)` to check `->modalContent()` output — modal content is lazily rendered and won't appear in a plain `->html()`/`->assertSee()` snapshot even after mounting the action. See `[[project_internachi_modular]]`-style module layout in `app-modules/ai-agent`.

## Custom-data table bulk actions: fetchSelectedRecords(false) still hydrates full rows
On a `Table::records()`-backed (non-Eloquent) page, `BulkAction::make(...)->fetchSelectedRecords(false)` does NOT skip hydration the way it does for an Eloquent table — the `Collection $records` passed to `->action()` still contains full row arrays (keyed by the record key), not bare ids. Use `$records->keys()->all()` to get just the selected ids; `$records->all()`/`$records->values()` gives you full row data instead. See `ManageSuggestions::deleteSelectedBulkAction()` / `ManageSuggestionMisses::promoteBulkAction()`.

Also: `BnfexpressAdminClient`'s non-2xx handling (`errorMessage()`) must handle `detail` being a list of `{msg, ...}` objects, not just a string — FastAPI's own request-validation failures (422s) return `detail` in that shape, and casting it straight to `(string)` silently produces the literal "Array".

## "Promote" on ManageSuggestionMisses now reuses the Create Many bulk-add modal
`ManageSuggestionMisses::promoteBulkAction()` no longer calls `promoteSuggestionMisses()` (miss-id based). It opens the same modal shape as `ManageSuggestions::createManyAction()` — info text + editable `items_raw` textarea + lang/intent — prefilled via `fillForm(fn (Collection $records) => ...)` (auto-injected selected records, works in `fillForm` the same way it does in a bulk `->action()`) from the selected misses' own `text_norm`/`lang`. Submits through `batchCreateSuggestions()`, so edited/added lines in the textarea are honored — the created rows no longer map 1:1 to the original miss ids, and the original miss rows are NOT auto-dismissed by this path (unlike the old promote endpoint).

## `assertNotified()` pulls (and clears) ALL queued notifications on its first call
Filament's `Notification::assertNotified()` reads via `session()->pull('filament.notifications')`, which empties the session key. Chaining `->assertNotified('A')->assertNotified('B')` after one action fails on the second call even if both were actually sent — the first call already drained the queue. To check two notifications from one action, assert only one (whichever isn't covered by another test) rather than chaining. See `ManageSuggestionMisses`'s promote-cleanup-failure test.
