From 76e10fc40447169736301634119b052cceaeda92 Mon Sep 17 00:00:00 2001 From: ahmido Date: Fri, 2 Jan 2026 13:59:15 +0000 Subject: [PATCH] 015-policy-picker-ux (#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the “Add Policies” picker with a modal table (search, pagination, multi-select). Adds filters: policy type, platform, last synced, ignored, has versions; “Select all” applies to the current filtered results. Improves identifiers shown (short external id), and fixes has-versions filtering behavior. Backup set items table: groups row actions (View policy / Remove) into an action group. Adds bulk action to remove multiple backup items at once. Updates/adds tests covering the picker table bulk add and backup items bulk remove. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/21 --- .../BackupItemsRelationManager.php | 191 ++++++------- app/Livewire/BackupSetPolicyPickerTable.php | 261 ++++++++++++++++++ .../modals/backup-set-policy-picker.blade.php | 3 + .../backup-set-policy-picker-table.blade.php | 20 ++ .../checklists/requirements.md | 30 ++ specs/015-policy-picker-ux/plan.md | 32 +++ specs/015-policy-picker-ux/spec.md | 37 +++ specs/015-policy-picker-ux/tasks.md | 24 ++ tests/Feature/Filament/BackupCreationTest.php | 20 +- .../Filament/BackupItemsBulkRemoveTest.php | 74 +++++ .../BackupSetPolicyPickerTableTest.php | 97 +++++++ tests/Unit/PolicyPickerOptionLabelTest.php | 13 + 12 files changed, 691 insertions(+), 111 deletions(-) create mode 100644 app/Livewire/BackupSetPolicyPickerTable.php create mode 100644 resources/views/filament/modals/backup-set-policy-picker.blade.php create mode 100644 resources/views/livewire/backup-set-policy-picker-table.blade.php create mode 100644 specs/015-policy-picker-ux/checklists/requirements.md create mode 100644 specs/015-policy-picker-ux/plan.md create mode 100644 specs/015-policy-picker-ux/spec.md create mode 100644 specs/015-policy-picker-ux/tasks.md create mode 100644 tests/Feature/Filament/BackupItemsBulkRemoveTest.php create mode 100644 tests/Feature/Filament/BackupSetPolicyPickerTableTest.php create mode 100644 tests/Unit/PolicyPickerOptionLabelTest.php diff --git a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php index eddf393..914991f 100644 --- a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php +++ b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php @@ -4,17 +4,15 @@ use App\Filament\Resources\PolicyResource; use App\Models\BackupItem; -use App\Models\Policy; -use App\Models\Tenant; use App\Services\Intune\AuditLogger; -use App\Services\Intune\BackupService; use Filament\Actions; -use Filament\Forms; use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; use Filament\Tables\Table; +use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; class BackupItemsRelationManager extends RelationManager { @@ -99,113 +97,102 @@ public function table(Table $table): Table Actions\Action::make('addPolicies') ->label('Add Policies') ->icon('heroicon-o-plus') - ->form([ - Forms\Components\Select::make('policy_ids') - ->label('Policies') - ->multiple() - ->required() - ->searchable() - ->options(function (RelationManager $livewire) { - $backupSet = $livewire->getOwnerRecord(); - $tenantId = $backupSet?->tenant_id ?? Tenant::current()->getKey(); - - $existing = $backupSet - ? $backupSet->items()->pluck('policy_id')->filter()->all() - : []; - - return Policy::query() - ->where('tenant_id', $tenantId) - ->whereNull('ignored_at') - ->where('last_synced_at', '>', now()->subDays(7)) // Hide deleted policies (Feature 005 workaround) - ->when($existing, fn (Builder $query) => $query->whereNotIn('id', $existing)) - ->orderBy('display_name') - ->pluck('display_name', 'id'); - }), - Forms\Components\Checkbox::make('include_assignments') - ->label('Include assignments') - ->default(true) - ->helperText('Captures assignment include/exclude targeting and filters.'), - Forms\Components\Checkbox::make('include_scope_tags') - ->label('Include scope tags') - ->default(true) - ->helperText('Captures policy scope tag IDs.'), - Forms\Components\Checkbox::make('include_foundations') - ->label('Include foundations') - ->default(true) - ->helperText('Captures assignment filters, scope tags, and notification templates.'), - ]) - ->action(function (array $data, BackupService $service) { - if (empty($data['policy_ids'])) { - Notification::make() - ->title('No policies selected') - ->warning() - ->send(); - - return; - } - + ->modalHeading('Add Policies') + ->modalSubmitAction(false) + ->modalCancelActionLabel('Close') + ->modalContent(function (): View { $backupSet = $this->getOwnerRecord(); - $tenant = $backupSet?->tenant ?? Tenant::current(); - $service->addPoliciesToSet( - tenant: $tenant, - backupSet: $backupSet, - policyIds: $data['policy_ids'], - actorEmail: auth()->user()?->email, - actorName: auth()->user()?->name, - includeAssignments: $data['include_assignments'] ?? false, - includeScopeTags: $data['include_scope_tags'] ?? false, - includeFoundations: $data['include_foundations'] ?? false, - ); - - $notificationTitle = ($data['include_foundations'] ?? false) - ? 'Backup items added' - : 'Policies added to backup'; - - Notification::make() - ->title($notificationTitle) - ->success() - ->send(); + return view('filament.modals.backup-set-policy-picker', [ + 'backupSetId' => $backupSet->getKey(), + ]); }), ]) ->actions([ - Actions\ViewAction::make() - ->label('View policy') - ->url(fn ($record) => $record->policy_id ? PolicyResource::getUrl('view', ['record' => $record->policy_id]) : null) - ->hidden(fn ($record) => ! $record->policy_id) - ->openUrlInNewTab(true), - Actions\Action::make('remove') - ->label('Remove') - ->color('danger') - ->icon('heroicon-o-x-mark') - ->requiresConfirmation() - ->action(function (BackupItem $record, AuditLogger $auditLogger) { - $record->delete(); + Actions\ActionGroup::make([ + Actions\ViewAction::make() + ->label('View policy') + ->url(fn ($record) => $record->policy_id ? PolicyResource::getUrl('view', ['record' => $record->policy_id]) : null) + ->hidden(fn ($record) => ! $record->policy_id) + ->openUrlInNewTab(true), + Actions\Action::make('remove') + ->label('Remove') + ->color('danger') + ->icon('heroicon-o-x-mark') + ->requiresConfirmation() + ->action(function (BackupItem $record, AuditLogger $auditLogger) { + $record->delete(); - if ($record->backupSet) { - $record->backupSet->update([ - 'item_count' => $record->backupSet->items()->count(), - ]); - } + if ($record->backupSet) { + $record->backupSet->update([ + 'item_count' => $record->backupSet->items()->count(), + ]); + } - if ($record->tenant) { - $auditLogger->log( - tenant: $record->tenant, - action: 'backup.item_removed', - resourceType: 'backup_set', - resourceId: (string) $record->backup_set_id, - status: 'success', - context: ['metadata' => ['policy_id' => $record->policy_id]] - ); - } + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'backup.item_removed', + resourceType: 'backup_set', + resourceId: (string) $record->backup_set_id, + status: 'success', + context: ['metadata' => ['policy_id' => $record->policy_id]] + ); + } - Notification::make() - ->title('Policy removed from backup') - ->success() - ->send(); - }), + Notification::make() + ->title('Policy removed from backup') + ->success() + ->send(); + }), + ])->icon('heroicon-o-ellipsis-vertical'), ]) - ->bulkActions([]); + ->bulkActions([ + Actions\BulkActionGroup::make([ + Actions\BulkAction::make('bulk_remove') + ->label('Remove selected') + ->icon('heroicon-o-x-mark') + ->color('danger') + ->requiresConfirmation() + ->action(function (Collection $records, AuditLogger $auditLogger) { + if ($records->isEmpty()) { + return; + } + + $backupSet = $this->getOwnerRecord(); + + $records->each(fn (BackupItem $record) => $record->delete()); + + $backupSet->update([ + 'item_count' => $backupSet->items()->count(), + ]); + + $tenant = $records->first()?->tenant; + + if ($tenant) { + $auditLogger->log( + tenant: $tenant, + action: 'backup.items_removed', + resourceType: 'backup_set', + resourceId: (string) $backupSet->id, + status: 'success', + context: [ + 'metadata' => [ + 'removed_count' => $records->count(), + 'policy_ids' => $records->pluck('policy_id')->filter()->values()->all(), + 'policy_identifiers' => $records->pluck('policy_identifier')->filter()->values()->all(), + ], + ] + ); + } + + Notification::make() + ->title('Policies removed from backup') + ->success() + ->send(); + }), + ]), + ]); } /** diff --git a/app/Livewire/BackupSetPolicyPickerTable.php b/app/Livewire/BackupSetPolicyPickerTable.php new file mode 100644 index 0000000..dc17f1e --- /dev/null +++ b/app/Livewire/BackupSetPolicyPickerTable.php @@ -0,0 +1,261 @@ +backupSetId = $backupSetId; + } + + public static function externalIdShort(?string $externalId): string + { + $value = (string) ($externalId ?? ''); + + $normalized = preg_replace('/[^A-Za-z0-9]/', '', $value) ?? ''; + + if ($normalized === '') { + return '—'; + } + + return substr($normalized, -8); + } + + public function table(Table $table): Table + { + $backupSet = BackupSet::query()->find($this->backupSetId); + $tenantId = $backupSet?->tenant_id ?? Tenant::current()->getKey(); + $existingPolicyIds = $backupSet + ? $backupSet->items()->pluck('policy_id')->filter()->all() + : []; + + return $table + ->queryStringIdentifier('backupSetPolicyPicker'.Str::studly((string) $this->backupSetId)) + ->query( + Policy::query() + ->where('tenant_id', $tenantId) + ->when($existingPolicyIds !== [], fn (Builder $query) => $query->whereNotIn('id', $existingPolicyIds)) + ) + ->deferLoading(! app()->runningUnitTests()) + ->paginated([25, 50, 100]) + ->defaultPaginationPageOption(25) + ->searchable() + ->striped() + ->columns([ + TextColumn::make('display_name') + ->label('Name') + ->searchable() + ->sortable() + ->wrap(), + TextColumn::make('policy_type') + ->label('Type') + ->badge() + ->formatStateUsing(fn (?string $state): string => (string) (static::typeMeta($state)['label'] ?? $state ?? '—')), + TextColumn::make('platform') + ->label('Platform') + ->badge() + ->default('—') + ->sortable(), + TextColumn::make('external_id') + ->label('External ID') + ->formatStateUsing(fn (?string $state): string => static::externalIdShort($state)) + ->tooltip(fn (?string $state): ?string => filled($state) ? $state : null) + ->extraAttributes(['class' => 'font-mono text-xs']) + ->toggleable(), + TextColumn::make('versions_count') + ->label('Versions') + ->state(fn (Policy $record): int => (int) ($record->versions_count ?? 0)) + ->badge() + ->sortable(), + TextColumn::make('last_synced_at') + ->label('Last synced') + ->dateTime() + ->since() + ->sortable() + ->toggleable(), + TextColumn::make('ignored_at') + ->label('Ignored') + ->badge() + ->color(fn (?string $state): string => filled($state) ? 'warning' : 'gray') + ->formatStateUsing(fn (?string $state): string => filled($state) ? 'yes' : 'no') + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->modifyQueryUsing(fn (Builder $query) => $query->withCount('versions')) + ->filters([ + SelectFilter::make('policy_type') + ->label('Policy type') + ->options(static::policyTypeOptions()), + SelectFilter::make('platform') + ->label('Platform') + ->options(fn (): array => Policy::query() + ->where('tenant_id', $tenantId) + ->whereNotNull('platform') + ->distinct() + ->orderBy('platform') + ->pluck('platform', 'platform') + ->all()), + SelectFilter::make('synced_within') + ->label('Last synced') + ->options([ + '7' => 'Within 7 days', + '30' => 'Within 30 days', + '90' => 'Within 90 days', + 'any' => 'Any time', + ]) + ->default('7') + ->query(function (Builder $query, array $data): Builder { + $value = (string) ($data['value'] ?? '7'); + + if ($value === 'any') { + return $query; + } + + $days = is_numeric($value) ? (int) $value : 7; + + return $query->where('last_synced_at', '>', now()->subDays(max(1, $days))); + }), + TernaryFilter::make('ignored') + ->label('Ignored') + ->nullable() + ->queries( + true: fn (Builder $query) => $query->whereNotNull('ignored_at'), + false: fn (Builder $query) => $query->whereNull('ignored_at'), + ) + ->default(false), + SelectFilter::make('has_versions') + ->label('Has versions') + ->options([ + '1' => 'Has versions', + '0' => 'No versions', + ]) + ->query(function (Builder $query, array $data): Builder { + $value = $data['value'] ?? null; + + if ($value === null || $value === '') { + return $query; + } + + return match ((string) $value) { + '1' => $query->whereHas('versions'), + '0' => $query->whereDoesntHave('versions'), + default => $query, + }; + }), + ]) + ->bulkActions([ + BulkAction::make('add_selected_to_backup_set') + ->label('Add selected') + ->icon('heroicon-m-plus') + ->action(function (Collection $records, BackupService $service): void { + $backupSet = BackupSet::query()->findOrFail($this->backupSetId); + $tenant = $backupSet->tenant ?? Tenant::current(); + + $policyIds = $records->pluck('id')->all(); + + if ($policyIds === []) { + Notification::make() + ->title('No policies selected') + ->warning() + ->send(); + + return; + } + + $service->addPoliciesToSet( + tenant: $tenant, + backupSet: $backupSet, + policyIds: $policyIds, + actorEmail: auth()->user()?->email, + actorName: auth()->user()?->name, + includeAssignments: $this->include_assignments, + includeScopeTags: $this->include_scope_tags, + includeFoundations: $this->include_foundations, + ); + + $notificationTitle = $this->include_foundations + ? 'Backup items added' + : 'Policies added to backup'; + + Notification::make() + ->title($notificationTitle) + ->success() + ->send(); + + $this->resetTable(); + }), + ]); + } + + public function render(): View + { + return view('livewire.backup-set-policy-picker-table'); + } + + /** + * @return array{label:?string,category:?string,restore:?string,risk:?string}|array + */ + private static function typeMeta(?string $type): array + { + if ($type === null) { + return []; + } + + $types = array_merge( + config('tenantpilot.supported_policy_types', []), + config('tenantpilot.foundation_types', []) + ); + + return collect($types) + ->firstWhere('type', $type) ?? []; + } + + /** + * @return array + */ + private static function policyTypeOptions(): array + { + $types = array_merge( + config('tenantpilot.supported_policy_types', []), + config('tenantpilot.foundation_types', []) + ); + + return collect($types) + ->mapWithKeys(function (array $meta): array { + $type = (string) ($meta['type'] ?? ''); + + if ($type === '') { + return []; + } + + $label = (string) ($meta['label'] ?? $type); + + return [$type => $label]; + }) + ->all(); + } +} diff --git a/resources/views/filament/modals/backup-set-policy-picker.blade.php b/resources/views/filament/modals/backup-set-policy-picker.blade.php new file mode 100644 index 0000000..8d5502f --- /dev/null +++ b/resources/views/filament/modals/backup-set-policy-picker.blade.php @@ -0,0 +1,3 @@ +
+ +
diff --git a/resources/views/livewire/backup-set-policy-picker-table.blade.php b/resources/views/livewire/backup-set-policy-picker-table.blade.php new file mode 100644 index 0000000..2c8b681 --- /dev/null +++ b/resources/views/livewire/backup-set-policy-picker-table.blade.php @@ -0,0 +1,20 @@ +
+
+ + + + + +
+ + {{ $this->table }} +
diff --git a/specs/015-policy-picker-ux/checklists/requirements.md b/specs/015-policy-picker-ux/checklists/requirements.md new file mode 100644 index 0000000..5281408 --- /dev/null +++ b/specs/015-policy-picker-ux/checklists/requirements.md @@ -0,0 +1,30 @@ +# Specification Quality Checklist: Policy Picker UX + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-01-02 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification diff --git a/specs/015-policy-picker-ux/plan.md b/specs/015-policy-picker-ux/plan.md new file mode 100644 index 0000000..72ee802 --- /dev/null +++ b/specs/015-policy-picker-ux/plan.md @@ -0,0 +1,32 @@ +# Plan: Policy Picker UX (015) + +**Branch**: `015-policy-picker-ux` +**Date**: 2026-01-02 +**Input**: [spec.md](./spec.md) + +## Goal +Improve the “Add Policies” picker UX by making option labels self-describing (type/platform/external id) to reduce mistakes with duplicate policy names. + +## Scope + +### In scope +- Update the “Add Policies” action in the Backup Set items relation manager. +- Present the picker as a modal table (row selection). +- Table shows: display name, policy type (human label if available), platform, short external id. +- Filters: policy type, platform, last synced, ignored, has versions. +- “Select all” selects the current filtered results. +- Add a unit/feature test covering the label formatting. + +### Out of scope +- Adding filters, select-all, new pages, or additional UI flows. + +## Approach +1. Replace the Select-based picker with a Livewire/Filament table component rendered inside the action modal. +2. Add the required filters and columns. +3. Implement a bulk action to add selected policies to the backup set. +4. Add tests asserting the picker table bulk action works and filters are available. +4. Run targeted tests and Pint. + +## Success Criteria +- Picker options are clearly distinguishable for policies with duplicate names. +- Tests are green. diff --git a/specs/015-policy-picker-ux/spec.md b/specs/015-policy-picker-ux/spec.md new file mode 100644 index 0000000..2a62264 --- /dev/null +++ b/specs/015-policy-picker-ux/spec.md @@ -0,0 +1,37 @@ +# Feature Specification: Policy Picker UX (015) + +**Feature Branch**: `015-policy-picker-ux` +**Created**: 2026-01-02 +**Status**: Draft + +## User Scenarios & Testing + +### User Story 1 — Disambiguate duplicate policy names (Priority: P1) + +As an admin, I want policy options in the “Add Policies” picker to be clearly distinguishable, so I can confidently select the correct policy even when multiple policies share the same display name. + +**Acceptance Scenarios** +1. Given multiple policies with the same display name, when I open the “Add Policies” picker, then each option shows additional identifiers (type, platform, short external id). +2. Given a policy option, when I search in the picker, then results remain searchable by display name. + +### User Story 2 — Add policies efficiently (Priority: P1) + +As an admin, I want to browse and select policies in a table with filters and multi-select, so I can add the right set of policies without repetitive searching. + +**Acceptance Scenarios** +1. When I open the “Add Policies” picker, then I see a table with policy rows and selectable checkboxes. +2. When I filter by policy type / platform / last synced / ignored / has versions, then only matching policies are shown. +3. When I click “select all”, then only the currently filtered results are selected. + +## Requirements + +### Functional Requirements +- **FR-001**: The “Add Policies” picker MUST be presented as a table inside the modal. +- **FR-002**: Each policy row MUST show: display name, policy type, platform, and a short external id. +- **FR-003**: The picker MUST support multi-select. +- **FR-004**: The picker MUST provide filtering for: policy type, platform, last synced, ignored, and has versions. +- **FR-005**: The picker MUST support “select all” for the currently filtered results (not all policies in the tenant). + +## Success Criteria +- **SC-001**: In tenants with duplicate policy names, admins can identify the correct policy from the picker without trial-and-error. +- **SC-002**: Admins can add large sets of policies efficiently using filters + multi-select. diff --git a/specs/015-policy-picker-ux/tasks.md b/specs/015-policy-picker-ux/tasks.md new file mode 100644 index 0000000..7f888ed --- /dev/null +++ b/specs/015-policy-picker-ux/tasks.md @@ -0,0 +1,24 @@ +# Tasks: Policy Picker UX (015) + +**Branch**: `015-policy-picker-ux` | **Date**: 2026-01-02 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [X] T001 Create spec/plan/tasks and checklist. + +## Phase 2: Core +- [X] T002 Update “Add Policies” picker option labels to include type/platform/short external id. +- [X] T006 Replace picker with a modal table (multi-select). +- [X] T007 Add filters: policy type, platform, last synced, ignored, has versions. +- [X] T008 Implement “select all” for filtered results (via Filament table selection). +- [X] T012 Group row actions (View/Remove) in backup items table. +- [X] T013 Add bulk remove action for backup items. + +## Phase 3: Tests + Verification +- [X] T003 Add test coverage for policy picker option labels. +- [X] T004 Run targeted tests. +- [X] T005 Run Pint (`./vendor/bin/pint --dirty`). +- [X] T009 Update/add tests for table picker bulk add. +- [X] T010 Run targeted tests. +- [X] T011 Run Pint (`./vendor/bin/pint --dirty`). +- [X] T014 Add test coverage for bulk remove. diff --git a/tests/Feature/Filament/BackupCreationTest.php b/tests/Feature/Filament/BackupCreationTest.php index 728a913..a08c7d5 100644 --- a/tests/Feature/Filament/BackupCreationTest.php +++ b/tests/Feature/Filament/BackupCreationTest.php @@ -8,9 +8,9 @@ use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; use App\Services\Graph\ScopeTagResolver; +use App\Services\Intune\BackupService; use App\Services\Intune\PolicySnapshotService; use Illuminate\Foundation\Testing\RefreshDatabase; -use Livewire\Livewire; use Mockery\MockInterface; uses(RefreshDatabase::class); @@ -106,14 +106,16 @@ public function request(string $method, string $path, array $options = []): Grap 'name' => 'Test backup', ]); - Livewire::test(\App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager::class, [ - 'ownerRecord' => $backupSet, - 'pageClass' => \App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet::class, - ])->callTableAction('addPolicies', data: [ - 'policy_ids' => [$policyA->id], - 'include_assignments' => false, - 'include_scope_tags' => true, - ]); + app(BackupService::class)->addPoliciesToSet( + tenant: $tenant, + backupSet: $backupSet, + policyIds: [$policyA->id], + actorEmail: $user->email, + actorName: $user->name, + includeAssignments: false, + includeScopeTags: true, + includeFoundations: true, + ); $backupSet->refresh(); diff --git a/tests/Feature/Filament/BackupItemsBulkRemoveTest.php b/tests/Feature/Filament/BackupItemsBulkRemoveTest.php new file mode 100644 index 0000000..a1a0ae6 --- /dev/null +++ b/tests/Feature/Filament/BackupItemsBulkRemoveTest.php @@ -0,0 +1,74 @@ +create(); + $tenant->makeCurrent(); + + $user = User::factory()->create(); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Test backup', + 'item_count' => 0, + ]); + + $policyA = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'ignored_at' => null, + 'last_synced_at' => now(), + ]); + + $policyB = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'ignored_at' => null, + 'last_synced_at' => now(), + ]); + + $itemA = BackupItem::factory()->create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policyA->id, + 'policy_identifier' => $policyA->external_id, + 'policy_type' => $policyA->policy_type, + 'platform' => $policyA->platform, + ]); + + $itemB = BackupItem::factory()->create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policyB->id, + 'policy_identifier' => $policyB->external_id, + 'policy_type' => $policyB->policy_type, + 'platform' => $policyB->platform, + ]); + + $backupSet->update(['item_count' => $backupSet->items()->count()]); + expect($backupSet->refresh()->item_count)->toBe(2); + + Livewire::actingAs($user) + ->test(BackupItemsRelationManager::class, [ + 'ownerRecord' => $backupSet, + 'pageClass' => \App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet::class, + ]) + ->callTableBulkAction('bulk_remove', collect([$itemA, $itemB])) + ->assertHasNoTableBulkActionErrors(); + + $backupSet->refresh(); + + expect($backupSet->items()->count())->toBe(0); + expect($backupSet->item_count)->toBe(0); + + $this->assertSoftDeleted('backup_items', ['id' => $itemA->id]); + $this->assertSoftDeleted('backup_items', ['id' => $itemB->id]); +}); diff --git a/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php b/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php new file mode 100644 index 0000000..e207423 --- /dev/null +++ b/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php @@ -0,0 +1,97 @@ +create(); + $tenant->makeCurrent(); + + $user = User::factory()->create(); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Test backup', + ]); + + $policies = Policy::factory()->count(2)->create([ + 'tenant_id' => $tenant->id, + 'ignored_at' => null, + 'last_synced_at' => now(), + ]); + + $this->mock(BackupService::class, function (MockInterface $mock) use ($tenant, $backupSet, $policies, $user) { + $mock->shouldReceive('addPoliciesToSet') + ->once() + ->withArgs(function ($tenantArg, $backupSetArg, $policyIds, $actorEmail, $actorName, $includeAssignments, $includeScopeTags, $includeFoundations) use ($tenant, $backupSet, $policies, $user) { + expect($tenantArg->id)->toBe($tenant->id); + expect($backupSetArg->id)->toBe($backupSet->id); + expect($policyIds)->toBe($policies->pluck('id')->all()); + expect($actorEmail)->toBe($user->email); + expect($actorName)->toBe($user->name); + expect($includeAssignments)->toBeTrue(); + expect($includeScopeTags)->toBeTrue(); + expect($includeFoundations)->toBeTrue(); + + return true; + }); + }); + + Livewire::actingAs($user) + ->test(BackupSetPolicyPickerTable::class, [ + 'backupSetId' => $backupSet->id, + ]) + ->callTableBulkAction('add_selected_to_backup_set', $policies) + ->assertHasNoTableBulkActionErrors(); +}); + +test('policy picker table can filter by has versions', function () { + $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); + + $user = User::factory()->create(); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Test backup', + ]); + + $withVersions = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'display_name' => 'With Versions', + 'ignored_at' => null, + 'last_synced_at' => now(), + ]); + + PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $withVersions->id, + 'policy_type' => $withVersions->policy_type, + 'platform' => $withVersions->platform, + ]); + + $withoutVersions = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'display_name' => 'Without Versions', + 'ignored_at' => null, + 'last_synced_at' => now(), + ]); + + Livewire::actingAs($user) + ->test(BackupSetPolicyPickerTable::class, [ + 'backupSetId' => $backupSet->id, + ]) + ->filterTable('has_versions', '1') + ->assertSee('With Versions') + ->assertDontSee('Without Versions'); +}); diff --git a/tests/Unit/PolicyPickerOptionLabelTest.php b/tests/Unit/PolicyPickerOptionLabelTest.php new file mode 100644 index 0000000..ae24d71 --- /dev/null +++ b/tests/Unit/PolicyPickerOptionLabelTest.php @@ -0,0 +1,13 @@ +toBe('1234abcd'); + + expect(\App\Livewire\BackupSetPolicyPickerTable::externalIdShort(null)) + ->toBe('—'); +});