015-policy-picker-ux #21

Merged
ahmido merged 2 commits from 015-policy-picker-ux into dev 2026-01-02 13:59:15 +00:00
12 changed files with 691 additions and 111 deletions
Showing only changes of commit 5f1f3b0bfd - Show all commits

View File

@ -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();
}),
]),
]);
}
/**

View File

@ -0,0 +1,261 @@
<?php
namespace App\Livewire;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\Tenant;
use App\Services\Intune\BackupService;
use Filament\Actions\BulkAction;
use Filament\Notifications\Notification;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Filament\Tables\TableComponent;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class BackupSetPolicyPickerTable extends TableComponent
{
public int $backupSetId;
public bool $include_assignments = true;
public bool $include_scope_tags = true;
public bool $include_foundations = true;
public function mount(int $backupSetId): void
{
$this->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<string,mixed>
*/
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<string, string>
*/
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();
}
}

View File

@ -0,0 +1,3 @@
<div class="space-y-4">
<livewire:backup-set-policy-picker-table :backupSetId="$backupSetId" />
</div>

View File

@ -0,0 +1,20 @@
<div class="space-y-4">
<div class="grid gap-3 sm:grid-cols-3">
<label class="flex items-center gap-2">
<input type="checkbox" wire:model.live="include_assignments" class="fi-checkbox-input" />
<span class="text-sm">Include assignments</span>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" wire:model.live="include_scope_tags" class="fi-checkbox-input" />
<span class="text-sm">Include scope tags</span>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" wire:model.live="include_foundations" class="fi-checkbox-input" />
<span class="text-sm">Include foundations</span>
</label>
</div>
{{ $this->table }}
</div>

View File

@ -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

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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();

View File

@ -0,0 +1,74 @@
<?php
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('backup items table can bulk remove selected items', function () {
$tenant = Tenant::factory()->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]);
});

View File

@ -0,0 +1,97 @@
<?php
use App\Livewire\BackupSetPolicyPickerTable;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Intune\BackupService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery\MockInterface;
uses(RefreshDatabase::class);
test('policy picker table bulk adds selected policies to backup set', function () {
$tenant = Tenant::factory()->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');
});

View File

@ -0,0 +1,13 @@
<?php
use Tests\TestCase;
uses(TestCase::class);
it('shortens external ids for picker display', function () {
expect(\App\Livewire\BackupSetPolicyPickerTable::externalIdShort('00000000-0000-0000-0000-1234abcd'))
->toBe('1234abcd');
expect(\App\Livewire\BackupSetPolicyPickerTable::externalIdShort(null))
->toBe('—');
});