docs: add canonical filament UI standards #153
@ -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.
|
- 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.
|
- 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
|
### 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`.
|
- 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).
|
- New work branches from `dev` using `feat/<NNN>-<slug>` (spec + code in the same PR).
|
||||||
|
|||||||
727
docs/audits/filter-audit-comprehensive.md
Normal file
727
docs/audits/filter-audit-comprehensive.md
Normal 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.14–4.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 1–2 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 1–2 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 1–2 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 1–2 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 1–2 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:** ~3–4 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 | 1–2 | 0 | 0 | 0 | 0 | 1–2 | ❌ | 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)
|
||||||
48
docs/product/standards/README.md
Normal file
48
docs/product/standards/README.md
Normal 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) |
|
||||||
122
docs/product/standards/filament-actions-ux.md
Normal file
122
docs/product/standards/filament-actions-ux.md
Normal 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`
|
||||||
277
docs/product/standards/filament-filter-ux.md
Normal file
277
docs/product/standards/filament-filter-ux.md
Normal 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 1–2 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 1–2 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 3–8 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 2–4 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 1–2 resources: typically 2–5 filters
|
||||||
|
- Tier 3 resources: 0–3 filters
|
||||||
|
- Relation managers: 0–2 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.
|
||||||
249
docs/product/standards/filament-table-ux.md
Normal file
249
docs/product/standards/filament-table-ux.md
Normal 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
|
||||||
78
docs/product/standards/list-surface-review-checklist.md
Normal file
78
docs/product/standards/list-surface-review-checklist.md
Normal 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 1–2 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 (2–5 for Tier 1–2, 0–3 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 1–2) 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.
|
||||||
Loading…
Reference in New Issue
Block a user