From 02028be7e45ef2869782e4443ed998e9451db06c Mon Sep 17 00:00:00 2001 From: ahmido Date: Sun, 8 Mar 2026 23:17:37 +0000 Subject: [PATCH] docs: add canonical filament UI standards (#153) ## Summary - add canonical UI standards under `docs/product/standards/` - add a comprehensive filter audit as source material for future filter standardization work - extend the constitution with incremental UI standards enforcement guidance ## Included - `docs/product/standards/README.md` - `docs/product/standards/filament-table-ux.md` - `docs/product/standards/filament-filter-ux.md` - `docs/product/standards/filament-actions-ux.md` - `docs/product/standards/list-surface-review-checklist.md` - `docs/audits/filter-audit-comprehensive.md` - `.specify/memory/constitution.md` ## Notes - this is documentation and governance work only; no runtime code paths changed - no tests were run because the change is docs-only - the new standards structure separates permanent principles, living standards, rollout audits, and review checklists ## Review Focus - confirm the standards location under `docs/product/standards/` - confirm the constitution principle belongs at the constitutional level - confirm the filter audit should live under `docs/audits/` as reference material Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/153 --- .specify/memory/constitution.md | 11 + docs/audits/filter-audit-comprehensive.md | 727 ++++++++++++++++++ docs/product/standards/README.md | 48 ++ docs/product/standards/filament-actions-ux.md | 122 +++ docs/product/standards/filament-filter-ux.md | 277 +++++++ docs/product/standards/filament-table-ux.md | 249 ++++++ .../list-surface-review-checklist.md | 78 ++ 7 files changed, 1512 insertions(+) create mode 100644 docs/audits/filter-audit-comprehensive.md create mode 100644 docs/product/standards/README.md create mode 100644 docs/product/standards/filament-actions-ux.md create mode 100644 docs/product/standards/filament-filter-ux.md create mode 100644 docs/product/standards/filament-table-ux.md create mode 100644 docs/product/standards/list-surface-review-checklist.md diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index b8d5ecb..85f26b5 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -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/-/` with `spec.md`, `plan.md`, `tasks.md`, and `checklists/requirements.md`. - New work branches from `dev` using `feat/-` (spec + code in the same PR). diff --git a/docs/audits/filter-audit-comprehensive.md b/docs/audits/filter-audit-comprehensive.md new file mode 100644 index 0000000..5a06a0d --- /dev/null +++ b/docs/audits/filter-audit-comprehensive.md @@ -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) diff --git a/docs/product/standards/README.md b/docs/product/standards/README.md new file mode 100644 index 0000000..65027a1 --- /dev/null +++ b/docs/product/standards/README.md @@ -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) | diff --git a/docs/product/standards/filament-actions-ux.md b/docs/product/standards/filament-actions-ux.md new file mode 100644 index 0000000..1f411c3 --- /dev/null +++ b/docs/product/standards/filament-actions-ux.md @@ -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` diff --git a/docs/product/standards/filament-filter-ux.md b/docs/product/standards/filament-filter-ux.md new file mode 100644 index 0000000..6995273 --- /dev/null +++ b/docs/product/standards/filament-filter-ux.md @@ -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. diff --git a/docs/product/standards/filament-table-ux.md b/docs/product/standards/filament-table-ux.md new file mode 100644 index 0000000..0d30d31 --- /dev/null +++ b/docs/product/standards/filament-table-ux.md @@ -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 diff --git a/docs/product/standards/list-surface-review-checklist.md b/docs/product/standards/list-surface-review-checklist.md new file mode 100644 index 0000000..3ac95ed --- /dev/null +++ b/docs/product/standards/list-surface-review-checklist.md @@ -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.