docs: add canonical filament UI standards

This commit is contained in:
Ahmed Darrazi 2026-03-09 00:16:50 +01:00
parent a4f5c4f122
commit 3221ba42d1
7 changed files with 1512 additions and 0 deletions

View File

@ -320,6 +320,17 @@ ### Badge Semantics Are Centralized (BADGE-001)
- Introducing or changing a status-like value MUST include updating the relevant badge mapper and adding/updating tests for the mapping.
- Tag/category chips (e.g., type/platform/environment) are not status-like and are not governed by BADGE-001.
### Incremental UI Standards Enforcement (UI-STD-001)
- UI consistency is enforced incrementally, not by recurring cleanup passes.
- New tables, filters, and list surfaces MUST follow established Filament-native standards from the first implementation.
- Deviations MUST be explicit and justified in the spec or PR.
- Canonical standards live in `docs/product/standards/` and are the source of truth for:
- Table UX (column tiers, sort, search, toggle, pagination, persistence, empty states)
- Filter UX (persistence, soft-delete, date range, enum sourcing, defaults)
- Actions UX (row/bulk/header actions, grouping, destructive safety)
- Guard tests enforce critical constraints automatically; the list surface review checklist catches the rest.
- A new spec that adds or modifies a list surface MUST reference the review checklist (`docs/product/standards/list-surface-review-checklist.md`).
### Spec-First Workflow
- For any feature that changes runtime behavior, include or update `specs/<NNN>-<slug>/` with `spec.md`, `plan.md`, `tasks.md`, and `checklists/requirements.md`.
- New work branches from `dev` using `feat/<NNN>-<slug>` (spec + code in the same PR).

View File

