015-policy-picker-ux #21
@ -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();
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
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\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();
|
||||
|
||||
|
||||
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