Compare commits
2 Commits
dev
...
015-policy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d03ccc39c1 | ||
|
|
5f1f3b0bfd |
@ -4,17 +4,15 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\PolicyResource;
|
use App\Filament\Resources\PolicyResource;
|
||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
use App\Models\Policy;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Intune\BackupService;
|
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms;
|
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
class BackupItemsRelationManager extends RelationManager
|
class BackupItemsRelationManager extends RelationManager
|
||||||
{
|
{
|
||||||
@ -99,76 +97,19 @@ public function table(Table $table): Table
|
|||||||
Actions\Action::make('addPolicies')
|
Actions\Action::make('addPolicies')
|
||||||
->label('Add Policies')
|
->label('Add Policies')
|
||||||
->icon('heroicon-o-plus')
|
->icon('heroicon-o-plus')
|
||||||
->form([
|
->modalHeading('Add Policies')
|
||||||
Forms\Components\Select::make('policy_ids')
|
->modalSubmitAction(false)
|
||||||
->label('Policies')
|
->modalCancelActionLabel('Close')
|
||||||
->multiple()
|
->modalContent(function (): View {
|
||||||
->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;
|
|
||||||
}
|
|
||||||
|
|
||||||
$backupSet = $this->getOwnerRecord();
|
$backupSet = $this->getOwnerRecord();
|
||||||
$tenant = $backupSet?->tenant ?? Tenant::current();
|
|
||||||
|
|
||||||
$service->addPoliciesToSet(
|
return view('filament.modals.backup-set-policy-picker', [
|
||||||
tenant: $tenant,
|
'backupSetId' => $backupSet->getKey(),
|
||||||
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();
|
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
|
Actions\ActionGroup::make([
|
||||||
Actions\ViewAction::make()
|
Actions\ViewAction::make()
|
||||||
->label('View policy')
|
->label('View policy')
|
||||||
->url(fn ($record) => $record->policy_id ? PolicyResource::getUrl('view', ['record' => $record->policy_id]) : null)
|
->url(fn ($record) => $record->policy_id ? PolicyResource::getUrl('view', ['record' => $record->policy_id]) : null)
|
||||||
@ -204,8 +145,54 @@ public function table(Table $table): Table
|
|||||||
->success()
|
->success()
|
||||||
->send();
|
->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();
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
261
app/Livewire/BackupSetPolicyPickerTable.php
Normal file
261
app/Livewire/BackupSetPolicyPickerTable.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
<div class="space-y-4">
|
||||||
|
<livewire:backup-set-policy-picker-table :backupSetId="$backupSetId" />
|
||||||
|
</div>
|
||||||
@ -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>
|
||||||
30
specs/015-policy-picker-ux/checklists/requirements.md
Normal file
30
specs/015-policy-picker-ux/checklists/requirements.md
Normal 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
|
||||||
32
specs/015-policy-picker-ux/plan.md
Normal file
32
specs/015-policy-picker-ux/plan.md
Normal 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.
|
||||||
37
specs/015-policy-picker-ux/spec.md
Normal file
37
specs/015-policy-picker-ux/spec.md
Normal 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.
|
||||||
24
specs/015-policy-picker-ux/tasks.md
Normal file
24
specs/015-policy-picker-ux/tasks.md
Normal 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.
|
||||||
@ -8,9 +8,9 @@
|
|||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Graph\GraphResponse;
|
use App\Services\Graph\GraphResponse;
|
||||||
use App\Services\Graph\ScopeTagResolver;
|
use App\Services\Graph\ScopeTagResolver;
|
||||||
|
use App\Services\Intune\BackupService;
|
||||||
use App\Services\Intune\PolicySnapshotService;
|
use App\Services\Intune\PolicySnapshotService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Livewire\Livewire;
|
|
||||||
use Mockery\MockInterface;
|
use Mockery\MockInterface;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
@ -106,14 +106,16 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
'name' => 'Test backup',
|
'name' => 'Test backup',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Livewire::test(\App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager::class, [
|
app(BackupService::class)->addPoliciesToSet(
|
||||||
'ownerRecord' => $backupSet,
|
tenant: $tenant,
|
||||||
'pageClass' => \App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet::class,
|
backupSet: $backupSet,
|
||||||
])->callTableAction('addPolicies', data: [
|
policyIds: [$policyA->id],
|
||||||
'policy_ids' => [$policyA->id],
|
actorEmail: $user->email,
|
||||||
'include_assignments' => false,
|
actorName: $user->name,
|
||||||
'include_scope_tags' => true,
|
includeAssignments: false,
|
||||||
]);
|
includeScopeTags: true,
|
||||||
|
includeFoundations: true,
|
||||||
|
);
|
||||||
|
|
||||||
$backupSet->refresh();
|
$backupSet->refresh();
|
||||||
|
|
||||||
|
|||||||
74
tests/Feature/Filament/BackupItemsBulkRemoveTest.php
Normal file
74
tests/Feature/Filament/BackupItemsBulkRemoveTest.php
Normal 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]);
|
||||||
|
});
|
||||||
97
tests/Feature/Filament/BackupSetPolicyPickerTableTest.php
Normal file
97
tests/Feature/Filament/BackupSetPolicyPickerTableTest.php
Normal 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');
|
||||||
|
});
|
||||||
13
tests/Unit/PolicyPickerOptionLabelTest.php
Normal file
13
tests/Unit/PolicyPickerOptionLabelTest.php
Normal 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('—');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user