@ -0,0 +1,727 @@
# TenantPilot — Comprehensive Filter Audit
> **Stack:** Laravel 12 · Filament v5 · Livewire v4 · PostgreSQL
> **Scope:** Every table surface across Admin Panel, System Console, Dashboard Widgets & Livewire Pickers
> **Date:** 2025-07-11
---
## 1. Executive Summary
The platform has **36 table surfaces** across 18 Resources, 6 RelationManagers, 9 custom/system pages, 2 dashboard widgets, and 3 Livewire picker components.
**Key findings:**
| Metric | Value |
|---|---|
| Surfaces with ≥1 filter | 16 of 36 (44 %) |
| Surfaces with zero filters | 20 of 36 (56 %) |
| Total filter instances | ~54 |
| Surfaces with `persistFiltersInSession()` | 7 of 36 (19 %) |
| Surfaces with filter defaults | 6 |
| Filter types used | `SelectFilter` (dominant), `Filter::make` (boolean), `TrashedFilter`, `TernaryFilter`, `DatePicker` range |
| Highest filter count on one surface | 9 (FindingResource) |
| Resources with zero filters that need them | 5+ (identified below) |
**Critical inconsistencies:**
- Session persistence is applied to only 7 of 16 filtered surfaces — the other 9 filtered surfaces lose user state on navigation.
- Filter defaults vary wildly: some surfaces pre-filter to "active" records, others show everything.
- No date range filter exists on surfaces that clearly need one (Findings, AlertDelivery, RestoreRun).
- Status/enum filters use inconsistent option sets and labeling across the platform.
- The `TrashedFilter` (Active/All/Archived) pattern is consistent where used but only applied to 4 resources.
---
## 2. Complete Surface Inventory
### 2A. Resources (18)
| # | Resource | Domain | Scope | Filters | Persist? | Default |
|---|---|---|---|---|---|---|
| 1 | PolicyResource | Inventory | Tenant | 4 (visibility, policy_type, category, platform) | ✅ | visibility=active |
| 2 | FindingResource | Governance | Tenant | 9 (open, overdue, high_severity, my_assigned, status, finding_type, evidence_fidelity, scope_key, run_ids) | ✅ | open=true |
| 3 | OperationRunResource | Monitoring | Workspace | 6 (tenant_id, type, status, outcome, initiator_name, created_at) | ✅ | tenant=active, created_at=30d |
| 4 | TenantResource | Settings | Workspace | 3 (trashed, environment, app_status) | ✅ | trashed=active |
| 5 | BackupScheduleResource | Backups | Tenant | 2 (trashed, enabled_state) | ✅ | — |
| 6 | BackupSetResource | Backups | Tenant | 1 (trashed) | ✅ | — |
| 7 | ProviderConnectionResource | Settings | Workspace | 5 (tenant, provider, status, health_status, default_only) | ✅ | tenant=scoped |
| 8 | PolicyVersionResource | Inventory | Tenant | 1 (trashed) | ❌ | — |
| 9 | RestoreRunResource | Backups | Tenant | 1 (trashed) | ❌ | — |
| 10 | InventoryItemResource | Inventory | Tenant | 2 (policy_type, category) | ❌ | — |
| 11 | ReviewPackResource | Reporting | Tenant | 1 (status) | ❌ | — |
| 12 | AlertDeliveryResource | Monitoring | Workspace | 3 (status, event_type, alert_destination_id) | ❌ | — |
| 13 | EntraGroupResource | Directory | Tenant | 2 (stale, group_type) | ❌ | — |
| 14 | AlertRuleResource | Monitoring | Workspace | 0 | ❌ | — |
| 15 | AlertDestinationResource | Monitoring | Workspace | 0 | ❌ | — |
| 16 | BaselineProfileResource | Governance | Workspace | 0 | ❌ | — |
| 17 | BaselineSnapshotResource | Governance | Workspace | 0 | ❌ | — |
| 18 | WorkspaceResource | Settings | Workspace | 0 | ❌ | — |
### 2B. RelationManagers (6)
| # | RelationManager | Parent | Filters | Notes |
|---|---|---|---|---|
| 1 | VersionsRelationManager | PolicyResource | 0 (`->filters([])`) | Explicit empty |
| 2 | BackupItemsRelationManager | BackupSetResource | 0 (`->filters([])`) | Explicit empty |
| 3 | BackupScheduleOperationRunsRelationManager | BackupScheduleResource | 0 (`->filters([])`) | Explicit empty |
| 4 | BaselineTenantAssignmentsRelationManager | BaselineProfileResource | 0 | No filters block |
| 5 | TenantMembershipsRelationManager | TenantResource | 0 | No filters block |
| 6 | WorkspaceMembershipsRelationManager | WorkspaceResource | 0 | No filters block |
### 2C. Custom & System Pages (9)
| # | Page | Panel | Filters | Notes |
|---|---|---|---|---|
| 1 | InventoryCoverage | Admin | 1-2 (category, restore mode) | Dynamic — restore filter only if options exist |
| 2 | Monitoring/Operations | Admin | Reuses OperationRunResource::table() | Inherits 6 filters + persistence |
| 3 | System/Ops/Runs | System | 0 | No filters on platform ops listing |
| 4 | System/Ops/Failures | System | 0 | Pre-filtered query (failed only), no user filters |
| 5 | System/Ops/Stuck | System | 0 | Pre-filtered via StuckRunClassifier, no user filters |
| 6 | System/Directory/Tenants | System | 0 | No filters |
| 7 | System/Directory/Workspaces | System | 0 | No filters |
| 8 | System/Security/AccessLogs | System | 0 | No filters |
| 9 | System/RepairWorkspaceOwners | System | 0 | No filters |
### 2D. Dashboard Widgets (2)
| # | Widget | Filters | Notes |
|---|---|---|---|
| 1 | RecentDriftFindings | 0 | Polling-based, no filter UI |
| 2 | RecentOperations | 0 | Polling-based, no filter UI |
### 2E. Livewire Picker Components (3)
| # | Component | Filters | Notes |
|---|---|---|---|
| 1 | BackupSetPolicyPickerTable | 5 (policy_type, platform, synced_within, ignored, has_versions) | Most filters of any picker; synced_within default=7, ignored default=false |
| 2 | EntraGroupCachePickerTable | 2 (stale, group_type) | Mirrors EntraGroupResource filters |
| 3 | SettingsCatalogSettingsTable | 0 | Read-only inspection table; purely search-driven |
---
## 3. Metrics Dashboard
### 3A. Filter Type Distribution
| Filter Type | Count | Surfaces Using It |
|---|---|---|
| `SelectFilter` | ~35 | PolicyResource, FindingResource, OperationRunResource, TenantResource, BackupScheduleResource, ProviderConnectionResource, InventoryItemResource, ReviewPackResource, AlertDeliveryResource, EntraGroupResource, InventoryCoverage, BackupSetPolicyPicker, EntraGroupCachePicker |
| `Filter::make` (boolean/custom) | ~8 | FindingResource (4: open, overdue, high_severity, my_assigned), ProviderConnectionResource (1: default_only), FindingResource (2: scope_key, run_ids) |
| `TrashedFilter` | 4 | TenantResource, BackupScheduleResource, BackupSetResource, PolicyVersionResource, RestoreRunResource |
| `TernaryFilter` | 1 | BackupSetPolicyPickerTable (ignored) |
| Date range (`DatePicker`) | 1 | OperationRunResource (created_at) |
### 3B. Persistence Coverage
| Category | With Persistence | Without | Total |
|---|---|---|---|
| Resources with filters | 7 | 6 | 13 |
| RelationManagers | 0 | 6 | 6 |
| Custom/System Pages | 1 (via reuse) | 8 | 9 |
| Widgets | 0 | 2 | 2 |
| Livewire Pickers | 0 | 3 | 3 |
### 3C. Default Filter Coverage
| Surface | Filter | Default Value | Impact |
|---|---|---|---|
| PolicyResource | visibility | `active` | Hides ignored policies on first load |
| FindingResource | open | `true` | Shows only open findings on first load |
| OperationRunResource | tenant_id | Active tenant | Scopes to current tenant context |
| OperationRunResource | created_at | Last 30 days | Time-bounds initial view |
| TenantResource | trashed | `true` (Active) | Hides archived tenants |
| BackupSetPolicyPickerTable | synced_within | `7` (7 days) | Only recently synced policies |
| BackupSetPolicyPickerTable | ignored | `false` | Hides ignored policies |
| ProviderConnectionResource | tenant | Scoped tenant external_id | Pre-scopes to current tenant |
---
## 4. Detailed Findings Per Table
### 4.1 PolicyResource
**Filters (4):**
```
SelectFilter('visibility') → default('active'), custom query (active hides ignored_at)
SelectFilter('policy_type') → from config, no searchable
SelectFilter('category') → custom query mapping types to categories
SelectFilter('platform') → from distinct DB values
```
**Implicit filter:** `modifyQueryUsing` hides policies not synced in 7 days.
**Assessment:**
- ✅ Persistence: full (search, sort, filters)
- ✅ Default: sensible (filters to active policies)
- ⚠️ `policy_type` options come from config — could be `->searchable()` for large lists
- ⚠️ `platform` options use raw DB distinct — no label formatting
- ⚠️ No date range filter (last_synced_at is sortable but not filterable)
---
### 4.2 FindingResource
**Filters (9) — highest in the product:**
```
Filter::make('open') → boolean quick, default(true)
Filter::make('overdue') → boolean quick
Filter::make('high_severity') → boolean quick
Filter::make('my_assigned') → boolean quick
SelectFilter('status') → 8 manual options
SelectFilter('finding_type') → 3 manual options
SelectFilter('evidence_fidelity') → 2 manual options
Filter::make('scope_key') → TextInput form
Filter::make('run_ids') → 2 TextInputs (baseline + current run)
```
**Assessment:**
- ✅ Persistence: full
- ✅ Default: sensible (open=true)
- ⚠️ 9 filters may overwhelm users — consider filter groups or collapsible sections
- ⚠️ `scope_key` and `run_ids` are advanced/developer filters — should be in an "Advanced" section
- ⚠️ No date range filter (created_at, due_at exist but are not filterable)
- ⚠️ `status` has 8 manual options — should use enum for consistency
---
### 4.3 OperationRunResource
**Filters (6):**
```
SelectFilter('tenant_id') → dynamic default=active tenant, relationship options, searchable
SelectFilter('type') → from distinct DB values
SelectFilter('status') → from OperationRunStatus enum
SelectFilter('outcome') → from OperationRunOutcome enum with labels
SelectFilter('initiator_name') → from distinct DB, searchable
Filter::make('created_at') → DatePicker from/until, default=last 30 days
```
**Assessment:**
- ✅ Persistence: full
- ✅ Defaults: excellent (tenant + date range scoped)
- ✅ Best filter UX in the product — good model for others
- ⚠️ `type` uses raw DB distinct instead of OperationCatalog labels
- ✅ Date range filter — only surface that has one
---
### 4.4 TenantResource
**Filters (3):**
```
TrashedFilter::make() → labels (Active/All/Archived), default(true)
SelectFilter('environment') → 4 options
SelectFilter('app_status') → 4 options
```
**Assessment:**
- ✅ Persistence: full
- ✅ TrashedFilter pattern with default
- ✅ Good but minimal
---
### 4.5 BackupScheduleResource
**Filters (2):**
```
TrashedFilter::make() → Active/All/Archived
SelectFilter('enabled_state') → custom query
```
**Assessment:**
- ✅ Persistence: full
- ⚠️ Only 2 filters for a moderately complex resource
---
### 4.6 BackupSetResource
**Filters (1):**
```
TrashedFilter::make() → Active/All/Archived
```
**Assessment:**
- ✅ Persistence: full
- ⚠️ Minimal filtering — no status, no date range, no policy type summary filter
---
### 4.7 ProviderConnectionResource
**Filters (5):**
```
SelectFilter('tenant') → default=scoped tenant external_id, custom query
SelectFilter('provider') → 1 option (microsoft)
SelectFilter('status') → 4 options
SelectFilter('health_status') → 4 options
Filter::make('default_only') → boolean
```
**Assessment:**
- ✅ Persistence: full
- ✅ Good filter coverage for the domain
- ⚠️ `provider` filter has only 1 option — may be premature; could be hidden until >1 provider exists
---
### 4.8 PolicyVersionResource
**Filters (1):**
```
TrashedFilter::make() → Active/All/Archived
```
**Assessment:**
- ❌ No persistence
- ⚠️ Missing: policy_type, platform, date range (captured_at)
- ⚠️ Gap: this is a high-traffic resource with version history — more filters needed
---
### 4.9 RestoreRunResource
**Filters (1):**
```
TrashedFilter::make() → Active/All/Archived
```
**Assessment:**
- ❌ No persistence
- ⚠️ Missing: status, outcome, date range (started_at), dry_run flag
- ⚠️ Gap: operators need to quickly find failed or dry-run restores
---
### 4.10 InventoryItemResource
**Filters (2):**
```
SelectFilter('policy_type') → searchable
SelectFilter('category') → searchable
```
**Assessment:**
- ❌ **No persistence** — critical gap since this is a frequently navigated list
- ⚠️ Missing: platform, sync freshness, version count
- ⚠️ Both filters are searchable — good, but should match PolicyResource pattern
---
### 4.11 ReviewPackResource
**Filters (1):**
```
SelectFilter('status') → from ReviewPackStatus enum
```
**Assessment:**
- ❌ No persistence
- ⚠️ Single filter is reasonable for current scope
---
### 4.12 AlertDeliveryResource
**Filters (3):**
```
SelectFilter('status') → 6 options
SelectFilter('event_type') → dynamic from DB
SelectFilter('alert_destination_id') → dynamic from DB
```
**Assessment:**
- ❌ No persistence
- ⚠️ Missing: date range (created_at), tenant filter if multi-tenant scope
- ⚠️ Status options are manual strings — should use enum
---
### 4.13 EntraGroupResource
**Filters (2):**
```
SelectFilter('stale') → config-based staleness cutoff query
SelectFilter('group_type') → complex JSON/boolean query
```
**Assessment:**
- ❌ No persistence
- ⚠️ Complex custom queries — well-implemented but should document the staleness logic
- ⚠️ Mirrors EntraGroupCachePickerTable filters — good consistency
---
### 4.144.18 Resources with ZERO filters
| Resource | Assessment |
|---|---|
| **AlertRuleResource** | ⚠️ Missing: is_active toggle, event type, severity |
| **AlertDestinationResource** | ⚠️ Missing: type/channel filter |
| **BaselineProfileResource** | ⚠️ Missing: status filter |
| **BaselineSnapshotResource** | ⚠️ Missing: state filter (gaps/complete) |
| **WorkspaceResource** | ✅ Acceptable — small dataset, search suffices |
---
## 5. Cross-Cutting Inconsistencies
### 5.1 Persistence Gap
**7 resources** have persistence. **11 resources + all 6 relation managers + 8 system pages** do not.
The guard test (`FilamentTableStandardsGuardTest`) only enforces persistence on 7 "critical" resources:
- TenantResource, PolicyResource, BackupSetResource, BackupScheduleResource
- ProviderConnectionResource, FindingResource, OperationRunResource
**Missing from guard:** PolicyVersionResource, RestoreRunResource, InventoryItemResource, AlertDeliveryResource, EntraGroupResource, ReviewPackResource.
### 5.2 Filter Default Inconsistency
| Pattern | Active-by-default Behavior | Surfaces |
|---|---|---|
| `SelectFilter('visibility')->default('active')` | Custom query | PolicyResource |
| `Filter::make('open')->default()` | Boolean toggle | FindingResource |
| `TrashedFilter->default(true)` | Hides archived | TenantResource |
| `SelectFilter('tenant_id')->default(...)` | Scopes to context | OperationRunResource |
| No default | Shows everything | 10+ surfaces |
**Problem:** Users expect consistent behavior across similar resources. Some show "active" records by default, some show everything.
### 5.3 Status Filter Inconsistency
Status filtering is implemented differently across surfaces:
| Surface | Approach |
|---|---|
| OperationRunResource | Enum → `OperationRunStatus::class` |
| FindingResource | Manual array of 8 strings |
| AlertDeliveryResource | Manual array of 6 strings |
| ReviewPackResource | Enum → `ReviewPackStatus::class` |
| PolicyVersion/RestoreRun | TrashedFilter (soft-delete based) |
**Recommendation:** Always source status options from the model's status enum or a centralized catalog.
### 5.4 Date Range Filter Gap
Only **OperationRunResource** has a date range filter (`created_at` with DatePicker form).
Surfaces that should have one but don't:
- **FindingResource** — has `created_at`, `due_at`
- **AlertDeliveryResource** — has `created_at`
- **RestoreRunResource** — has `started_at`, `completed_at`
- **PolicyVersionResource** — has `captured_at`
- **BackupScheduleResource** — has execution history
### 5.5 TrashedFilter Labeling
All 4 resources using TrashedFilter apply the same label pattern — **this is consistent ✅:**
```php
TrashedFilter::make()
->label('Archived')
->placeholder('Active')
->trueLabel('All')
->falseLabel('Archived')
```
### 5.6 Tenant Scoping in Filters
| Surface | Approach |
|---|---|
| OperationRunResource | Explicit `SelectFilter('tenant_id')` with dynamic default |
| ProviderConnectionResource | Explicit `SelectFilter('tenant')` with external_id default |
| AlertDeliveryResource | ❌ No tenant filter (scoped in query only) |
| System pages | ❌ No filters at all (but cross-tenant by design) |
---
## 6. Repeated Patterns (Extractable)
### Pattern A: TrashedFilter Standard
```php
TrashedFilter::make()
->label('Archived')
->placeholder('Active')
->trueLabel('All')
->falseLabel('Archived')
```
**Used in:** TenantResource, BackupScheduleResource, BackupSetResource, PolicyVersionResource, RestoreRunResource
**Extraction:** Could be a helper `FilterPresets::trashedArchive()`.
### Pattern B: Config-Sourced SelectFilter
```php
SelectFilter::make('policy_type')
->label('Policy type')
->options($options) // from config or DB
->searchable()
```
**Used in:** PolicyResource, InventoryItemResource, BackupSetPolicyPickerTable
**Note:** Options source varies (config vs DB distinct vs static array).
### Pattern C: Dynamic DB Distinct Options
```php
SelectFilter::make('platform')
->options(fn (): array => Policy::query()
->where(...)
->whereNotNull('platform')
->distinct()
->orderBy('platform')
->pluck('platform', 'platform')
->all())
```
**Used in:** PolicyResource (platform), OperationRunResource (type, initiator_name), BackupSetPolicyPickerTable (platform), AlertDeliveryResource (event_type, alert_destination_id)
**Risk:** N+1 on every filter render — should cache or use `->options()` with static method.
### Pattern D: Date Range with DatePicker (ONLY ONE INSTANCE)
```php
Filter::make('created_at')
->form([
Forms\Components\DatePicker::make('from')->label('From'),
Forms\Components\DatePicker::make('until')->label('Until'),
])
->query(...)
->indicateUsing(...)
```
**Used only in:** OperationRunResource
**Should be in:** FindingResource, AlertDeliveryResource, RestoreRunResource, PolicyVersionResource
### Pattern E: Boolean Quick Filters
```php
Filter::make('open')
->query(fn (Builder $query): Builder => $query->where('status', 'open'))
->default()
```
**Used in:** FindingResource (4 boolean quick filters)
**Note:** Filament v5's `ToggleFilter` or `TernaryFilter` may provide better UX than `Filter::make` with `->default()`.
### Pattern F: Staleness Cutoff Filter
```php
SelectFilter::make('stale')
->options(['1' => 'Stale', '0' => 'Fresh'])
->query(function (Builder $query, array $data) use ($cutoff): Builder { ... })
```
**Used in:** EntraGroupResource, EntraGroupCachePickerTable (**consistent ✅**)
---
## 7. Recommended Filter UX Standard
### 7.1 Tier System
| Tier | Surface Type | Filter Requirements |
|---|---|---|
| **Tier 1 — Critical** | High-traffic Resource lists (Policy, Finding, OperationRun, Tenant, InventoryItem) | Full persistence, smart defaults, date range where applicable |
| **Tier 2 — Important** | Moderate-traffic Resources (BackupSchedule, BackupSet, RestoreRun, PolicyVersion, ProviderConnection, AlertDelivery, EntraGroup) | Persistence, domain-appropriate filters, defaults for soft-delete |
| **Tier 3 — Standard** | Low-traffic Resources (ReviewPack, AlertRule, AlertDestination, BaselineProfile, BaselineSnapshot, Workspace) | Optional persistence, basic status filter if applicable |
| **Tier 4 — Embedded** | RelationManagers, Widgets, System pages | No persistence needed, filters only if >50 typical records |
| **Tier 5 — Pickers** | Modal picker tables | Task-specific filters, smart defaults to reduce noise |
### 7.2 Mandatory Filter Rules
1. **Persistence:** All Tier 1 and Tier 2 surfaces MUST have `->persistFiltersInSession()`, `->persistSearchInSession()`, `->persistSortInSession()`.
2. **Soft-delete:** Every resource with `SoftDeletes` MUST have the standard `TrashedFilter` with Active/All/Archived labels.
3. **Status enums:** All status filters MUST source options from the model's enum — no manual string arrays.
4. **Date range:** Surfaces with time-series data (created_at, captured_at, started_at) in Tier 12 SHOULD have a date range filter.
5. **Defaults:** Surfaces that commonly have "inactive" records (ignored, archived, completed) SHOULD default to showing only "active" records.
### 7.3 Filter Catalog (Extractable Presets)
```php
// Proposed: App\Support\Filament\FilterPresets
class FilterPresets
{
public static function trashedArchive(): TrashedFilter;
public static function dateRange(string $column, ?int $defaultDays = null): Filter;
public static function policyType(?int $tenantId = null): SelectFilter;
public static function platform(?int $tenantId = null): SelectFilter;
public static function tenantScope(): SelectFilter;
}
```
---
## 8. Filament-Native Feasibility
| Pattern | Filament v5 Native? | Notes |
|---|---|---|
| `SelectFilter` | ✅ | Core, well-supported |
| `TrashedFilter` | ✅ | Built-in, perfect for soft-delete |
| `TernaryFilter` | ✅ | Used once (BackupSetPolicyPicker); underutilized |
| `Filter::make` + `->default()` | ✅ | Works but boolean toggles are better as `TernaryFilter` |
| `Filter::make` + `DatePicker` form | ✅ | Native pattern |
| `->persistFiltersInSession()` | ✅ | Just needs to be added |
| `->indicateUsing()` | ✅ | Critical UX — shows active filter badges |
| Filter groups / sections | ⚠️ | Not natively supported; would need layout-level workaround |
| FilterPresets helper | Custom | Thin wrapper — not against Filament philosophy |
| Filter guards (tests) | Custom | Already done via `FilamentTableStandardsGuardTest` |
**Verdict:** 100% achievable with Filament-native APIs. The only custom code needed is a thin `FilterPresets` helper for DRY and a guard test expansion.
---
## 9. Prioritized Gap List & Refactor Plan
### P0 — Critical (Session + Data Integrity)
| # | Surface | Action | Effort |
|---|---|---|---|
| 1 | InventoryItemResource | Add `persistFiltersInSession()` + `persistSearchInSession()` + `persistSortInSession()` | XS |
| 2 | PolicyVersionResource | Add full persistence trio | XS |
| 3 | RestoreRunResource | Add full persistence trio | XS |
| 4 | AlertDeliveryResource | Add full persistence trio | XS |
| 5 | EntraGroupResource | Add full persistence trio | XS |
| 6 | Guard test | Expand persistence enforcement list to include all Tier 12 resources | S |
### P1 — High (Missing Essential Filters)
| # | Surface | Action | Effort |
|---|---|---|---|
| 7 | RestoreRunResource | Add status, outcome, date range filters | S |
| 8 | PolicyVersionResource | Add policy_type, platform, date range (captured_at) filters | S |
| 9 | FindingResource | Add date range filter (created_at/due_at) | S |
| 10 | AlertDeliveryResource | Add date range filter (created_at) | S |
| 11 | InventoryItemResource | Add platform, sync freshness filters | S |
| 12 | BaselineProfileResource | Add status filter | XS |
### P2 — Medium (Consistency & UX Polish)
| # | Surface | Action | Effort |
|---|---|---|---|
| 13 | FindingResource status | Refactor from manual strings to enum options | XS |
| 14 | AlertDeliveryResource status | Refactor from manual strings to enum options | XS |
| 15 | OperationRunResource type | Use OperationCatalog labels instead of raw DB strings | XS |
| 16 | FilterPresets helper | Extract TrashedFilter, dateRange, policyType presets | S |
| 17 | ProviderConnectionResource | Consider hiding provider filter until >1 provider exists | XS |
| 18 | AlertRuleResource | Add is_active, event_type filters | S |
| 19 | System/Ops/Runs | Add basic type/status/workspace filters | M |
| 20 | System/Directory/Tenants | Add workspace, status filters | S |
### P3 — Low (Nice-to-Have)
| # | Surface | Action | Effort |
|---|---|---|---|
| 21 | FindingResource | Move scope_key and run_ids filters to "Advanced" section | S |
| 22 | BackupSetResource | Add backup frequency or item count filter | S |
| 23 | BaselineSnapshotResource | Add state filter (has_gaps/complete) | S |
| 24 | System/Security/AccessLogs | Add status and date range filters | S |
| 25 | `->indicateUsing()` | Add active filter indicators to all date range filters | S |
---
## 10. Test Strategy
### Existing Coverage
- `FilamentTableStandardsGuardTest` enforces: defaultSort, emptyStateHeading, persistence (7 resources), TablePaginationProfiles usage.
### Recommended Extensions
**10.1 Expand Persistence Guard**
Add all Tier 12 resources to the persistence enforcement list.
**10.2 Filter Consistency Guard**
```php
it('uses enum-based options for status filters', function () {
// Scan all files for SelectFilter('status') and verify they use enum classes
});
it('applies TrashedFilter on all soft-deletable resource tables', function () {
// Cross-reference models with SoftDeletes trait against resource filter lists
});
```
**10.3 Filter Default Guard**
```php
it('applies smart defaults on tier-1 resource filters', function () {
// Verify PolicyResource, FindingResource, OperationRunResource have defaults
});
```
**10.4 Functional Filter Tests**
For each Tier 12 surface, test:
- Filter applies correct query scope
- Filter default shows expected subset
- Filter clears fully when reset
- Multiple filters compose correctly
---
## 11. Spec Recommendations
### Proposed Spec: `XXX-filter-ux-standardization`
**Scope:** Establish and enforce a repo-wide filter UX standard.
**Deliverables:**
1. `FilterPresets` helper class with extractable patterns
2. Persistence trio added to all Tier 12 surfaces
3. Date range filters added to 5 time-series surfaces
4. Status filters migrated to enum-based options
5. Guard test expanded with filter-specific assertions
6. TrashedFilter standardized across all soft-deletable resources
**Dependencies:**
- Builds on completed `125-table-ux-standardization` spec
- No breaking changes — purely additive
**Estimated effort:** ~34 working sessions
---
## Appendix A: Full Filter Inventory Matrix
| Surface | SelectFilter | Filter::make | TrashedFilter | TernaryFilter | DatePicker | Total | Persist | Defaults |
|---|---|---|---|---|---|---|---|---|
| PolicyResource | 4 | 0 | 0 | 0 | 0 | 4 | ✅ | 1 |
| FindingResource | 3 | 6 | 0 | 0 | 0 | 9 | ✅ | 1 |
| OperationRunResource | 5 | 1 | 0 | 0 | 1 | 6 | ✅ | 2 |
| TenantResource | 2 | 0 | 1 | 0 | 0 | 3 | ✅ | 1 |
| BackupScheduleResource | 1 | 0 | 1 | 0 | 0 | 2 | ✅ | 0 |
| BackupSetResource | 0 | 0 | 1 | 0 | 0 | 1 | ✅ | 0 |
| ProviderConnectionResource | 4 | 1 | 0 | 0 | 0 | 5 | ✅ | 1 |
| PolicyVersionResource | 0 | 0 | 1 | 0 | 0 | 1 | ❌ | 0 |
| RestoreRunResource | 0 | 0 | 1 | 0 | 0 | 1 | ❌ | 0 |
| InventoryItemResource | 2 | 0 | 0 | 0 | 0 | 2 | ❌ | 0 |
| ReviewPackResource | 1 | 0 | 0 | 0 | 0 | 1 | ❌ | 0 |
| AlertDeliveryResource | 3 | 0 | 0 | 0 | 0 | 3 | ❌ | 0 |
| EntraGroupResource | 2 | 0 | 0 | 0 | 0 | 2 | ❌ | 0 |
| AlertRuleResource | 0 | 0 | 0 | 0 | 0 | 0 | ❌ | 0 |
| AlertDestinationResource | 0 | 0 | 0 | 0 | 0 | 0 | ❌ | 0 |
| BaselineProfileResource | 0 | 0 | 0 | 0 | 0 | 0 | ❌ | 0 |
| BaselineSnapshotResource | 0 | 0 | 0 | 0 | 0 | 0 | ❌ | 0 |
| WorkspaceResource | 0 | 0 | 0 | 0 | 0 | 0 | ❌ | 0 |
| InventoryCoverage | 12 | 0 | 0 | 0 | 0 | 12 | ❌ | 0 |
| BackupSetPolicyPickerTable | 3 | 0 | 0 | 1 | 0 | 5* | ❌ | 2 |
| EntraGroupCachePickerTable | 2 | 0 | 0 | 0 | 0 | 2 | ❌ | 0 |
| All 6 RelationManagers | 0 | 0 | 0 | 0 | 0 | 0 | ❌ | 0 |
| All 7 System pages | 0 | 0 | 0 | 0 | 0 | 0 | ❌ | 0 |
| All 2 Widgets | 0 | 0 | 0 | 0 | 0 | 0 | ❌ | 0 |
| SettingsCatalogSettingsTable | 0 | 0 | 0 | 0 | 0 | 0 | ❌ | 0 |
*BackupSetPolicyPickerTable has `SelectFilter::make('synced_within')` (custom query acting as date filter) + `SelectFilter::make('has_versions')` in addition to the standard ones.
---
## Appendix B: Quick Reference — Which Files to Touch
### P0 (Persistence only — 5 files + 1 test):
- `app/Filament/Resources/InventoryItemResource.php`
- `app/Filament/Resources/PolicyVersionResource.php`
- `app/Filament/Resources/RestoreRunResource.php`
- `app/Filament/Resources/AlertDeliveryResource.php`
- `app/Filament/Resources/EntraGroupResource.php`
- `tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
### P1 (New filters — 5 files):
- `app/Filament/Resources/RestoreRunResource.php`
- `app/Filament/Resources/PolicyVersionResource.php`
- `app/Filament/Resources/FindingResource.php`
- `app/Filament/Resources/AlertDeliveryResource.php`
- `app/Filament/Resources/InventoryItemResource.php`
### P2 (Presets + consistency — 4 files + 1 new):
- `app/Support/Filament/FilterPresets.php` (new)
- `app/Filament/Resources/FindingResource.php` (enum migration)
- `app/Filament/Resources/AlertDeliveryResource.php` (enum migration)
- `app/Filament/Resources/OperationRunResource.php` (labels)
- `app/Filament/Resources/BaselineProfileResource.php` (status filter)

View File

@ -0,0 +1,48 @@
# Product Standards
> Canonical, living standards that govern all new and modified Filament UI surfaces.
> Specs reference these standards; they do not redefine them.
> Guard tests enforce critical constraints automatically.
**Last reviewed**: 2026-03-09
---
## Standards Index
| Standard | File | Governs |
|---|---|---|
| Table UX | [filament-table-ux.md](filament-table-ux.md) | Column tiers, sort, search, toggle, pagination, persistence, empty states, timestamps, IDs |
| Filter UX | [filament-filter-ux.md](filament-filter-ux.md) | Filter patterns, persistence, soft-delete, date range, enum sourcing, defaults |
| Actions UX | [filament-actions-ux.md](filament-actions-ux.md) | Row/bulk/header actions, grouping, destructive safety, inspect affordance |
| Review Checklist | [list-surface-review-checklist.md](list-surface-review-checklist.md) | PR/spec checklist for any new or modified list surface |
---
## How Standards Are Enforced
1. **Constitution** — Principles in `.specify/memory/constitution.md` govern why we build this way.
2. **Standards** (this directory) — Concrete rules for how every surface must behave.
3. **Guard tests** — Automated Pest tests that fail CI when critical standards are violated.
4. **PR review** — The [review checklist](list-surface-review-checklist.md) is checked for every spec or PR that touches a list surface.
---
## When to Update Standards
- When a spec introduces a new surface type or pattern not yet covered.
- When a standard proves too rigid or too loose after real usage.
- When Filament version changes invalidate or enable new native features.
Update the standard first, then adjust implementation to match.
---
## Related Docs
| Document | Location | Purpose |
|---|---|---|
| Constitution | `.specify/memory/constitution.md` | Permanent principles (UX-001, Action Surface Contract, RBAC-UX) |
| Product Principles | `docs/product/principles.md` | High-level product decisions |
| Table Rollout Audit | `docs/ui/filament-table-standard.md` | Rollout inventory and implementation state from Spec 125 |
| Action Surface Contract | `docs/ui/action-surface-contract.md` | Original action surface reference (now governed by this standard) |

View File

@ -0,0 +1,122 @@
# Filament Actions UX Standard
> Canonical rules for row actions, bulk actions, header actions, and inspect affordances on all Filament table surfaces.
> This standard consolidates the Action Surface Contract from the constitution and `docs/ui/action-surface-contract.md`.
**Last reviewed**: 2026-03-09
---
## Governing Principle
**Filament-native first.**
Action patterns use native Filament action APIs.
The constitution's Action Surface Contract (in `.specify/memory/constitution.md`) is the authoritative source for safety and RBAC rules.
This document provides the quick-reference standard for implementation.
---
## Required Surfaces
Every list/table must define:
- **Header Actions** — primary CTA (create, sync, etc.)
- **Row Actions** — inspect + contextual operations
- **Bulk Actions** — where batch operations exist
- **Empty-State CTA** — exactly 1 primary action, RBAC-gated
---
## Inspect Affordance (Required)
Every list-style surface must provide a way to open a record.
### Preferred: clickable rows
```php
$table->recordUrl(fn ($record) => /* route to view/edit */)
```
### Alternative: primary link column or View action
Use only when clickable rows are impractical.
### Rule: no lone "View" button
If "View" is the only row action, prefer clickable rows and set `actions([])` to avoid an unnecessary Actions column.
---
## Row Action Limits
- **Max 2 visible** row actions (typically View/Edit or Edit/Delete)
- Everything else goes into an `ActionGroup` labeled "More"
- Destructive actions must never be the primary visible action
---
## Bulk Actions
- Bulk actions must be grouped via `BulkActionGroup`
- Destructive bulk actions require confirmation
- Typed confirmation may be required for large/bulk changes
---
## Destructive Action Safety
- All destructive actions must use `->requiresConfirmation()`
- Destructive actions must not be styled as primary
- Confirmation text must clearly describe the consequence
- Server-side authorization must still enforce the action regardless of UI confirmation
This is a constitution-level non-negotiable (RBAC-UX-005).
---
## Action Consistency
### Standard action labels
| Action | Label | Icon guidance |
|---|---|---|
| View record | "View" or clickable row | heroicon-o-eye |
| Edit record | "Edit" | heroicon-o-pencil-square |
| Delete record | "Delete" | heroicon-o-trash |
| Archive record | "Archive" | heroicon-o-archive-box |
| Restore record | "Restore" | heroicon-o-arrow-uturn-left |
| Force delete | "Force Delete" | heroicon-o-trash |
### Action ordering in "More" group
1. Non-destructive operations first
2. Destructive operations last
3. Separated by divider if Filament supports it
---
## RBAC Enforcement
- Non-member access: abort(404), do not leak existence
- Member without capability: visible but disabled with tooltip
- Server-side must enforce via `Gate::authorize(...)` or Policy method
- Missing server-side authorization is a P0 security bug
---
## Spec / DoD Gate
Every spec with UI changes must include a **UI Action Matrix** listing:
- Which actions exist on which surfaces
- Which are destructive and how confirmation is handled
- Which capabilities gate each action
A change is not "Done" unless the Action Surface Contract is met.
---
## Canonical Sources
- Constitution: `.specify/memory/constitution.md` → "Filament UI — Action Surface Contract"
- Detailed reference: `docs/ui/action-surface-contract.md`

View File

@ -0,0 +1,277 @@
# Filament Filter UX Standard
> Canonical rules for all Filament table filters in TenantPilot.
> Every new or modified filtered surface must follow this standard.
> Deviations must be documented in the spec or PR with rationale.
**Last reviewed**: 2026-03-09
---
## Governing Principle
**Filament-native first.**
Filter standardization uses native Filament filter APIs wherever reasonably possible.
A thin optional helper for repeated mechanical patterns (e.g., TrashedFilter preset, date range) is allowed but not required.
---
## Tiered Persistence Model
### Tier 1 — Critical resources (mandatory persistence)
- PolicyResource
- FindingResource
- OperationRunResource
- TenantResource
- InventoryItemResource
### Tier 2 — Important resources (mandatory persistence)
- BackupScheduleResource
- BackupSetResource
- RestoreRunResource
- PolicyVersionResource
- ProviderConnectionResource
- AlertDeliveryResource
- EntraGroupResource
### Tier 3 — Standard resources (persistence optional)
- ReviewPackResource
- AlertRuleResource
- AlertDestinationResource
- BaselineProfileResource
- BaselineSnapshotResource
- WorkspaceResource
### Persistence trio
All Tier 12 resource lists with filters must use:
```php
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
```
### Documented exceptions
- Dashboard widgets: no persistence (glance surfaces)
- Picker tables: no persistence (workflow-local)
- Relation managers: persistence optional, per judgment
---
## Soft-Delete Filter Standard
Every soft-deletable resource table must include:
```php
TrashedFilter::make()
->label('Archived')
->placeholder('Active')
->trueLabel('All')
->falseLabel('Archived')
```
Labels must stay consistent across all resources. Do not vary wording.
---
## Status / Enum Filter Standard
### Rule
Status and outcome filter options must derive from model enums or an equivalent central catalog.
Do not use hardcoded string arrays.
### Pattern
```php
SelectFilter::make('status')
->options(MyStatusEnum::class)
```
Or, if using a catalog:
```php
SelectFilter::make('status')
->options(StatusCatalog::filterOptions())
```
### Forbidden
```php
// Do not do this
SelectFilter::make('status')
->options([
'active' => 'Active',
'inactive' => 'Inactive',
])
```
Temporary transitional arrays are allowed only when no enum exists yet and introducing one would materially expand the scope of the current spec.
---
## Date Range Filter Standard
Time-based Tier 12 surfaces should use a native date range filter:
```php
Filter::make('date_range')
->form([
DatePicker::make('from'),
DatePicker::make('until'),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when($data['from'], fn (Builder $q, $date) => $q->whereDate('created_at', '>=', $date))
->when($data['until'], fn (Builder $q, $date) => $q->whereDate('created_at', '<=', $date));
})
->indicateUsing(function (array $data): array {
$indicators = [];
if ($data['from'] ?? null) {
$indicators[] = Indicator::make('From ' . Carbon::parse($data['from'])->toFormattedDateString())
->removeField('from');
}
if ($data['until'] ?? null) {
$indicators[] = Indicator::make('Until ' . Carbon::parse($data['until'])->toFormattedDateString())
->removeField('until');
}
return $indicators;
})
```
### Required date range surfaces
- FindingResource (`created_at`)
- AlertDeliveryResource (`created_at`)
- RestoreRunResource (`created_at`)
- PolicyVersionResource (`captured_at`)
- OperationRunResource (already has date range)
### Indicator requirement
All date range filters must implement `indicateUsing()` so active filters are visible in the filter summary.
---
## Filter Type Decision Guide
| Scenario | Recommended filter | Notes |
|---|---|---|
| Enum/status with 38 options | `SelectFilter` | Most common and clearest |
| Boolean yes/no/all | `TernaryFilter` | e.g., "Has errors", "Is current" |
| Soft-delete active/archived | `TrashedFilter` | Standard labels, see above |
| Date range | `Filter::make()` + DatePicker form | With `indicateUsing()` |
| Multi-select (e.g., platforms) | `SelectFilter` with `->multiple()` | When users need to select several |
| Cross-cutting dimension (status as tabs) | Filament Tabs | Only when 24 high-frequency states dominate the workflow |
| Complex custom logic | `Filter::make()` + custom form | Last resort; keep query simple |
### When NOT to filter
- Very small tables (< 20 typical rows)
- Read-only evidence/log tables where search + sort covers the use case
- Picker/selection tables where narrowing is done via search only
---
## Filter Count and Ordering
### Count guideline
- Tier 12 resources: typically 25 filters
- Tier 3 resources: 03 filters
- Relation managers: 02 filters
- Widgets: 0 filters (use summary/glance, not filtering)
### Ordering rule
Filters should appear in priority order:
1. Status / primary state dimension
2. Type / category
3. Severity / risk
4. Date range
5. Soft-delete (archived)
6. Secondary dimensions
---
## Smart Defaults
### When defaults are allowed
- Active-only / open-only: when the typical workflow focuses on actionable records
- Recent date windows: only on monitoring/time-series surfaces
- Soft-delete active-only: standard via TrashedFilter placeholder behavior
### Guardrail
Defaults must be:
- obvious (user can see what's filtered)
- one-click reversible
- not hiding data the user expects to see
---
## Labels and Terminology
### Requirements
- Filter labels must be clear, enterprise-appropriate, and consistent with domain vocabulary.
- Use sentence case for filter labels.
- Use the established term from the enum/status catalog.
### Consistency targets
| Concept | Standard label |
|---|---|
| Soft-delete visibility | "Archived" (not "Trashed", not "Deleted") |
| Record lifecycle | "Status" |
| Operation result | "Outcome" |
| Platform dimension | "Platform" |
| Policy category | "Policy Type" |
| Time range | "Date Range" or "Period" |
---
## Query and Performance Guardrails
- Relation-heavy or computed filters must be reviewed for query cost.
- Custom query filters with subqueries should be kept simple.
- Filters must not alter tenancy/workspace scoping behavior.
- New filters must compose correctly with existing filters.
---
## RBAC / Tenancy Safety
- Filters must not expose sensitive values to unauthorized roles.
- Cross-tenant / cross-workspace filters must preserve proper entitlement checks.
- Filter additions must not change authorization behavior.
---
## What Remains Table-Local
- Exact filter choices per domain
- Custom boolean quick filters
- Advanced governance/compliance filters
- System page-specific filter design
- Picker-specific narrowing rules
---
## Shared Helper (Optional)
A thin `App\Support\Filament\FilterPresets` helper may be introduced for clearly repeated patterns only:
- TrashedFilter with standard labels
- Date range pattern
- Policy type / platform multi-select
This is DRY convenience, not a framework. Keep it thin and optional.

View File

@ -0,0 +1,249 @@
# Filament Table UX Standard
> Canonical rules for all Filament table/list surfaces in TenantPilot.
> Every new or modified table must follow this standard.
> Deviations must be documented in the spec or PR with rationale.
**Last reviewed**: 2026-03-09
---
## Governing Principle
**Filament-native first.**
Table UX standardization uses native Filament features wherever reasonably possible.
Custom helpers, macros, traits, or plugins are only allowed when Filament cannot express the pattern cleanly enough inline.
---
## Column Tier Model
Every production table must classify its columns into one of three tiers.
This is a review convention, not a code abstraction.
### Primary
Always visible. Not toggle-hidden. Anchors the row identity.
- name, display_name, title
- primary status (if truly central)
- core subject identifier
### Context
Visible by default. May be toggleable. Provides operational context needed for a normal scan.
- environment, type, category, severity
- key counts / aggregations
- last sync / last activity
- tenant/workspace context where needed
### Detail
Hidden by default (`toggleable(isToggledHiddenByDefault: true)`). Technical or low-frequency information.
- IDs (tenant_id, external_id, entra_id)
- created_at, updated_at
- created_by
- domains, low-frequency secondary statuses
### Visible Column Limit
General-purpose tables should expose **7 or fewer** columns by default.
Denser power-user surfaces may exceed this with documented justification.
---
## Sortability Rules
### Mandatory sortable (when present)
- Primary identifier fields (name, display_name, title)
- Status / outcome / enum columns
- All key timestamps used for recency
- Numeric count and size columns
- Version / sequence numbers
### Default sort
Every production list must define an explicit `defaultSort()`.
| List type | Default pattern |
|---|---|
| Time-series / logs / runs / alerts | newest first |
| Named entities | name ascending |
| Versioned entities | latest/highest version first |
| Audit / evidence | newest recorded first |
---
## Searchability Rules
- Every non-trivial production table must make its primary identifier `searchable()`.
- Additional fields only if query-safe and operationally useful.
- Avoid searchable on relations without proper indexing.
- Avoid searchable on computed values without clear SQL handling.
---
## Toggleability Rules
### Must be hidden by default
- Technical IDs
- External IDs
- Tenant/workspace internal references (unless primary context)
- `created_at`, `updated_at`
- Secondary technical metadata
### Overflow rule
If a table exceeds the calm default surface, lower-value columns must move into Detail via `toggleable(isToggledHiddenByDefault: true)` before any cosmetic workaround is considered.
---
## Timestamp Standard
### Lists
```php
TextColumn::make('created_at')
->since()
->sortable()
->toggleable(isToggledHiddenByDefault: true)
->tooltip(fn ($record) => $record->created_at?->toDateTimeString());
```
- Primary visual: `->since()` for scan-first recency
- Absolute date/time available via tooltip
### Detail / view pages
- Use absolute `->dateTime()` or equivalent explicit formatting.
### Exceptions
- Version or evidence tables may use absolute time when exact chronology is the primary task.
---
## Null / Empty Values
```php
->placeholder('—')
```
Consistent across all tables. Do not mix blank cells, `n/a`, `unknown`, or dash variants.
---
## ID Presentation
- Hidden by default
- `copyable()` when operationally relevant
- `limit()` and/or `tooltip()` for long identifiers
- Monospaced styling preferred for technical IDs
- Must not dominate layout width
---
## Status / Badge / Boolean
- Status and enum columns use native `badge()` via centralized `BadgeCatalog` / `BadgeRenderer` (per BADGE-001).
- Booleans use native icon or badge-based rendering consistently.
---
## Empty State Standard
Every production table must define a domain-specific empty state:
```php
->emptyStateHeading('No policies found')
->emptyStateDescription('Sync your first tenant to see policies here.')
->emptyStateActions([
// exactly 1 primary CTA, RBAC-gated
])
```
---
## Pagination Profiles
Implementation: `App\Support\Filament\TablePaginationProfiles`
| Surface | Page sizes | Default |
|---|---|---|
| Resource | `25, 50, 100` | `25` |
| Relation manager | `10, 25, 50` | `10` |
| Widget | `10` | `10` |
| Picker | `25, 50, 100` | `25` |
| Custom page | `25, 50, all` | `25` unless override documented |
---
## Table State Persistence
Resource list tables in the critical set must use:
```php
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
```
### Critical persistence set
- TenantResource
- PolicyResource
- BackupSetResource
- BackupScheduleResource
- ProviderConnectionResource
- FindingResource
- OperationRunResource
### Documented exceptions
- Dashboard widgets: no persistence (glance surfaces)
- Directory pages: no persistence (computed health metrics)
- Picker tables: no persistence (workflow-local)
---
## Responsive / Overflow Guardrails
- Long text: use `wrap()` where appropriate
- Long IDs / technical strings: use `limit()` and `tooltip()`
- Solve width problems via better default visibility, not manual width hacks
- No `extraAttributes()` width tuning as a default pattern
- No resize plugin as substitute for better defaults
---
## Performance Guardrails
- No casual `sortable()` / `searchable()` on relation or computed columns without query review
- No row-by-row counts or `exists()` checks in hot lists unless optimized
- Prefer eager loading where relation-backed columns are rendered repeatedly
- No costly default sorts without understanding DB impact
---
## RBAC / Tenancy Safety
- Technical cross-tenant identifiers must not be more prominent than needed
- Empty-state actions and row/header actions remain capability-gated
- Cross-tenant/workspace tables must preserve enough context to avoid ambiguity
- This standard must not change authorization behavior
---
## What Remains Table-Local
These areas must stay explicit per table unless a later spec proves a shared need:
- Filters (governed separately by [filament-filter-ux.md](filament-filter-ux.md))
- Row actions, bulk actions, header actions (governed by [filament-actions-ux.md](filament-actions-ux.md))
- Query scoping
- Empty-state copy
- Domain-specific badge labels/colors/mappings

View File

@ -0,0 +1,78 @@
# List Surface Review Checklist
> Use this checklist for every spec or PR that creates or modifies a Filament table/list surface.
> All items must be satisfied or have a documented exception.
---
## Table Structure
- [ ] Columns follow the tier model (Primary / Context / Detail)
- [ ] Max ~7 visible columns by default (or density justified)
- [ ] Primary identifier is `searchable()` and `sortable()`
- [ ] Explicit `defaultSort()` is defined
- [ ] Timestamps use `->since()` + tooltip in lists
- [ ] Null values use `->placeholder('—')` consistently
- [ ] IDs are `toggleable(isToggledHiddenByDefault: true)` and `copyable()` where useful
- [ ] Status/enum columns use `badge()` via `BadgeCatalog` / `BadgeRenderer`
- [ ] Pagination profile matches the surface type (see table standard)
## Empty State
- [ ] `emptyStateHeading(...)` is domain-specific
- [ ] `emptyStateDescription(...)` explains what the user should do
- [ ] Exactly 1 primary CTA in `emptyStateActions([...])`, RBAC-gated
- [ ] When non-empty, CTA is in header only (not duplicated)
## Filters
- [ ] Tier 12 resources use the persistence trio
- [ ] Status/state filters use enum-backed options
- [ ] Soft-deletable resources use standard `TrashedFilter` labels
- [ ] Date range filters use `indicateUsing()`
- [ ] Filter count is reasonable (25 for Tier 12, 03 for Tier 3)
- [ ] Filter labels are clear and consistent with domain vocabulary
## Actions
- [ ] Inspect affordance exists (clickable rows preferred)
- [ ] No lone "View" row action button
- [ ] Max 2 visible row actions; rest in "More" group
- [ ] Destructive actions use `->requiresConfirmation()`
- [ ] Bulk actions use `BulkActionGroup`
- [ ] All actions are server-side authorized
## Persistence & State
- [ ] Resource lists (Tier 12) persist filters, search, sort in session
- [ ] Picker/widget tables do NOT persist state
- [ ] Refresh does not destroy user context on critical lists
## Performance
- [ ] No casual `sortable()` / `searchable()` on relations without query review
- [ ] No row-by-row counts in hot lists unless optimized
- [ ] Eager loading where relation-backed columns are rendered
- [ ] Default sort is query-efficient
## RBAC / Tenancy
- [ ] Cross-tenant/workspace tables provide enough context
- [ ] Technical IDs are not more prominent than needed
- [ ] Actions/CTAs are capability-gated
- [ ] Non-member access is 404, not 403
## Responsive
- [ ] No unnecessary horizontal overload
- [ ] Long text uses `wrap()` or `limit()` + `tooltip()`
- [ ] Long IDs do not break layout
---
## How to Use
1. Before submitting a PR that touches a table surface, walk through this checklist.
2. Mark items as checked or note the documented exception.
3. If you're adding a new resource, this checklist applies from the first implementation.
4. Guard tests enforce the most critical items automatically — this checklist catches the rest.