Implemented the first version of review output resolve actions. Included a ReviewOutputResolveActionMapper, commands to seed browser fixtures, updated CustomerReviewWorkspace, EnvironmentReviewResource, UI enforcement, and related views. Also added extensive unit, feature, and browser tests, and updated the design coverage matrix. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #422
378 lines
13 KiB
PHP
378 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\ResolutionGuidance;
|
|
|
|
use App\Models\EnvironmentReview;
|
|
use App\Support\Audit\AuditActionId;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\EnvironmentReviewStatus;
|
|
use App\Support\OperationRunType;
|
|
use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance;
|
|
|
|
final class ReviewOutputResolveActionMapper
|
|
{
|
|
/**
|
|
* @param array<string, mixed> $guidance
|
|
* @param array{
|
|
* review:?string,
|
|
* evidence:?string,
|
|
* operation:?string,
|
|
* download:?string,
|
|
* successor_review:?string
|
|
* } $urls
|
|
* @param array{
|
|
* can_manage_review?:bool,
|
|
* successor_review_status?:?string
|
|
* } $execution
|
|
* @return array{
|
|
* primary_action:array{
|
|
* key:string,
|
|
* label:string,
|
|
* type:string,
|
|
* url:?string,
|
|
* icon:string,
|
|
* kind:string,
|
|
* action_name:?string,
|
|
* capability:?string,
|
|
* requires_confirmation:bool,
|
|
* audit_event:?string,
|
|
* operation_run_type:?string,
|
|
* disabled_reason:?string
|
|
* },
|
|
* secondary_actions:list<array{
|
|
* key:string,
|
|
* label:string,
|
|
* type:string,
|
|
* url:?string,
|
|
* icon:string,
|
|
* kind:string,
|
|
* action_name:?string,
|
|
* capability:?string,
|
|
* requires_confirmation:bool,
|
|
* audit_event:?string,
|
|
* operation_run_type:?string,
|
|
* disabled_reason:?string
|
|
* }>
|
|
* }
|
|
*/
|
|
public static function map(
|
|
EnvironmentReview $review,
|
|
array $guidance,
|
|
string $sourceSurface,
|
|
array $urls,
|
|
array $execution = [],
|
|
): array {
|
|
$fallbackPrimary = ResolutionAction::fromArray(
|
|
is_array($guidance['primary_action'] ?? null) ? $guidance['primary_action'] : null,
|
|
self::caseKey((string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN)).'.primary_action',
|
|
__('localization.review.review_output_limitations'),
|
|
);
|
|
$fallbackSecondary = self::secondaryActions($guidance);
|
|
$candidatePrimary = self::candidatePrimaryAction($review, $guidance, $sourceSurface, $urls, $execution);
|
|
|
|
if ($candidatePrimary === null) {
|
|
return [
|
|
'primary_action' => $fallbackPrimary,
|
|
'secondary_actions' => $fallbackSecondary,
|
|
];
|
|
}
|
|
|
|
$primaryAction = ResolutionAction::fromArray(
|
|
$candidatePrimary,
|
|
self::caseKey((string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN)).'.primary_action',
|
|
__('localization.review.review_output_limitations'),
|
|
);
|
|
|
|
$secondaryActions = self::deduplicateActions(array_merge(
|
|
self::shouldPromoteFallbackPrimary($fallbackPrimary, $primaryAction) ? [$fallbackPrimary] : [],
|
|
$fallbackSecondary,
|
|
), $primaryAction['key']);
|
|
|
|
return [
|
|
'primary_action' => $primaryAction,
|
|
'secondary_actions' => $secondaryActions,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $guidance
|
|
* @param array{
|
|
* review:?string,
|
|
* evidence:?string,
|
|
* operation:?string,
|
|
* download:?string,
|
|
* successor_review:?string
|
|
* } $urls
|
|
* @param array{
|
|
* can_manage_review?:bool,
|
|
* successor_review_status?:?string
|
|
* } $execution
|
|
* @return array{
|
|
* key:string,
|
|
* label:string,
|
|
* type:string,
|
|
* url:?string,
|
|
* icon:string,
|
|
* kind:string,
|
|
* action_name:?string,
|
|
* capability:?string,
|
|
* requires_confirmation:bool,
|
|
* audit_event:?string,
|
|
* operation_run_type:?string
|
|
* }|null
|
|
*/
|
|
private static function candidatePrimaryAction(
|
|
EnvironmentReview $review,
|
|
array $guidance,
|
|
string $sourceSurface,
|
|
array $urls,
|
|
array $execution,
|
|
): ?array {
|
|
$state = (string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN);
|
|
$canManageReview = (bool) ($execution['can_manage_review'] ?? false);
|
|
$successorReviewUrl = self::normalizedUrl($urls['successor_review'] ?? null);
|
|
$successorReviewStatus = EnvironmentReviewStatus::tryFrom((string) ($execution['successor_review_status'] ?? ''));
|
|
|
|
if ($successorReviewUrl !== null) {
|
|
return [
|
|
'key' => 'open_successor_review',
|
|
'label' => $successorReviewStatus?->isMutable()
|
|
? __('localization.review.open_draft_review')
|
|
: __('localization.review.open_successor_review'),
|
|
'type' => ResolutionAction::TYPE_NAVIGATION,
|
|
'url' => $successorReviewUrl,
|
|
'icon' => 'heroicon-o-arrow-top-right-on-square',
|
|
'kind' => 'environment_link',
|
|
'action_name' => $sourceSurface === 'environment_review_detail'
|
|
? 'open_successor_review'
|
|
: null,
|
|
'capability' => null,
|
|
'requires_confirmation' => false,
|
|
'audit_event' => null,
|
|
'operation_run_type' => null,
|
|
];
|
|
}
|
|
|
|
if ($review->statusEnum() === EnvironmentReviewStatus::Ready) {
|
|
if (! $canManageReview || ! self::supportsExecutableAction($sourceSurface, 'publish_review')) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'key' => 'publish_review',
|
|
'label' => __('localization.review.publish_review'),
|
|
'type' => ResolutionAction::TYPE_DOMAIN_ACTION,
|
|
'url' => null,
|
|
'icon' => 'heroicon-o-check-badge',
|
|
'kind' => 'environment_action',
|
|
'action_name' => 'publish_review',
|
|
'capability' => Capabilities::ENVIRONMENT_REVIEW_MANAGE,
|
|
'requires_confirmation' => true,
|
|
'audit_event' => AuditActionId::EnvironmentReviewPublished->value,
|
|
'operation_run_type' => null,
|
|
];
|
|
}
|
|
|
|
if ($review->isMutable() && $state !== ReviewPackOutputResolutionGuidance::STATE_CUSTOMER_SAFE_READY) {
|
|
if (! $canManageReview || ! self::supportsExecutableAction($sourceSurface, 'refresh_review')) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'key' => 'refresh_review',
|
|
'label' => __('localization.review.refresh_review'),
|
|
'type' => ResolutionAction::TYPE_OPERATION_ACTION,
|
|
'url' => null,
|
|
'icon' => 'heroicon-o-arrow-path',
|
|
'kind' => 'environment_action',
|
|
'action_name' => 'refresh_review',
|
|
'capability' => Capabilities::ENVIRONMENT_REVIEW_MANAGE,
|
|
'requires_confirmation' => true,
|
|
'audit_event' => AuditActionId::EnvironmentReviewRefreshed->value,
|
|
'operation_run_type' => OperationRunType::EnvironmentReviewCompose->value,
|
|
];
|
|
}
|
|
|
|
if (! $review->isPublished() || ! in_array($state, [
|
|
ReviewPackOutputResolutionGuidance::STATE_PUBLICATION_BLOCKED,
|
|
ReviewPackOutputResolutionGuidance::STATE_PUBLISHED_WITH_LIMITATIONS,
|
|
ReviewPackOutputResolutionGuidance::STATE_EXPORT_NOT_READY,
|
|
ReviewPackOutputResolutionGuidance::STATE_INTERNAL_ONLY,
|
|
], true)) {
|
|
return null;
|
|
}
|
|
|
|
if (! $canManageReview || ! self::supportsExecutableAction($sourceSurface, 'create_next_review')) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'key' => 'create_next_review',
|
|
'label' => __('localization.review.create_next_review'),
|
|
'type' => ResolutionAction::TYPE_OPERATION_ACTION,
|
|
'url' => null,
|
|
'icon' => 'heroicon-o-document-duplicate',
|
|
'kind' => 'environment_action',
|
|
'action_name' => $sourceSurface === 'customer_review_workspace'
|
|
? 'createNextReview'
|
|
: 'create_next_review',
|
|
'capability' => Capabilities::ENVIRONMENT_REVIEW_MANAGE,
|
|
'requires_confirmation' => true,
|
|
'audit_event' => AuditActionId::EnvironmentReviewSuccessorCreated->value,
|
|
'operation_run_type' => OperationRunType::EnvironmentReviewCompose->value,
|
|
];
|
|
}
|
|
|
|
private static function supportsExecutableAction(string $sourceSurface, string $actionName): bool
|
|
{
|
|
return match ($sourceSurface) {
|
|
'customer_review_workspace' => $actionName === 'create_next_review',
|
|
'environment_review_detail' => in_array($actionName, [
|
|
'refresh_review',
|
|
'publish_review',
|
|
'create_next_review',
|
|
], true),
|
|
default => false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $guidance
|
|
* @return list<array{
|
|
* key:string,
|
|
* label:string,
|
|
* type:string,
|
|
* url:?string,
|
|
* icon:string,
|
|
* kind:string,
|
|
* action_name:?string,
|
|
* capability:?string,
|
|
* requires_confirmation:bool,
|
|
* audit_event:?string,
|
|
* operation_run_type:?string,
|
|
* disabled_reason:?string
|
|
* }>
|
|
*/
|
|
private static function secondaryActions(array $guidance): array
|
|
{
|
|
$secondaryActions = is_array($guidance['secondary_actions'] ?? null) ? $guidance['secondary_actions'] : [];
|
|
|
|
return array_values(array_map(
|
|
static fn (array $action, int $index): array => ResolutionAction::fromArray(
|
|
$action,
|
|
self::caseKey((string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN)).'.secondary_action_'.$index,
|
|
__('localization.review.review_output_limitations'),
|
|
),
|
|
array_values(array_filter($secondaryActions, static fn (mixed $action): bool => is_array($action))),
|
|
array_keys(array_values(array_filter($secondaryActions, static fn (mixed $action): bool => is_array($action)))),
|
|
));
|
|
}
|
|
|
|
/**
|
|
* @param list<array{
|
|
* key:string,
|
|
* label:string,
|
|
* type:string,
|
|
* url:?string,
|
|
* icon:string,
|
|
* kind:string,
|
|
* action_name:?string,
|
|
* capability:?string,
|
|
* requires_confirmation:bool,
|
|
* audit_event:?string,
|
|
* operation_run_type:?string,
|
|
* disabled_reason:?string
|
|
* }> $actions
|
|
* @return list<array{
|
|
* key:string,
|
|
* label:string,
|
|
* type:string,
|
|
* url:?string,
|
|
* icon:string,
|
|
* kind:string,
|
|
* action_name:?string,
|
|
* capability:?string,
|
|
* requires_confirmation:bool,
|
|
* audit_event:?string,
|
|
* operation_run_type:?string,
|
|
* disabled_reason:?string
|
|
* }>
|
|
*/
|
|
private static function deduplicateActions(array $actions, string $primaryKey): array
|
|
{
|
|
$seen = [$primaryKey => true];
|
|
$deduplicated = [];
|
|
|
|
foreach ($actions as $action) {
|
|
if (($action['type'] ?? ResolutionAction::TYPE_NONE) === ResolutionAction::TYPE_NONE) {
|
|
continue;
|
|
}
|
|
|
|
$key = (string) ($action['key'] ?? '');
|
|
|
|
if ($key === '' || isset($seen[$key])) {
|
|
continue;
|
|
}
|
|
|
|
$seen[$key] = true;
|
|
$deduplicated[] = $action;
|
|
}
|
|
|
|
return $deduplicated;
|
|
}
|
|
|
|
/**
|
|
* @param array{
|
|
* key:string,
|
|
* label:string,
|
|
* type:string,
|
|
* url:?string,
|
|
* icon:string,
|
|
* kind:string,
|
|
* action_name:?string,
|
|
* capability:?string,
|
|
* requires_confirmation:bool,
|
|
* audit_event:?string,
|
|
* operation_run_type:?string,
|
|
* disabled_reason:?string
|
|
* } $fallbackPrimary
|
|
* @param array{
|
|
* key:string,
|
|
* label:string,
|
|
* type:string,
|
|
* url:?string,
|
|
* icon:string,
|
|
* kind:string,
|
|
* action_name:?string,
|
|
* capability:?string,
|
|
* requires_confirmation:bool,
|
|
* audit_event:?string,
|
|
* operation_run_type:?string,
|
|
* disabled_reason:?string
|
|
* } $primaryAction
|
|
*/
|
|
private static function shouldPromoteFallbackPrimary(array $fallbackPrimary, array $primaryAction): bool
|
|
{
|
|
if ($fallbackPrimary['key'] === $primaryAction['key']) {
|
|
return false;
|
|
}
|
|
|
|
return $fallbackPrimary['type'] !== ResolutionAction::TYPE_NONE
|
|
&& ($fallbackPrimary['url'] !== null || $fallbackPrimary['action_name'] !== null);
|
|
}
|
|
|
|
private static function normalizedUrl(mixed $url): ?string
|
|
{
|
|
return is_string($url) && trim($url) !== ''
|
|
? trim($url)
|
|
: null;
|
|
}
|
|
|
|
private static function caseKey(string $state): string
|
|
{
|
|
return 'review_output.'.$state;
|
|
}
|
|
}
|