refactor: normalize filament action surfaces
This commit is contained in:
parent
f6dc5ed947
commit
d39d6f0982
@ -22,7 +22,6 @@
|
|||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ViewAction;
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
@ -322,9 +321,7 @@ public static function table(Table $table): Table
|
|||||||
}),
|
}),
|
||||||
FilterPresets::dateRange('created_at', 'Created', 'created_at'),
|
FilterPresets::dateRange('created_at', 'Created', 'created_at'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([])
|
||||||
ViewAction::make()->label('View'),
|
|
||||||
])
|
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading('No alert deliveries')
|
->emptyStateHeading('No alert deliveries')
|
||||||
->emptyStateDescription('Deliveries appear automatically when alert rules fire.')
|
->emptyStateDescription('Deliveries appear automatically when alert rules fire.')
|
||||||
|
|||||||
@ -18,8 +18,6 @@
|
|||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Actions\BulkActionGroup;
|
|
||||||
use Filament\Actions\EditAction;
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TagsInput;
|
use Filament\Forms\Components\TagsInput;
|
||||||
@ -191,9 +189,6 @@ public static function table(Table $table): Table
|
|||||||
->since(),
|
->since(),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
EditAction::make()
|
|
||||||
->label('Edit')
|
|
||||||
->visible(fn (AlertDestination $record): bool => static::canEdit($record)),
|
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
Action::make('toggle_enabled')
|
Action::make('toggle_enabled')
|
||||||
->label(fn (AlertDestination $record): string => $record->is_enabled ? 'Disable' : 'Enable')
|
->label(fn (AlertDestination $record): string => $record->is_enabled ? 'Disable' : 'Enable')
|
||||||
@ -253,9 +248,6 @@ public static function table(Table $table): Table
|
|||||||
}),
|
}),
|
||||||
])->label('More'),
|
])->label('More'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
|
||||||
BulkActionGroup::make([])->label('More'),
|
|
||||||
])
|
|
||||||
->emptyStateActions([
|
->emptyStateActions([
|
||||||
\Filament\Actions\CreateAction::make()
|
\Filament\Actions\CreateAction::make()
|
||||||
->label('Create target')
|
->label('Create target')
|
||||||
|
|||||||
@ -20,8 +20,6 @@
|
|||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Actions\BulkActionGroup;
|
|
||||||
use Filament\Actions\EditAction;
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
@ -248,9 +246,6 @@ public static function table(Table $table): Table
|
|||||||
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
|
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
EditAction::make()
|
|
||||||
->label('Edit')
|
|
||||||
->visible(fn (AlertRule $record): bool => static::canEdit($record)),
|
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
Action::make('toggle_enabled')
|
Action::make('toggle_enabled')
|
||||||
->label(fn (AlertRule $record): string => $record->is_enabled ? 'Disable' : 'Enable')
|
->label(fn (AlertRule $record): string => $record->is_enabled ? 'Disable' : 'Enable')
|
||||||
@ -311,9 +306,6 @@ public static function table(Table $table): Table
|
|||||||
}),
|
}),
|
||||||
])->label('More'),
|
])->label('More'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
|
||||||
BulkActionGroup::make([])->label('More'),
|
|
||||||
])
|
|
||||||
->emptyStateHeading('No alert rules')
|
->emptyStateHeading('No alert rules')
|
||||||
->emptyStateDescription('Create a rule to route notifications when monitored events fire.')
|
->emptyStateDescription('Create a rule to route notifications when monitored events fire.')
|
||||||
->emptyStateIcon('heroicon-o-bell');
|
->emptyStateIcon('heroicon-o-bell');
|
||||||
|
|||||||
@ -39,7 +39,6 @@
|
|||||||
use Filament\Actions\BulkAction;
|
use Filament\Actions\BulkAction;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Actions\CreateAction;
|
use Filament\Actions\CreateAction;
|
||||||
use Filament\Actions\EditAction;
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms\Components\CheckboxList;
|
use Filament\Forms\Components\CheckboxList;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
@ -571,16 +570,12 @@ public static function table(Table $table): Table
|
|||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
|
||||||
EditAction::make()
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Action::make('archive')
|
Action::make('archive')
|
||||||
->label('Archive')
|
->label('Archive')
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
||||||
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||||
$record = static::resolveProtectedScheduleRecordOrFail($record);
|
$record = static::resolveProtectedScheduleRecordOrFail($record);
|
||||||
@ -666,6 +661,7 @@ public static function table(Table $table): Table
|
|||||||
->label('Force delete')
|
->label('Force delete')
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
->visible(fn (BackupSchedule $record): bool => $record->trashed())
|
->visible(fn (BackupSchedule $record): bool => $record->trashed())
|
||||||
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||||
$record = static::resolveProtectedScheduleRecordOrFail($record);
|
$record = static::resolveProtectedScheduleRecordOrFail($record);
|
||||||
|
|||||||
@ -13,7 +13,6 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
@ -25,25 +24,12 @@ class BackupScheduleOperationRunsRelationManager extends RelationManager
|
|||||||
|
|
||||||
protected static ?string $title = 'Executions';
|
protected static ?string $title = 'Executions';
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $arguments
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
|
|
||||||
{
|
|
||||||
if (($context['table'] ?? false) === true && $name === 'view' && filled($context['recordKey'] ?? null)) {
|
|
||||||
$this->resolveOwnerScopedOperationRun($context['recordKey']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parent::mountAction($name, $arguments, $context);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||||
->exempt(ActionSurfaceSlot::ListHeader, 'Executions relation list has no header actions.')
|
->exempt(ActionSurfaceSlot::ListHeader, 'Executions relation list has no header actions.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Single primary row action is exposed, so no row menu is needed.')
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary because this relation manager has no secondary row actions.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for operation run safety.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for operation run safety.')
|
||||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed in this embedded relation manager.');
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed in this embedded relation manager.');
|
||||||
}
|
}
|
||||||
@ -54,6 +40,12 @@ public function table(Table $table): Table
|
|||||||
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey()))
|
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey()))
|
||||||
->defaultSort('created_at', 'desc')
|
->defaultSort('created_at', 'desc')
|
||||||
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
|
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
|
||||||
|
->recordUrl(function (OperationRun $record): string {
|
||||||
|
$record = $this->resolveOwnerScopedOperationRun($record);
|
||||||
|
$tenant = Tenant::currentOrFail();
|
||||||
|
|
||||||
|
return OperationRunLinks::view($record, $tenant);
|
||||||
|
})
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('created_at')
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
->label('Enqueued')
|
->label('Enqueued')
|
||||||
@ -96,18 +88,7 @@ public function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->filters([])
|
->filters([])
|
||||||
->headerActions([])
|
->headerActions([])
|
||||||
->actions([
|
->actions([])
|
||||||
Actions\Action::make('view')
|
|
||||||
->label('View')
|
|
||||||
->icon('heroicon-o-eye')
|
|
||||||
->url(function (OperationRun $record): string {
|
|
||||||
$record = $this->resolveOwnerScopedOperationRun($record);
|
|
||||||
$tenant = Tenant::currentOrFail();
|
|
||||||
|
|
||||||
return OperationRunLinks::view($record, $tenant);
|
|
||||||
})
|
|
||||||
->openUrlInNewTab(true),
|
|
||||||
])
|
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading('No schedule runs yet')
|
->emptyStateHeading('No schedule runs yet')
|
||||||
->emptyStateDescription('Operation history will appear here after this schedule has been enqueued.');
|
->emptyStateDescription('Operation history will appear here after this schedule has been enqueued.');
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
use App\Filament\Resources\PolicyVersionResource;
|
use App\Filament\Resources\PolicyVersionResource;
|
||||||
use App\Jobs\RemovePoliciesFromBackupSetJob;
|
use App\Jobs\RemovePoliciesFromBackupSetJob;
|
||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
@ -21,6 +22,10 @@
|
|||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
@ -64,6 +69,16 @@ public function mountAction(string $name, array $arguments = [], array $context
|
|||||||
return parent::mountAction($name, $arguments, $context);
|
return parent::mountAction($name, $arguments, $context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Refresh and Add Policies actions are available in the relation header.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Remove remains grouped under More.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk remove remains grouped under More.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state exposes the Add Policies CTA.');
|
||||||
|
}
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
$refreshTable = Actions\Action::make('refreshTable')
|
$refreshTable = Actions\Action::make('refreshTable')
|
||||||
@ -257,6 +272,7 @@ public function table(Table $table): Table
|
|||||||
->persistFiltersInSession()
|
->persistFiltersInSession()
|
||||||
->persistSearchInSession()
|
->persistSearchInSession()
|
||||||
->persistSortInSession()
|
->persistSortInSession()
|
||||||
|
->recordUrl(fn (BackupItem $record): ?string => $this->backupItemInspectUrl($record))
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('policy.display_name')
|
Tables\Columns\TextColumn::make('policy.display_name')
|
||||||
->label('Item')
|
->label('Item')
|
||||||
@ -358,23 +374,6 @@ public function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
Actions\ViewAction::make()
|
|
||||||
->label(fn (BackupItem $record): string => $record->policy_version_id ? 'View version' : 'View policy')
|
|
||||||
->url(function (BackupItem $record): ?string {
|
|
||||||
$tenant = $this->getOwnerRecord()->tenant ?? \App\Models\Tenant::current();
|
|
||||||
|
|
||||||
if ($record->policy_version_id) {
|
|
||||||
return PolicyVersionResource::getUrl('view', ['record' => $record->policy_version_id], tenant: $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $record->policy_id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return PolicyResource::getUrl('view', ['record' => $record->policy_id], tenant: $tenant);
|
|
||||||
})
|
|
||||||
->hidden(fn (BackupItem $record) => ! $record->policy_version_id && ! $record->policy_id)
|
|
||||||
->openUrlInNewTab(true),
|
|
||||||
$removeItem,
|
$removeItem,
|
||||||
])
|
])
|
||||||
->label('More')
|
->label('More')
|
||||||
@ -449,7 +448,39 @@ private static function applyRestoreModeFilter(Builder $query, mixed $value): Bu
|
|||||||
return $query->whereIn('policy_type', $types);
|
return $query->whereIn('policy_type', $types);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveOwnerScopedBackupItemId(\App\Models\BackupSet $backupSet, mixed $record): int
|
private function backupItemInspectUrl(BackupItem $record): ?string
|
||||||
|
{
|
||||||
|
$backupSet = $this->getOwnerRecord();
|
||||||
|
$resolvedId = $this->resolveOwnerScopedBackupItemId($backupSet, $record);
|
||||||
|
|
||||||
|
$resolvedRecord = $backupSet->items()
|
||||||
|
->with(['policy', 'policyVersion', 'policyVersion.policy'])
|
||||||
|
->where('tenant_id', (int) $backupSet->tenant_id)
|
||||||
|
->whereKey($resolvedId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $resolvedRecord instanceof BackupItem) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $backupSet->tenant ?? Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($resolvedRecord->policy_version_id) {
|
||||||
|
return PolicyVersionResource::getUrl('view', ['record' => $resolvedRecord->policy_version_id], tenant: $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolvedRecord->policy_id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return PolicyResource::getUrl('view', ['record' => $resolvedRecord->policy_id], tenant: $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveOwnerScopedBackupItemId(BackupSet $backupSet, mixed $record): int
|
||||||
{
|
{
|
||||||
$recordId = $this->normalizeBackupItemKey($record);
|
$recordId = $this->normalizeBackupItemKey($record);
|
||||||
|
|
||||||
@ -472,7 +503,7 @@ private function resolveOwnerScopedBackupItemId(\App\Models\BackupSet $backupSet
|
|||||||
/**
|
/**
|
||||||
* @return array<int, int>
|
* @return array<int, int>
|
||||||
*/
|
*/
|
||||||
private function resolveOwnerScopedBackupItemIdsFromKeys(\App\Models\BackupSet $backupSet, array $recordKeys): array
|
private function resolveOwnerScopedBackupItemIdsFromKeys(BackupSet $backupSet, array $recordKeys): array
|
||||||
{
|
{
|
||||||
$requestedIds = collect($recordKeys)
|
$requestedIds = collect($recordKeys)
|
||||||
->map(fn (mixed $record): int => $this->normalizeBackupItemKey($record))
|
->map(fn (mixed $record): int => $this->normalizeBackupItemKey($record))
|
||||||
|
|||||||
@ -36,7 +36,6 @@
|
|||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Actions\BulkActionGroup;
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms\Components\Placeholder;
|
use Filament\Forms\Components\Placeholder;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
@ -131,8 +130,8 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Create baseline profile (capability-gated).')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Create baseline profile (capability-gated).')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions (edit, archive) under "More".')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while edit and archive remain grouped under "More".')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for baseline profiles in v1.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for baseline profiles in v1.')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List defines empty-state create CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List defines empty-state create CTA.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture + edit actions.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture + edit actions.');
|
||||||
@ -340,6 +339,7 @@ public static function table(Table $table): Table
|
|||||||
return $table
|
return $table
|
||||||
->defaultSort('name')
|
->defaultSort('name')
|
||||||
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
||||||
|
->recordUrl(fn (BaselineProfile $record): string => static::getUrl('view', ['record' => $record]))
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('name')
|
TextColumn::make('name')
|
||||||
->searchable()
|
->searchable()
|
||||||
@ -412,10 +412,6 @@ public static function table(Table $table): Table
|
|||||||
->options(FilterOptionCatalog::baselineProfileStatuses()),
|
->options(FilterOptionCatalog::baselineProfileStatuses()),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view')
|
|
||||||
->label('View')
|
|
||||||
->url(fn (BaselineProfile $record): string => static::getUrl('view', ['record' => $record]))
|
|
||||||
->icon('heroicon-o-eye'),
|
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
Action::make('edit')
|
Action::make('edit')
|
||||||
->label('Edit')
|
->label('Edit')
|
||||||
@ -425,9 +421,7 @@ public static function table(Table $table): Table
|
|||||||
self::archiveTableAction($workspace),
|
self::archiveTableAction($workspace),
|
||||||
])->label('More'),
|
])->label('More'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([])
|
||||||
BulkActionGroup::make([])->label('More'),
|
|
||||||
])
|
|
||||||
->emptyStateHeading('No baseline profiles')
|
->emptyStateHeading('No baseline profiles')
|
||||||
->emptyStateDescription('Create a baseline profile to define what "good" looks like for your tenants.')
|
->emptyStateDescription('Create a baseline profile to define what "good" looks like for your tenants.')
|
||||||
->emptyStateActions([
|
->emptyStateActions([
|
||||||
|
|||||||
@ -112,7 +112,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Create snapshot is available from the list header.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Create snapshot is available from the list header.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes a Create snapshot CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes a Create snapshot CTA.')
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Evidence snapshots keep only primary View and Expire row actions.')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Expire snapshot remains grouped under More.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Evidence snapshots do not support bulk actions.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Evidence snapshots do not support bulk actions.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes Refresh evidence and Expire snapshot actions.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes Refresh evidence and Expire snapshot actions.');
|
||||||
}
|
}
|
||||||
@ -257,33 +257,32 @@ public static function table(Table $table): Table
|
|||||||
->options(BadgeCatalog::options(BadgeDomain::EvidenceCompleteness, EvidenceCompletenessState::values())),
|
->options(BadgeCatalog::options(BadgeDomain::EvidenceCompleteness, EvidenceCompletenessState::values())),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_snapshot')
|
Actions\ActionGroup::make([
|
||||||
->label('View snapshot')
|
UiEnforcement::forTableAction(
|
||||||
->url(fn (EvidenceSnapshot $record): string => static::getUrl('view', ['record' => $record])),
|
Actions\Action::make('expire')
|
||||||
UiEnforcement::forTableAction(
|
->label('Expire snapshot')
|
||||||
Actions\Action::make('expire')
|
->color('danger')
|
||||||
->label('Expire snapshot')
|
->hidden(fn (EvidenceSnapshot $record): bool => ! static::canExpireRecord($record))
|
||||||
->color('danger')
|
->requiresConfirmation()
|
||||||
->hidden(fn (EvidenceSnapshot $record): bool => ! static::canExpireRecord($record))
|
->action(function (EvidenceSnapshot $record): void {
|
||||||
->requiresConfirmation()
|
$user = auth()->user();
|
||||||
->action(function (EvidenceSnapshot $record): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
app(EvidenceSnapshotService::class)->expire($record, $user);
|
app(EvidenceSnapshotService::class)->expire($record, $user);
|
||||||
static::truthEnvelope($record->refresh(), fresh: true);
|
static::truthEnvelope($record->refresh(), fresh: true);
|
||||||
|
|
||||||
Notification::make()->success()->title('Snapshot expired')->send();
|
Notification::make()->success()->title('Snapshot expired')->send();
|
||||||
}),
|
}),
|
||||||
fn (EvidenceSnapshot $record): EvidenceSnapshot => $record,
|
fn (EvidenceSnapshot $record): EvidenceSnapshot => $record,
|
||||||
)
|
)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
||||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
->apply(),
|
->apply(),
|
||||||
|
])->label('More'),
|
||||||
])
|
])
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading('No evidence snapshots yet')
|
->emptyStateHeading('No evidence snapshots yet')
|
||||||
|
|||||||
@ -38,7 +38,6 @@
|
|||||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
@ -77,7 +76,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
ActionSurfaceSlot::ListHeader,
|
ActionSurfaceSlot::ListHeader,
|
||||||
'Run-log list intentionally has no list-header actions; navigation actions are provided by Monitoring shell pages.',
|
'Run-log list intentionally has no list-header actions; navigation actions are provided by Monitoring shell pages.',
|
||||||
)
|
)
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->exempt(
|
->exempt(
|
||||||
ActionSurfaceSlot::ListBulkMoreGroup,
|
ActionSurfaceSlot::ListBulkMoreGroup,
|
||||||
'Operation runs are immutable records; bulk export is deferred and tracked outside this retrofit.',
|
'Operation runs are immutable records; bulk export is deferred and tracked outside this retrofit.',
|
||||||
@ -128,6 +127,7 @@ public static function table(Table $table): Table
|
|||||||
->persistFiltersInSession()
|
->persistFiltersInSession()
|
||||||
->persistSearchInSession()
|
->persistSearchInSession()
|
||||||
->persistSortInSession()
|
->persistSortInSession()
|
||||||
|
->recordUrl(fn (OperationRun $record): string => OperationRunLinks::tenantlessView($record))
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('status')
|
Tables\Columns\TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
@ -242,11 +242,7 @@ public static function table(Table $table): Table
|
|||||||
'until' => now()->toDateString(),
|
'until' => now()->toDateString(),
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([])
|
||||||
Actions\ViewAction::make()
|
|
||||||
->label('View run')
|
|
||||||
->url(fn (OperationRun $record): string => OperationRunLinks::tenantlessView($record)),
|
|
||||||
])
|
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading('No operation runs found')
|
->emptyStateHeading('No operation runs found')
|
||||||
->emptyStateDescription('Queued, running, and completed operations will appear here when work is triggered in this scope.')
|
->emptyStateDescription('Queued, running, and completed operations will appear here when work is triggered in this scope.')
|
||||||
|
|||||||
@ -99,7 +99,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Sync from Intune.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Sync from Intune.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
||||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines an empty-state CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines an empty-state CTA.')
|
||||||
@ -365,6 +365,7 @@ public static function table(Table $table): Table
|
|||||||
->persistFiltersInSession()
|
->persistFiltersInSession()
|
||||||
->persistSearchInSession()
|
->persistSearchInSession()
|
||||||
->persistSortInSession()
|
->persistSortInSession()
|
||||||
|
->recordUrl(fn (Policy $record): string => static::getUrl('view', ['record' => $record]))
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('display_name')
|
Tables\Columns\TextColumn::make('display_name')
|
||||||
->label('Policy')
|
->label('Policy')
|
||||||
@ -490,7 +491,6 @@ public static function table(Table $table): Table
|
|||||||
->all()),
|
->all()),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ViewAction::make(),
|
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
UiEnforcement::forTableAction(
|
UiEnforcement::forTableAction(
|
||||||
Actions\Action::make('ignore')
|
Actions\Action::make('ignore')
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
namespace App\Filament\Resources\PolicyResource\RelationManagers;
|
namespace App\Filament\Resources\PolicyResource\RelationManagers;
|
||||||
|
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
|
use App\Filament\Resources\PolicyVersionResource;
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
@ -49,8 +50,8 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||||
->exempt(ActionSurfaceSlot::ListHeader, 'Versions sub-list intentionally has no header actions.')
|
->exempt(ActionSurfaceSlot::ListHeader, 'Versions sub-list intentionally has no header actions.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Only two row actions are present, so no secondary row menu is needed.')
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while restore remains the only inline row shortcut.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for version restore safety.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for version restore safety.')
|
||||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'No inline empty-state action is exposed in this embedded relation manager.');
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'No inline empty-state action is exposed in this embedded relation manager.');
|
||||||
}
|
}
|
||||||
@ -181,13 +182,11 @@ public function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->defaultSort('version_number', 'desc')
|
->defaultSort('version_number', 'desc')
|
||||||
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
|
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
|
||||||
|
->recordUrl(fn (PolicyVersion $record): string => PolicyVersionResource::getUrl('view', ['record' => $record]))
|
||||||
->filters([])
|
->filters([])
|
||||||
->headerActions([])
|
->headerActions([])
|
||||||
->actions([
|
->actions([
|
||||||
$restoreToIntune,
|
$restoreToIntune,
|
||||||
Actions\ViewAction::make()
|
|
||||||
->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record]))
|
|
||||||
->openUrlInNewTab(false),
|
|
||||||
])
|
])
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading('No versions captured')
|
->emptyStateHeading('No versions captured')
|
||||||
|
|||||||
@ -82,7 +82,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Edit and provider operations are grouped under "More" while clickable-row view remains primary.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Provider connections intentionally omit bulk actions.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Provider connections intentionally omit bulk actions.')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines empty-state guidance and CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines empty-state guidance and CTA.')
|
||||||
->exempt(ActionSurfaceSlot::DetailHeader, 'View page has no additional header mutations in this resource.');
|
->exempt(ActionSurfaceSlot::DetailHeader, 'View page has no additional header mutations in this resource.');
|
||||||
|
|||||||
@ -37,6 +37,10 @@
|
|||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\RestoreRunIdempotency;
|
use App\Support\RestoreRunIdempotency;
|
||||||
use App\Support\RestoreRunStatus;
|
use App\Support\RestoreRunStatus;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
@ -103,6 +107,17 @@ public static function canCreate(): bool
|
|||||||
&& $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
|
&& $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Create restore run is available from the list header whenever records already exist.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while rerun and archive lifecycle actions stay grouped under More.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk restore-run maintenance actions are grouped under More.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state exposes the New restore run CTA.')
|
||||||
|
->exempt(ActionSurfaceSlot::DetailHeader, 'View page has no additional header actions.');
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
@ -862,8 +877,8 @@ public static function table(Table $table): Table
|
|||||||
FilterPresets::dateRange('started_at', 'Started', 'started_at'),
|
FilterPresets::dateRange('started_at', 'Started', 'started_at'),
|
||||||
FilterPresets::archived(),
|
FilterPresets::archived(),
|
||||||
])
|
])
|
||||||
|
->recordUrl(fn (RestoreRun $record): string => static::getUrl('view', ['record' => $record]))
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ViewAction::make(),
|
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
static::rerunActionWithGate(),
|
static::rerunActionWithGate(),
|
||||||
UiEnforcement::forTableAction(
|
UiEnforcement::forTableAction(
|
||||||
@ -975,7 +990,9 @@ public static function table(Table $table): Table
|
|||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->apply(),
|
->apply(),
|
||||||
])->icon('heroicon-o-ellipsis-vertical'),
|
])
|
||||||
|
->label('More')
|
||||||
|
->icon('heroicon-o-ellipsis-vertical'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
@ -1232,7 +1249,7 @@ public static function table(Table $table): Table
|
|||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
->apply(),
|
->apply(),
|
||||||
]),
|
])->label('More'),
|
||||||
])
|
])
|
||||||
->emptyStateHeading('No restore runs')
|
->emptyStateHeading('No restore runs')
|
||||||
->emptyStateDescription('Start a restoration from a backup set.')
|
->emptyStateDescription('Start a restoration from a backup set.')
|
||||||
|
|||||||
@ -99,9 +99,9 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action available in list header.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action available in list header.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes Generate CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes Generate CTA.')
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Only two primary row actions (Download, Expire); no secondary menu needed.')
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Download and Expire remain direct row shortcuts.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Download and Regenerate actions in ViewReviewPack header.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Download and Regenerate actions in ViewReviewPack header.');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -146,10 +146,10 @@ public static function canDeleteAny(): bool
|
|||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
->withListRowPrimaryActionLimit(2)
|
->withListRowPrimaryActionLimit(1)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'List page provides a capability-gated create action.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'List page provides a capability-gated create action.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'At most two row actions stay primary; lifecycle-adjacent and contextual secondary actions move under "More".')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'At most one non-inspect row action stays primary; lifecycle-adjacent and destructive actions move under "More".')
|
||||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create action is reused in the list empty state.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create action is reused in the list empty state.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Tenant view page exposes header actions via an action group.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Tenant view page exposes header actions via an action group.');
|
||||||
@ -245,6 +245,7 @@ public static function table(Table $table): Table
|
|||||||
->persistFiltersInSession()
|
->persistFiltersInSession()
|
||||||
->persistSearchInSession()
|
->persistSearchInSession()
|
||||||
->persistSortInSession()
|
->persistSortInSession()
|
||||||
|
->recordUrl(fn (Tenant $record): string => static::getUrl('view', ['record' => $record]))
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('name')
|
Tables\Columns\TextColumn::make('name')
|
||||||
->searchable()
|
->searchable()
|
||||||
@ -317,49 +318,11 @@ public static function table(Table $table): Table
|
|||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view')
|
|
||||||
->label('View')
|
|
||||||
->icon('heroicon-o-eye')
|
|
||||||
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record])),
|
|
||||||
Actions\Action::make('related_onboarding')
|
Actions\Action::make('related_onboarding')
|
||||||
->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'Resume onboarding')
|
->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'Resume onboarding')
|
||||||
->icon(fn (Tenant $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-path')
|
->icon(fn (Tenant $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-path')
|
||||||
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||||
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'related_onboarding'),
|
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'related_onboarding'),
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('restore')
|
|
||||||
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Restore')
|
|
||||||
->color('success')
|
|
||||||
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-uturn-left')
|
|
||||||
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant restored')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Restore tenant')
|
|
||||||
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
|
|
||||||
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'restore')
|
|
||||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
|
||||||
static::restoreTenant($record, $auditLogger);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('archive')
|
|
||||||
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Archive')
|
|
||||||
->color('danger')
|
|
||||||
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-archive-box-x-mark')
|
|
||||||
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant archived')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Archive tenant')
|
|
||||||
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
|
|
||||||
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'archive')
|
|
||||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
|
||||||
static::archiveTenant($record, $auditLogger);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
|
||||||
->apply(),
|
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
Actions\Action::make('related_onboarding_overflow')
|
Actions\Action::make('related_onboarding_overflow')
|
||||||
->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'View related onboarding')
|
->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'View related onboarding')
|
||||||
@ -367,6 +330,40 @@ public static function table(Table $table): Table
|
|||||||
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||||
->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
|
->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
|
||||||
&& static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
|
&& static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('restore')
|
||||||
|
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Restore')
|
||||||
|
->color('success')
|
||||||
|
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-uturn-left')
|
||||||
|
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant restored')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Restore tenant')
|
||||||
|
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
|
||||||
|
->visible(fn (Tenant $record): bool => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->key === 'restore')
|
||||||
|
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||||
|
static::restoreTenant($record, $auditLogger);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
|
->apply(),
|
||||||
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('archive')
|
||||||
|
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Archive')
|
||||||
|
->color('danger')
|
||||||
|
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-archive-box-x-mark')
|
||||||
|
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant archived')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Archive tenant')
|
||||||
|
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
|
||||||
|
->visible(fn (Tenant $record): bool => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->key === 'archive')
|
||||||
|
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||||
|
static::archiveTenant($record, $auditLogger);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->preserveVisibility()
|
||||||
|
->requireCapability(Capabilities::TENANT_DELETE)
|
||||||
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('syncTenant')
|
Actions\Action::make('syncTenant')
|
||||||
->label('Sync')
|
->label('Sync')
|
||||||
|
|||||||
@ -2,12 +2,17 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\RelationManagers;
|
namespace App\Filament\Resources\TenantResource\RelationManagers;
|
||||||
|
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\ManageTenantMemberships;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantMembership;
|
use App\Models\TenantMembership;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Auth\TenantMembershipManager;
|
use App\Services\Auth\TenantMembershipManager;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -15,11 +20,48 @@
|
|||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class TenantMembershipsRelationManager extends RelationManager
|
class TenantMembershipsRelationManager extends RelationManager
|
||||||
{
|
{
|
||||||
protected static string $relationship = 'memberships';
|
protected static string $relationship = 'memberships';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Add member action is available in the relation header.')
|
||||||
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'Tenant membership rows are managed inline and have no separate inspect destination.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Change role and remove stay direct for focused inline membership management.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk membership mutations are intentionally omitted.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed; add member remains available in the header.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
|
||||||
|
{
|
||||||
|
if (! $ownerRecord instanceof Tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pageClass !== ManageTenantMemberships::class) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($ownerRecord)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->can($user, $ownerRecord, Capabilities::TENANT_MEMBERSHIP_VIEW);
|
||||||
|
}
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
|
|||||||
@ -120,7 +120,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes exactly one Create first review CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes exactly one Create first review CTA.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Tenant reviews do not expose bulk actions in the first slice.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Tenant reviews do not expose bulk actions in the first slice.')
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Primary row actions stay limited to View review and Export executive pack.')
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Export executive pack remains the only inline row shortcut.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail exposes Refresh review, Publish review, Export executive pack, Archive review, and Create next review as applicable.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail exposes Refresh review, Publish review, Export executive pack, Archive review, and Create next review as applicable.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -311,9 +311,6 @@ public static function table(Table $table): Table
|
|||||||
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_review')
|
|
||||||
->label('View review')
|
|
||||||
->url(fn (TenantReview $record): string => static::tenantScopedUrl('view', ['record' => $record], $record->tenant)),
|
|
||||||
UiEnforcement::forTableAction(
|
UiEnforcement::forTableAction(
|
||||||
Actions\Action::make('export_executive_pack')
|
Actions\Action::make('export_executive_pack')
|
||||||
->label('Export executive pack')
|
->label('Export executive pack')
|
||||||
|
|||||||
@ -9,7 +9,11 @@
|
|||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\WorkspaceRole;
|
use App\Support\Auth\WorkspaceRole;
|
||||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
@ -21,6 +25,16 @@ class WorkspaceMembershipsRelationManager extends RelationManager
|
|||||||
{
|
{
|
||||||
protected static string $relationship = 'memberships';
|
protected static string $relationship = 'memberships';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Add member action is available in the relation header.')
|
||||||
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'Workspace membership rows are managed inline and have no separate inspect destination.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Change role stays inline while destructive removal is grouped under More.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk membership mutations are intentionally omitted.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed; add member remains available in the header.');
|
||||||
|
}
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
@ -177,46 +191,47 @@ public function table(Table $table): Table
|
|||||||
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
|
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
|
||||||
->tooltip('You do not have permission to manage workspace memberships.')
|
->tooltip('You do not have permission to manage workspace memberships.')
|
||||||
->apply(),
|
->apply(),
|
||||||
|
ActionGroup::make([
|
||||||
|
WorkspaceUiEnforcement::forTableAction(
|
||||||
|
Action::make('remove')
|
||||||
|
->label(__('Remove'))
|
||||||
|
->color('danger')
|
||||||
|
->icon('heroicon-o-x-mark')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (WorkspaceMembership $record, WorkspaceMembershipManager $manager): void {
|
||||||
|
$workspace = $this->getOwnerRecord();
|
||||||
|
|
||||||
WorkspaceUiEnforcement::forTableAction(
|
if (! $workspace instanceof Workspace) {
|
||||||
Action::make('remove')
|
abort(404);
|
||||||
->label(__('Remove'))
|
}
|
||||||
->color('danger')
|
|
||||||
->icon('heroicon-o-x-mark')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (WorkspaceMembership $record, WorkspaceMembershipManager $manager): void {
|
|
||||||
$workspace = $this->getOwnerRecord();
|
|
||||||
|
|
||||||
if (! $workspace instanceof Workspace) {
|
$actor = auth()->user();
|
||||||
abort(404);
|
if (! $actor instanceof User) {
|
||||||
}
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
$actor = auth()->user();
|
try {
|
||||||
if (! $actor instanceof User) {
|
$manager->removeMember(workspace: $workspace, actor: $actor, membership: $record);
|
||||||
abort(403);
|
} catch (\Throwable $throwable) {
|
||||||
}
|
Notification::make()
|
||||||
|
->title(__('Failed to remove member'))
|
||||||
|
->body($throwable->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
try {
|
return;
|
||||||
$manager->removeMember(workspace: $workspace, actor: $actor, membership: $record);
|
}
|
||||||
} catch (\Throwable $throwable) {
|
|
||||||
Notification::make()
|
|
||||||
->title(__('Failed to remove member'))
|
|
||||||
->body($throwable->getMessage())
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
Notification::make()->title(__('Member removed'))->success()->send();
|
||||||
}
|
$this->resetTable();
|
||||||
|
}),
|
||||||
Notification::make()->title(__('Member removed'))->success()->send();
|
fn () => $this->getOwnerRecord(),
|
||||||
$this->resetTable();
|
)
|
||||||
}),
|
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
|
||||||
fn () => $this->getOwnerRecord(),
|
->tooltip('You do not have permission to manage workspace memberships.')
|
||||||
)
|
->destructive()
|
||||||
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
|
->apply(),
|
||||||
->tooltip('You do not have permission to manage workspace memberships.')
|
])->label('More'),
|
||||||
->destructive()
|
|
||||||
->apply(),
|
|
||||||
])
|
])
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading(__('No workspace members'))
|
->emptyStateHeading(__('No workspace members'))
|
||||||
|
|||||||
@ -96,8 +96,8 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'List page provides a capability-gated create action.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'List page provides a capability-gated create action.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Workspace list intentionally uses only primary View/Edit row actions.')
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Workspace list intentionally uses only row-click inspection plus a primary Edit action.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Workspace list intentionally omits bulk actions.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Workspace list intentionally omits bulk actions.')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines a capability-gated empty-state create CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines a capability-gated empty-state create CTA.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Workspace view page exposes a capability-gated edit action.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Workspace view page exposes a capability-gated edit action.');
|
||||||
@ -151,6 +151,7 @@ public static function table(Table $table): Table
|
|||||||
return $table
|
return $table
|
||||||
->defaultSort('name')
|
->defaultSort('name')
|
||||||
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
||||||
|
->recordUrl(fn (Workspace $record): string => static::getUrl('view', ['record' => $record]))
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('name')
|
Tables\Columns\TextColumn::make('name')
|
||||||
->searchable()
|
->searchable()
|
||||||
@ -160,7 +161,6 @@ public static function table(Table $table): Table
|
|||||||
->sortable(),
|
->sortable(),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ViewAction::make(),
|
|
||||||
WorkspaceUiEnforcement::forTableAction(
|
WorkspaceUiEnforcement::forTableAction(
|
||||||
Actions\EditAction::make(),
|
Actions\EditAction::make(),
|
||||||
fn (): ?Workspace => null,
|
fn (): ?Workspace => null,
|
||||||
|
|||||||
@ -10,6 +10,10 @@
|
|||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\System\SystemDirectoryLinks;
|
use App\Support\System\SystemDirectoryLinks;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
@ -31,6 +35,16 @@ class Tenants extends Page implements HasTable
|
|||||||
|
|
||||||
protected string $view = 'filament.system.pages.directory.tenants';
|
protected string $view = 'filament.system.pages.directory.tenants';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||||
|
->exempt(ActionSurfaceSlot::ListHeader, 'System tenant directory stays scan-first and does not expose page header actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Tenant directory rows navigate directly to the detail page and have no secondary actions.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'System tenant directory does not expose bulk actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains that tenants appear here after onboarding and inventory sync.');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
$user = auth('platform')->user();
|
$user = auth('platform')->user();
|
||||||
|
|||||||
@ -14,6 +14,10 @@
|
|||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\System\SystemDirectoryLinks;
|
use App\Support\System\SystemDirectoryLinks;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
@ -35,6 +39,16 @@ class Workspaces extends Page implements HasTable
|
|||||||
|
|
||||||
protected string $view = 'filament.system.pages.directory.workspaces';
|
protected string $view = 'filament.system.pages.directory.workspaces';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||||
|
->exempt(ActionSurfaceSlot::ListHeader, 'System workspace directory stays scan-first and does not expose page header actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Workspace directory rows navigate directly to the detail page and have no secondary actions.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'System workspace directory does not expose bulk actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains that workspaces appear here once the platform inventory is seeded.');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
$user = auth('platform')->user();
|
$user = auth('platform')->user();
|
||||||
|
|||||||
@ -15,6 +15,10 @@
|
|||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -39,6 +43,16 @@ class Failures extends Page implements HasTable
|
|||||||
|
|
||||||
protected string $view = 'filament.system.pages.ops.failures';
|
protected string $view = 'filament.system.pages.ops.failures';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
||||||
|
->exempt(ActionSurfaceSlot::ListHeader, 'System failures stay scan-first and rely on row triage rather than page header actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Failed-run triage stays per run and intentionally omits bulk actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when there are no failed runs to triage.')
|
||||||
|
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical system run detail page, which owns header actions.');
|
||||||
|
}
|
||||||
|
|
||||||
public static function getNavigationBadge(): ?string
|
public static function getNavigationBadge(): ?string
|
||||||
{
|
{
|
||||||
$count = OperationRun::query()
|
$count = OperationRun::query()
|
||||||
|
|||||||
@ -13,6 +13,10 @@
|
|||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -37,6 +41,16 @@ class Runs extends Page implements HasTable
|
|||||||
|
|
||||||
protected string $view = 'filament.system.pages.ops.runs';
|
protected string $view = 'filament.system.pages.ops.runs';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
||||||
|
->exempt(ActionSurfaceSlot::ListHeader, 'System ops runs rely on inline row triage and do not expose page header actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'System ops triage stays per run and intentionally omits bulk actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when no system runs have been queued yet.')
|
||||||
|
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical system run detail page, which owns header actions.');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
$user = auth('platform')->user();
|
$user = auth('platform')->user();
|
||||||
|
|||||||
@ -15,6 +15,10 @@
|
|||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
use App\Support\SystemConsole\StuckRunClassifier;
|
use App\Support\SystemConsole\StuckRunClassifier;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -39,6 +43,16 @@ class Stuck extends Page implements HasTable
|
|||||||
|
|
||||||
protected string $view = 'filament.system.pages.ops.stuck';
|
protected string $view = 'filament.system.pages.ops.stuck';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
||||||
|
->exempt(ActionSurfaceSlot::ListHeader, 'System stuck-run triage relies on row actions and does not expose page header actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Stuck-run triage stays per run and intentionally omits bulk actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when no queued or running runs cross the stuck thresholds.')
|
||||||
|
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical system run detail page, which owns header actions.');
|
||||||
|
}
|
||||||
|
|
||||||
public static function getNavigationBadge(): ?string
|
public static function getNavigationBadge(): ?string
|
||||||
{
|
{
|
||||||
$count = app(StuckRunClassifier::class)
|
$count = app(StuckRunClassifier::class)
|
||||||
|
|||||||
@ -7,6 +7,9 @@
|
|||||||
use App\Models\AuditLog;
|
use App\Models\AuditLog;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
@ -28,6 +31,16 @@ class AccessLogs extends Page implements HasTable
|
|||||||
|
|
||||||
protected string $view = 'filament.system.pages.security.access-logs';
|
protected string $view = 'filament.system.pages.security.access-logs';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
||||||
|
->exempt(ActionSurfaceSlot::ListHeader, 'Access logs remain scan-first and do not expose page header actions.')
|
||||||
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'Access logs intentionally keep auth and break-glass events inline without a separate inspect view.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Access logs are immutable and intentionally omit bulk actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when no platform auth or break-glass events match the current log scope.')
|
||||||
|
->exempt(ActionSurfaceSlot::DetailHeader, 'The access log page does not open per-record detail headers; review stays inline in the table.');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
$user = auth('platform')->user();
|
$user = auth('platform')->user();
|
||||||
|
|||||||
@ -153,7 +153,7 @@ private function viewAction(): TenantActionDescriptor
|
|||||||
family: TenantActionFamily::Neutral,
|
family: TenantActionFamily::Neutral,
|
||||||
label: 'View',
|
label: 'View',
|
||||||
icon: 'heroicon-o-eye',
|
icon: 'heroicon-o-eye',
|
||||||
group: 'primary',
|
group: 'inspect',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,9 +28,6 @@ public static function baseline(): self
|
|||||||
'App\\Filament\\Pages\\TenantDashboard' => 'Dashboard retrofit deferred; widget and summary surfaces are excluded from this contract.',
|
'App\\Filament\\Pages\\TenantDashboard' => 'Dashboard retrofit deferred; widget and summary surfaces are excluded from this contract.',
|
||||||
'App\\Filament\\Pages\\Workspaces\\ManagedTenantOnboardingWizard' => 'Onboarding wizard has dedicated conformance tests and remains exempt in spec 082.',
|
'App\\Filament\\Pages\\Workspaces\\ManagedTenantOnboardingWizard' => 'Onboarding wizard has dedicated conformance tests and remains exempt in spec 082.',
|
||||||
'App\\Filament\\Pages\\Workspaces\\ManagedTenantsLanding' => 'Managed-tenant landing retrofit deferred to workspace feature track.',
|
'App\\Filament\\Pages\\Workspaces\\ManagedTenantsLanding' => 'Managed-tenant landing retrofit deferred to workspace feature track.',
|
||||||
'App\\Filament\\Resources\\BackupSetResource\\RelationManagers\\BackupItemsRelationManager' => 'Backup items relation manager retrofit deferred to backup set track.',
|
|
||||||
'App\\Filament\\Resources\\TenantResource\\RelationManagers\\TenantMembershipsRelationManager' => 'Tenant memberships relation manager retrofit deferred to RBAC membership track.',
|
|
||||||
'App\\Filament\\Resources\\Workspaces\\RelationManagers\\WorkspaceMembershipsRelationManager' => 'Workspace memberships relation manager retrofit deferred to workspace RBAC track.',
|
|
||||||
], TenantOwnedModelFamilies::actionSurfaceBaselineExemptions()));
|
], TenantOwnedModelFamilies::actionSurfaceBaselineExemptions()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -90,8 +90,8 @@ public static function firstSlice(): array
|
|||||||
'resource' => RestoreRunResource::class,
|
'resource' => RestoreRunResource::class,
|
||||||
'tenant_relationship' => 'tenant',
|
'tenant_relationship' => 'tenant',
|
||||||
'search_posture' => 'not_applicable',
|
'search_posture' => 'not_applicable',
|
||||||
'action_surface' => 'baseline_exemption',
|
'action_surface' => 'declared',
|
||||||
'action_surface_reason' => 'Restore run resource retrofit is deferred to the restore track and remains explicitly exempt in the action-surface baseline.',
|
'action_surface_reason' => 'RestoreRunResource declares its action surface contract directly.',
|
||||||
'notes' => 'Restore runs are not part of global search.',
|
'notes' => 'Restore runs are not part of global search.',
|
||||||
],
|
],
|
||||||
'Finding' => [
|
'Finding' => [
|
||||||
|
|||||||
52
tests/Browser/TenantMembershipsPageTest.php
Normal file
52
tests/Browser/TenantMembershipsPageTest.php
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
|
||||||
|
pest()->browser()->timeout(15_000);
|
||||||
|
|
||||||
|
it('renders tenant memberships only on the dedicated memberships page after scroll hydration', function (): void {
|
||||||
|
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$member = User::factory()->create([
|
||||||
|
'email' => 'browser-tenant-member@example.test',
|
||||||
|
]);
|
||||||
|
$member->tenants()->syncWithoutDetaching([
|
||||||
|
$tenant->getKey() => ['role' => 'readonly'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($owner)->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
$viewPage = visit(TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin'));
|
||||||
|
|
||||||
|
$viewPage
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee((string) $tenant->name)
|
||||||
|
->assertScript("document.body.innerText.includes('Add member')", false)
|
||||||
|
->assertScript("document.body.innerText.includes('browser-tenant-member@example.test')", false);
|
||||||
|
|
||||||
|
$membershipsPage = visit(TenantResource::getUrl('memberships', ['record' => $tenant->getRouteKey()], panel: 'admin'));
|
||||||
|
|
||||||
|
$membershipsPage
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Tenant memberships');
|
||||||
|
|
||||||
|
$membershipsPage->script(<<<'JS'
|
||||||
|
window.scrollTo(0, document.body.scrollHeight);
|
||||||
|
JS);
|
||||||
|
|
||||||
|
$membershipsPage
|
||||||
|
->waitForText('Add member')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Memberships')
|
||||||
|
->assertSee('Add member')
|
||||||
|
->assertSee('browser-tenant-member@example.test')
|
||||||
|
->assertSee('Change role')
|
||||||
|
->assertSee('Remove');
|
||||||
|
});
|
||||||
@ -74,6 +74,47 @@ function getBackupScheduleEmptyStateAction(Testable $component, string $name): ?
|
|||||||
})->toThrow(AuthorizationException::class);
|
})->toThrow(AuthorizationException::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('requires confirmation for destructive backup schedule lifecycle actions', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$activeSchedule = BackupSchedule::query()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Lifecycle confirmation active',
|
||||||
|
'is_enabled' => true,
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'frequency' => 'daily',
|
||||||
|
'time_of_day' => '01:00:00',
|
||||||
|
'days_of_week' => null,
|
||||||
|
'policy_types' => ['deviceConfiguration'],
|
||||||
|
'include_foundations' => true,
|
||||||
|
'retention_keep_last' => 30,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$archivedSchedule = BackupSchedule::query()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Lifecycle confirmation archived',
|
||||||
|
'is_enabled' => true,
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'frequency' => 'daily',
|
||||||
|
'time_of_day' => '02:00:00',
|
||||||
|
'days_of_week' => null,
|
||||||
|
'policy_types' => ['deviceConfiguration'],
|
||||||
|
'include_foundations' => true,
|
||||||
|
'retention_keep_last' => 30,
|
||||||
|
]);
|
||||||
|
$archivedSchedule->delete();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListBackupSchedules::class)
|
||||||
|
->assertTableActionExists('archive', fn (Action $action): bool => $action->isConfirmationRequired(), $activeSchedule);
|
||||||
|
|
||||||
|
Livewire::test(ListBackupSchedules::class)
|
||||||
|
->filterTable(TrashedFilter::class, false)
|
||||||
|
->assertTableActionExists('forceDelete', fn (Action $action): bool => $action->isConfirmationRequired(), BackupSchedule::withTrashed()->findOrFail($archivedSchedule->id));
|
||||||
|
});
|
||||||
|
|
||||||
it('disables backup schedule create in the empty state for members without manage capability', function (): void {
|
it('disables backup schedule create in the empty state for members without manage capability', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
|
|||||||
@ -76,6 +76,8 @@ function makeBackupScheduleForTenant(\App\Models\Tenant $tenant, string $name):
|
|||||||
'pageClass' => EditBackupSchedule::class,
|
'pageClass' => EditBackupSchedule::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(fn () => $component->instance()->mountTableAction('view', (string) $foreignRun->getKey()))
|
$table = $component->instance()->getTable();
|
||||||
|
|
||||||
|
expect(fn () => $table->getRecordUrl($foreignRun))
|
||||||
->toThrow(NotFoundHttpException::class);
|
->toThrow(NotFoundHttpException::class);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -364,7 +364,17 @@ function seedEvidenceDomain(Tenant $tenant): void
|
|||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
expect($primaryRowActionNames)->toBe(['view_snapshot', 'expire'])
|
$moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup);
|
||||||
|
$moreActionNames = collect($moreGroup?->getActions())
|
||||||
|
->map(static fn ($action): ?string => $action->getName())
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($primaryRowActionNames)->toBe([])
|
||||||
|
->and($moreGroup)->toBeInstanceOf(ActionGroup::class)
|
||||||
|
->and($moreGroup?->getLabel())->toBe('More')
|
||||||
|
->and($moreActionNames)->toEqualCanonicalizing(['expire'])
|
||||||
->and($table->getBulkActions())->toBeEmpty()
|
->and($table->getBulkActions())->toBeEmpty()
|
||||||
->and($table->getRecordUrl($snapshot))->toBe(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot]));
|
->and($table->getRecordUrl($snapshot))->toBe(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot]));
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -13,7 +13,6 @@
|
|||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
@ -110,16 +109,16 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Livewire::test(BackupItemsRelationManager::class, [
|
$component = Livewire::test(BackupItemsRelationManager::class, [
|
||||||
'ownerRecord' => $backupSet,
|
'ownerRecord' => $backupSet,
|
||||||
'pageClass' => EditBackupSet::class,
|
'pageClass' => EditBackupSet::class,
|
||||||
])
|
])
|
||||||
->assertTableColumnFormattedStateSet('policy.display_name', 'Captured RBAC role', $backupItem)
|
->assertTableColumnFormattedStateSet('policy.display_name', 'Captured RBAC role', $backupItem);
|
||||||
->assertTableActionVisible('view', $backupItem)
|
|
||||||
->assertTableActionExists('view', function (Action $action) use ($tenant, $version): bool {
|
$table = $component->instance()->getTable();
|
||||||
return $action->getLabel() === 'View version'
|
|
||||||
&& $action->getUrl() === PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant);
|
expect($table->getRecordUrl($backupItem))
|
||||||
}, $backupItem);
|
->toBe(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 404 and queues nothing when a forged foreign-tenant row action record is submitted', function (): void {
|
it('returns 404 and queues nothing when a forged foreign-tenant row action record is submitted', function (): void {
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
@ -224,7 +225,7 @@ function tenantActionSurfaceSearchTitles($results): array
|
|||||||
->assertTableActionHidden('archive', $archivedTenant);
|
->assertTableActionHidden('archive', $archivedTenant);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('documents and preserves the tenant row overflow contract for lifecycle actions', function (): void {
|
it('documents the tenant row-click and More-menu contract for lifecycle actions', function (): void {
|
||||||
$tenant = Tenant::factory()->active()->create();
|
$tenant = Tenant::factory()->active()->create();
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
@ -242,17 +243,36 @@ function tenantActionSurfaceSearchTitles($results): array
|
|||||||
|
|
||||||
$declaration = TenantResource::actionSurfaceDeclaration();
|
$declaration = TenantResource::actionSurfaceDeclaration();
|
||||||
|
|
||||||
expect($declaration->listRowPrimaryActionLimit())->toBe(2)
|
expect($declaration->listRowPrimaryActionLimit())->toBe(1)
|
||||||
->and((string) ($declaration->slot(ActionSurfaceSlot::ListRowMoreMenu)?->details ?? ''))->toContain('two');
|
->and((string) ($declaration->slot(ActionSurfaceSlot::ListRowMoreMenu)?->details ?? ''))->toContain('one');
|
||||||
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
Filament::setTenant(null, true);
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
$component = Livewire::actingAs($user)
|
||||||
->test(ListTenants::class)
|
->test(ListTenants::class)
|
||||||
->assertTableActionVisible('view', $tenant)
|
|
||||||
->assertTableActionVisible('archive', $tenant)
|
->assertTableActionVisible('archive', $tenant)
|
||||||
->assertTableActionHidden('related_onboarding', $tenant)
|
->assertTableActionHidden('related_onboarding', $tenant)
|
||||||
->assertTableActionVisible('related_onboarding_overflow', $tenant)
|
->assertTableActionVisible('related_onboarding_overflow', $tenant)
|
||||||
->assertTableActionHidden('restore', $tenant);
|
->assertTableActionHidden('restore', $tenant);
|
||||||
|
|
||||||
|
$table = $component->instance()->getTable();
|
||||||
|
$rowActions = $table->getActions();
|
||||||
|
$primaryRowActionNames = collect($rowActions)
|
||||||
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
||||||
|
->map(static fn ($action): ?string => $action->getName())
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
$moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup);
|
||||||
|
$moreActionNames = collect($moreGroup?->getActions() ?? [])
|
||||||
|
->map(static fn ($action): ?string => $action->getName())
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($primaryRowActionNames)->not->toContain('view')
|
||||||
|
->and($table->getRecordUrl($tenant))->toBe(TenantResource::getUrl('view', ['record' => $tenant]))
|
||||||
|
->and($moreActionNames)->toContain('archive')
|
||||||
|
->and($moreActionNames)->toContain('related_onboarding_overflow');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -36,7 +36,6 @@
|
|||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ListTenants::class)
|
->test(ListTenants::class)
|
||||||
->assertTableActionVisible('view', $tenant)
|
|
||||||
->assertTableActionVisible('related_onboarding', $tenant)
|
->assertTableActionVisible('related_onboarding', $tenant)
|
||||||
->assertTableActionExists('related_onboarding', fn (Action $action): bool => $action->getLabel() === 'Resume onboarding', $tenant)
|
->assertTableActionExists('related_onboarding', fn (Action $action): bool => $action->getLabel() === 'Resume onboarding', $tenant)
|
||||||
->assertTableActionHidden('archive', $tenant)
|
->assertTableActionHidden('archive', $tenant)
|
||||||
|
|||||||
@ -43,6 +43,29 @@
|
|||||||
->assertSee('No review records match this view');
|
->assertSee('No review records match this view');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps tenant review list inspection on row click and reserves the row action for executive export', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$review = composeTenantReviewForTest($tenant, $user);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
setTenantPanelContext($tenant);
|
||||||
|
|
||||||
|
$livewire = Livewire::actingAs($user)
|
||||||
|
->test(ListTenantReviews::class)
|
||||||
|
->assertCanSeeTableRecords([$review]);
|
||||||
|
|
||||||
|
$table = $livewire->instance()->getTable();
|
||||||
|
$rowActionNames = collect($table->getActions())
|
||||||
|
->map(static fn ($action): ?string => $action->getName())
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($rowActionNames)->toEqualCanonicalizing(['export_executive_pack'])
|
||||||
|
->and($table->getBulkActions())->toBeEmpty()
|
||||||
|
->and($table->getRecordUrl($review))->toBe(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant));
|
||||||
|
});
|
||||||
|
|
||||||
it('requires confirmation for destructive tenant-review actions and preserves disabled management visibility for readonly users', function (): void {
|
it('requires confirmation for destructive tenant-review actions and preserves disabled management visibility for readonly users', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|||||||
@ -86,7 +86,7 @@
|
|||||||
->and($catalog[2]->label)->toBe('View completed onboarding');
|
->and($catalog[2]->label)->toBe('View completed onboarding');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps tenant index catalogs within the two-primary-action overflow contract', function (): void {
|
it('keeps tenant index catalogs within the clickable-row overflow contract', function (): void {
|
||||||
$tenant = Tenant::factory()->active()->create();
|
$tenant = Tenant::factory()->active()->create();
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
@ -110,15 +110,22 @@
|
|||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
|
$inspectKeys = collect($catalog)
|
||||||
|
->filter(static fn ($action): bool => $action->group === 'inspect')
|
||||||
|
->map(static fn ($action): string => $action->key)
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
$overflowKeys = collect($catalog)
|
$overflowKeys = collect($catalog)
|
||||||
->filter(static fn ($action): bool => $action->group === 'overflow')
|
->filter(static fn ($action): bool => $action->group === 'overflow')
|
||||||
->map(static fn ($action): string => $action->key)
|
->map(static fn ($action): string => $action->key)
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
expect($primaryKeys)->toBe(['view', 'archive'])
|
expect($inspectKeys)->toBe(['view'])
|
||||||
|
->and($primaryKeys)->toBe(['archive'])
|
||||||
->and($overflowKeys)->toBe(['related_onboarding'])
|
->and($overflowKeys)->toBe(['related_onboarding'])
|
||||||
->and(TenantResource::actionSurfaceDeclaration()->listRowPrimaryActionLimit())->toBe(2);
|
->and(TenantResource::actionSurfaceDeclaration()->listRowPrimaryActionLimit())->toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('invalidates cached tenant index catalogs when the related onboarding draft lifecycle changes', function (): void {
|
it('invalidates cached tenant index catalogs when the related onboarding draft lifecycle changes', function (): void {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user