## Summary - add the tenant review domain with tenant-scoped review library, canonical workspace review register, lifecycle actions, and review-derived executive pack export - extend review pack, operations, audit, capability, and badge infrastructure to support review composition, publication, export, and recurring review cycles - add product backlog and audit documentation updates for tenant review and semantic-clarity follow-up candidates ## Testing - `vendor/bin/sail bin pint --dirty --format agent` - `vendor/bin/sail artisan test --compact --filter="TenantReview"` - `CI=1 vendor/bin/sail artisan test --compact` ## Notes - Livewire v4+ compliant via existing Filament v5 stack - panel providers remain in `bootstrap/providers.php` via existing Laravel 12 structure; no provider registration moved to `bootstrap/app.php` - `TenantReviewResource` is not globally searchable, so the Filament edit/view global-search constraint does not apply - destructive review actions use action handlers with confirmation and policy enforcement Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #185
563 lines
23 KiB
PHP
563 lines
23 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Resources;
|
|
|
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
|
use App\Filament\Resources\TenantReviewResource\Pages;
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantReview;
|
|
use App\Models\TenantReviewSection;
|
|
use App\Models\User;
|
|
use App\Services\ReviewPackService;
|
|
use App\Services\TenantReviews\TenantReviewService;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Badges\BadgeDomain;
|
|
use App\Support\Badges\BadgeRenderer;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\OperationRunType;
|
|
use App\Support\OpsUx\OperationUxPresenter;
|
|
use App\Support\Rbac\UiEnforcement;
|
|
use App\Support\TenantReviewStatus;
|
|
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 Filament\Actions;
|
|
use Filament\Facades\Filament;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Infolists\Components\RepeatableEntry;
|
|
use Filament\Infolists\Components\TextEntry;
|
|
use Filament\Infolists\Components\ViewEntry;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Resources\Resource;
|
|
use Filament\Schemas\Components\Section;
|
|
use Filament\Schemas\Schema;
|
|
use Filament\Support\Enums\TextSize;
|
|
use Filament\Tables;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Str;
|
|
use UnitEnum;
|
|
|
|
class TenantReviewResource extends Resource
|
|
{
|
|
use InteractsWithTenantOwnedRecords;
|
|
use ResolvesPanelTenantContext;
|
|
|
|
protected static bool $isDiscovered = false;
|
|
|
|
protected static ?string $model = TenantReview::class;
|
|
|
|
protected static ?string $slug = 'reviews';
|
|
|
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
|
|
|
protected static bool $isGloballySearchable = false;
|
|
|
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-magnifying-glass';
|
|
|
|
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
|
|
|
|
protected static ?string $navigationLabel = 'Reviews';
|
|
|
|
protected static ?int $navigationSort = 45;
|
|
|
|
public static function shouldRegisterNavigation(): bool
|
|
{
|
|
return Filament::getCurrentPanel()?->getId() === 'tenant';
|
|
}
|
|
|
|
public static function canViewAny(): bool
|
|
{
|
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
if (! $user->canAccessTenant($tenant)) {
|
|
return false;
|
|
}
|
|
|
|
return $user->can(Capabilities::TENANT_REVIEW_VIEW, $tenant);
|
|
}
|
|
|
|
public static function canView(Model $record): bool
|
|
{
|
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User || ! $record instanceof TenantReview) {
|
|
return false;
|
|
}
|
|
|
|
if (! $user->canAccessTenant($tenant)) {
|
|
return false;
|
|
}
|
|
|
|
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
|
return false;
|
|
}
|
|
|
|
return $user->can('view', $record);
|
|
}
|
|
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Create review is available from the review library header.')
|
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
->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::ListRowMoreMenu, 'Primary row actions stay limited to View review and Export executive pack.')
|
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail exposes Refresh review, Publish review, Export executive pack, Archive review, and Create next review as applicable.');
|
|
}
|
|
|
|
public static function getEloquentQuery(): Builder
|
|
{
|
|
return static::getTenantOwnedEloquentQuery()
|
|
->with(['tenant', 'evidenceSnapshot', 'operationRun', 'initiator', 'publisher', 'currentExportReviewPack', 'sections'])
|
|
->latest('generated_at')
|
|
->latest('id');
|
|
}
|
|
|
|
public static function resolveScopedRecordOrFail(int|string|null $record): Model
|
|
{
|
|
return static::resolveTenantOwnedRecordOrFail($record);
|
|
}
|
|
|
|
public static function form(Schema $schema): Schema
|
|
{
|
|
return $schema;
|
|
}
|
|
|
|
public static function infolist(Schema $schema): Schema
|
|
{
|
|
return $schema->schema([
|
|
Section::make('Review')
|
|
->schema([
|
|
TextEntry::make('status')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
|
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
|
|
TextEntry::make('completeness_state')
|
|
->label('Completeness')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
|
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
|
TextEntry::make('tenant.name')->label('Tenant'),
|
|
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
|
|
TextEntry::make('published_at')->dateTime()->placeholder('—'),
|
|
TextEntry::make('evidenceSnapshot.id')
|
|
->label('Evidence snapshot')
|
|
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
|
->url(fn (TenantReview $record): ?string => $record->evidenceSnapshot
|
|
? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
|
|
: null),
|
|
TextEntry::make('currentExportReviewPack.id')
|
|
->label('Current export')
|
|
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
|
->url(fn (TenantReview $record): ?string => $record->currentExportReviewPack
|
|
? ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant)
|
|
: null),
|
|
TextEntry::make('fingerprint')
|
|
->copyable()
|
|
->placeholder('—')
|
|
->columnSpanFull()
|
|
->fontFamily('mono')
|
|
->size(TextSize::ExtraSmall),
|
|
])
|
|
->columns(2)
|
|
->columnSpanFull(),
|
|
Section::make('Executive posture')
|
|
->schema([
|
|
ViewEntry::make('review_summary')
|
|
->hiddenLabel()
|
|
->view('filament.infolists.entries.tenant-review-summary')
|
|
->state(fn (TenantReview $record): array => static::summaryPresentation($record))
|
|
->columnSpanFull(),
|
|
])
|
|
->columnSpanFull(),
|
|
Section::make('Sections')
|
|
->schema([
|
|
RepeatableEntry::make('sections')
|
|
->hiddenLabel()
|
|
->schema([
|
|
TextEntry::make('title'),
|
|
TextEntry::make('completeness_state')
|
|
->label('Completeness')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
|
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
|
TextEntry::make('measured_at')->dateTime()->placeholder('—'),
|
|
Section::make('Details')
|
|
->schema([
|
|
ViewEntry::make('section_payload')
|
|
->hiddenLabel()
|
|
->view('filament.infolists.entries.tenant-review-section')
|
|
->state(fn (TenantReviewSection $record): array => static::sectionPresentation($record))
|
|
->columnSpanFull(),
|
|
])
|
|
->collapsible()
|
|
->collapsed()
|
|
->columnSpanFull(),
|
|
])
|
|
->columns(3),
|
|
])
|
|
->columnSpanFull(),
|
|
]);
|
|
}
|
|
|
|
public static function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->defaultSort('generated_at', 'desc')
|
|
->persistFiltersInSession()
|
|
->persistSearchInSession()
|
|
->persistSortInSession()
|
|
->recordUrl(fn (TenantReview $record): string => static::tenantScopedUrl('view', ['record' => $record], $record->tenant))
|
|
->columns([
|
|
Tables\Columns\TextColumn::make('status')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
|
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
|
|
->sortable(),
|
|
Tables\Columns\TextColumn::make('completeness_state')
|
|
->label('Completeness')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
|
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness))
|
|
->sortable(),
|
|
Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
|
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
|
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
|
Tables\Columns\TextColumn::make('summary.section_state_counts.missing')->label('Missing'),
|
|
Tables\Columns\IconColumn::make('summary.has_ready_export')
|
|
->label('Export')
|
|
->boolean(),
|
|
Tables\Columns\TextColumn::make('fingerprint')
|
|
->toggleable(isToggledHiddenByDefault: true)
|
|
->searchable(),
|
|
])
|
|
->filters([
|
|
Tables\Filters\SelectFilter::make('status')
|
|
->options(collect(TenantReviewStatus::cases())
|
|
->mapWithKeys(fn (TenantReviewStatus $status): array => [$status->value => Str::headline($status->value)])
|
|
->all()),
|
|
Tables\Filters\SelectFilter::make('completeness_state')
|
|
->options([
|
|
'complete' => 'Complete',
|
|
'partial' => 'Partial',
|
|
'missing' => 'Missing',
|
|
'stale' => 'Stale',
|
|
]),
|
|
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
|
])
|
|
->actions([
|
|
Actions\Action::make('view_review')
|
|
->label('View review')
|
|
->url(fn (TenantReview $record): string => static::tenantScopedUrl('view', ['record' => $record], $record->tenant)),
|
|
UiEnforcement::forTableAction(
|
|
Actions\Action::make('export_executive_pack')
|
|
->label('Export executive pack')
|
|
->icon('heroicon-o-arrow-down-tray')
|
|
->visible(fn (TenantReview $record): bool => in_array($record->status, [
|
|
TenantReviewStatus::Ready->value,
|
|
TenantReviewStatus::Published->value,
|
|
], true))
|
|
->action(fn (TenantReview $record): mixed => static::executeExport($record)),
|
|
fn (TenantReview $record): TenantReview => $record,
|
|
)
|
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
->preserveVisibility()
|
|
->apply(),
|
|
])
|
|
->bulkActions([])
|
|
->emptyStateHeading('No tenant reviews yet')
|
|
->emptyStateDescription('Create the first review from an anchored evidence snapshot to start the recurring review history for this tenant.')
|
|
->emptyStateActions([
|
|
static::makeCreateReviewAction(
|
|
name: 'create_first_review',
|
|
label: 'Create first review',
|
|
icon: 'heroicon-o-plus',
|
|
),
|
|
]);
|
|
}
|
|
|
|
public static function getPages(): array
|
|
{
|
|
return [
|
|
'index' => Pages\ListTenantReviews::route('/'),
|
|
'view' => Pages\ViewTenantReview::route('/{record}'),
|
|
];
|
|
}
|
|
|
|
public static function makeCreateReviewAction(
|
|
string $name = 'create_review',
|
|
string $label = 'Create review',
|
|
string $icon = 'heroicon-o-plus',
|
|
): Actions\Action {
|
|
return UiEnforcement::forAction(
|
|
Actions\Action::make($name)
|
|
->label($label)
|
|
->icon($icon)
|
|
->form([
|
|
Section::make('Evidence basis')
|
|
->schema([
|
|
Select::make('evidence_snapshot_id')
|
|
->label('Evidence snapshot')
|
|
->required()
|
|
->options(fn (): array => static::evidenceSnapshotOptions())
|
|
->searchable()
|
|
->helperText('Choose the anchored evidence snapshot for this review.'),
|
|
]),
|
|
])
|
|
->action(fn (array $data): mixed => static::executeCreateReview($data)),
|
|
)
|
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
->apply();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
public static function executeCreateReview(array $data): void
|
|
{
|
|
$tenant = Filament::getTenant();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
Notification::make()->danger()->title('Unable to create review — missing context.')->send();
|
|
|
|
return;
|
|
}
|
|
|
|
if (! $user->canAccessTenant($tenant)) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! $user->can(Capabilities::TENANT_REVIEW_MANAGE, $tenant)) {
|
|
abort(403);
|
|
}
|
|
|
|
$snapshotId = $data['evidence_snapshot_id'] ?? null;
|
|
$snapshot = is_numeric($snapshotId)
|
|
? EvidenceSnapshot::query()
|
|
->whereKey((int) $snapshotId)
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->first()
|
|
: null;
|
|
|
|
if (! $snapshot instanceof EvidenceSnapshot) {
|
|
Notification::make()->danger()->title('Select a valid evidence snapshot.')->send();
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$review = app(TenantReviewService::class)->create($tenant, $snapshot, $user);
|
|
} catch (\Throwable $throwable) {
|
|
Notification::make()->danger()->title('Unable to create review')->body($throwable->getMessage())->send();
|
|
|
|
return;
|
|
}
|
|
|
|
if (! $review->wasRecentlyCreated) {
|
|
Notification::make()
|
|
->success()
|
|
->title('Review already available')
|
|
->body('A matching mutable review already exists for this evidence basis.')
|
|
->actions([
|
|
Actions\Action::make('view_review')
|
|
->label('View review')
|
|
->url(static::tenantScopedUrl('view', ['record' => $review], $tenant)),
|
|
])
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$toast = OperationUxPresenter::queuedToast(OperationRunType::TenantReviewCompose->value)
|
|
->body('The review is being composed in the background.');
|
|
|
|
if ($review->operation_run_id) {
|
|
$toast->actions([
|
|
Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::tenantlessView((int) $review->operation_run_id)),
|
|
]);
|
|
}
|
|
|
|
$toast->send();
|
|
}
|
|
|
|
public static function executeExport(TenantReview $review): void
|
|
{
|
|
$review->loadMissing(['tenant', 'currentExportReviewPack']);
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User || ! $review->tenant instanceof Tenant) {
|
|
Notification::make()->danger()->title('Unable to export review — missing context.')->send();
|
|
|
|
return;
|
|
}
|
|
|
|
if (! $user->canAccessTenant($review->tenant)) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! $user->can('export', $review)) {
|
|
abort(403);
|
|
}
|
|
|
|
$service = app(ReviewPackService::class);
|
|
|
|
if ($service->checkActiveRunForReview($review)) {
|
|
OperationUxPresenter::alreadyQueuedToast(OperationRunType::ReviewPackGenerate->value)
|
|
->body('An executive pack export is already queued or running for this review.')
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$pack = $service->generateFromReview($review, $user, [
|
|
'include_pii' => true,
|
|
'include_operations' => true,
|
|
]);
|
|
} catch (\Throwable $throwable) {
|
|
Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send();
|
|
|
|
return;
|
|
}
|
|
|
|
if (! $pack->wasRecentlyCreated) {
|
|
Notification::make()
|
|
->success()
|
|
->title('Executive pack already available')
|
|
->body('A matching executive pack already exists for this review.')
|
|
->actions([
|
|
Actions\Action::make('view_pack')
|
|
->label('View pack')
|
|
->url(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $review->tenant)),
|
|
])
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value)
|
|
->body('The executive pack is being generated in the background.')
|
|
->send();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $parameters
|
|
*/
|
|
public static function tenantScopedUrl(
|
|
string $page = 'index',
|
|
array $parameters = [],
|
|
?Tenant $tenant = null,
|
|
?string $panel = null,
|
|
): string {
|
|
$panelId = $panel ?? 'tenant';
|
|
|
|
return static::getUrl($page, $parameters, panel: $panelId, tenant: $tenant);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private static function evidenceSnapshotOptions(): array
|
|
{
|
|
$tenant = Filament::getTenant();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return [];
|
|
}
|
|
|
|
return EvidenceSnapshot::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->whereNotNull('generated_at')
|
|
->orderByDesc('generated_at')
|
|
->orderByDesc('id')
|
|
->get()
|
|
->mapWithKeys(static fn (EvidenceSnapshot $snapshot): array => [
|
|
(string) $snapshot->getKey() => sprintf(
|
|
'#%d · %s · %s',
|
|
(int) $snapshot->getKey(),
|
|
Str::headline((string) $snapshot->completeness_state),
|
|
$snapshot->generated_at?->format('Y-m-d H:i') ?? 'Pending'
|
|
),
|
|
])
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private static function summaryPresentation(TenantReview $record): array
|
|
{
|
|
$summary = is_array($record->summary) ? $record->summary : [];
|
|
|
|
return [
|
|
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
|
|
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
|
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
|
'metrics' => [
|
|
['label' => 'Findings', 'value' => (string) ($summary['finding_count'] ?? 0)],
|
|
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)],
|
|
['label' => 'Operations', 'value' => (string) ($summary['operation_count'] ?? 0)],
|
|
['label' => 'Sections', 'value' => (string) ($summary['section_count'] ?? 0)],
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private static function sectionPresentation(TenantReviewSection $section): array
|
|
{
|
|
$summary = is_array($section->summary_payload) ? $section->summary_payload : [];
|
|
$render = is_array($section->render_payload) ? $section->render_payload : [];
|
|
$review = $section->tenantReview;
|
|
$tenant = $section->tenant;
|
|
|
|
return [
|
|
'summary' => collect($summary)->map(function (mixed $value, string $key): ?array {
|
|
if (is_array($value) || $value === null || $value === '') {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'label' => Str::headline($key),
|
|
'value' => (string) $value,
|
|
];
|
|
})->filter()->values()->all(),
|
|
'highlights' => is_array($render['highlights'] ?? null) ? $render['highlights'] : [],
|
|
'entries' => is_array($render['entries'] ?? null) ? $render['entries'] : [],
|
|
'disclosure' => is_string($render['disclosure'] ?? null) ? $render['disclosure'] : null,
|
|
'next_actions' => is_array($render['next_actions'] ?? null) ? $render['next_actions'] : [],
|
|
'empty_state' => is_string($render['empty_state'] ?? null) ? $render['empty_state'] : null,
|
|
'links' => [],
|
|
];
|
|
}
|
|
}
|